提出对 AI Agent 运行做 trace 对比的二分发调试法——跑两次任务对齐事件流,找出首个分歧点即定位 bug,避免阅读完整 diff。
AI 写代码的转变有一个隐性副作用:团队里现在人人都是 reviewer。Review 一次 Agent 运行是一个二分查找问题,而不是阅读理解问题。最终的 diff 展示的是改了什么,却很少说明是哪个工具调用导致了这次变更。当一次运行失败时,最快的诊断方式是用同样的任务跑第二次 trace,对齐直到路径分叉——第一个分歧点就是你的 bug,之后的一切都是下游损伤。
我的前几篇文章介绍了 run receipts 和 trace 优先的调试循环;而这是我一直漏掉的那一步。Receipts 告诉你发生了什么,但不告诉你在哪里变了故事线。Harness 可以捕获每一个事件,难的是决定先读哪一个。Trace 对比把这个决策变成了一次搜索:任务跑两遍,对齐事件流,报告第一个不匹配的索引。
Schema 有意做得很小。少量的事件类型,每行一个 JSON 对象,没有花哨的东西:
{"task_id":"a1b2","seq":0,"type":"model_request","prompt_tokens":812,"ts":0.12}
{"task_id":"a1b2","seq":1,"type":"tool_call","name":"read_file","path":"src/main.py","ts":0.21}
{"task_id":"a1b2","seq":2,"type":"tool_result","name":"read_file","status":"ok","ts":0.29}
{"task_id":"a1b2","seq":3,"type":"file_edit","name":"replace","path":"src/main.py","ts":0.34}
一个诚实的说明:token 字段是字数统计,不是真正的 tokenizer。这个精度对 trace 对比足够,对计费则不够。
捕获靠两个钩子。一个挂在模型客户端上,一个挂在工具分发器上:
import json
import sys
import time
import uuid
class TraceLogger:
def __init__(self, sink):
self.sink = sink
self.seq = 0
self.task_id = uuid.uuid4().hex[:8]
def log(self, event_type, **fields):
row = {
"task_id": self.task_id,
"seq": self.seq,
"type": event_type,
"ts": time.time(),
}
row.update(fields)
self.seq += 1
self.sink.write(json.dumps(row) + "\n")
trace = TraceLogger(sys.stdout)
def call_model(prompt, **kwargs):
trace.log("model_request", prompt_tokens=len(prompt.split()))
out = model(prompt, **kwargs) # your client here
trace.log("model_response", finish_reason=getattr(out, "finish_reason", None))
return out
def run_tool(name, args):
trace.log("tool_call", name=name, path=args.get("path"))
result = dispatch(name, args) # your dispatcher here
trace.log("tool_result", name=name, status="ok" if result.ok else "error")
return result
然后把同样的任务跑两遍,对比 projections。Projection 去掉参数、时间戳和 token 计数,只保留运行的骨架:
import json
import sys
def load(path):
return [json.loads(line) for line in open(path) if line.strip()]
def projection(events):
out = []
for e in events:
t = e["type"]
if t == "tool_call":
out.append("call:" + e["name"])
elif t == "tool_result":
tag = e["name"]
if e.get("status") == "error":
tag += ":error"
out.append("res:" + tag)
elif t == "file_edit":
out.append("edit:" + e["path"])
else:
out.append(t)
return out
def first_divergence(g, b):
n = min(len(g), len(b))
for i in range(n):
if g[i] != b[i]:
return i
return n
g = projection(load(sys.argv[1]))
b = projection(load(sys.argv[2]))
i = first_divergence(g, b)
print(f"common prefix: {i} events")
print(f"good: {g[i] if i < len(g) else '<end>'}")
print(f"bad : {b[i] if i < len(b) else '<end>'}")
print("-- failing run, events after the split --")
for e in load(sys.argv[2])[i:i + 6]:
print(json.dumps(e))
python tracediff.py good_run.jsonl bad_run.jsonl
输出回答了两个问题。common-prefix 长度是 Agent 的最后一个正确节点;第一个不匹配的事件就是拐点。读失败运行中接下来的六个事件,你就能看到错误转向及其上下文。大多数时候,Agent 的 bug 就住在这里:一个早期的观察,要么是过期的,要么是被不信任的,要么是被过度信任的。Diff 在几秒内就找到它,比你打开最终 diff 早得多。
你可以继续二分。在分歧事件处截断对话,重新运行。如果 Agent 以同样的方式失败,原因在上游;如果它恢复了,分歧事件本身就是原因。那次重运行应该很便宜,这就是免费端点存在的意义。一个免费层的 probe model 就足以验证分叉点;在回归重运行上花付费配额,就违背了二分循环的目的。
这就是我开始评估 MonkeyCode 的原因。它是一个开源项目,当前提供免费模型访问和免费服务器来托管 harness,声明的免费额度是 10M tokens。披露:本文是作为 MonkeyCode 产品推广的一部分撰写的。请把配额当作起点,而不是合同:免费层会变动,10M 这个数字来自项目团队,而非我运行的基准测试。README 是权威来源。
三个注意事项。第一,你需要一条你信任的基线;如果两次运行都是垃圾,分歧就毫无意义。第二,对齐是结构性的,不是语义性的:两个模型可以用真正不同的顺序解决同一个任务,使第一个差异变成噪音。在 diff 之前,先规范化工具名称,折叠重复的只读调用。第三,trace 是一场数据泄露——它包含 prompt、文件路径和 diff。不要把运行日志推送到共享服务器,除非你控制着数据保留;免费服务器是方便的,但不是合规的答案。
谁应该跳过这个?从不重放失败运行的团队,以及没有标准解法的任务。如果你的 Agent 写营销文案或一次性诗歌,对比两次生成告诉不了你什么。如果你每周 review 二十个失败运行,这个循环在第一个小时就物超所值了。剩下的时间它只是一个安静的习惯:捕获、对齐、二分、修复。如果你想用预算原型化这个循环,MonkeyCode 的免费层是一个合理的起点——周一用你自己的 harness 拿两条 trace,运行这个脚本。第一个分歧点就是赢家。