实现篇故障手册:Agent 为什么会失灵

小A的 Agent 跑了一夜,凌晨日志里躺着三条记录:模型流中断、工具反复调用同一个文件、会话恢复后状态对不上。他对着屏幕发愁,不知道该从哪条查起。
“别慌,也别一条条瞎猜。”老Z说,“把它当排障手册用:先看症状,再找证据,最后验证根因,别反过来。本附录服务 Part III,实现或验证最小 Agent 出现异常时查阅。每项把症状、证据、根因假设和验证步骤分开列,先把可复现的东西留住;源码位置是排查线索,不构成产品保证。”
“为什么要先把证据和根因分开?”小A问。
“因为根因会骗你,证据不会。”老Z说,“症状是‘它坏了’,证据是‘它在哪一行、带着什么状态坏的’,根因是你对证据的解释。同一个证据能撑起两个根因,解释可能错;但证据是复现的钥匙。先记证据再谈根因,返工的只是假设;反过来,返工的是整个排查。”
“总原则再补一条:一次只验证一个假设。”老Z说,“两个假设一起试,成功了也分不清是谁的功劳。改一处、复现一次、看结果,这跟实验室里控制变量是同一件事。排障比的是谁把假设删得干净。”
模型请求失败或被取消
症状: 没有最终回答,或显示取消。 证据: 记录 Provider、模型 ID、AbortSignal、stop reason 与原始错误。 根因假设: 认证未配置、网络失败、模型返回 error,或用户取消。 验证: 先在不调用工具的最小请求中复现;检查 ModelsImpl.applyAuth、streamSimple 与调用方是否传递同一 signal。不要把取消当作成功空响应。
机制上先要提醒一个最常踩的坑。老Z说:“AbortSignal 的设计是同一根 signal 贯穿调用栈,中间只要有一层没把 signal 往下传,你在最外层 abort,最内层的请求并不会停,它会继续跑。你以为取消成功了,其实是取消了个寂寞,请求还在烧钱。”小A要核对的,是调用方到 streamSimple 这条链上 signal 有没有一路传下去,而不是只看最外层有没有 abort。
小A:“如果最小请求也复现不了,说明问题出在别处?”
“那就往下游查。”老Z说,“最小请求过了,说明模型层没问题,问题在装配层:工具描述太长把请求撑爆、上下文里塞进了坏数据,或者消息结构不符合 provider 的要求。最小请求是条分水岭,它之前的问题归模型层,之后的问题归装配层,能帮你划掉一大半‘模型坏了’的猜测。”
“还有一类失败容易漏掉,”老Z说,“模型返回了 error,stop reason 却被记成 complete。有些 provider 在部分失败时可能先发错误事件再发结束事件;具体事件顺序和 stop reason 映射要按目标 Provider 版本验证。排查时应同时保存原始错误和 stop reason。所以证据里要同时记原始错误和 stop reason,两样对不上,就是有信息丢在了适配器里。”
“那‘没有最终回答’这个症状,除了失败和取消,还有别的可能吗?”小A追问。
“有,而且藏得深:流停了但没人知道。”老Z说,“连接没报错,signal 也没触发,delta 事件就是不来了。可能是 provider 静默断开,也可能是适配器吞掉了结束事件。这种既不成功也不失败的状态最磨人,因为它不在任何一条错误路径上。排查靠心跳:一段时间收不到任何事件,就主动判超时,别无限等下去。没有超时的等待不算等待,算挂起。”
“超时和取消,是同一个机制吧?”
“不是,别混。”老Z说,“取消是外部主动要停,用户点了取消,或者上层发了 abort;超时是被动判死,约定时间内没动静。证据也不同:取消有 signal 触发的痕迹,超时只剩时间戳。验证也别混,测取消用 AbortSignal,测超时用短超时参数。故障是哪一种,决定你查哪个分支的代码。”
工具反复调用或写入冲突
症状: 同一工具重复执行,或同一文件修改丢失。 证据: 保留 tool call ID、参数摘要、tool result、轮次和文件路径。 根因假设: 描述/错误结果不足以让模型改策,或并发写入共享资源。 验证: 设置轮次预算;让工具把失败显式返回;对同一路径检查 withFileMutationQueue 的序列化,而不要假定所有 tool call 可安全并发。
老Z把两种根因分开讲:“重复执行和写入冲突看着像同一个症状,根在两层。重复执行多半出在模型层:失败信息太模糊,比如只返回一个 ‘Error’,模型不知道为什么失败,只能换个参数再试,换来换去都在错的轨道上。写入冲突出在执行层:多个 tool call 同时落同一个文件,没有串行化队列,后写的覆盖先写的。修法也分开:前者把更具体的失败原因塞进 tool result,后者靠 withFileMutationQueue 把同一路径的写串起来。这两层兜不了彼此的底,失败信息写得再清楚,挡不住并发踩踏;队列加得再严,也救不了模型瞎试。”
小A:“那这一夜的日志,怎么区分是哪一种?”
“时间戳只是线索,不能单独定位层级。”老Z说,“要看 tool call ID、批次、队列覆盖范围和文件状态:重复执行常表现为同一工具反复出现;写入冲突可能是同一批 call 并发,也可能是外部进程绕过了队列。经过 withFileMutationQueue 的同路径写会串行化,队列外写入不受这条保护。分不清先查调用 ID 和资源归属,再判断并发边界。”
“反复调用还可能藏在工具描述里。”老Z补充,“描述没说清楚这个工具什么时候该用、什么时候不该用,模型就把它当万金油,一遍遍拿来试。这不算 bug,症状却一模一样,查日志之前先看一眼描述,能省掉一晚上的考古。”
小A抢着问:“那给写操作都加上队列,冲突不就没了?”
“加队列之前还要再分一次:是并发写入,还是顺序覆盖。”老Z说,“并发写入是同一时刻两个 tool call 落盘,靠 withFileMutationQueue 序列化就能解决;顺序覆盖是模型自己连着两次写同一个文件,第二次基于第一次的旧内容,这是模型层的问题,加队列也没用。时间戳能帮你分:两个写几乎同时出现是并发,一前一后隔着好几轮是顺序。两种修法完全不同。”
“最容易漏的在同一轮内部。”老Z说,“模型一次请求可能带出多个 tool call,宿主全并行执行,它们之间再有共享资源,冲突就在这一轮里爆发。排查时看同一轮的多个 tool call 是否共享了文件或状态,共享了,要么串行执行,要么划清边界。并行不一定都是模型要求的,宿主自己也可能在制造并发。”
上下文过长或压缩失败
症状: 请求超过窗口,或摘要后丢失关键约束。 证据: 保存压缩前后消息、token 估计、保留边界和压缩失败的错误文本。 根因假设: 大工具输出进入上下文,或摘要模型被中止/失败。 验证: 读取 packages/coding-agent/src/core/compaction/compaction.ts 的 prepareCompaction、compact 与 getSummarizationFailure;单独测试摘要失败时是否保留原历史并向用户报告。
老Z让小A先把“压缩失败”拆成两种,别混着查:“一种是压不掉,摘要模型崩了或者被中止;另一种是压掉了但压错了,摘要把关键约束丢了。前者是运行时错误,看摘要请求的 stop reason 和错误文本就能定位,error 和被 token 上限截断的 length 都会让压缩失败;后者是语义错误,日志干净得很,模型从下一轮开始就忘了你交代过的硬约束。查后者要对比压缩前后的消息,看保留边界有没有把 system prompt 或早期用户约束一起压没了。”
“‘压掉了但压错了’这种,最坏会坏到什么程度?”
“坏到看起来正常。”老Z说,“它不报错、不中断,请求照发,模型只是每轮都在缺约束的状态下干活。这种错误最危险的地方,是没有任何信号提示它发生了。唯一的办法是留住压缩前的历史,哪怕只留一份副本,出事后对比‘模型认为的约束’和‘你实际给的约束’。没有压缩前副本,这种错误永远查不出来,所以证据里那栏‘压缩前后消息’不是建议,是必需品。”
“预防也要从源头做,”老Z说,“先查是什么把上下文撑爆的。九成是一个大工具输出,比如 read 读了一个几万行的文件,或者 list 列了一个巨型目录。压缩是在收拾烂摊子,管住大输出才是别让烂摊子出现。两件事都要做,前者治标,后者治本。”
“token 估计这块也有讲究,”老Z说,“‘按字符估算’和‘按 token 计数’是两种预算,不能混着用。有的实现按字符粗算,便宜,但可能过估也可能低估;有的按 token 精算,准,但要多花一次计数调用。排障先确认自己的预算口径,拿字符预算去套 token 窗口,结论必然对不上。把口径写进证据,比‘我觉得差不多’可靠得多。”
“压缩的触发点本身也是可调的,”老Z接着说,“是到窗口的 80% 就压,还是撞墙了才压?阈值提前一点,压缩的压力就小一点;太晚,压缩发生时历史已经很大,摘要负担也大。这个阈值要在浪费上下文和摘要丢失风险之间取平衡,它是按任务特性调的参数,不是默认值。”小A在手册空白处记下了“80%”这个数字。
会话恢复后状态不对
症状: 重启后缺少历史,或 fork 后文件状态与预期不同。 证据: 保存 JSONL 条目、session ID、fork 起点和文件系统状态。 根因假设: 把会话条目误当消息,或把 session fork 误当 Git worktree。 验证: 对照 packages/coding-agent/src/core/session-manager.ts 与 agent-session-runtime.ts 的 fork;独立检查 Git/工作区隔离机制。
老Z先纠正一个常见误会:“JSONL 里写的是消息流,不是可执行状态。重启时你拿到的是历史消息的回放,拿不到上一轮工具执行到一半时的内存状态。所以恢复之后,要是有未完成的副作用悬着,比如一个 tool call 发了网络请求但结果没回来,这个状态恢复不出来,你得把它当成‘未知’,而不是‘没发生’。把 session fork 误当 Git worktree 也是常见的错位:fork 复制的是会话历史,不一定复制工作区文件,这两者的隔离边界要分开核。”
“‘当成未知而不是没发生’,落到操作上是什么样?”
“恢复后先显式盘点一次副作用。”老Z说,“启动时把可能还挂着的写操作列出来问一遍:上次的临时文件还在吗?发送过的请求结果回来了吗?然后要么补齐,要么撤销,要么明确标上‘这个分支状态未知,需要人工确认’。最忌讳的是假装无事发生直接接着跑,那等于让 Agent 在一个它自以为干净的世界上继续动手,而世界里还挂着半截没完成的动作。”
“会话条目的类型那么多,排查时怎么判断哪条该看?”
“按恢复语义分。”老Z说,“消息类条目(用户、助手、工具)决定‘模型看到什么’,配置类条目决定‘工具和模型怎么配’,压缩类条目决定‘旧历史还剩多少’。恢复后状态不对,先问一句:是模型看到的不对,还是装配的不对?前者查消息类,后者查配置类。把条目类型当路标,别从头到尾一条条读。”
远程界面与服务端不同步
症状: 进度显示与最终 transcript 不一致,或断线后请求结果未知。 证据: 保存 request ID、attachmentId、订阅快照、收到的 service_update、连接关闭原因。 根因假设: 用增量拼凑权威状态、重连后没有重新订阅,或在未知提交状态下盲目重试。 验证: 重新订阅,用新快照重建视图;阅读 packages/server/src/server.ts 的订阅分支与 session-router.ts 的 requireAttachment。不要宣称本快照实现自动重连或 exactly-once。
小A:“进度和 transcript 不一致,这是 UI 的 bug 还是服务端的 bug?”
“先别急着分锅。”老Z说,“先问:‘当前状态’是哪个源算出来的?UI 要是自己拿事件一条条累加出状态,中间丢一条就全错,这是拿增量当权威的错,不是某个渲染函数的 bug。正确的源是订阅时拿到的快照,再按顺序叠上 service_update;断线之后,客户端的订阅会被清空,必须重新订阅拿新快照。先判定哪个才是权威状态,再去定位渲染代码。”
“断线时的行为还要单独核验。”老Z补了一句,“要是有个请求已经发出去、结果还不知道,界面必须把这个状态显式标出来。‘连接已断开’和‘请求结果未知’是两个不同的提示:前者只说连接没了,后者说请求可能没完成。很多断线事故的根因,就是界面只报了前者,用户或者上层逻辑以为没结果就是没发生,副作用其实已经执行了。未知要标成未知,不能标成没发生。”
“重连也有纪律:可以重试,不能盲目重试。”老Z说,“你不知道上一次提交成没成功,重试就可能产生重复副作用,比如重复发消息、重复扣费。重连前先把上次到底提交成功没有问清楚;问不清的状态宁可让人工确认,别自动再来一遍。重试的前提是幂等保证,不是默认配置。”
“那增量和快照到底该信哪个?”
“以快照为起点。”老Z说,“快照是‘那一刻的完整状态’,增量只在它上面有意义。顺序反了,断线后界面就会把残缺的增量当真相。证据里记下重连后的新快照,就是为了断线后能回答‘我现在看到的状态从哪来’。”小A把这条抄进了排障清单。
协议输入被拒绝
症状: decoder 报 ProtocolValidationError。 证据: 记录 frame 长度、连接阶段和截断后的错误,不记录秘密 payload。 根因假设: 长度超限、CBOR 损坏、schema 不匹配或 EOF 半帧。 验证: 用 ClientMessageDecoder 复现,检查 FrameDecoder、decodeCbor、schema;失败后新建 decoder 和连接状态,而不是继续 push。
小A试探着问:“decoder 报错了,重试一下不就行了?”
“不行,decoder 失败后必须重建,不能继续喂字节。”老Z说,“协议 decoder 是有状态的,内部记着现在在第几个 frame、长度前缀读了几个字节。一旦出错,这个状态就不可信了,继续往里 push,只会把损坏带进后续解码。正确做法是丢弃旧 decoder、新建一个、重置连接状态。这条对半帧尤其重要:EOF 时只读到一半,半帧不是错误,是未完成;但 decoder 一旦把它判成错误,恢复路径就只剩重建。”
“四个根因假设里,怎么区分是哪一个?”
“看报错发生在哪个阶段。”老Z说,“FrameDecoder 之前是长度层,长度超限、EOF 半帧都在这层;FrameDecoder 到 schema 之间是编码层,CBOR 损坏在这层;schema 校验之后是语义层,消息形状不对在这层。ProtocolValidationError 是在哪一层抛的,日志里应该带得上。先定位阶段,再猜根因,能砍掉一半选项。”
“安全上还有一条硬要求:证据里不记录 payload 本身。”老Z说,“frame 内容里可能带密钥、带用户数据、带 prompt 全文。排障要记的是‘它多长、坏在哪一层、断了没’,不是‘它里面写了什么’。为了查一个协议 bug 把敏感内容打进日志,等于用一个 bug 换来另一个。”
成本失控或意外账单
小A在实现篇第一周就撞上过这个:模型在循环里反复调用同一个工具,一次任务烧掉几百次请求,账单翻了几倍。老Z当时只问了一句:“你的 maxTurns 设了多少?”
症状: 单次任务 token 用量或费用远超预期。 证据: 记录每轮的工具调用次数、输入/输出 token、stop reason 和总轮数。 根因假设: 没有轮次预算,或工具失败信息不足以让模型改变策略,导致它一遍遍重试同一动作。 验证: 核对 runAgent 的 maxTurns;检查工具错误结果是否足够具体(“文件不存在:src/a.ts”比“Error: ENOENT”更可能让模型改路径);对循环的 token 用量加日志,而不是只看总账单。
“只看总账单为什么不行?”
“因为总账单不告诉你钱烧在哪个环节。”老Z说,“同样多花三倍钱,可能是死循环,轮次问题;可能是上下文爆涨,每条消息都重新发送历史;可能是缓存没命中,前缀不稳;也可能是某个工具输出巨大,一条 tool result 顶一百轮。四个根因修法完全不同,总账单却长得一模一样。按轮记录 token,你才能把‘烧钱’定位到‘哪一轮、哪个工具、哪种 stop reason’。”
“容易被低估的还有重试本身。”老Z说,“模型每试一次都发一次完整请求,历史越长,单次越贵。所以失败的轮次比成功的轮次更值得看,很多失控账单的曲线是失败尝试的累计,不是任务本身的成本。给失败轮次单独记账,你会更快看见哪里在烧钱。”
记忆锚点:“轮次上限 + 可读失败”是成本控制的两个把手。没有
maxTurns,再聪明的模型也会在死循环里烧钱;错误信息不具体,模型就只能瞎试。
提示词缓存一直没命中
症状: 账单上 cacheRead 长期为零,或请求延迟没降下来。 证据: 记录发送请求的前缀结构、缓存字段和 provider 的命中报告。 根因假设: 稳定前缀经常变动(比如把时间戳、随机 ID 或项目路径拼进了 system prompt 的开头),导致前缀无法复用。 验证: 把“稳定规则”(安全规则、输出契约)放前缀,“动态内容”(任务、时间)放后缀;核对 provider 文档中缓存的匹配规则和有效期;用两次相同前缀的请求对比用量字段。
小A:“除了前缀顺序,还有哪些常见的‘杀死缓存’写法?我猜是时间戳这类东西?”
“猜对了一个,四样最常见。”老Z说,“一是时间戳或随机数,每次请求都不一样,前缀必废;二是项目路径,同一个 prompt 在不同机器上跑,前缀就分叉了;三是工具定义的顺序,工具列表一变,前缀也跟着变;四是消息格式的微小差异,空白、换行、引号看似无关,哈希上完全不同。缓存命中的前提是‘字节级稳定’,不是‘意思上稳定’。排查时对比两次请求的原始字节,而不是对比‘看起来差不多的 prompt’。”
“边界也要认清:缓存命中是 provider 的服务行为,不是你的代码行为。”老Z说,“它什么时候失效、按什么粒度匹配、对多长的前缀计费,都由 provider 文档说了算。验证方法不是‘我觉得它应该命中’,而是发两次完全相同的请求,看用量字段里 cache 相关的值变没变。把 provider 的缓存当黑盒来测,别当成本地配置来调。”
记忆锚点:缓存按“稳定前缀”命中。前缀一变,后面全废。这不是省钱技巧,是结构问题。
排障心法
小A把这些场景跑了一遍,问老Z:“有没有一个统一的排查顺序?”
老Z给了他四条:
- 先看停止原因:
complete/max_turns/aborted/error,决定了你该往哪条路查。- 先最小复现:不调用工具的最小请求里,问题还在吗?在,是模型层;不在,是工具或装配层。
- 证据留结构:记下 provider、模型 ID、stop reason、调用 ID,别只记“它出错了”。
- 别把取消当成功:
aborted不是空响应,length截断不是完整答案,max_turns不是正常结束。三种状态三种处理。
“把这四条和上面的场景对着用,”老Z说,“大部分 Agent 故障都能定位到层:模型、工具、循环、会话、协议,各归各的。”
“排障有没有‘最后一招’?”
“有,而且最土:写一份复现脚本,把故障固定下来。”老Z说,“现场勘查只能查一次,复现脚本能查无数次,每次改动后跑一遍,看故障还在不在。排障手册里每一项都值得配一个复现脚本,没有脚本的排查结论,过一个月就没人信了。”
“最后提醒一句,排障记录本身要留。”老Z说,“这次你怎么定位的、哪个假设被哪个证据砍掉的,写下来。下次同一个故障再来,你可能直接跳到验证步骤,省下的时间就是你这次记录换来的利息。”
