Skip to content

实现篇故障手册: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,会把一次失败误判成成功。所以证据里要同时记原始错误和 stop reason,两样对不上,就有信息被丢在了适配器里。”

小a:“那‘没有最终回答’这个症状,除了失败和取消,还有别的可能吗?”

“有,而且藏得深:流停了但没人知道。”老z说,“连接没有报错、signal 也没触发,但 delta 事件就是不来了——可能是 provider 静默断开,可能是适配器吞掉了结束事件。这种‘既不成功也不失败’的状态最磨人,因为它不在任何一条错误路径上。排查靠的是心跳:如果一段时间没有收到任何事件,就应该主动判超时,而不是无限等下去。没有超时的等待,不算等待,算挂起。”

小a:“超时和取消,是不是同一个机制?”

“不是,别混。”老z说,“取消是外部主动要停(用户点了取消、上层发了 abort),超时是被动判死(约定时间内没动静)。两者的证据不同——取消有 signal 触发的痕迹,超时只有时间戳。验证时也别混:测取消,用 AbortSignal;测超时,用短超时参数。一个故障是哪种,决定你查代码的哪个分支。”

工具反复调用或写入冲突 ​

症状: 同一工具重复执行,或同一文件修改丢失。 证据: 保留 tool call ID、参数摘要、tool result、轮次和文件路径。 根因假设: 描述/错误结果不足以让模型改策,或并发写入共享资源。 验证: 设置轮次预算;让工具把失败显式返回;对同一路径检查 withFileMutationQueue 的序列化,而不要假定所有 tool call 可安全并发。

老z点出这里的两种根因要分开查:“重复执行”和“写入冲突”看起来像同一个症状,其实根在两层。 重复执行多半出在模型层——失败信息太模糊(比如只返回一个 "Error"),模型不知道为什么失败,只能换个参数再试,结果换来换去都在错的轨道上。写入冲突则出在执行层——多个 tool call 同时落同一文件,没有串行化队列,后写的覆盖先写的。前者要在 tool result 里塞更具体的失败原因,后者要靠 withFileMutationQueue 把同一路径的写串起来。两层不能互相兜底:你把失败信息写得再清楚,也挡不住并发踩踏;你把队列加得再严,也救不了模型瞎试。

小a:“那怎么区分这一夜日志里的是哪一种?”

“看时间戳。”老z说,“重复执行是串行的——同一把工具在同一轮里反复出现,一轮比一轮慢,最后撞上轮次上限;写入冲突是并发的——两个工具几乎同一时刻碰同一个文件,日志里的顺序是随机的。**串行出现是模型层,并发出现是执行层。**分不清时先看是不是并发,因为并发问题更难复现,越早锁定越好。”

“工具反复调用还有一个隐藏诱因,”老z补充,“工具描述本身写得不明确。 如果描述没说清楚‘这个工具什么时候该用、什么时候不该用’,模型会把它当成万金油,一遍遍拿来试。这不算 bug,但症状一模一样——查排障之前先看一眼描述,可能省掉一晚上日志考古。”

“写入冲突那条还要加一层判断,”老z说,“确认一下是‘并发写入’还是‘顺序覆盖’。 并发写入是同一时刻两个 tool call 落盘,靠 withFileMutationQueue 序列化解决;顺序覆盖是模型自己连续两次写同一文件,第二次基于第一次的旧内容——这是模型层的问题,加队列也没用。**时间戳能帮你区分:两个写几乎同时出现是并发,一前一后隔着好几轮是顺序。**两种的修法完全不同,别把队列当成万能药。”

“还有一个最容易被忽视的,”老z说,“同一工具在同一轮里被并行调用。 模型一次请求里可能带出多个 tool call,宿主如果全部并行执行,它们之间如果有共享资源,冲突就会在这一轮内部爆发。排查时看‘同一轮里的多个 tool call 是否共享了文件或状态’——共享了,就要么串行执行,要么给它们划清边界。不是所有并行都是模型要求的,宿主自己可能也在制造并发。”

上下文过长或压缩失败 ​

症状: 请求超过窗口,或摘要后丢失关键约束。 证据: 保存压缩前后消息、token 估计、保留边界和 CompactionError。 根因假设: 大工具输出进入上下文,或摘要模型被中止/失败。 验证: 读取 harness/compaction/compaction.ts 的 prepareCompaction 与 compact;单独测试摘要失败时是否保留原历史并向用户报告。

老z提醒小a,这里的“压缩失败”有两种完全不同的失败模式,不能混着查:一种是“压不掉”(摘要模型崩了或被中止),另一种是“压掉了但压错了”(摘要把关键约束丢了)。 前者是运行时错误,看 CompactionError 就能定位;后者是语义错误,日志干净得很,但模型从下一轮开始就忘了你交代过的硬约束。查后者要对比压缩前后的消息,看保留边界有没有把 system prompt 或早期用户约束一起压没了。

小a:“‘压掉了但压错了’这种,最坏会坏到什么程度?”

“坏到‘看起来正常’。”老z说,“它不会报错、不会中断、请求也能继续发——只是模型每轮都在缺约束的状态下干活。**这种错误最危险,因为没有任何信号提示你它发生了。**唯一的排查办法是保留压缩前的历史(哪怕只保留一份副本),出事后对比‘模型认为的约束’和‘你实际给的约束’。没有压缩前副本,这种错误永远查不出来——所以证据里那栏‘压缩前后消息’不是建议,是必需品。”

“还有一条源头上的预防,”老z说,“先查是什么把上下文撑爆的。 九成情况是一个大工具输出——比如 read 读了一个几万行的文件、list 列了一个巨型目录。压缩是在收拾烂摊子,管住大输出才是别让烂摊子出现。两件事都要做,但前者是治标,后者是治本。”

“token 估计这块也有讲究。”老z说,“‘按字符估算’和‘按 token 计数’是两种预算,不能混着用。 有的实现按字符粗算,有的按 token 精算——前者便宜但可能过估或低估,后者准但要多花一次计数调用。排障时先确认你的预算口径是什么:拿字符预算去套 token 窗口,结论必然对不上。预算口径写在证据里,比‘我觉得差不多’可靠得多。”

“还有一条,”老z说,“压缩的触发点本身也是可调的。 是到窗口的 80% 就压,还是撞墙了才压?阈值提前一点,压缩的压力就小一点;阈值太晚,压缩发生时历史已经很大,摘要负担也大。这个阈值要在‘浪费上下文’和‘摘要丢失风险’之间取平衡——它不是默认值,是需要你按任务特性调的参数。”小a在手册空白处记下了“80%”这个数字。

会话恢复后状态不对 ​

症状: 重启后缺少历史,或 fork 后文件状态与预期不同。 证据: 保存 JSONL 条目、session ID、fork 起点和文件系统状态。 根因假设: 把会话条目误当消息,或把 session fork 误当 Git worktree。 验证: 对照 jsonl-store.ts、session.ts 与 fork.ts;独立检查 Git/工作区隔离机制。

老z补了一条:“JSONL 里写的是消息流,不是‘可执行状态’。 你重启时拿到的是历史消息的回放,但你拿不到‘上一轮工具执行到一半时的内存状态’。所以会话恢复后,如果有未完成的副作用悬着(比如一个 tool call 发了网络请求但结果没回来),这个状态恢复不出来——你得把它当成‘未知’,而不是‘没发生’。把 session fork 误当 Git worktree 也是一个常见错位:fork 复制的是会话历史,不一定复制工作区文件,这两者的隔离边界要分开核。”

小a:“‘当成未知而不是没发生’——这话怎么落到操作上?”

“恢复后先显式盘点一次副作用。”老z说,“启动时把‘可能还挂着的写操作’列出来问一遍:上次的临时文件还在吗?发送过的请求结果回来了吗?然后要么补齐、要么撤销、要么明确标记‘这个分支状态未知,需要人工确认’。最忌讳的是假装无事发生,直接接着跑——那等于让 Agent 在一个它自以为干净的世界上继续动手,而世界上还挂着半截没完成的动作。”

小a:“会话条目的类型那么多,排查时怎么判断哪条该看?”

“按恢复语义分。”老z说,“消息类条目(用户、助手、工具)决定‘模型看到什么’,配置类条目决定‘工具和模型怎么配’,压缩类条目决定‘旧历史还剩多少’。恢复后状态不对,先问一句‘是模型看到的不对,还是装配的不对’——前者查消息类,后者查配置类。把条目类型当成路标,别从头到尾一条条读。”

远程界面与服务端不同步 ​

症状: 进度显示与最终 transcript 不一致,或断线后请求结果未知。 证据: 保存 request ID、snapshot revision、progress、连接关闭原因。 根因假设: 以 progress 累积权威状态,或在未知提交状态下盲目重试。 验证: 以较新 snapshot 重建视图;阅读 ServerSnapshotPublisher 与 requireAttached。不要宣称本快照实现自动重连或 exactly-once。

小a:“进度和 transcript 不一致,这是 UI 的 bug 还是服务端的 bug?”

“先别急着分锅。”老z说,“先问一个问题:‘当前状态’是哪个源算出来的? 如果 UI 拿 progress 累加出状态,而 progress 是增量的、可能丢的,那 UI 算出来的状态天然不可靠——这是以 progress 为权威的错,不是某个渲染函数的 bug。正确的源是较新的 snapshot:它带 revision,是完整状态。判定‘哪个才是权威状态’通常比定位渲染代码更先一步。”

“断线后的行为也要单独核验,”老z补了一句,“断线时如果有一个请求已经发出、结果未知,界面上必须把这个状态显式标出来。‘连接已断开’和‘请求结果未知’是两个不同的提示——前者只说了连接没了,后者说了请求可能没完成。很多断线事故的根因,就是界面只报前者,用户(或上层逻辑)以为‘没结果就是没发生’,结果副作用其实已经执行了。未知要标成未知,不能标成没发生。”

“重新连接这块,还有一条纪律。”老z说,“重连可以重试,但不能盲目重试。 如果你不知道上一次提交是否成功,重试就可能产生重复副作用——比如重复发消息、重复扣费。所以重连前要先把‘上次到底提交成功没有’问清楚;问不清的状态,宁可让人工确认,也不要自动再来一遍。‘重试’是幂等保证的勋章,不是默认配置。”

小a:“那 progress 和 snapshot 到底该信哪个?”

“永远信 snapshot。”老z说,“progress 是‘正在进行’的提示,snapshot 是‘现在是这个状态’的定格。UI 要做的是用 snapshot 定稿、用 progress 做过渡动画——顺序反了,界面就会在断线后把进度条当真相。证据里记 snapshot 的 revision,就是为了断线后能回答‘我看到的到底是第几版的状态’。”小a把这条抄进了排障清单。

协议输入被拒绝 ​

症状: decoder 报 ProtocolValidationError。 证据: 记录 frame 长度、连接阶段和截断后的错误,不记录秘密 payload。 根因假设: 长度超限、CBOR 损坏、schema 不匹配或 EOF 半帧。 验证: 用 ClientMessageDecoder 复现,检查 FrameDecoder、decodeCbor、schema;失败后新建 decoder 和连接状态,而不是继续 push。

老z点了一个细节:“decoder 失败后必须重建,不能继续喂字节。 这是因为协议 decoder 是有状态的——它内部记着‘现在在第几个 frame、长度前缀读了几个字节’。一旦出错,这个状态就不可信了;继续往里 push 只会把损坏状态带进后续解码,错上加错。所以正确的做法是丢弃旧 decoder、新建一个、重置连接状态,而不是觉得‘重试一下就好’。这条对‘半帧’(EOF 时只读到一半)尤其重要——半帧不是错误,是未完成,但 decoder 一旦判定为错误,恢复路径只有重建。”

小a:“四个根因假设里,怎么区分是哪一个?”

“看报错发生的阶段。”老z说,“FrameDecoder 之前是长度层——长度超限、EOF 半帧都在这层;FrameDecoder 到 schema 之间是编码层——CBOR 损坏在这层;schema 校验之后是语义层——消息形状不对在这层。ProtocolValidationError 是在哪一层抛的,日志里应该带得上;先定位阶段,再猜根因,能砍掉一半选项。”

“还有一条安全提醒,”老z说,“证据里不记录 payload 本身。 frame 内容里可能带密钥、带用户数据、带 prompt 全文。排障要记的是‘它多长、坏在哪一层、断了没’,不是‘它里面写了什么’。为了查一个协议 bug 把敏感内容打进日志,等于用一个 bug 换另一个 bug。”

成本失控或意外账单 ​

小a在实现篇第一周就撞上过这个:模型在循环里反复调用同一个工具,一次任务烧掉几百次请求,账单翻了几倍。老z当时只问了一句:"你的 maxTurns 设了多少?"

症状: 单次任务 token 用量或费用远超预期。 证据: 记录每轮的工具调用次数、输入/输出 token、stop reason 和总轮数。 根因假设: 没有轮次预算,或工具失败信息不足以让模型改变策略,导致它一遍遍重试同一动作。 验证: 核对 runAgent 的 maxTurns;检查工具错误结果是否足够具体("文件不存在:src/a.ts"比"Error: ENOENT"更可能让模型改路径);对循环的 token 用量加日志,而不是只看总账单。

小a:“只看总账单为什么不行?”

“因为总账单不告诉你钱烧在哪个环节。”老z说,“同样多花三倍钱,可能是死循环(轮次问题)、可能是上下文爆涨(每条消息都重新发送历史)、可能是缓存没命中(前缀不稳)、也可能是某个工具输出巨大(一条 tool result 顶一百轮)。**四个根因的修法完全不同,但总账单长得一模一样。**按轮记录 token,你才能把‘烧钱’定位到‘哪一轮、哪个工具、哪种 stop reason’。”

“还有一个容易被低估的,”老z说,“工具重试本身也可能烧钱。 模型每试一次都发一次完整请求,历史越长,单次越贵。所以‘失败的轮次’比‘成功的轮次’更值得看——很多失控账单的曲线,是失败尝试的累计,不是任务本身的成本。给失败轮次单独记账,你会更快看见哪里在烧钱。”

记忆锚点:"轮次上限 + 可读失败"是成本控制的两个把手。 没有 maxTurns,再聪明的模型也会在死循环里烧钱;错误信息不具体,模型就只能瞎试。

提示词缓存一直没命中 ​

症状: 账单上 cacheRead 长期为零,或请求延迟没降下来。 证据: 记录发送请求的前缀结构、缓存字段和 provider 的命中报告。 根因假设: 稳定前缀经常变动(比如把时间戳、随机 ID 或项目路径拼进了 system prompt 的开头),导致前缀无法复用。 验证: 把"稳定规则"(安全规则、输出契约)放前缀,"动态内容"(任务、时间)放后缀;核对 provider 文档中缓存的匹配规则和有效期;用两次相同前缀的请求对比用量字段。

小a:“除了前缀顺序,还有哪些常见的‘杀死缓存’写法?”

“四样最常见。”老z说,“一是时间戳或随机数——每次请求都不一样,前缀必废;二是项目路径——同一个 prompt 在不同机器上跑,前缀就分叉了;三是工具定义的顺序——工具列表的顺序一变,前缀也跟着变;四是消息格式的微小差异——比如空白、换行、引号,看似无关,哈希上完全不同。**缓存命中的前提是‘字节级稳定’,不是‘意思上稳定’。**排查时对比两次请求的原始字节,而不是对比‘看起来差不多的 prompt’。”

“还有一条边界要认清,”老z说,“缓存命中是 provider 的服务行为,不是你的代码行为。 它什么时候失效、按什么粒度匹配、对多长的前缀计费,都由 provider 文档说了算。所以验证方法不是‘我觉得它应该命中’,而是‘两次完全相同的请求,看用量字段里 cache 相关的值变没变’。把 provider 的缓存当成黑盒来测,而不是当成本地配置来调。”

记忆锚点:缓存按"稳定前缀"命中。 前缀一变,后面全废——这不是省钱技巧,是结构问题。

排障心法 ​

小a把这些场景跑了一遍,问老z:"有没有一个统一的排查顺序?"

老z给了他四条:

  1. 先看停止原因——complete/max_turns/aborted/error,决定了你该往哪条路查。
  2. 先最小复现——不调用工具的最小请求里,问题还在吗?在,是模型层;不在,是工具或装配层。
  3. 证据留结构——记下 provider、模型 ID、stop reason、调用 ID,而不是只记"它出错了"。
  4. 别把取消当成功——aborted 不是空响应,length 截断不是完整答案,max_turns 不是正常结束。三种状态三种处理。

"把这四条和上面的场景对着用,"老z说,"大部分 Agent 故障都能定位到层——模型、工具、循环、会话、协议,各归各的。"

小a:“排障有没有‘最后一招’?”

“有,而且是最土的一招:写一份复现脚本,把故障固定下来。”老z说,“现场勘查只能查一次,复现脚本能查无数次——每次改动后跑一遍,看故障在不在,这是把排障从‘一次性的灵光’变成‘可迭代的流程’。排障手册里每一项都值得配一个复现脚本;没有脚本的排查结论,过一个月就没人信了。”

“最后提醒一句,”老z说,“**排障记录本身要留。**这次你怎么定位的、哪个假设被哪个证据砍掉的,写下来。下次同一个故障再来,你可能直接跳到验证步骤——那省下的时间,就是你这次记录换来的利息。”