重试、错误、取消与恢复
失败发生在不同边界时,哪一层拥有决定权
结论:失败恢复是一组分层提交协议,不是一个万能的 retry
DeepSeek Harness 把失败恢复拆在五条边界上:adapter 把 provider 故障归一化为可记录事实;Agent 只在模型请求失败接缝内决定是否重试;工具超时和取消等待已启动工作收敛;checkpoint 在外部副作用之前强制持久化意图;恢复器只修补可证明的日志结构,不猜测进程崩溃时外部世界发生了什么。
这套设计最重要的性质是不把不同的不确定性混成同一种错误。请求可以重放,已启动的写工具却可能只能标成“结果未知”;超时可以发出取消信号,却不能把不合作的 Promise 变成已停止;内存 append 可以表示已接纳,但只有 flush/fsync 才表示进入持久化前缀。理解这些提交点,比背错误码更接近真实恢复模型。
1. 失败所有权地图:每一层只恢复自己能证明的事情
| 层 | 拥有的事实 | 允许的恢复动作 | 明确不负责 |
|---|---|---|---|
| Provider adapter | HTTP、SSE、idle、caller abort | 形成稳定 failure snapshot,关闭 transport | 决定业务重试次数 |
| Request recovery | 当前 Turn/Step、provider policy、失败历史 | 等待后重发同一模型请求 | 吞掉 middleware、consumer 或 cleanup bug |
| Tool runtime | 哪些 call 已启动、是否观察到 abort | 停止补充 dispatch,drain 已启动 call,补齐有序结果 | 强杀不合作的进程内工具 |
| Persistence/checkpoint | 哪些事件已经 durable | 副作用前 flush;写失败回滚并保留 batch | 证明外部副作用结果 |
| Crash repair | 最后一个完整可解析前缀 | 截断 torn tail,合成 tool/step/turn closer | 臆测未持久化的工具结果 |
恢复能力来自所有权边界,而不是 try/catch 数量。越过边界替另一层猜答案,会把可诊断失败变成重复副作用、伪造成功或不可回放日志。
2. Adapter 只把自己拥有的失败降格为终止 chunk
直接调用未 prepared 的 ctx.llm.stream() 时,最终 adapter 边界拥有 adapter 选择、异步 exact-model 解析、dispatch、iterator 构造与消费;这些步骤的异常会变成唯一的 terminal finish(error|aborted)。Agent 主路径则先在 buildRequest() 调用 prepareCall():除 NO_ADAPTER 回退外,选择或配置解析错误会在 stream 边界之前抛出,由 Turn 记录为 error,而不会进入 agent/request-error。FAILURE-AGENT-PREPARE-BOUNDARY准备成功后,adapter dispatch 与 iterator 错误仍会变成 terminal chunk。middleware、下游 consumer 与 iterator cleanup 错误保持抛出;consumer 提前停止时,边界会 await iterator.return()。FAILURE-ADAPTER-BOUNDARY
normalizeLlmFailure() 仅在 Error 暴露相互匹配的自有 data descriptor failure 与 code 时信任携带快照;随后在 try 内读取并校验快照字段,但不要求这些内部字段本身也是自有属性。message/code 必须非空,status、Retry-After 与 requestId 必须满足类型和范围。单独出现、没有匹配 failure snapshot 的第三方 SDK code 不会直接进入 Harness taxonomy,而是降为 UNKNOWN。快照被复制并冻结,不把活的 Error 对象塞进事件流。FAILURE-SNAPSHOT
窄边界让 provider 故障可由同一协议恢复,同时保留插件与消费端缺陷的堆栈和失败可见性。代价是扩展作者不能期待“任何 stream 抛错都会自动重试”。
3. DeepSeek adapter 把 HTTP 建议、transport 中断和 idle 分开分类
非 2xx 响应会把 401/403、429、400、5xx 映射为稳定的 AUTH、RATE_LIMIT、INVALID_REQUEST、SERVER 等代码;同时提取秒数或 HTTP-date 形式的 Retry-After,以及两个可能的 request-id header。FAILURE-DEEPSEEK-HTTP
一次 stream call 的稳定快照
connection + credential + user id
│
├─ caller abort ───────────────→ ABORTED
├─ outstanding read 无进展 ───→ TIMEOUT
├─ 其他 fetch/SSE 故障 ───────→ TRANSPORT
└─ HTTP 非 2xx ───────────────→ AUTH / RATE_LIMIT / SERVER / ...
每次 stream 调用只解析一份连接与凭据快照,进行中的请求不会混入下一代配置。idle watchdog 只在一次 iterator demand 未完成时计时;退出时 adapter abort consumer controller,并在 iterator 未耗尽时等待 return(),避免“上层已经恢复、底层 transport 还在跑”。FAILURE-DEEPSEEK-STREAM
4. agent/request-error 是窄接缝:只有终止失败才进入
一个 Step 只打开一次,内部 while (true) 为每次尝试重新构造 request 与 assembler。只有 assembler 收到 finish(error|aborted) 才调用 agent/request-error;listener 返回 { kind: "retry" } 才继续循环,否则抛出结构化 LlmError。成功后才写唯一的 assistant/message 并执行工具。FAILURE-REQUEST-LOOP
回归测试证明:request middleware 失败不会进入恢复 listener;连续两次 provider 失败再成功仍处于 Turn 1 / Step 1;取消即使与 retry action 同时发生也优先;恢复 listener 自己失败会关闭 Turn,而不是再尝试一次。FAILURE-REQUEST-TEST
5. Retry policy 属于 provider route;executor 只执行已经解析的决定
| 模式 | 资格 | 预算 | 下游组合 |
|---|---|---|---|
normal | 只匹配配置的错误码;默认含 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT | 默认首次请求后最多 2 次;500ms 起步、10s 上限、0.1 jitter | 不匹配、耗尽或 provider 延迟越界时交给下游 |
always | 所有模型请求失败 | 无次数上限,直到成功、取消或 disposal | 先询问下游;其 retry 优先,失败则记录并回落本地策略 |
解析器拒绝未知 key、非法边界、空或重复错误码,并生成冻结的 route policy。执行插件本身没有可配置重试策略,避免同一个 provider 被全局 executor 配置暗中改写。FAILURE-RETRY-POLICY
执行时先按同一 turn/step/provider/policyKey 查找历史 retry 次数,再决定 delay。合法且不超过上限的 provider Retry-After 原样采用;normal 模式遇到超上限建议则放弃本地恢复,always 模式改用本地 backoff。llm/retry 在等待之前写入,等待完成才写 llm/retry-started;取消与插件 disposal 都能终止等待,disposal 还会 drain 已捕获的 recovery。FAILURE-RETRY-EXECUTOR
6. 重试保留失败尝试的原始 chunk,但不把它提交成消息或工具副作用
单元测试把失败尝试的 partial text/tool-call chunks 留在 Step 的事件历史中,同时验证它们不成为 assistant/message 的 source、不会产生 tool/call,危险工具的执行次数仍为零;成功尝试才提交该 Step 唯一的 assistant message。测试也固定了重试计划先落事件、指数退避和耗尽后的错误关闭。FAILURE-RETRY-SEMANTICS-TEST
真实 HTTP/SSE 测试进一步覆盖拒绝连接后端点上线、stream disconnect、partial disconnect、空完成与 stalled body;重发 body 保持一致,且仍是同一 Step。一个重要反例是“合法结束但 partial EOF”:它被分类为 STREAM_CLOSED,不在默认 retry code 集合里,因此不会因为“看起来像网络问题”而盲重试。FAILURE-WIRE-RECOVERY-TEST
保留 raw chunks 提供取证能力,而 derived transcript 只暴露完整消息。重试仍可能增加时延、provider 配额或计费;Harness 能避免重复工具副作用,却不能撤销 provider 已经接收的一次模型请求。
7. 文档漂移:当前实现是在同一 Turn、同一 Step 内 retry
packages/llm/llm-retry/README.md 仍写着“每次 retry 打开新的编号 Turn”以及“先关闭失败 Turn,再打开 retry Turn”。这与当前 loop 的 Step 内循环,以及证明所有尝试均为 Turn 1 / Step 1 的单元和 wire 测试冲突。FAILURE-RETRY-DOC-DRIFT
在固定提交 47f943859bef60e4160492346772ded9b24f765a 上,应以源码和可执行测试为准:retry event 仍带同一坐标,Step 只写一次 start/end,成功 assistant message 也只写一次。README 描述应视为尚未同步的旧语义,而不是实现存在两种模式。
8. 工具 timeout 是协作式 deadline,不是 Promise race 或 hard kill
timeout policy 只包装声明了 timeoutMs 的工具。它把 caller signal 与本地 deadline 融合,dispatch 期间临时替换 exec.signal,随后恢复原 signal;最关键的是它 await downstream tool,仅当自己的 timeout code 先赢时才把最终结果替换成 TOOL_TIMEOUT。它不会提前放弃仍在运行的 Promise。FAILURE-TOOL-TIMEOUT
底层 deadline 文档明确说 signal 只负责通知,调用方必须自行停止工作;AbortSignal.any 保留第一个原因,带 code 的 timeoutOf() 用来区分本层 timeout 与外层取消。idle watchdog 同样只在 outstanding demand 时武装计时器。FAILURE-TIMEOUT-SIGNAL
测试覆盖三类竞态:合作工具收到 abort 后返回、工具以 abort error 抛出、caller abort 与 timeout 先后到达。timeout 先到时,Promise 会一直等到工具的清理 gate 放行才完成;caller 先到则保留普通 abort,而不会误报 timeout。FAILURE-TIMEOUT-TEST
9. 并行工具取消:停止补充、排空已启动、为未启动项补齐结构
并行池在 abort 后不再启动新 call,但会等待所有已经 dispatch 的 call 收敛,按模型顺序提交其 result 和 additional context;随后给尚未启动的每个模型 call 写入有序的 synthetic aborted-before-dispatch call/result。scheduler 自身失败也会 allSettled 已在途 dispatch,但不会伪造成功恢复结果。FAILURE-TOOL-DRAIN
从 assistant/message observer 触发取消的测试证明:危险工具 body 完全未执行,但日志仍出现匹配的 tool/call 和 TOOL_ABORTED_BEFORE_DISPATCH result;下一次模型请求可以回放这个 provider-valid 对。FAILURE-CANCEL-TOOL-TEST
这里的目标不是“取消返回得最快”,而是让取消之后没有匿名后台工作,并让 transcript 在每个 assistant tool request 后仍有对应结果。quiescence 是恢复正确性的组成部分。
10. Agent cancellation 是状态转换;cancel 返回不等于活动已结束
默认 cancel() 清空未 claim Inbox 并 abort 当前 phase;keepInbox 只保留未 claim 工作。abort-to-idle 窗口中新到达的 waking input 会被重分类到 next Turn 并设置 wake latch,旧 driver 在 turn/end(aborted) 后回到 idle 再启动它。driver 容纳已报告错误,连续 Turn 则各自换新的 AbortController。FAILURE-AGENT-CANCEL
测试验证 post-abort wake 不丢失、默认 cancel 会同时清队列和 latch、删除已 latch 消息会抑制空重放、慢收敛期间仍能 latch,而 disposal 原因永不 latch 新工作。另一个测试固定了“中途取消丢掉 queued tail”的默认语义。FAILURE-CANCEL-TEST
11. Disposal 是共享的静止点:先 cancel,再 drain,再逆序拆资源
Agent 生命周期把 caller、owner 和 factory 三个取消来源融合到同一个 setup signal。memoized disposer 只执行一次:发出 disposed-cause cancel,await whenIdle(),dispose 私有 scope,最后才从 agent/session registry detach 并释放 ownership 记账。所有并发调用方等待同一个 Promise。FAILURE-AGENT-DISPOSAL
生命周期测试验证 turn/end 先于 unregister、并发 owner unload 与 handle disposal 共享同一 quiescence、异步 scope cleanup 完成前旧 ID 仍被占用,完成后才允许同 ID 创建替代实例。FAILURE-DISPOSAL-TEST
这种 teardown 可能比“删 map 后立即返回”更慢,但它避免新实例与旧资源同名并存,也避免测试通过而生产进程仍残留 timer、listener、tool 或 transport。
12. Write-behind 把“事件已接纳”和“事件已 durable”明确拆开
每个 live Session 有独立 pending queue、固定 batching deadline、active write 和共享 flush barrier。enqueue 会 clone event 并立即返回;flush 取消 timer,等待重叠写,再一直 drain 到队列静止。写失败时 batch 按原顺序放回队首,自动路径暂停;后台失败只报告,显式 barrier 则 reject。FAILURE-WRITE-BEHIND
| 时刻 | 可以声称 | 不能声称 |
|---|---|---|
| Session append / enqueue 返回 | live event 已进入持久化拥有的内存队列 | 磁盘已经 fsync |
| 后台 batch 成功 | 该稳定前缀已由 backend 确认 durable | 随后到达的事件也已写入 |
| flush resolve | flush barrier 观察到的队列已 drain 到静止点 | 外部工具副作用已经完成 |
| flush reject | 失败 batch 仍保留、可再次尝试 | 调用方可以继续 dispatch 副作用 |
协调器利用逆序 teardown 先关闭 event admission,再做最终 flush、等待所有 session chain,最后关闭 backend;session retirement 也先 flush,之后才释放精确 lifecycle state,且 close error 不会遮蔽更早的 drain error。FAILURE-PERSISTENCE-WIRING
13. Semantic checkpoint 把可恢复意图放在不可逆副作用之前
模型路径:turn/start → claim → step/start → user/message → flush → adapter dispatch
工具路径:assistant/message → tool/call → flush → tool body
下一 Step:上一轮 response/result batch → pre-step flush → 新请求
Agent 先记录 Turn/Step、输入消息、request header/context,再构造带 session id 的请求并进入 stream。FAILURE-CHECKPOINT-AGENT-ORDERFAILURE-CHECKPOINT-REQUEST-PREFIX llm/stream wrapper 延迟构造下游 stream,直到该 request prefix flush。工具路径先 append tool/call,再进入 scheduler dispatch;dispatch 通过 tools/execute waterfall 才能抵达 body。FAILURE-CHECKPOINT-TOOL-INTENTFAILURE-CHECKPOINT-TOOL-WRAPPER checkpoint policy 在这个 waterfall 中 flush 顶层 call;abort 若在 checkpoint 中发生则返回 canonical aborted-before-dispatch result。pre-step 还会 flush 前一 Step 的 response/result batch。checkpoint 失败全部 fail-closed,不调用 adapter 或工具 body。FAILURE-CHECKPOINT-POLICY
单元测试证明 flush 完成后才调用 adapter 或工具 body、flush rejection 会阻止副作用、checkpoint 期间取消生成结构化未 dispatch 结果,并区分 nested tool 复用 outer checkpoint 的路径;这些测试直接调用 llm.stream/tools.execute,不单独证明上游事件记录顺序。FAILURE-CHECKPOINT-TEST
checkpoint 建立的是“发生副作用前,恢复者至少能看到意图”的 happens-before;它不是对 provider 或工具的 exactly-once 承诺。进程可能在副作用完成后、结果 fsync 前崩溃,这正是下一节的 unknown-outcome 窗口。
14. 硬崩溃恢复先保护字节前缀,再修补事件语义
独立子进程 E2E 在模型 dispatch 和工具 side-effect 边界收到 SIGKILL。子进程分别在 adapter 收到请求及工具写出外部副作用 marker 后永久等待;父进程观察 marker 后硬杀它。恢复后,第一个场景仍保有完整模型请求前缀;第二个场景证明 tool/call 在外部 marker 之前 durable,而没有结果的已启动工具被恢复为 TOOL_OUTCOME_UNKNOWN。本次复核中该文件 2/2 用例通过。FAILURE-CRASH-FIXTUREFAILURE-CRASH-E2E
原始 JSONL scanner 只提交以换行结束、成功解码且 sequence 连续的记录;Zstd scanner 只列出结构完整的 frame,并把 EOF 中断的最后一帧标为 torn。FAILURE-JSONL-RAW-SCANFAILURE-JSONL-ZSTD-SCAN Loader 把这些完整边界变成稳定前缀;发现 torn tail 时保留完整记录并生成 opaque truncation marker,repair 先截断,再追加可恢复事件和 synthetic closers。FAILURE-JSONL-PREFIX
首次 materialize 使用已 fsync 落盘的临时文件和不会覆盖已有目标的 publish:POSIX 通过 link() 发布并 fsync 父目录,Windows 则用不允许替换的 MoveFileExW(..., MOVEFILE_WRITE_THROUGH)。后续 append 写入并 fsync;write/sync 失败会关闭 handle、截回原长度并再次 fsync,避免同一 sequence 在重试时重复。repair 的 truncate 也会 fsync。FAILURE-JSONL-ATOMICITYFAILURE-JSONL-WIN32
JSONL 回归测试覆盖 torn partial line、保留完整开放事件、合成 step/turn closer、已提交字节不变,以及 fsync 失败回滚后无 sequence gap 的再次写入。FAILURE-JSONL-TEST
15. Crash repair 修的是 transcript truth,不是 external side-effect truth
| 崩溃前最后可证明的形状 | 合成结果 | 安全后续 |
|---|---|---|
assistant 要求工具,但没有 tool/call | TOOL_NOT_STARTED | 仍需要时可以重试 |
已有 tool/call,没有 durable result | TOOL_OUTCOME_UNKNOWN | 只对只读/幂等操作重试;否则先核验外部状态或询问用户 |
| Step 开放 | 补 step/end;其所在开放 Turn 再补 turn/end(interrupted) | 从 provider-valid transcript 恢复 |
| Turn 开放,但没有开放 Step | 只补 turn/end(interrupted) | 从 Turn 边界恢复 |
| 日志已平衡 | 不生成任何事件 | 保持原事实不变 |
修复器使用最后一个真实事件的时间戳和连续 sequence,先为 dangling tool calls 合成 result,再关闭 Step 和 Turn;是否“已启动”只由 durable tool/call 事实决定。其模型面对的恢复文本也明确禁止盲重试潜在副作用。FAILURE-CRASH-REPAIR
backend-agnostic shared contract 定义了 interrupted Turn、未开始工具与结果未知工具的恢复行为;该 contract 分别由内存、JSONL 与 SQLite backend 的测试套件实例化,因此这些语义不只绑定在 JSONL 实现上。FAILURE-CRASH-CONTRACTFAILURE-CRASH-CONTRACT-MEMORYFAILURE-CRASH-CONTRACT-JSONLFAILURE-CRASH-CONTRACT-SQLITE
UNKNOWN 看起来不如自动恢复“顺滑”,但它诚实保存了信息边界。若业务要求自动化,应让工具暴露幂等键、查询接口或补偿动作;日志修复器不应伪造它无法观察的现实。
16. Misconfiguration 不是一类故障:启动、热更新和缺凭据采用三种策略
| 场景 | 策略 | 理由 |
|---|---|---|
| 首次 composition 的结构/边界非法 | 注册前 fail loud | 没有可安全继续服务的 previous generation |
| live settings candidate 非法 | 完整保留 last-good snapshot 并记录错误 | 避免旧 endpoint 与新 key 等跨 generation 混配 |
| 缺少 credential | route/catalog 仍可发现;每次请求返回 MISSING_CREDENTIAL | 密钥稍后加入后,下一请求可恢复,无需重启 |
| 顶层配置/用户 patch 热更新失败 | 拒绝 candidate,保留 running tree;后续合法 edit 可恢复 | transactional replacement 优于半应用状态 |
DeepSeek 配置有 schema 和显式 resolver 两层验证;首次 options() 在 adapter publication 前执行,而 live snapshot 失败时返回整个 lastGood。FAILURE-CONFIG-RESOLUTION凭据则从该请求的同一 connection snapshot 延迟解析;retry policy 是注册时捕获的例外,因此用同步 replace 更新,避免发布空 route 窗口。FAILURE-CREDENTIAL-ROUTE
动态配置测试证明:下一请求同时看到新 endpoint 和 key;keyless 首次请求失败,加入 key 后成功;policy 替换期间 provider 从未消失;包含新 URL 的非法 generation 完全不泄漏,随后合法 generation 可恢复。FAILURE-DYNAMIC-CONFIG-TEST
应用层 watcher 对 present-but-invalid patch 明确 fail loud,并把新 patch 交给一次 entry update;配置 loader 的事务语义负责保留上一棵可用树。FAILURE-HMR-WATCH测试覆盖 parse/validation 失败后的 last-good 与下一次合法 refresh。FAILURE-HMR-CONFIG-TEST另一组测试覆盖用户 patch 的 add → apply failure → parse failure → recovery → removal 全链路。FAILURE-HMR-USER-PATCH-TEST
17. 与 Codex 的公开合同比较:恢复会话与约束权限,不等于证明副作用结果
| 维度 | DeepSeek Harness(本章源码) | Codex(仅公开文档) |
|---|---|---|
| 恢复单位 | 事件日志的 durable prefix、Turn/Step/tool closer | codex resume / /resume 继续保存的 chat;恢复 transcript 与记录的工作目录,文件取自当前 working tree |
| 执行安全 | checkpoint、合作式 abort、scope quiescence | sandbox 规定技术边界,approval policy 决定何时停下请求用户授权 |
| 未知副作用 | 显式 TOOL_OUTCOME_UNKNOWN,要求按幂等性核验 | 所引公开页面没有给出 dangling tool outcome 的 crash-repair 协议 |
| 可比较结论 | 应用内恢复协议可从源码和测试验证 | 公开可确认的是交互续接与权限边界,不推断其私有实现 |
Codex 文档说明可用 resume 命令继续保存的会话;Projects and chats 进一步说明恢复 transcript 与记录的工作目录,而文件来自当前 working tree。Agent approvals & security 与 Sandboxing 把 sandbox 和 approval 描述为互补控制(检索于 2026-08-13)。
18. 保证、取舍和仍需上层解决的空白
| 本层可以保证 | 成立条件 | 仍需上层处理 |
|---|---|---|
| 瞬态模型失败可有界或持续重试 | 失败进入 adapter terminal protocol,policy 允许,未取消/未 disposal | 成本预算、全局退避、业务 SLA |
| 失败 partial output 不触发工具 | 只有完整成功 attempt 才形成 assistant message | provider 已产生的成本和请求去重 |
| 工具 timeout 有稳定分类 | 工具声明 timeout 且遵守 signal | 不合作代码的 hard isolation |
| 取消/disposal 后达到进程内 quiescence | adapter、工具、listener 都遵守异步合同 | 外部服务已经接受的副作用 |
| 崩溃日志恢复为 provider-valid transcript | 至少有一个完整 durable prefix | unknown outcome 的核验、幂等或补偿 |
| live 配置失败不污染 last-good generation | 更新走受管理的 transactional seam | 静默失效的外部 secret/endpoint 健康监控 |
最值得复用的设计不是某个错误码,而是三条规则:先确定谁拥有失败事实;把恢复决定记录在与失败相同的坐标;在不可逆副作用前持久化意图,在无法证明结果时保留 unknown。
19. 本章复核范围
所有仓库内结论均针对上游固定提交 47f943859bef60e4160492346772ded9b24f765a。本次聚焦验证运行 13 个 source-level 测试文件,共 407 个用例;另以 E2E 配置独立运行 hard-crash 文件 2 个用例。合计 14 个文件、409 个测试全部通过。
13 focused source-level files → 407 passed
1 hard-crash E2E file → 2 passed
total → 409 passed
测试覆盖 provider adapter、request recovery、retry 与真实 HTTP/SSE transport、timeout/cancel/disposal、checkpoint/JSONL repair、动态 DeepSeek 配置和应用配置热更新。它们证明这里描述的边界行为;没有把 load、性能、跨机器断电或真实第三方 API 可用性包装成已经验证的结论。
我的学习体会
内容仅自动保存到当前浏览器,不上传、不进入仓库。你可以导出 Markdown 自行归档。