学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 04
第 4 章 · EP 04

Esc 之后发生了什么:取消、崩溃恢复与重入

时长 15:38音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 停得干净,只停该停的1:52
1:52取消权跟着轮次走2:53
4:46权力交接在发布终态之前1:10
5:56中断也要把账记平2:24
8:20崩溃恢复补齐,不截断2:35
10:56只修冷会话,还有检查点1:19
12:15一次推演2:00
14:15可带走的原则1:22
解读全文

ep36 · Esc 之后发生了什么:取消、崩溃恢复与重入

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座
来源:xueai.miyang.cn(小山学堂 · 洛小山)

一句话速览

每轮一个 AbortSignal,中断也要写回日志,kill -9 之后重启还能接着跑,已落盘的劳动成果一条不丢。

核心判断:取消的难点是两件事 —— 停得干净,和只停该停的。全局开关这两样都给不了。判断一个系统能不能扛事故,不要看它正常时跑得多顺,要看它的日志在异常退出时还认不认账。


能力地图

取消信号的作用域

属性设计
创建时机驱动器每次醒来干活,新建一个 AbortController
生命周期与轮次等长,一轮跑完、队列有活则换新
同时有效性任何时刻最多只有一个控制器有效
传递方式显式参数,发给每个 await 边界
收手方式协作式 —— 每个边界自己检查,不用 Promise.race

出处:packages/core/agent-loop/src/agent.ts L187(每轮新建)、L325(跑完换新)、L134–140(cancel 入口)。

日志里的两种死法

结局谁写的含义
aborted循环亲手写下有人调了 cancel,轮次有序收尾
interrupted循环从不发,只在崩溃恢复时由持久化后端合成意外身亡,唯一非循环出品的结局
体面告别和意外身亡,日志里一眼可辨。

崩溃恢复的合成顺序(有讲究)

  1. 给每个悬空的工具调用补一条错误占位的 tool/result;
  2. 关掉开着的 step;
  3. 最后合成 turn/end,结局标 interrupted。
step 还开着就写 turn/end 违反日志不变式 —— 所以先补 step 边界,再补 turn 边界。合成事件的时间戳复用最后一条真实事件的,绝不发明未来时间。

出处:packages/core/session/src/repair.ts L126–132。


设计思路一 · 取消权跟着轮次走

反面做法:全局开关

想象取消做成一个全局布尔标志位,谁都能设,各处代码自己抽空看一眼。两种翻车迟早发生:

  1. 上一轮注册的超时回调半夜苏醒,顺手把正在跑的新一轮取消了;
  2. 取消用 Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态 —— 僵尸工作。
停得干净、只停该停的 —— 这两样,全局开关都给不了。

机制

Esc 只是拉了一下这根线:界面把按键翻译成 agent.cancel({ kind: 'user' }),子 agent 被父级打断则是 { kind: 'parent' } —— 取消自带身份。

cancel 入口小到可以背下来,就两个动作:

  1. 默认先清空 inbox(排队没跑的消息全部作废);想保住排队工作就传 keepInbox,只中断当前活动;
  2. 对当前控制器 abort(cause)。

空闲时调 cancel 是空操作 —— 不会给未来的工作预埋取消状态。

同一个 signal 显式发给:pre-step、提示词组装、模型请求、流式读取、工具执行、审批 —— 连 bash 工具都能顺着它杀掉整个进程组。

提示1 · 权力交接在发布终态之前

循环在发布 turn/end 之前就清掉本轮的取消持有者。之后哪怕持久化刷新还没结算完,谁也取消不了已经完成的轮次工作;下一轮拿到的是全新 signal,旧回调想越权,连把手都摸不到。

这一处解决了一类很难查的问题:若没有这一步,完成态与取消态之间会有一个窗口,迟到的取消能把已写完的轮次标记成中断 —— 日志里出现「内容完整、结局却是中断」的自相矛盾记录。

为什么长期成立

显式令牌加作用域绑定,是结构化并发的通则:Go 的 context、.NET 的 CancellationToken 走的都是这条路。令牌由创建者负责收回,活不过自己的作用域,越权自然无从谈起。


设计思路二 · 中断也要把账记平

问题

假设被取消的轮次不写终态:

  • 日志停在半截,回放的人不知道这轮怎么结束的;
  • UI 没法如实告诉用户哪些排队的活被扔了;
  • 下游拿到日志,分不清这轮是被人有序停掉的,还是意外死掉的。
中断是正常业务,不记账的中断才是事故。

机制

  • 轮次主体逻辑包在 try 里;
  • catch 分支发现 signal.aborted → 结局定为 aborted;
  • finally 里无论如何写一条 turn/end 回日志;
  • 已经落盘的流式 chunk、工具输出一个都不删。

提示2 · 日志只存粗粒度结局

日志只存 aborted,不存是谁按的 —— user 还是 parent 属于运行时信息,回放不需要也不该知道。

提示3 · 被清掉的排队消息怎么交代

被清掉的排队消息没有任何 turn/end 描述它。靠 foldConsumedWork 单遍扫日志,从 inbox 记录里的 outcome: 'canceled' 算出 droppedUnrun —— 界面才能如实说「有活被扔了、没跑」。

出处:agent.ts L302–305(catch 定结局)、L316–323(finally 写 turn/end);consumed-work.ts L87(droppedUnrun 折叠)。

为什么长期成立:所有退出路径都写终态,是一切拿日志当权威状态的系统的底线 —— 数据库事务日志的 commit 和 abort 记录同理。异常路径和正常路径出同样的账,重建现场的人才不用猜。


设计思路三 · 崩溃恢复补齐,不截断

问题

取消好歹有 finally 善后,kill -9 连善后的机会都没有。进程死掉的瞬间,日志停在半截:turn/start 开着,某个工具调用记了 tool/call,却永远等不到 tool/result。

重启后冷加载,摆在面前的是一道选择题:把没写完的轮次删掉,还是补齐?

删掉看似干净,代价大得多。长周期任务的单个轮次可能非常庞大(几十个步骤、大量工具输出),这些在崩溃前都已持久追加。截断等于把用户的劳动成果陪葬。

机制

DSH 选补齐。恢复逻辑生成几条确定性的合成事件把尾巴关上:

  1. 给每个悬空的工具调用补一条错误占位的 tool/result;
  2. 关掉开着的 step;
  3. 合成 turn/end,结局标 interrupted。

时间戳复用最后一条真实事件的,绝不发明未来时间。

提示4 · 占位结果的两种措辞

情况占位措辞
工具记录了启动但结果没落盘结果未知;只有只读或幂等操作才可以重试,有副作用的要先核实外部状态或问用户 —— 原文强调 「Do not retry blindly」
工具压根没启动直接说:需要就重试

出处:packages/core/session/src/repair.ts L91–124。

这两句措辞的区别看似很小,其实是在替模型做安全判断。分不清这两种情况,模型就会盲目重试一个可能已经执行了一半的写操作。

提示5 · 只对冷会话生效

会话还活着时,load 会等权威内存快照落盘、只在日志配平时返回;活跃轮次没闭合就直接拒绝 —— 绝不给一个正在跑的轮次插入合成边界。出处:docs/subsystems/persistence.zh.md L17。

若对热会话也做修复,就可能在运行中插入合成边界,而这个轮次其实还在正常工作 —— 结果是一个活着的轮次被凭空宣布死亡。

写盘那头也有讲究:持久化插件批量落盘,循环在领取下一轮之前用 session/flush 做检查点,把顺序和写盘错误都看在眼里。

为什么长期成立

追加式日志加读取时修复,就是数据库 WAL(预写日志) 恢复的思路:崩溃后不改写历史,只补足让状态机能继续走的最小事件。

反过来说,敢截断日志的系统,等于默认单轮工作便宜到可以随便扔 —— 这个假设在长任务时代不成立。


横向对比 · 两家怎么面对中断与崩溃

层级机制
DSH会话日志取消权跟轮次;恢复语义做进持久化契约(load 承诺补齐中断尾部、不改写已提交事件)
Grok Build进程专职 crate xai-crash-handler,sigaction 接 SIGSEGV/SIGBUS,信号安全操作写 last-crash.bin,恢复终端,保留最近 5 份
Claude Code任务killAsyncAgent 从任务状态取 abortController 调 abort,任务标 killed;控制器生命周期跟着任务走
  • Grok 回答的是「进程怎么死的、终端别留烂摊子」;DSH 的 repair.ts 回答的是「会话日志怎么活下来」。两家各占一层。在已核对的 Grok 材料中未见等价的会话日志配平机制。
  • DSH 把粒度再切细一档:控制器跟着轮次走(Claude Code 跟着任务走),同一个 agent 的下一轮自动拿新信号。DSH 明确不持久化取消原因,durable 日志只留 aborted;Claude Code 的会话恢复与中断记录细节未在已核对书稿章节展开,保留未知项。

审查清单

  • 取消是显式令牌还是全局布尔标志位?
  • 控制器生命周期是否与轮次/任务等长?会不会跨轮泄漏?
  • 取消是否显式传递到每个 await 边界(含工具执行、进程组)?
  • 收手是协作式还是 Promise.race 半路丢弃(会留僵尸工作)?
  • 权力交接是否在发布终态之前完成?(防迟到取消造成矛盾记录)
  • 被中断的轮次是否也写了 turn/end?
  • 日志是否只存粗粒度结局,不存「谁按的」?
  • 被清掉的排队消息是否有 droppedUnrun 交代?
  • 崩溃恢复是补齐还是截断?合成顺序是否先 step 后 turn?
  • 修复是否只对冷会话生效?
  • 占位结果是否区分了「记录启动」与「压根没启动」两种措辞?

排查路径

  1. 新一轮莫名被取消 → 查取消权是否跨轮泄漏(全局标志位 / 旧回调越权)。
  2. 工具在后台偷偷改文件 → 查是否用了 Promise.race 半路丢弃,留下僵尸工作。
  3. 日志出现「内容完整但结局是中断」 → 查权力交接是否在发布终态之前。
  4. 回放不知道轮次怎么结束的 → 查 finally 是否无条件写 turn/end。
  5. 崩溃后劳动成果丢失 → 查恢复逻辑是截断还是补齐。

课堂练习 · 推演两个时刻的日志差异

同一个轮次,取消发生在两个不同时刻:

  • a) 模型流式输出到一半;
  • b) bash 工具正在执行。

问题一:分别写出日志从 step/start 到 turn/end 之间会出现哪些事件,turn/end 的 reason 是什么。

问题二:把这两种情况的事故换成 kill -9,重启后 repair 分别要合成几条 closer?

提示与答案要点:
- a) 日志为 流式 chunk → step/end → turn/end{aborted}。排队消息被清掉,靠折叠算出 droppedUnrun。
- b) 多一条 tool/call,取消信号顺着传到进程组,工具被杀;结果可能已落盘也可能没有。
- a) 换 kill -9:无悬空 tool/call,只需关 step + 合成 turn/end,共 2 条。
- b) 换 kill -9:需补「结果未知」占位 → 关 step → 补 turn/end,共 3 条。
结论:合成顺序不是随意的,严格对应日志不变式 —— 先补内层边界,再补外层边界。

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号或产品行为。
  2. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  3. 源码时效:依据本地仓库 deepseek-harness-master,核对日期 2026-08-13;横向对比部分基于已公开材料,未知项已明确标注。
  4. 演示说明:原文交互演示的事件条目做了教学化简化,机制对应真实源码;情景 C 的截断式恢复为教学假设,DSH 未实现该行为,用于对照数据损失。
  5. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。

Takeaway

取消是一根显式的线:每轮一个 AbortController,cancel 清 inbox 再 abort,中断的轮次照样写 turn/end { aborted },取消权在 turn/end 发布前收回、绝不跨轮。

崩溃恢复不截断:冷加载给悬空工具补错误占位、合成 turn/end { interrupted } 配平,崩溃前已落盘的每一条事件都保留。体面告别和意外身亡,日志里一眼可辨。


*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*