Appearance
第 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-build | 429、部分 5xx、传输/流中断、空响应 | 首次普通重试重建 HTTP/1.1 客户端;图片过大时移除图片后只重试一次 | 400/401/403/404/408/422、TLS 525/526、认证配置、序列化、max-token 截断 |
| dsh | 默认最多 5 次:空响应、限流、服务端、超时、传输错误 | 重试计划写进会话日志,进程恢复后延续原计数;可配置持续恢复模式 | 普通模式预算用尽;服务端等待时间超过本地上限时不盲等 |
| pi | 408/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_paused | provider、验证器或执行设施故障 | 否,设施恢复后显式续跑 |
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 的边界
| codex | grok-build | dsh | |
|---|---|---|---|
| 持久状态 | State DB 中的线程级 Goal 记录 | 独立快照文件中的 Goal 协调状态 | 会话日志中的 Goal 变更全量事件 |
| 状态 | Active/Paused/Blocked/UsageLimited/BudgetLimited/Complete | Active、多种 paused、Blocked/BudgetLimited/Complete,另有 Planning/Executing phase | active/paused/blocked/complete |
| 预算 | token budget + token/time usage | token 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。恢复后只接受新版本号,旧进程的监视任务和自动续跑请求全部作废。
恢复长任务分两步:
- 从日志/数据库重建 Goal、Plan、证据、预算和未完成工作
- 重新取得执行授权,再决定是否发起下一 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 升级并等待整棵进程树退出 |
| completed | UI/模型没收到结果 | 重放结果,不重新执行命令 |
| 没有 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 终止一个后台任务 |
| goal | disarm 自动续跑,并按策略取消该 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 的事件流接到前端:流式渲染、根据事件重建状态、审批、重连与对话恢复。