Skip to content

第 19 章 可靠性与故障恢复 ​

本章要解决的问题

第 14~16 章让任务能够跨断线、新会话和子任务延续,第 17、18 章又让 Agent 接入外部能力和网络内容。这两条线在本章汇合:任务运行越久、依赖越多,越容易遇到模型输出中断、网络静默挂起、外部进程失联和成本失控。一次运行顺利,不等于产品能在这些故障下保持正确。

  • 失败后什么时候能重试,什么时候必须停止或纠正?
  • 长任务怎样跨压缩、断线和重启守住目标?
  • 怎样用日志、预算和缓存控制上线风险与成本?

本章按故障影响逐步扩大:先处理一次调用和一轮任务的失败,再处理长任务与后台进程,最后处理依赖隔离、故障证据和成本。任务质量评估与上线交付分别放在第 21、22 章。

19.1 三类常见故障及处理方法 ​

生产级 Agent 的三类高发故障,每类都有标准解法(四个实现均有印证):

① 输出中断:模型生成到一半被截断 ​

长回答撞上 max_tokens,输出戛然而止——补丁写了一半,JSON 工具参数没闭合。这里把一次独立的请求尝试称为 attempt。恢复前先问:这次尝试是否已经产生外部副作用? 没有副作用时可以丢弃整个 attempt 后重采样;有副作用时必须先恢复工具状态,不能把“重试模型”当作回滚。可以按以下顺序处理:

text
第 1 级:完整 attempt 尚未提交且没有副作用 → 丢弃并提高上限重采样
第 2 级:仅纯文本被截断 → 把已完成文本作为显式上下文,请求继续
第 3 级:工具参数/结构化输出被截断 → 不拼接半截 JSON,重新生成完整结构
第 4 级:恢复预算用尽或工具结果未知 → 停止并把原因暴露给用户

dsh 的 step 循环让插件依次处理 agent/request-error 事件——错误事件按插件注册顺序传递,先认领的插件决定恢复动作——因此每个插件都可以注册自己的恢复方法(比如对特定错误码做特殊重试)。还有个反直觉的双重标准:同一产品通常还会把会话导出为训练数据,合成的修补内容会成为污染样本,因此产品模式可以用占位符修补损坏的消息结构,训练数据收集模式则拒绝修补(产品宽容、训练严格)。

pi 选择更保守的执行边界:只有完整 assistant message 结束后才执行工具;若模型服务返回 stopReason = "length",本轮全部工具调用都标记失败,即使流式参数看起来已经能解析也不执行。这会牺牲一次看似可挽救的调用,换来“UI 中的半成品永远不会产生副作用”的简单规则。

② 连接卡死:最危险的失败不是断,是"静默挂起" ​

流式 API 的陷阱:连接建立成功,数据流却停了——水管通着但不出水。SDK 的连接超时管不到这里。解法是增加一个空闲超时监视器(watchdog,俗称看门狗):

text
记录最后一次收到数据的时间戳
每收到一个 chunk → 刷新时间戳
超过 N 秒无数据 → 判定卡死 → 主动 abort → 按错误类型重试、切换备用线路或停止

可推广的原则:每个长连接都要能证明自己仍在收发数据,不能只检查连接是否建立。本章末尾的“自建起步”会把它具体列成设计决定。

③ 状态循环:Agent 陷入重复 ​

表现:反复读同一个文件、反复发同一个工具调用、验证者连续指出相同缺口、上下文溢出后钩子又触发钩子。要分别处理:

  • 轮次上限(第 4 章要点三:循环必须带 i < 20 这类上限)——达到上限后必须停止
  • 重复检测:相邻两轮工具调用完全相同 → 注入提醒或强制中断
  • 钩子死循环防护(一个真实案例):上下文溢出导致 Agent 停止 → 触发“结束时自动提交”钩子 → 钩子调 LLM 写 commit message → 再次溢出 → 再触发钩子……解法:递归深度计数器,当前任务记录已经不完整时不再触发钩子
  • 错误签名去重:对 (错误类型,工具名,规范化参数,验证缺口) 做指纹;相同指纹连续出现且没有新证据,不要继续原方案
  • 四类恢复各有上限:传输重试、参数纠错、验证返工、重复保护不能共用一个模糊的 maxRetries

dsh 的会话日志也解决了另一个容易混淆的问题:崩溃后它不是简单“截到最后一个完整 turn”。已经完整写入的崩溃尾部会保留;恢复器会为已经开始但没有结束的工具、step 和 turn 追加确定的结束事件。尤其要区分:

text
tool request 已记录,tool/start 未记录 → TOOL_NOT_STARTED,若仍需要可重新调度
tool/start 已记录,tool/result 未记录 → TOOL_OUTCOME_UNKNOWN,先查外部状态,禁止盲重试

这两个状态看起来只差一条事件,后续处理却完全相反。

先判断错误是否值得重试 ​

前面的故障分类回答了"错误长什么样";这一节回答"错误来了怎么办"。天真的实现是"所有失败都重试三次"——直到你发现有些错误重试必然再失败,白白烧掉预算;有些错误重试等于重复付费。重试决策要过三问:

text
问 1:这是谁的错?    传输层(网络/服务端)/ 模型行为(截断/坏参数)/ 系统 bug(自己代码)
问 2:重试会变好吗?  瞬态故障(重试可能成功) vs 确定性失败(同样输入必然同样输出)
问 3:重试安全吗?    幂等性(4.5.3)——LLM 调用安全,带副作用的工具调用不一定

grok 的重试实现是三问答案的范本——它给每类错误单独标了 is_retryable,而不是一个全局策略:

错误类型可重试?理由(源码注释原意)
空响应 / 流中断 / 流错误✅瞬态,预算默认 14 次,指数退避+抖动,含 HTTP/2→1.1 客户端重建
max_tokens 截断❌ 刻意不重试"重采样同样的 max_tokens 只会再截断"——先修上限,再谈重试
响应解析失败(Serialization)❌确定性失败,防止烧完 14 次预算反复撞同一堵墙
推理死循环(DoomLoop)🔄 换法重采样重试无意义,换参数重采样才有意义
413/图片超限🔄 减少输入后重试剥掉图片再试(改输入,不是原样重试)

注意表格里的三种做法:原样重试(瞬态故障)、修复后重试(先修 max_tokens)、减少输入后重试(剥图)——后两种会先改变输入,这样下一次才有可能变好。dsh 的错误恢复则交给 19.1① 介绍的插件链处理。

生产实现还需要三个教程常漏的限制:

  • 尊重服务端 Retry-After,退避等待必须可被 AbortSignal 取消
  • SDK、provider、Agent loop、MCP 各自重试时,共享一个父级截止时间和成本预算,避免重试次数相乘
  • 每个 attempt 有独立 ID;失败 attempt 的流式文本只能标记为失败或替换,不能和下一次输出拼成一条消息

根据错误类型决定是否重试

text
429/部分 5xx/断流   → 结合服务端提示和本地错误分类;允许时退避重试,并受总截止时间与预算限制
连接/空闲超时       → 先分清发生阶段和副作用;只有被判为瞬态且可安全重放时才重试
max_tokens 截断     → 不用相同输入和上限原样重试;调整上限/输入或采用安全的续写方式,并记录新 attempt
解析/参数校验失败    → 不重试;错误回传模型自纠(4.4.5)
认证/配额/余额      → 不重试;立即上报用户(fail fast)
工具执行失败        → 回传模型;仅幂等工具可自动重试(4.5.3)
工具结果未知        → 不重试;先查询外部状态或请求用户确认

重试发生在哪一层,决定它会不会被放大 ​

一次“请模型继续完成任务”可能同时经过四层重试:

text
SDK/传输层    重连、重建 HTTP 客户端、重放同一次模型请求
Agent 请求层  根据错误码重新请求 assistant message
工具层        超时后重新调用文件、Shell、网络或业务工具
任务层        验证失败后重跑一轮计划,甚至重新创建子 Agent

如果每层都设 3 次,最坏情况不是 3 次,而可能是 3 × 3 × 3 × 3。所以每层可以有自己的局部计数器,但都必须消费同一个总截止时间、费用和 attempt 预算。日志还要写清 retryLayer,否则排查时只会看到“第 2 次重试”,却不知道是 HTTP 重连还是整个任务重做。

四个实现的 provider 重试策略已经能看出明显取舍:

默认重试对象特殊恢复明确不重试或停止
codex普通服务错误与断流,指数退避并优先采用服务端等待时间WebSocket 普通重试耗尽后可切 HTTPS;特定长连接会进入单独的持续重连模式普通重试达到 provider 上限;持续重连只用于受控会话,不是所有请求
grok-build429、部分 5xx、传输/流中断、空响应首次普通重试重建 HTTP/1.1 客户端;图片过大时移除图片后只重试一次400/401/403/404/408/422、TLS 525/526、认证配置、序列化、max-token 截断
dsh默认最多 5 次:空响应、限流、服务端、超时、传输错误重试计划写进会话日志,进程恢复后延续原计数;可配置持续恢复模式普通模式预算用尽;服务端等待时间超过本地上限时不盲等
pi408/409/429/5xx 与传输错误;Agent 层另有有限次 assistant 重试每次用新请求,禁用 SDK 自带重复重试;退避可取消服务端要求等待超过默认 60 秒时直接上抛;上下文溢出走压缩而非普通重试

codex:普通重试与“连接恢复”分开记账 ​

codex 没有把所有失败塞进同一个 retryCount。普通重试受 provider 配置限制,退避带少量随机抖动;服务端给出可用等待时间时优先采用。WebSocket 路径普通重试耗尽后,可以切换到 HTTPS 并重置普通计数,避免一个连接协议的问题直接结束任务。

另有一条面向持续会话的连接恢复路径:等待从 5 秒起翻倍,最多 60 秒,并持续向用户显示重连状态。它只在特定采样会话和 provider 条件下启用,不应概括成“codex 所有请求都会无限重试”。普通失败次数和连接恢复次数分开,才能在 UI 里解释系统到底在做什么。

grok-build:先分类动作,再由执行层真正等待 ​

grok-build 把错误分类写成近似纯函数,输出 retry / do-not-retry / modify-and-retry 等动作,网络执行层再负责等待和重建客户端。这让分类可以用表格测试,不需要真的睡几十秒。

几个细节很有代表性:

  • x-should-retry: false 可以否决重试;true 只是一条建议,不会强迫系统重放明显不安全的错误。
  • 普通退避大致为 2、4、8、16 秒,之后封顶在约 30 秒并加入随机抖动;默认最多 15 个 attempt。429 更早进入专用处理,并完整尊重合理的 Retry-After。
  • 第一次普通重试会重建仅用 HTTP/1.1 的客户端,规避已经损坏的 HTTP/2 连接池;后续仍失败才继续普通退避。
  • 图片处理导致 413 时,可以删除图片后重试一次。这里下一次输入已经不同,所以它属于“降级恢复”,不是原样重放。
  • 推理死循环的恢复会重新采样,而不是长时间等待;等待不能改变同一随机过程的坏轨迹。

dsh:连“准备重试”也写进会话 ​

dsh 在真正睡眠前追加一条 retry 事件,等待结束、准备再次请求时再追加 retry-started。事件带稳定的策略键和 attempt 标识;崩溃恢复后,会从最后一条匹配事件继续计数,而不是把进程重启误当成“重试次数清零”。

它还允许持续恢复模式:只要没有取消或销毁,就不断尝试。但“无限”只描述次数,不代表无限占用生命周期;插件被卸载时会取消并等待所有恢复任务停稳。服务端给出的等待时间只有在正数且不超过本地上限时才接受,避免一个异常响应让 Agent 静默挂几个小时。

pi:provider 重试和 Agent 重试保持两层 ​

pi 的 provider 工具负责 HTTP 状态、响应头和可取消退避;Agent 循环再决定是否重试整个 assistant 调用,并通过 scheduled/start/end 回调更新界面。上下文溢出不会落入普通网络重试,而是先压缩上下文再重试一次。

它主动关闭底层 SDK 自带重试,每个 attempt 都发起新请求,避免“SDK 已经重试两次、外层又重试三次”却无法观测。服务端要求等待超过默认 60 秒时直接返回错误,也是一种产品取舍:让上层和用户决定是否继续,比一个不可见的长等待更可控。

重试、熔断和循环保护不是一回事 ​

重试与熔断器的状态和职责

  • 重试解决一次请求的瞬态失败:等一下,再试同一件事。
  • 熔断解决下游整体持续故障:先拒绝新请求,让服务和自身资源恢复。
  • 循环保护解决 Agent 自己重复同一错误:即使下游健康,也要强制换方案或停下。

熔断器通常有三个状态:Closed 正常放行并统计失败率;达到最小样本数且错误率超过阈值后进入 Open,直接拒绝;冷却期后进入 HalfOpen,只允许少量探测,成功则关闭,失败则重新打开。

grok-build 仓库提供了可复用的三态熔断组件,还处理了一个教程很少提的取消问题:HalfOpen 的探测拿到名额后若被取消,名额可能永远不归还,熔断器就卡死。它给探测名额设置租约,过期后允许新请求回收。代价是极端情况下旧的慢探测与新探测会短暂重叠——这里明确选择“偶尔多一个探测”而不是“永远无法恢复”。

不能因为仓库里出现熔断器组件,就声称所有模型请求都已经接入它;接线位置和覆盖范围仍要逐条确认。其余三个样本的模型主路径里,本轮源码检查没有发现同等的通用三态 provider 熔断器;它们主要依靠有限重试、连接恢复、总预算和循环保护。没有熔断器不一定是缺陷:单用户 CLI 的并发量低,立即暴露错误可能比维护全局故障窗口更简单;多租户服务端才更需要共享熔断状态。

工具超时为什么不能直接 Promise.race ​

一种常见错误是:超时后让 Promise.race 返回 error,随后立刻释放文件锁。真实工具 Promise 仍可能在后台继续写入,下一次重试又开始写同一个资源,最终出现两个“已经超时”的调用交错完成。

dsh 的超时策略会发出取消信号,但仍等待真实工具到达稳定结束,再把结果映射成结构化 timeout;pi 的文件写队列也不会在 abort 事件到达时提前 reject,而是等当前文件操作 settle 后才释放队列。两者解决的是同一个问题:对调用者超时,不代表资源层已经停稳。 对不支持协作取消的外部进程,要终止整个进程组或交给容器回收,不能只丢弃 JavaScript/Rust future。

19.2 Agent 如何根据失败自动改正 ​

“纠错”不是一条 while (!done)。生产系统至少要分别处理四类情况,它们的触发条件、允许动作和停止条件都不同:

情况何时触发正确动作主要风险
网络请求失败429、5xx、断流、空闲超时同请求退避重试或切备用线路SDK、provider、Agent 各自重试,次数相乘
参数或方案错误schema 错、工具参数错、写入版本已过期把结构化错误回给模型,重读后修参数或方案把确定性失败当瞬态故障
验证后返工编译、测试、e2e 或独立审查不通过带证据返工,必要时重新规划修改测试来骗过验证
重复保护相同调用、错误或缺口反复出现换策略、暂停、阻塞或请求人介入“继续努力”式烧预算

统一控制流程可以写成:

text
发现问题
  → 判断错误类型、能否重试、是否已经产生副作用
  → 选择动作(retry / repair / reread / replan / rollback / inspect / escalate)
  → 保存证据(command, exitCode, diff, artifact, verifierVerdict)
  → 扣减 attempt / round / token / time / cost 预算
  → 进入 completed / blocked / paused / budget_limited / infra_paused

每次纠错都要留下稳定的 attemptId、错误指纹和新证据。若下一轮没有新证据、错误指纹也没变,继续原动作没有意义。

优先使用能自动检查的证据 ​

text
弱 ← 类型检查 < lint < 单元测试 < 集成/e2e < 独立审查者 < 人 → 强
      (快、确定)                         (慢、贵、接近真实目标)

同一个模型“再想一遍”容易复现原盲点;编译器、测试和真实产物不关心模型的自信。投入顺序应是:先建立可自动执行的检查,再增加审查模型和提示词。

独立审查有三种常见做法:

  • 同一 Agent 自审:最便宜,但作者与审查者共享上下文和盲点
  • 另开上下文的审查 Agent:只拿目标、diff 和验证证据,不继承生成过程中的自我辩护
  • 只读的审查 Agent:除另开上下文外,再用只读沙箱和权限限制,禁止它顺手改产物。codex 的 guardian 是这种做法在危险操作和审批审查上的例子,但它不负责判断 Goal(跨多轮保存的长期目标)是否完成

grok 的 Goal 新路径更进一步:实现者完成一轮后,由一个评估者和多个“怀疑者”重新检查目标,NotAchieved(审查结论:目标未达成)的 gaps 会交给下一轮继续修;相同 gap 指纹连续出现则进入 NoProgressPaused(没有进展,暂停)。这些名称来自源码,但不要把机制写成“任何 Grok 任务都必须通过审查 Agent”:

  • 审查 Agent 受 Goal 模式和配置开关控制;关闭分类器时存在 CompletedWithoutClassifier(不经分类器检查就标记完成)的分支
  • 验证基础设施故障时,存在 FailOpenAchieved(验证设施挂了仍标记目标完成)的分支

所以生产设计必须明确选择:验证不可用时停止并等待恢复,还是带着风险标记继续。高风险代码修改建议进入 infra_paused,而不是把“验证器挂了”解释为“目标完成了”。

provider、Agent loop 和审查 Agent 必须共享总预算 ​

假设 provider 可重试 5 次、Agent 最多 64 轮、每轮 3 个审查 Agent,如果它们只管自己的上限,最坏调用量不是 64,而是成百上千。建议从 Goal 总预算中逐级分配:

text
GoalBudget(token/time/cost/deadline)
├─ TurnBudget(maxTurns)
│  ├─ ProviderBudget(maxAttempts)
│  ├─ ToolBudget(maxCalls/perToolTimeout)
│  └─ VerificationBudget(maxRounds/maxReviewers)
└─ BackgroundBudget(maxJobs/maxRuntime/maxOutput)

子预算只能消费父预算,不能自行续杯。达到不同边界时保留不同停止状态:

状态含义能否自动继续
paused用户主动暂停否,等待用户 resume
blocked缺少输入/权限/外部条件否,条件变化后再评估
infra_pausedprovider、验证器或执行设施故障否,设施恢复后显式续跑
usage_limited账号/速率限制否,限制解除后再启动
budget_limited本 Goal 的 token/time/cost 用尽否,需提高预算或缩小目标
complete验收条件已有证据最终状态,不会自动继续

不要把"本轮停止"写成"目标完成",也不要用一个 error 覆盖所有可恢复状态。

子预算粒度是调参负担:起步两层(Goal + attempt)即可,验证预算在引入审查 Agent 后再拆。

保存完成依据:complete 状态本身不是证据 ​

status = complete 只是一次状态变更。可靠实现还应保存一份完成依据,把完成结论绑定到目标版本和验证证据。字段不必照抄,但要能回答四个追问:

追问建议记录(示意)
验证的是哪个目标版本goalId + goalRevision——目标被编辑后 revision 改变,旧完成依据自动失效
跑了什么命令清单(命令 + 退出码 + 输出引用)
看了什么产物逐条 requirement 的裁决(pass/waived + 证据引用)、reviewedDiff、artifacts
谁做了最终判定检查者的类型与身份:固定规则 / agent / human

这份记录不是为了堆字段,而是把“完成”从一句模型自述,变成可以检查、并会随目标版本变化而失效的一组证据。

修复后怎样一步步证明故障已经消失 ​

text
第 1 步  复现优先
         修改前先得到稳定失败的测试/脚本/输入,记录原始现象。
第 2 步  证明原故障消失
         同一复现路径在修复后通过;flaky 场景要重复运行并记录次数。
第 3 步  按影响范围扩大回归测试
         类型检查/lint → 受影响模块测试 → 按改动范围选择全量/e2e。
第 4 步  保存证据
         保存命令、退出码、关键输出、diff/产物引用和目标 revision。

会话事件日志能保存工具调用和结果,但证据不会因此“天然可靠”:还要保证关键结果确实写入长期保存的日志、超长输出有稳定引用、系统能从这些记录重建状态,并且证据绑定正确的目标版本。

防止"把测试改绿" ​

模型可能通过削弱断言、加 skip、删除失败用例来获得绿色结果。防御清单:

text
① 测试 diff 必审:新增断言通常合理,删除/放宽断言必须解释
② 复现用例不可撤:修改前失败的用例在最终变更中仍存在且启用
③ CI 统计新增 skip/xfail,超过基线直接失败
④ 测试目录可设为敏感路径,高风险任务需人工批准
⑤ 保留真实产物通道:例如真正渲染页面/文档后检查,而不只测生成代码
⑥ 审查 Agent 只读:不能一边判卷一边改答案

最后,失败的任务记录本身也是改进 Agent 的依据。按错误指纹统计参数或方案修正次数、审查 Agent 的拒绝原因、重复保护触发点和人工改写率,才能知道该改工具、约束还是提示词。

19.3 长任务如何持续记住目标 ​

19.1、19.2 处理的主要是一次调用或一个当前回合怎样从失败中恢复。任务跨越很多回合后,即使每次局部恢复都正确,全局目标仍可能丢失。长任务不只会"忘记目标",还会发生状态覆盖、自动续跑旧目标、预算失控和恢复后重复副作用。第一步是把四个经常混用的对象拆开:

在保存 Plan 之前,先决定这项任务是否值得进入正式规划流程。计划不是越多越安全;对一个单文件文案修正写十步计划,只会制造陈旧状态。可以这样判断:

任务形态推荐流程为什么
单文件、边界明确、可快速回滚直接探索 → 修改 → 验证计划成本可能高于任务本身
跨模块、需求仍有歧义、会改变 API/状态模型先澄清 → 形成计划文档 → 再执行需要先固定边界、依赖与验收
架构迁移、数据变更或难以撤销设计文档 → 人工批准 → 分阶段保存恢复信息不能让模型边探索边扩大影响面

前端任务的计划文档(Plan artifact)不应只列文件名,至少写清页面入口、组件/store/API 边界、loading/empty/error 状态、兼容面和验证方式。目标明确但不可自动验证的任务,速度会受人工审查限制;目标模糊但自动验证很强更危险——Agent 会高效地把错误目标做完。当目标与验收都不清楚时,正确动作是补需求,不是增加轮数。

对象回答的问题保存多久能否作为执行依据
Goal最终要达到什么、验收条件是什么跨 turn、压缩和进程重启是
Plan mode当前是否只允许规划、是否禁止执行/编辑通常是当前会话模式是(控制策略)
计划文档(Plan artifact)具体准备怎么做,依赖与验证步骤是什么可版本化、可审查是(执行依据)
Todo/progress现在做到哪一步,接下来做什么高频更新,可由 Goal/Plan 重建否,是根据目标和计划算出的临时状态

update_plan、todo_write 不是 Goal。codex 的 update_plan 约束同一时刻至多一个 in_progress;dsh 的 todo 是写入会话的整表快照,但下一次 turn/start 会清空这份临时进度。它们适合保持当前焦点,不能独自承担跨重启目标。

三家的 Goal 解法,以及 pi 的边界 ​

codexgrok-builddsh
持久状态State DB 中的线程级 Goal 记录独立快照文件中的 Goal 协调状态会话日志中的 Goal 变更全量事件
状态Active/Paused/Blocked/UsageLimited/BudgetLimited/CompleteActive、多种 paused、Blocked/BudgetLimited/Complete,另有 Planning/Executing phaseactive/paused/blocked/complete
预算token budget + token/time usagetoken budget、worker/verify 轮次、分类器调用上限Goal 最大轮数上限 + 已启动轮数计数
并发保护Goal 状态锁 + 预期目标 ID 校验,避免外部更新与空闲时的自动续跑请求穿插互斥锁让 Goal 更新依次执行;重启时 Planning/Executing 回到 Idle,Active 改为用户暂停,并清空子 Agent 记录id + revision 的 CAS,即仅版本匹配时才更新的原子写;旧引用被拒绝并得到版本过期错误
进程重启后Active Goal 恢复后重新标记 active,空闲时可自动续跑Active 恢复为用户暂停,Planning/Executing 归 Idle持久记录可保持 active,但进程内执行授权在会话启动时强制解除,不能自动恢复执行

最后一行体现了一个关键取舍:codex 倾向于恢复后继续追目标;grok 与 dsh 恢复已经保存的状态,但收回自动执行权。两种都合理,但教程必须把它写成产品决策,而不是把“会话恢复”自动等同于“任务继续”。

pi 核心没有独立的 Goal 状态机、预算续跑协议或后台目标恢复。它保存会话分支与压缩状态,但“重新打开会话”仍只是恢复可见历史,不代表自动继续长期任务。需要 Goal 时应由扩展或外层控制面实现,并把执行授权与会话恢复分开。

保存了目标状态,不等于允许继续执行 ​

推荐单独保存一份带过期时间的执行授权,记录它允许执行哪个 goalId + goalRevision、由哪个当前进程或 runner 持有,以及一个每次恢复都递增的执行授权版本号 executionEpoch。恢复后只接受新版本号,旧进程的监视任务和自动续跑请求全部作废。

恢复长任务分两步:

  1. 从日志/数据库重建 Goal、Plan、证据、预算和未完成工作
  2. 重新取得执行授权,再决定是否发起下一 turn

第二步至少检查:Goal 的版本号 revision 是否仍匹配、用户是否追加或修正了要求、预算是否充足、上个工具的结果是否未知、工作区/模型/权限是否变化。没有执行授权时,active 只表示目标尚未完成,不表示当前进程可以执行有副作用的动作。

用户追加或修正要求时也必须检查并发版本。用户编辑目标后 revision 递增;已排队的自动续跑请求带着旧 revision,启动前发现不匹配就丢弃。

dsh 用 GoalRef 的 CAS(只在版本未变时更新)明确实现了这条规则。codex 用状态锁、预期 goal id 和推迟自动续跑,避免外部更新与空闲续跑交错。若自建多进程服务,还需要数据库 CAS 或唯一的执行授权记录,只有进程内互斥锁还不够。

长任务要保存哪些恢复信息 ​

text
created → planning → running
                    ↘ waiting_tool / waiting_user
                    ↘ paused / blocked / infra_paused
                    ↘ usage_limited / budget_limited
                    ↘ completed / failed / cancelled

不要只保存一个 currentStep。一份可用于恢复长任务的记录至少应包含:

text
goalId + goalRevision + objective + acceptanceCriteria
planArtifactRef + planVersion + activeStep
completed/pending/blocked items
workspace baseline + changed files
pending jobs + outcome-unknown tools
latest verification evidence + proposed completion proof
token/time/cost/round budgets
last safe boundary + next action

恢复记录要在状态完整的节点写入:发 LLM 请求前、执行有副作用工具前、工具结果记入可信日志后、压缩完成后、turn 结束时。它不是另一份随意生成的摘要;字段应直接来自可信记录,并带 Goal revision。

压缩摘要不能决定 Goal 是否完成 ​

压缩摘要可以告诉模型"目前发生了什么",不能决定 Goal 是否完成。正确关系是:

text
持久保存的 Goal/Plan/Evidence ──按需读取──> 每轮动态注入
会话事件 ──有损压缩──> 模型历史摘要

压缩前后都从 Goal 服务重新读取 objective、验收条件、当前阶段、预算和下一步;摘要只引用 goalRevision,不要复制一份以后可能过期的目标。dsh 的 token-meter 会提示上下文还剩多少空间,但"何时压缩"与"是否继续 Goal"仍是两个决定。

分钟级任务可能一个 plan 就够;小时级任务至少需要持久保存的 Goal、恢复记录、明确的执行授权、逐级分配的预算和 19.2 的完成证据。

19.4 后台任务:活着不等于可恢复 ​

第 8 章讨论了前后台执行模型;长任务还要面对Agent 重启后的恢复:内存里的 Promise、回调和 AbortController 都没了,外部进程却可能仍在运行。

一个 job 至少要保存以下信息:

  • jobId、所属会话和轮次(ownerSessionId + ownerTurnId)
  • 命令指纹与进程标识
  • 状态(starting / running / stopping / completed / failed / orphaned)
  • 输出读取位置(outputOffset)与溢写文件路径
  • 起止时间与退出码

只有 PID 不够:操作系统会复用 PID,至少要同时校验启动时间/进程组身份;容器或远程 runner 则应使用平台提供的 execution id。

进程重启后先核对记录和实际进程 ​

持久记录外部现实恢复动作
running同一进程/runner 仍在重新 attach,从 outputOffset 补读
running进程已不存在标记 orphaned,不得伪造 exit code
stopping进程仍在继续 TERM→KILL 升级并等待整棵进程树退出
completedUI/模型没收到结果重放结果,不重新执行命令
没有 start 记录外部也没有执行证据若仍需要,可以重新调度
start 已记录,结果未知外部状态无法查询标记结果未知,请求检查或确认

dsh 的本地 subprocess 维护整棵进程树,输出通过单调 nextOffset 续读;当内存窗口已经滑走时返回 lossy。单路输出未超过 spill 上限时会提供文件路径;本地 bash/pwsh 的默认上限是 64 MiB,超过后删除 spill 文件,只保留内存尾部。

这能让当前进程持续读取子进程状态,不代表 daemon 重启后仍能重新 attach,也不保证任意大小的全文都可找回。产品能力表必须单独说明是否支持重启后恢复,以及能保留多少输出。

grok 的后台任务记录 owner_session_id,可以按 owner 列表、转移或取消任务;它还专门处理 wait 与 completion 同时发生时的通知丢失:只有真正拿到完成结果的 waiter 才能阻止自动唤醒,被取消或掉线的 waiter 不能让完成通知永久消失。这个时序问题值得自建实现直接照着测:

text
等待者注册 → task 恰好完成 → 等待者被取消或连接断开
期望:结果要么由等待者收到,要么写入一条重启后仍可读取的唤醒事件;不能两边都没有

取消必须明确目标 ​

cancel() 到底取消什么要写进 API,不要靠调用位置猜:

取消目标典型行为
attempt只取消当前 provider 请求,不结束 turn
tool-call终止该工具及其子进程树,记录 cancelled result
turn取消未启动工具和当前前台工作;后台 job 是否保留由策略决定
job按 jobId + ownerSessionId 终止一个后台任务
goaldisarm 自动续跑,并按策略取消该 Goal 拥有的 job/子 Agent
session停止该会话全部前台工作;不能误杀其他会话的共享任务

取消请求应幂等,迟到的完成事件仍要写入记录,但不能把已取消 Goal 自动唤醒。后台任务的实时输出与浏览器重连细节放到第 20 章。

19.5 如何提速,又不让一个失败拖垮整轮 ​

19.1~19.4 先保证失败可恢复、长任务不丢目标。在这个正确性底线上,才能讨论并发和提前执行;否则提速只会让副作用和失败扩散得更快。第 5 章的 5.3、第 4 章的 4.5 和第 9 章的 9.7 已分别介绍工具执行、并行和沙箱中的相关问题,这里列出三项相互影响的选择:

执行时机:流式输出下,"何时开始执行工具"各家选择不同——codex 在单个输出项完成时即可启动执行(不用等整条消息),dsh 和 pi 则等整个流组合成完整 assistant 消息后才执行。pi 遇到 length 截断还会让本轮全部调用失败。前者降低等待时间,后者更容易保证半截参数永不产生副作用。

并行执行:彼此独立的调用可以同时运行。dsh 要求每个工具通过 isConcurrencySafe() 声明是否允许并发;pi 默认并行,但同批只要有一个工具声明 executionMode: "sequential",整批就串行,同一真实文件路径的内部修改还会排队。结果仍按模型给出的顺序提交,保证上下文稳定。abort 时给未启动的调用补一条合成错误结果,这样从日志恢复时消息结构仍然完整。

失败彼此隔离:独立调用互不中止,错误作为该调用自己的结果回传,不上抛终止整个 turn(第 9 章同样采用这条原则)。如果希望前一个调用失败时自动取消依赖它的调用,就必须显式记录调用间的依赖;四家的实现都以互不连坐为主,不会自动推断依赖图。

19.6 可观测的三条记录流与故障注入回归 ​

故障处理与长任务管理解决的是 Agent 跑得对不对;要持续确认这一点,还需要两类设施:分清用途的记录流,和主动制造故障的回归测试。

了解系统正在发生什么要靠日志、指标和调用追踪。这些数据不能和会话恢复记录混为一谈,至少要分别记录三类:

text
会话可信记录  模型消息、工具调用/结果、Goal 变化;用于恢复与一般审计
运行指标      延迟、token、缓存命中、错误率;允许采样和丢失
安全审计记录  审批、提权、敏感路径/网络访问;使用独立留存策略

codex 分别写 rollout 与 otel/analytics;grok 把 updates.jsonl 作为会话的可信记录,events.jsonl 只尽力写入,失败可以丢弃;dsh 也把会话事件与根据事件计算 token 用量的 token-meter 分开。运行指标写失败不应阻断 Agent;会话可信记录写入失败时,继续执行有副作用的动作之前必须停止。

最小字段也不只是一行 token 账单。三条流都应带可关联的 sessionId/turnId/stepId/attemptId/toolCallId/jobId/goalId+revision,再记录模型、耗时、输入/输出/缓存 token、重试分类、停止原因和证据引用。这样才能从"一次调用贵"追到"哪个 Goal 的哪次纠错烧掉了预算"。

一次任务怎样追到每个模型和工具步骤? ​

一次任务使用一个稳定的任务追踪编号(traceId);每个 turn、模型请求、检索、工具、后台 job 和子 Agent 再记录自己的步骤编号与父步骤编号。步骤只保存诊断所需的时长、状态、token/成本、错误分类和脱敏后的证据引用,不要默认复制全部 prompt、源码和工具输出。

错误、安全事件和高成本任务应完整保留必要记录;普通成功任务可以按策略抽样。跨子 Agent 传递同一个任务追踪编号,才能知道总成本和失败来自哪一支。运行追踪用于诊断和评估,不是第 14 章的会话事实来源;即使指标系统丢数据,也不能据此重新执行工具。

pi 的 telemetry 设计把这条边界写进接口:调用方显式传入 TelemetryContext,通过回调产生类型化 span,没有全局隐式上下文或默认 exporter,并提供 no-op、内存适配器与一致性测试。诊断数据不能改变业务状态,也不应默认复制 prompt、工具参数和文件内容。可观测性失效时任务仍应按原语义运行。

用主动制造故障来测试恢复能力 ​

可靠性不能只靠线上日志。准备一个输出固定的假模型服务、一个能统计执行次数的假工具(分别模拟可安全重复执行与不可安全重复执行),然后在每个写入持久记录的前后主动崩溃,形成三组回归测试:

套件注入场景必须断言
恢复tool/start 前后、result 写入磁盘前后、compaction 中途、进程重启transcript 结构合法;已保存的结果只读取记录、不重新执行;未知结果不盲目重试;有外部影响的操作不重复执行
自动改正429、坏参数、相同测试失败、审查 Agent 不可用、重复 gap四类情况采用正确处理方式;共享截止时间生效;最终停止原因准确;不会把基础设施故障标成 complete
长任务和后台 job两次压缩后重启、Goal 被人工修改、wait/completion 同时发生、进程消失继续使用最新版 Goal/验收条件;旧 revision 的自动续跑请求被丢弃;只唤醒一次;失联进程标为 orphaned
模型服务适配纯文本、单工具、多个工具乱序完成、文本与工具增量交错、参数只收到一半就断线完成事件和 usage 只提交一次;工具按 ID 配对;半截参数不执行;失败 attempt 不与重试输出拼接

起步时先设三个最低通过条件:

text
恢复正确性:所有 crash point 都通过,非幂等副作用重复数 = 0
重复错误停止率:同一错误达到次数上限后应 100% 停止或换方法,不得继续原动作
长任务连续性:经历 N 次压缩 + 重启后,goalRevision/验收条件/预算逐字段一致

真实模型只用于补充评估,不替代这套确定性回归;否则一次模型波动会掩盖真正的状态机 bug。

19.7 把缓存命中纳入生产监控 ​

第 2 章已经说明前缀缓存的原理、失效原因和常规优化方法,第 13 章只补充压缩对缓存的影响。上线阶段不再重复这些设计规则,而要确认它们在真实流量里是否生效。

至少监控三项结果:

指标观察方式发现异常后先查什么
缓存输入占比缓存读取 token / 总输入 token系统说明、项目指令、工具清单或历史是否改变
首个输出耗时按模型和上下文长度分组比较缓存命中是否下降,模型服务或网络是否变慢
每个成功任务的费用总费用 / 成功任务数输入是否增长、压缩是否过频、重试是否增加

只看全站平均值容易掩盖问题。每次请求还应记录模型、提示词版本、工具清单版本、项目指令版本和压缩版本。命中率下降时,才能把变化追到一次具体发布,而不是猜测是哪段文字变了。

发布前用同一组代表任务比较新旧版本,同时检查任务成功率、缓存输入占比、首个输出耗时和每个成功任务的费用。服务商的最低缓存长度、有效期和路由策略会变化,不要把某个固定命中率写成所有模型的统一门禁;应使用当前线上基线和业务预算设阈值。

恢复会话或创建分支后第一次请求没有命中,也不一定是程序错误。只有模型、配置和重建出的前缀都一致时,它才保留命中机会;服务端仍可能因为缓存过期或路由变化而不复用。生产监控要把“请求前缀变化”和“服务端没有命中”分开记录。

自建起步:带看门狗的流式调用,七个设计决定 ​

把 19.1 的三类故障和重试判断落成一个可靠的模型调用层,不需要多少代码,但有七个必须显式做出的设计决定。下面以 OpenAI Chat Completions 风格的 SSE 适配器为例;Responses API、Anthropic content blocks 和其他服务的事件名、工具增量字段与完成条件不同,要在适配层分别实现。

① 连接超时与空闲看门狗是两回事。 连接建立成功但数据流停了(水管通着不出水),SDK 的连接超时管不到——必须独立记录"最后一次收到数据的时间戳",每收到一个 chunk 刷新,超过阈值(如 30s)判定卡死,主动 abort 后按可重试处理。

② 先收完整一个 attempt,再提交给调用方。 流式增量只进 UI,不进最终结果。必须按当前 provider 的协议等到响应、item 或 content block 完整结束,并确认没有拒绝、截断或流错误,再把 assistant 消息(文本 + 工具调用)作为最终返回。

Chat Completions 的 finish_reason 只是判断依据之一,不是所有 API 的通用完成信号。否则截断的流会被当成成功、半截工具参数会进入执行器——这对应 4.4.2 的调用侧检查。

③ SSE 帧与 UTF-8 都要跨 chunk 处理。 一块网络数据可能包含半帧、一帧或十帧;解析器保留未闭合尾巴直到空行;解码器复用同一个 TextDecoder 并带 { stream: true },否则跨块的中文字符会变乱码(第 20 章展开)。

④ 工具参数按调用标识拼接,流结束后才解析。 在 Chat Completions 适配器里,tool_calls 增量通常按 index 对应并拼接 arguments 字符串;其他 API 可能用 item ID/content block index。只在对应调用完整结束后整体解析——这对应 4.4.3 的“收完整后再解析”规则。

⑤ 按错误类别和服务端提示决定是否重试。 不能写成“所有 429/5xx 都重试、所有其他 4xx 都不重试”。服务可能用响应头明确禁止重试;某些 5xx 是证书或配置等确定性错误;413 或图片处理错误则可能在减少输入后成功。

认证、配额和坏请求通常先交给上层处理。真正的瞬态错误才走指数退避 + 抖动,并按服务规则处理 Retry-After。grok 的实现还专门限制连续 429 次数,并排除 Cloudflare 525/526。

⑥ 检查当前协议的截断信号。 在 Chat Completions 中,finish_reason == "length" 表示 token 上限截断;其他 API 的字段不同。相同输入和上限原样重采样通常还会截断,应先提高上限、减少输入,或只在纯文本且边界明确时带着已完成内容续写。工具参数和结构化输出不要拼半截 JSON,应创建新 attempt重新生成完整结构。

⑦ 重试不拼接输出。 每次尝试有独立 attemptId;失败的 partial 只进 UI(标记中断),绝不能和下一次的输出拼成一条消息(4.5.4)。

对照 19.1,这些检查分别处理不同故障:

  • 空闲超时监视器处理卡死
  • 错误分类处理网络和限流错误
  • provider 的完成与截断信号防止把不完整输出当成功
  • 独立的 attempt 记录防止把两次重试拼在一起
  • 轮次上限(第 4 章)处理状态循环

想读产品级实现,可对照 grok 的重试实现(逐类错误决定处理方式),以及 dsh 让插件依次处理 agent/request-error 的实现。

【对比小结】 ​

  • codex 选择更积极的 Goal 自动续跑,因此必须控制何时推迟续跑、剩余预算和副作用的幂等性
  • grok 对完成验证最积极,同时也暴露了审查开关、旧实现路径,以及验证设施故障时“停止等待”还是“带风险继续”的真实取舍
  • dsh 对持久化边界、Goal revision 和未知工具结果定义得最明确;代价是更多状态与恢复协议
  • pi 用完整消息后执行、会话分支和真实 AgentSession eval 保持核心边界简单;没有内置 Goal、审批和后台恢复,相关保证由扩展或外层系统承担
  • 四家都说明同一件事:保存状态、证明完成、重新获得执行授权是三个独立问题

本章小结 ​

  • 先按失败类型和副作用状态决定重试、纠正或停止;结果未知时先核对外部事实,不能盲目重放。
  • Goal、Todo、完成证据和执行授权是四类状态。恢复记录不等于重新获得执行权,用户改过目标后旧任务不能继续写入。
  • 长任务要保存 owner、进程身份、输出位置和通知状态;修复则要留下复现、回归与故障注入证据。
  • 会话记录、运行指标和安全审计分开保存,再用稳定 ID 关联;缓存与并发优化也必须服从同一份时间、token 和费用预算。

到这里,Agent 已经能够处理上下文、工具、沙箱、权限,以及约束、验证和纠正。下一章把 Agent 的事件流接到前端:流式渲染、根据事件重建状态、审批、重连与对话恢复。

基于 MIT 协议发布,欢迎转载与贡献。