进阶 📋 7 个步骤 第 467 / 467 篇

自建 Agent 会话回放器:结构化事件日志,坏步骤事后可查

不依赖 Langfuse 等平台:把 Agent 运行记成 JSONL 事件流(llm_call / tool_call / tool_result),~150 行代码实现时间线回放、错误过滤、耗时定位,以及从任意断点重建上下文继续调试。

2026.09.23· 15 分钟阅读· 约 2087 字· 🐍 Python / 🧿 事件日志

Agent 一旦上了重试(463)、并发(465)和缓存(466),行为就变得难以肉眼追踪:一次任务背后可能是几十次模型调用、上百次工具执行。出了问题,「它刚才到底干了什么」全靠猜。Langfuse 这类观测平台很好,但私有数据不出内网的要求、或者只是想快速定位一个坏步骤时,自建一套轻量回放器更直接:把运行记录成 JSONL 事件流,事后逐帧回放。本篇用不到 150 行代码实现。

💡 JSONL(每行一个 JSON 对象)是事件日志的通用格式:追加写入零成本、按行流式读取、grep 友好。别用一整个大 JSON 数组——任务崩了连文件都写不完整。

Step 1:定义事件模型——哪些时刻值得记录

1 事件粒度决定回放价值

回放器的前提是埋点模型。一次 Agent 运行至少有五类值得记录的事件:

事件类型        触发时机                 关键字段
run_started    任务开始               run_id, 任务描述
llm_call       发起模型请求           run_id, 消息长度, 模型名
llm_response   模型返回               run_id, 是否要求调工具, token 用量
tool_call      工具开始执行           run_id, 工具名, 参数
tool_result    工具返回               run_id, 工具名, 耗时ms, 成败
run_finished   任务结束               run_id, 状态, 总轮数
run_error      任务异常终止           run_id, 错误摘要

每条事件固定带三样东西:全局 run_id(串起一次任务)、单调递增 seq(排出先后)、UTC 时间戳。回放的所有功能——时间线、过滤、断点重建——都建立在这三样之上。

Step 2:写记录器——一个函数管所有埋点

2 追加写 + 立即落盘
import json, time, uuid, os

LOG_DIR = "replay_logs"
os.makedirs(LOG_DIR, exist_ok=True)

class EventLog:
    def __init__(self, run_id=None):
        self.run_id = run_id or uuid.uuid4().hex[:12]
        self.seq = 0
        self.path = os.path.join(LOG_DIR, f"{self.run_id}.jsonl")
        self.f = open(self.path, "a", encoding="utf-8")

    def emit(self, etype: str, **fields):
        self.seq += 1
        rec = {"seq": self.seq, "ts": time.strftime("%H:%M:%S"),
               "run": self.run_id, "type": etype, **fields}
        self.f.write(json.dumps(rec, ensure_ascii=False) + "
")
        self.f.flush()                      # 崩溃也不丢已发生的事件
        return rec

    def close(self):
        self.f.close()

flush 不能省。Agent 任务经常因为超时被外部杀掉,不带 flush 的缓冲区会丢掉最后几条事件——而排查问题时最有价值的恰恰是「死前最后一步」。也别用 O_APPEND 裸写多进程:并发场景要么每进程独立文件,要么加文件锁。

Step 3:给 461 的执行循环埋点

3 改动只有六行

以 461 的 ReAct 循环为例,埋点位置天然对齐事件模型:

def run_agent(task: str, max_steps: int = 10):
    log = EventLog()
    log.emit("run_started", task=task[:200])

    messages = [{"role": "user", "content": task}]
    try:
        for step in range(max_steps):
            log.emit("llm_call", step=step, n_msgs=len(messages))
            resp = chat(messages)                       # 461 的模型调用
            msg = resp.choices[0].message
            log.emit("llm_response", step=step,
                     wants_tool=bool(getattr(msg, "tool_calls", None)))

            if not getattr(msg, "tool_calls", None):
                log.emit("run_finished", status="ok", steps=step + 1)
                return msg.content

            for tc in msg.tool_calls:
                name, args = tc.function.name, tc.function.arguments
                t0 = time.perf_counter()
                log.emit("tool_call", step=step, tool=name, args=args[:300])
                result = dispatch(name, *parse(args))    # 466 的 dispatch
                log.emit("tool_result", step=step, tool=name,
                         ms=int((time.perf_counter() - t0) * 1000),
                         ok=not str(result).startswith("错误"))
                messages.append(tool_msg(tc.id, result))
    except Exception as e:
        log.emit("run_error", err=str(e)[:300])
        raise
    finally:
        log.close()

注意 args 截断到 300 字符:日志是给人看的时间线,不是数据库,塞满全文只会拖慢 grep。需要全文时在工具内部自己记。

Step 4:回放器——把事件流还原成时间线

4 三十行读出一个可读报告
def replay(run_id: str):
    path = os.path.join(LOG_DIR, f"{run_id}.jsonl")
    events = [json.loads(line) for line in open(path, encoding="utf-8")]
    for e in events:
        icon = {"llm_call": "🧠", "llm_response": "↩️",
                "tool_call": "🛠️", "tool_result": "✅",
                "run_finished": "🏁", "run_error": "❌"}.get(e["type"], "·")
        line = f"{e['seq']:>3} {e['ts']} {icon} {e['type']}"
        if e["type"] == "tool_call":
            line += f"  {e['tool']}({e['args'][:80]})"
        if e["type"] == "tool_result":
            line += f"  {e['tool']} {e['ms']}ms {'OK' if e['ok'] else 'FAIL'}"
        print(line)
    print(f"共 {len(events)} 个事件")

replay("a3f9c2e1b7d4")

输出长这样:

  1 10:21:03 🏃 run_started
  2 10:21:03 🧠 llm_call
  3 10:21:05 ↩️ llm_response
  4 10:21:05 🛠️ tool_call  search("北京 天气")
  5 10:21:06 ✅ tool_result  search 812ms OK
  6 10:21:06 🧠 llm_call
  ...
 14 10:21:11 🏁 run_finished

哪一步慢、哪个工具反复被调、任务在哪轮终止,一目了然。

💡 回放不必写代码也能用:JSONL 是纯文本,grep '"tool_call"' 某个文件就是完整调用清单,grep -c '"ok": false' 一行数出失败次数。把这套日志格式定下来,团队成员不用读你的回放器就能上手排查。

Step 5:过滤定位——三个常用切片

5 坏步骤通常藏在这三个切面里
def filter_events(run_id, etype=None, tool=None, min_ms=None, failed=False):
    path = os.path.join(LOG_DIR, f"{run_id}.jsonl")
    out = []
    for line in open(path, encoding="utf-8"):
        e = json.loads(line)
        if etype and e["type"] != etype:            continue
        if tool and e.get("tool") != tool:          continue
        if min_ms and e.get("ms", 0) < min_ms:      continue
        if failed and e["type"] == "tool_result" and e["ok"]:  continue
        out.append(e)
    return out

# 用法示例:
slow = filter_events(rid, etype="tool_result", min_ms=2000)   # 谁拖慢了任务
bad  = filter_events(rid, failed=True)                        # 哪些调用失败过
freq = {}                                                     # 工具调用频率
for e in filter_events(rid, etype="tool_call"):
    freq[e["tool"]] = freq.get(e["tool"], 0) + 1

实践中最有用的组合是「失败事件 + 前后各一条」:tool_result 为 FAIL 的 seq 前后各取一条 tool_call,就能还原「模型传了什么参数导致失败」,直接指导提示词或参数清洗的修改。

💡 把 freq 统计接进周报:某个工具调用次数突然翻倍,往往意味着模型陷入了重复调用循环(上游模型换了版本时尤其常见),比等用户投诉早发现一周。

Step 6:从事件流重建上下文——断点续调

6 回放的进阶用法:把历史喂回去继续跑

事件流记录了每次工具调用与结果,逆操作就能重建当时的消息列表:把 tool_call 与 tool_result 按序还原成 assistant 的 tool_calls 消息和 tool 角色消息,再接上一条「请基于以上进度继续」的用户消息,Agent 就能从断点接着跑,不用从头重付前几轮的 token:

def rebuild_messages(run_id, upto_seq=None):
    events = [json.loads(l) for l in
              open(os.path.join(LOG_DIR, f"{run_id}.jsonl"), encoding="utf-8")]
    if upto_seq:
        events = [e for e in events if e["seq"] <= upto_seq]
    msgs, pending = [], []
    for e in events:
        if e["type"] == "tool_call":
            pending.append({"id": f"c{e['seq']}", "type": "function",
                            "function": {"name": e["tool"], "arguments": e["args"]}})
        elif e["type"] == "tool_result" and pending:
            # 简化演示:一个调用一条结果;严格实现需按 id 配对
            msgs.append({"role": "assistant", "tool_calls": pending})
            msgs.append({"role": "tool", "tool_call_id": f"c{e['seq']}",
                         "content": str(e.get("tool", ""))})
            pending = []
    return msgs

简化版没有保存完整的 assistant 文本与参数原文(args 被截断过)。要在生产使用断点重建,事件里就得存全量参数,或者把原始 messages 也按轮落盘。这是「日志瘦身」与「可重建」之间的取舍,按团队需求定,别两头都想要结果两头都做不精。

Step 7:生产化前的三件事

7 从脚本到能在生产跑的日志

其一,脱敏:事件里可能出现用户手机号、密钥参数,写入前过一遍正则替换(把 11 位手机号、sk- 开头的串打成掩码),日志泄露是合规红线。其二,轮转与清理:按 run_id 一文件天然好清理,再配一个「保留 14 天」的定时删除即可,防止磁盘被日志吃满。其三,与平台观测的关系:本方案解决的是「快速定位坏步骤」,不提供面板、告警与团队协作;等团队规模或合规要求上来了,把同一套事件模型对接 Langfuse 或 OTLP(语义约定几乎一致),迁移成本很低——这也是为什么事件模型要在一开始就设计清楚。

脱敏正则务必在写入路径上而不是回放路径上:写进去之前就洗干净。回放路径上做脱敏意味着原始敏感数据一直躺在磁盘上,等于没做。

至此工具调用工程系列四篇成套:461 执行循环、463 失败恢复、465 并发吞吐、466 重复治理,加上本篇的行为可观测。五块拼起来,就是一个能扛住真实生产的工具层骨架——先有骨架,再谈框架选型,顺序别反。

← 返回教程中心