DSHarness 系统拆解 固定基线 47f943859b · 36 已复核 / 0 撰写中 / 36 章
English
可靠性与产品面·第 27 章

重试、错误、取消与恢复

失败发生在不同边界时,哪一层拥有决定权

已复核上游 47f943859b范围: 分析 provider retry、request-error recovery、tool timeout、abort、cleanup、misconfiguration 和 crash-safe persistence。

结论:失败恢复是一组分层提交协议,不是一个万能的 retry

DeepSeek Harness 把失败恢复拆在五条边界上:adapter 把 provider 故障归一化为可记录事实;Agent 只在模型请求失败接缝内决定是否重试;工具超时和取消等待已启动工作收敛;checkpoint 在外部副作用之前强制持久化意图;恢复器只修补可证明的日志结构,不猜测进程崩溃时外部世界发生了什么。

这套设计最重要的性质是不把不同的不确定性混成同一种错误。请求可以重放,已启动的写工具却可能只能标成“结果未知”;超时可以发出取消信号,却不能把不合作的 Promise 变成已停止;内存 append 可以表示已接纳,但只有 flush/fsync 才表示进入持久化前缀。理解这些提交点,比背错误码更接近真实恢复模型。

1. 失败所有权地图:每一层只恢复自己能证明的事情

拥有的事实允许的恢复动作明确不负责
Provider adapterHTTP、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-errorFAILURE-AGENT-PREPARE-BOUNDARY准备成功后,adapter dispatch 与 iterator 错误仍会变成 terminal chunk。middleware、下游 consumer 与 iterator cleanup 错误保持抛出;consumer 提前停止时,边界会 await iterator.return()FAILURE-ADAPTER-BOUNDARY

事实

normalizeLlmFailure() 仅在 Error 暴露相互匹配的自有 data descriptor failurecode 时信任携带快照;随后在 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 映射为稳定的 AUTHRATE_LIMITINVALID_REQUESTSERVER 等代码;同时提取秒数或 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/callTOOL_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 resolveflush 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/callTOOL_NOT_STARTED仍需要时可以重试
已有 tool/call,没有 durable resultTOOL_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 混配
缺少 credentialroute/catalog 仍可发现;每次请求返回 MISSING_CREDENTIAL密钥稍后加入后,下一请求可恢复,无需重启
顶层配置/用户 patch 热更新失败拒绝 candidate,保留 running tree;后续合法 edit 可恢复transactional replacement 优于半应用状态

DeepSeek 配置有 schema 和显式 resolver 两层验证;首次 options() 在 adapter publication 前执行,而 live snapshot 失败时返回整个 lastGoodFAILURE-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 closercodex resume / /resume 继续保存的 chat;恢复 transcript 与记录的工作目录,文件取自当前 working tree
执行安全checkpoint、合作式 abort、scope quiescencesandbox 规定技术边界,approval policy 决定何时停下请求用户授权
未知副作用显式 TOOL_OUTCOME_UNKNOWN,要求按幂等性核验所引公开页面没有给出 dangling tool outcome 的 crash-repair 协议
可比较结论应用内恢复协议可从源码和测试验证公开可确认的是交互续接与权限边界,不推断其私有实现
公开事实

Codex 文档说明可用 resume 命令继续保存的会话;Projects and chats 进一步说明恢复 transcript 与记录的工作目录,而文件来自当前 working tree。Agent approvals & securitySandboxing 把 sandbox 和 approval 描述为互补控制(检索于 2026-08-13)。

18. 保证、取舍和仍需上层解决的空白

本层可以保证成立条件仍需上层处理
瞬态模型失败可有界或持续重试失败进入 adapter terminal protocol,policy 允许,未取消/未 disposal成本预算、全局退避、业务 SLA
失败 partial output 不触发工具只有完整成功 attempt 才形成 assistant messageprovider 已产生的成本和请求去重
工具 timeout 有稳定分类工具声明 timeout 且遵守 signal不合作代码的 hard isolation
取消/disposal 后达到进程内 quiescenceadapter、工具、listener 都遵守异步合同外部服务已经接受的副作用
崩溃日志恢复为 provider-valid transcript至少有一个完整 durable prefixunknown 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 自行归档。