Agent 出事故却复现不了:一次失败要留哪几样才能原样重跑

2026-07-29

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

大部分”复现不了”的账都被算到了模型随机性头上,但从我排查过的案例看,真正让你重跑不出同一个结果的,八成是输入没被固定住——检索命中的片段换了、被读进去的文件已经被改过、某个工具当时返回的内容和现在不一样。采样随机确实存在,但它是最外面的一层,也是最容易单独消掉的一层。先怀疑输入,再怀疑模型,顺序反了就会在参数上耗掉一整天。

先划清楚这篇和邻居的分工。一次运行要打哪些日志、怎么把散落的调用串成一条链路,是 可观察的 Agent 日志 那篇的事;把已经复现出来的问题固化成长期跑的用例、防止它再回来,是 Agent 回归测试 的事。本篇卡在两者中间:日志你已经有了,但打开一看还是重跑不出同一个结果,缺的到底是哪几样材料。

一、先想清楚你要的是哪一档回放

“回放”这个词被用得太糊了。动手之前先确定你要哪一档,因为三档需要的材料量差一个数量级。

第一档是证据回放。你不重新执行任何东西,只是把当时的输入、每一步的工具调用和模型输出按时间顺序摊开看一遍。这一档的目标是定位——搞清楚是第几步开始跑偏的、是模型理解错了还是工具返回了脏数据。绝大多数事故复盘只需要这一档,而它只需要一份完整的记录,不需要任何确定性保证。

第二档是确定性重放。你把模型和外部工具全部换成回放桩:模型不真调,直接按记录返回当时那次的输出;工具不真跑,按请求指纹匹配当时那次的返回。这一档跑出来的轨迹应当和线上逐字一致,它验证的是你的编排代码——状态机、分支条件、上下文拼装、错误处理——在同样的输入下是否走同样的路。这一档是可以做到严格一致的,因为不确定的部分都被桩掉了。

第三档是真实重演。模型真调、工具真跑,你想看的是”换个模型版本/改了指令之后,同样的输入还会不会翻车”。这一档永远不要期待逐字一致,只能看结论层面稳不稳。有人在这一档上追求 bit 级一致,那是把力气花在了不可能的地方。

判断很简单:定位问题选第一档,验证代码逻辑选第二档,验证修复效果选第三档。三档要留的材料是包含关系,第一档的记录是另外两档的地基。

二、可回放的最小材料清单:六样

我把这六样按”缺了它就一定重跑不出来”的顺序排。

**一是输入快照,而且是展开后的完整输入。**这是最常缺的一样。很多系统只记了用户那句话,没记喂给模型的完整内容。可实际送进去的是用户输入加系统指令加历史摘要加检索片段加工具结果,其中检索片段和文件内容随时在变。只留用户那句话,等于只留了冰山一角。正确做法是把每次调用最终拼好的完整消息体整份落盘,另外单独存一份原始素材(检索到的文档 ID 与内容哈希、读过的文件路径与内容哈希)。为什么两份都要:完整消息体保证能重放,素材哈希保证你事后能一眼看出”这次和上次的差异出在哪个片段”。上下文被脏数据带偏是高发原因,而脏数据往往就藏在这些实时拼进去的片段里。

**二是指令与配置的版本。**系统指令、工具描述、路由规则、各种阈值,这些东西改起来毫无仪式感,一个人顺手改一行就上线了,事后没人记得当时是哪一版。把它们全部纳入版本管理,运行时记下 commit 号,是最省事的做法:

git rev-parse HEAD
git status --porcelain   # 输出非空说明有未提交改动,快照要额外存 diff

第二行不能省。跑出事故的那台机器上有未提交改动是常态,只记 commit 号会让你回放到一份根本不存在过的代码状态。

**三是模型与采样参数。**模型标识要记完整字符串,不要记别名——别名指向哪个具体版本是会变的,等它悄悄换了指向,你手上的记录就再也对不上号。采样温度、top-p 之类的参数一并记下。有的接口提供采样种子参数,能记就记,但要摆正预期:**同样的种子在同一次部署内通常能提高一致性,跨版本、跨部署、跨批处理规模都不保证一致,各家的支持程度和口径不同且会调整,以官方最新说明为准。**把温度设成 0 也一样——它压掉的是采样这一层的随机,压不掉浮点累加顺序、批处理拼批方式、后端路由差异带来的抖动。我的判断是:种子和零温度值得记、值得设,但你的复现方案不能建立在”它一定一致”的假设上,第二档的桩化重放才是可靠答案。

**四是工具调用记录,请求和返回都要。**只记”调用了搜索工具”没有意义,要记完整的入参、完整的返回体、耗时、状态码,以及失败时的错误类别。这份记录就是第二档回放的桩数据源。一个实用做法是给每次调用算一个请求指纹(把工具名加规范化后的参数序列化,取哈希),回放时按指纹查表:

printf '%s' "$tool_name|$normalized_args" | sha256sum

参数要先规范化——键排序、去掉时间戳类字段——否则同样的语义调用会算出不同的指纹,回放时全部落空。

**五是代码与环境指纹。**依赖版本、运行时版本、关键环境变量的存在性(只记键名和是否存在,绝不记值)。同一份代码在两台机器上行为不一致是老问题了,运行环境不一致 那篇专门讲过它的各种变体。

**六是外部状态的时点。**你的 Agent 读了数据库、读了工单系统、读了某个共享文档,这些东西在你复盘的时候早就变了。至少要记下读取时刻和读到内容的哈希;条件允许就把读到的内容本身存进快照。这一样常被跳过,代价是三天后你重跑,工具返回的是新数据,轨迹当然对不上,然后你开始怀疑模型——方向就此跑偏。

三、复现不了的判别表

材料齐了还是重跑不出来,按下表定位。左边是你看到的现象,右边是先验证什么、再做什么。

现象大概率成因怎么验证处置动作
每次重跑轨迹都不同,且分叉点很早输入没固定:检索片段或文件内容在变对比两次运行的完整输入哈希,逐段比对差异位置改用快照里的完整消息体直接重放,不再实时拼装
输入完全相同,模型输出仍有小幅差异采样层随机,属正常范围同一份输入连跑数次,看差异是否只在措辞而结论一致接受它,转做第二档桩化重放验证编排逻辑
输入相同、首轮模型输出也相同,但最终结果不同工具返回变了,或外部状态已变按请求指纹取出两次的工具返回体逐字段 diff,定位第一个不同的调用把该工具换成回放桩,锁死返回后再跑一遍确认轨迹归位
只在特定机器上复现环境差异:依赖版本、区域设置、路径大小写两边各导一份依赖清单和运行时版本做 diff统一到容器或锁定依赖后再谈复现
只在生产复现,本地永远正常并发、数据规模或权限差异看失败样本是否集中在某类数据或某个时段把生产的那条具体输入抓下来,本地单条重放
隔了几天就复现不出来了模型服务端版本变了,或依赖了别名检查记录里是不是别名而非完整版本标识改记完整标识,历史事故标注为不可精确复现
记录里有几步是空的采集埋点漏了,或超长内容被截断检查落盘时是否做了长度裁剪补埋点,超长内容存到对象存储只在记录里放引用
重放桩大面积不命中请求指纹没规范化,参数里混了时间戳打印未命中的入参和已存键做对比规范化参数,剔除易变字段后重算指纹

这张表的用法是从上往下走,不要跳。上面三行覆盖了我见过的多数情况,把它们排除掉再看环境和版本,能省很多无用功。

四、把这六样落地

**落盘时机要在”送出去之前”。**最常见的实现错误是在调用返回之后才记录——一旦调用崩了、进程被杀了,那次最关键的输入反而没了。正确顺序是:先把入参写盘,再发起调用,返回后追加写结果。写盘失败可以降级为告警,但不要因为写盘失败就跳过调用,也不要因为调用要紧就跳过写盘。

**目录结构按运行 ID 组织。**一次运行一个目录,里面按步骤序号分文件:完整输入、模型输出、工具请求与返回、元信息。元信息里放 commit 号、依赖清单哈希、模型标识、采样参数、开始与结束时间。这样打包给同事就是一个目录,不需要他去查任何系统。

**脱敏必须在写盘那一层做,不是在查看那一层做。**快照里几乎一定会混进密钥、令牌、身份证号、手机号。写盘前过一遍脱敏规则,把匹配到的值替换成占位符并保留长度信息。这件事的严重性在 日志泄露敏感信息 里讲过,快照比日志更危险,因为它存的是完整原文。同时给快照目录设独立的访问权限和保留期。

**体积要提前控制,否则这套东西第一个月就会被人关掉。**做法是分层:元信息和调用骨架永久留,完整输入输出留短期,超长内容(大文件、长网页)只存哈希加对象存储引用。另外只对失败运行和抽样的成功运行留全量,全量留所有成功运行是纯浪费。

**验证你的回放真的能用。**新建一条流水线,定期挑一条历史成功运行做桩化重放,断言轨迹一致。这条流水线一旦变红,说明要么快照格式变了、要么编排逻辑动了,两种情况都需要你知道。没有这条验证,你的快照会在某次重构后悄悄失效,等真出事故时才发现——那时候一切都晚了。

五、什么时候该停手

复现是手段不是目的。下面几种情况,我的建议是明确止损。

**追了半天只剩措辞差异,停。**如果多次重跑的结论一致、动作一致,只是自然语言表述不同,这就是采样层的正常抖动,没有可修的东西。继续往下钻只会让你去调参数,而参数不是病因。

**事故已经过了外部状态的保鲜期,停。**当时读的数据库行已经被更新、当时那个工单已经关闭、当时那个页面已经改版,此时你追求精确复现是在追一个不存在的世界。转而把这次事故当成一次输入样本,写成长期跑的用例,比死磕复现有用得多。

**修复方案和复现无关时,停。**有些失败的处置方式很明确——加校验、加超时、加人工确认卡点——你不需要复现就能改。这类问题上花在复现的时间是纯成本。参考 Agent 执行失败后怎么恢复 里对失败分类的处理思路,语义失败和瞬时故障要用完全不同的手段。

**回滚点要写死。**如果你为了复现而改动了生产的采集逻辑、加了大量埋点、调整了序列化格式,给自己划一条线:改动超过一定范围就先合回主干、发一版稳定的采集,再在旁路上继续折腾。别让复查工具本身变成下一次事故的成因。

换条路的判断依据是:同一个失败你已经在复现上花了两个工作日、判别表从上到下走了一遍仍无定论,那就承认这次不可精确复现,转向缩小影响面——加约束、加人工确认、把这条路径降级或临时关掉。承认复现不了不丢人,把团队拖在里面才是问题。

六、避坑清单

**只记用户输入不记完整消息体。**踩坑原因是埋点通常写在最外层入口,那里天然只看得见用户那句话。避法是把落盘钩子挂在最贴近模型调用的那一层,拿到什么就存什么。

**记了模型别名没记完整版本标识。**踩坑原因是别名短、好写、平时也够用,出事时才发现它指向的具体版本已经变了。避法是运行时把解析后的完整标识写进元信息,别名只用于配置。

**先调用后落盘。**踩坑原因是这样写代码更顺手,结果只有一个日志点。避法是拆成两次写,入参先落盘,返回再追加。

**快照里存了明文密钥。**踩坑原因是脱敏被放在了查看端,或者只对已知字段名做脱敏,而密钥恰好出现在一段自由文本里。避法是写盘层做值级别的模式匹配脱敏,并对快照目录单独控权,保留期到了就真删。

**请求指纹没规范化。**踩坑原因是直接把入参 JSON 字符串化取哈希,键顺序或时间戳字段一变就全不命中。避法是排序键、剔除易变字段,并对回放的命中率做监控——命中率掉下来说明桩已经失效。

**把不确定性当 bug 修。**踩坑原因是工程师的直觉是”同样输入必须同样输出”。避法是接受第三档回放本来就不一致,用桩化的第二档去保证代码可测。

**快照写了却从没读过。**踩坑原因是复盘时大家习惯翻日志、翻聊天记录,没人知道快照在哪、怎么打开。避法是给快照配一个最简单的查看入口,并在事故模板里写死”第一步先拉快照”。

**依赖了不在版本控制里的配置。**踩坑原因是有人把指令放进了数据库或某个后台界面,改动没有历史。避法是把这类内容纳入版本管理,或至少在每次运行时把它的内容哈希记进元信息。

**海外服务这一层要单独标注。**如果你的链路里接了海外模型服务,需要清楚这类服务通常对中国大陆有区域限制、不提供直接的商用接入,具体可用范围以各服务商官方条款为准;市面上存在第三方中转,但这里不做任何推荐、也不给渠道,你只需要知道中转会在链路上多加一层不受你控制的变量——它可能改写请求、缓存响应、切换后端,而这些都不会出现在你的记录里。真要用,就把中转返回的完整响应头和响应体一起存进快照,出问题时才有得对。

收个尾

可复查不是靠某个工具装上去的,是靠你在失败发生之前就决定好”要留什么”。这件事的回报很偏斜:平时它只是几行落盘代码,出事那天它决定你是半小时定位还是三天扯皮。

留一份自检清单,下次上线前对着过一遍:

  • 完整消息体(不是用户输入)有没有落盘?
  • commit 号和未提交改动有没有一起记?
  • 模型标识是完整版本还是别名?
  • 工具的入参和返回体都存了吗?请求指纹规范化了吗?
  • 依赖清单、运行时版本、环境变量键名有没有记?值有没有漏出去?
  • 外部数据的读取时刻和内容哈希有没有?
  • 落盘发生在调用之前还是之后?
  • 脱敏在写盘层还是查看层?
  • 有没有一条定期跑的桩化重放来证明快照还能用?
  • 快照的保留期、权限、体积上限定了吗?

十条里能答上八条,你的事故复盘就不会再从”我这边跑不出来”开始了。

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