agent 跑完出了问题,回头翻记录却看不懂它当时为什么那么做

2026-07-29

数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。

多数人把「事后看不懂 agent 为什么那么做」归因为日志记少了,于是把开关全部打到最详细,结果第二天翻出几万行输出,照样说不清它当时凭什么选中那个文件。真正缺的不是量,是维度:你记了它的输出,没记它的输入;记了最终结果,没记每个分支点上它当时能看见什么。 一次 agent 运行的可复盘性,取决于你能不能把「它看到的世界」还原出来。还原不出来,剩下的全是猜。

这篇讲的是「一次运行该留下什么痕迹」。跟它相邻的两篇分工是这样的:agent 日常运维 讲的是长期跑起来之后的巡检、告警和值班动作,日志太多怎么喂 讲的是记录已经很多了、怎么筛选和压缩才能读得动;本篇夹在中间,只回答一个问题——记录里必须有哪几类字段,你才有可能事后重建它的决策链。三篇不重复,缺任何一篇链条都是断的。

一、先分清你到底是哪一种「看不懂」

「看不懂」不是一个问题,是三个。动手补记录之前先判是哪一种,否则你补的字段大概率不在关键路径上。

第一种,改动可见、动机不可见。你能看到它新建了三个文件、删了一个函数,但完全不知道它为什么认定这个函数该删。这是上下文快照缺失——它当时读进去了什么、检索命中了哪些片段,没有留下来。

第二种,过程可见、复现不出来。事件流很完整,你照着重跑一遍,行为却不一样。这是环境指纹缺失——代码基线、依赖版本、模型标识、工具连通状态里有东西变了,而记录里没有这几行。

第三种,记录很全、串不起来。每个工具调用都有输出,但它们躺在不同的文件、不同的时间戳格式里,没有一根线把同一次运行的事件穿起来。这是运行标识缺失,最常见也最容易补。

三种成因的处置动作完全不同。下面这张表是我判断时实际过的一遍:

现象大概率成因怎么验证处置动作
知道改了什么,不知道为什么选中这个文件上下文快照缺失翻记录,看有没有「本次可见文件清单」这类条目;没有就是它在每个决策点前记录入参摘要与命中的文件路径列表
照着记录重跑,行为对不上环境指纹缺失对比两次运行的提交号、依赖锁文件哈希、模型标识,看是否有一项不同运行开始时固定采集基线指纹并写进头部事件
事件都在,但拼不出时间线运行标识缺失随手挑两条记录,看能否判断它们属不属于同一次运行全链路带同一个 run id,时间戳统一用带时区的格式
中途停了,不知道是完成还是被截断终止原因未落盘看记录最后一条是不是正常的收尾事件无论怎么退出都补一条终止事件,带退出码与原因分类
报错信息有,但看不出它读到的内容是什么返回值只记了状态没记摘要找一条工具调用记录,看有没有返回内容的前若干字符返回值统一记摘要与长度,超长截断但标注被截断
结论明显不对,却找不到从哪一步开始歪缺中间断言沿时间线找第一个与事实不符的陈述,看它前面有没有可核对的输入在关键节点记录它的判断依据,而不只是判断结论

判完了再往下。如果你三种都占,先补运行标识——它成本最低,而且不补这个,另外两类记录也拼不起来。

二、最小可复盘记录集:六类字段

不要一上来就设计一套完整的可观测体系,先把这六类字段落进去。经验是,六类齐了,八成的「为什么那么做」能在十几分钟内说清;缺任意两类,复盘就退化成猜测。

一是运行标识与边界。 一个全局唯一的 run id、开始与结束的带时区时间戳、触发方式(人工还是定时)、以及任务的原始描述文本。任务原文一定要存原样,不要存你后来复述的版本——很多「它理解错了」的争议,翻出原文就结了。

python -c "import uuid,datetime;print(uuid.uuid4(), datetime.datetime.now(datetime.timezone.utc).isoformat())"

二是输入基线快照。 运行开始那一刻的代码状态,至少三行:

git rev-parse HEAD
git status --porcelain
git rev-parse --abbrev-ref HEAD

第二条不能省。工作区是脏的却没记下来,是复现失败最常见的原因之一——你以为基线是那个提交,实际它跑在一堆未提交的改动上。依赖也一起记,锁文件算个哈希就够:

python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest()[:16])" package-lock.json

三是决策点。 这是本篇的核心,也是最常被漏掉的一类。每次工具调用记四样:调用了什么、入参摘要、返回摘要、以及它为什么走这个分支。前三样是机械的,第四样需要让 agent 在动手前先把判断依据写出来一句。听起来像多余的开销,但这一句是事后唯一能区分「它判断错了」和「它看到的信息本身就是错的」的凭据。关于「它看到的信息就是错的」这类情况,上下文污染 那篇讲得更细。

四是变更足迹。 改了哪些文件、每个文件的增删行数、执行过哪些命令、每条命令的退出码。

git diff --stat
git diff --name-status
git status --porcelain    # 这行不能省,理由见下

这里有个很容易翻车的地方:git diff 只看已跟踪文件的改动,agent 新建出来的文件是未跟踪状态,两条 git diff 一行都不会显示。而 agent 出问题时新建文件恰恰是重灾区——它可能悄悄多写了一个配置文件或一份重复实现。所以变更足迹必须带一条能看见未跟踪文件的命令,git status --porcelain 输出里前缀 ?? 的就是。想统一用 diff 的口径也行,先 git add -A 把新文件纳入索引,再用 git diff --cached --stat,两种都可以,选一种固定下来别混着用。

退出码要单独记。只记 stdout 不记退出码,你会遇到一堆「看起来跑完了其实失败了」的运行——agent 谎报成功大多就是这么来的。

五是外部依赖状态。 模型标识、外部服务的连通结果、HTTP 状态码、网络错误名。状态码要原样记:429 是限流、401 是凭据不对、403 是权限或区域拒绝、500 是对端自己出问题——这四类的处置动作完全不同,混成一句「调用失败」等于没记。网络层的 ETIMEDOUT 和 ECONNRESET 也是两回事,前者多半是路由或防火墙把包吞了,后者是连接建起来又被对端掐断。走企业内网代理的话,还要留一行 TLS 握手结果,自签证书导致的证书链校验失败在记录里的样子跟真正的网络不通很像,但一个改信任链就好,另一个改多少次都没用。

顺带说清楚一点:如果你的链路里接了海外的模型或工具服务,官方对中国大陆是有区域限制的、不支持直连,记录里出现连接超时或区域性拒绝属于预期之内,不要当成自己代码的 bug 排查。市面上确实存在第三方中转,我不做背书也不给具体渠道,你自己评估合规与数据风险。

六是终止原因。 正常完成、达到某种上限、人工中断、异常退出——四选一,必须落盘。很多复盘卡在第一步,就是因为没人说得清这次到底是跑完了还是被掐了。

三、把记录落到位的三个动作

动作一:运行前置采集。 把上面第一、二类字段做成一段固定脚本,在 agent 真正开工前跑一次,输出一条 JSONL 头部事件。这段脚本不依赖任何产品特性,纯 shell 加 git 就够,换工具也能直接搬。

动作二:过程用 JSONL,一行一事件。 不要用人类可读的多行文本记过程。一行一个 JSON 对象,字段固定为 run id、序号、时间戳、事件类型、载荷。这样做的好处是既能人眼扫,又能用一行命令过滤:

python -c "
import json,sys
for line in open('run.jsonl',encoding='utf-8'):
    line=line.strip()
    if not line: continue
    e=json.loads(line)
    if e.get('type')=='tool_call':
        print(e['seq'], e.get('payload',{}).get('name'))
"

这里刻意没用 grep '\"type\":\"tool_call\"' 这种字符串匹配,原因值得单说:Python 的 json.dumps 默认在冒号后加一个空格,写出来是 "type": "tool_call",跟没有空格的模式串对不上,你会得到一个空结果然后怀疑是记录没写进去。要么写盘时显式指定 separators=(',', ':') 把空格去掉,要么干脆按上面这样解析后再比字段——后者不受格式化风格影响,更省心。顺带一提,解析前跳过空行也是必要的,追加写盘的文件末尾常常多一个换行,json.loads('') 会直接抛异常打断整个过滤。

动作三:收尾生成一页摘要。 运行结束后,把六类字段压成一页,包含基线提交号、耗时、工具调用次数、变更文件数、错误计数、终止原因。这页摘要是给未来的你看的,原始 JSONL 是给出事时的你看的。日常只读摘要,出事才展开原始记录——这个分层能让记录量不再成为负担。

脱敏必须在写盘前做,不是在读的时候做。 密钥、令牌、内网地址一旦进了记录文件,就会跟着日志备份扩散到你控制不了的地方。至少把常见的环境变量名拉黑,参考 日志泄露敏感信息 的处理口径。

四、事后复盘的十五分钟流程

有了记录,复盘要有固定顺序,不然还是从头翻到尾。

第一步看终止原因。被掐断和跑完了,后面的读法完全不同。被掐断的,先确认掐断点之后有没有半成品落盘。

第二步倒着找第一个事实错误,而不是第一个报错。报错通常是结果,事实错误才是起点。所谓事实错误,是指记录里出现了一句与代码库真实情况不符的陈述——比如它认定某个函数在 A 文件里,而实际在 B 文件。找到这一条,往前翻一屏,看它当时的输入摘要,答案基本就在那儿。

第三步判归属。输入是对的、结论错了,是判断问题,处置方向是收窄任务边界或加约束;输入本身就是错的或残缺的,是检索与上下文问题,处置方向是改可见范围。这两类的修法南辕北辙,判错了就是白干一轮。

第四步验证。改完之后重跑,用同一个基线提交号,对比新旧两份记录的决策点序列,看分歧是否出现在你预期的位置。如果分歧点跟你预期的不一样,说明你改的和它实际受影响的不是同一处,回到第三步重判。

五、什么情况下别再折腾

复盘是有边际收益的,过了这几条线,继续挖不如重来。

止损线一:记录里缺基线指纹,且工作区已经被后续操作动过。 这种情况下你永远复现不了当时的状态,任何结论都是猜的。正确动作是回滚到一个干净基线,带完整记录重跑一次,用新的运行来复现问题。花两小时考古,不如花二十分钟重跑。

止损线二:同一个失败点,三次针对性修改都没让行为改变。 三次不动,说明你调的变量不在因果链上。停手,退回去重新做第一节的三分类判别——大概率是你一直在补的那类字段根本不是缺的那类。

止损线三:修复成本已经超过重做成本。 agent 改出来的东西,很多时候重新描述清楚任务再跑一遍,比在残局上缝补更快。判断依据很朴素:如果你要说清楚「该怎么改」所需的文字,已经比重新描述整个任务还长,就换条路。相关的回滚判断可以参考 AI 改坏代码怎么回滚

止损线四:问题只在某一次运行出现过,之后再没复现。 单次不可复现的异常,记录下来归档就行,别为它专门加一堆埋点。等它第二次出现,你会有两份记录可以对比,那时候查的效率高得多。

六、避坑清单

只记输出不记输入。 为什么会踩:输出是自然可见的,终端上就有;输入要主动构造才有。怎么避:把「本次可见范围」做成决策点事件的必填字段,缺了就让流程报错,靠自觉一定会漏。

时间戳不带时区。 为什么会踩:本地跑的时候看着没问题,一旦记录来自不同机器或容器,时区一混,时间线就排不出来。怎么避:统一用带偏移量的 ISO 格式,采集时就转好,不要留到读的时候再猜。

返回值只记长度不记内容。 为什么会踩:怕记录膨胀,于是只留一个字节数。怎么避:留前若干字符加总长度,并显式标注被截断。你要判断的是「它读到的是不是想要的东西」,开头那几行通常就够了。

把 agent 的自述当成事实。 为什么会踩:它写的「我选择这个文件因为它包含入口函数」读起来很有说服力。怎么避:自述记录的是它声称的理由,价值在于跟真实输入对照。两者不一致,恰恰是最有价值的信号,别把自述当证据链本身。

记录写在临时目录。 为什么会踩:临时目录写起来没有权限麻烦,顺手就用了。怎么避:出事那天重启一次,记录就没了。落到项目内的固定目录,并加进版本忽略规则,别提交进仓库。

详细开关长期全开。 为什么会踩:一次排查开了详细模式,事后忘了关。怎么避:把详细模式做成按次生效的环境变量,跟着单次运行走,不写进配置文件。

AGENT_TRACE=1 your-runner   # 只对这一次生效

并行运行共用一份记录文件。 为什么会踩:多开几个会话同时干活的时候,谁都往同一个文件追加。怎么避:记录文件名带上 run id,一次运行一个文件,最后再汇总。共写一个文件不仅会串行,还会因为写入交错把 JSONL 弄成半行。

脱敏做成正则黑名单就收工。 为什么会踩:写了几条规则,测了几个样例通过了,就当搞定了。怎么避:黑名单只能兜住你想得到的形态,配合一条硬规则更稳——凡是名字里带 KEY、TOKEN、SECRET、PASSWORD 的环境变量,一律不入记录,值也不入。

收束

可复盘性不是一个功能,是一组习惯。你不需要一开始就把六类字段全部工程化,先从最便宜的两样做起:给每次运行一个 id,运行结束补一条终止事件。这两样加起来不到十行脚本,却能把「串不起来」和「不知道是不是跑完了」这两类最常见的困扰直接消掉。

下一次运行结束,用这五条自检:

  1. 我能不能一眼说出这次运行的基线提交号,以及当时工作区干不干净?
  2. 记录里有没有一条能证明它「看见了什么」的条目,而不只是「做了什么」?
  3. 每个失败的外部调用,是否留下了状态码或错误名,而不是一句调用失败?
  4. 这次是正常结束还是被中断的,记录里有没有明确写?
  5. 把这份记录交给一个没参与的同事,他能不能在十五分钟内指出第一个出错的节点?

第五条答不上来,说明记录还是写给你自己看的,不是写给未来的复盘看的。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。