本内容改编自小山学堂《学 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 | 循环从不发,只在崩溃恢复时由持久化后端合成 | 意外身亡,唯一非循环出品的结局 |
体面告别和意外身亡,日志里一眼可辨。
tool/result;turn/end,结局标 interrupted。step 还开着就写 turn/end 违反日志不变式 —— 所以先补 step 边界,再补 turn 边界。合成事件的时间戳复用最后一条真实事件的,绝不发明未来时间。
出处:packages/core/session/src/repair.ts L126–132。
想象取消做成一个全局布尔标志位,谁都能设,各处代码自己抽空看一眼。两种翻车迟早发生:
Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态 —— 僵尸工作。停得干净、只停该停的 —— 这两样,全局开关都给不了。
Esc 只是拉了一下这根线:界面把按键翻译成 agent.cancel({ kind: 'user' }),子 agent 被父级打断则是 { kind: 'parent' } —— 取消自带身份。
cancel 入口小到可以背下来,就两个动作:
keepInbox,只中断当前活动;abort(cause)。空闲时调 cancel 是空操作 —— 不会给未来的工作预埋取消状态。
同一个 signal 显式发给:pre-step、提示词组装、模型请求、流式读取、工具执行、审批 —— 连 bash 工具都能顺着它杀掉整个进程组。
循环在发布 turn/end 之前就清掉本轮的取消持有者。之后哪怕持久化刷新还没结算完,谁也取消不了已经完成的轮次工作;下一轮拿到的是全新 signal,旧回调想越权,连把手都摸不到。
这一处解决了一类很难查的问题:若没有这一步,完成态与取消态之间会有一个窗口,迟到的取消能把已写完的轮次标记成中断 —— 日志里出现「内容完整、结局却是中断」的自相矛盾记录。
显式令牌加作用域绑定,是结构化并发的通则:Go 的 context、.NET 的 CancellationToken 走的都是这条路。令牌由创建者负责收回,活不过自己的作用域,越权自然无从谈起。
假设被取消的轮次不写终态:
中断是正常业务,不记账的中断才是事故。
try 里;catch 分支发现 signal.aborted → 结局定为 aborted;finally 里无论如何写一条 turn/end 回日志;日志只存 aborted,不存是谁按的 —— user 还是 parent 属于运行时信息,回放不需要也不该知道。
被清掉的排队消息没有任何 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 选补齐。恢复逻辑生成几条确定性的合成事件把尾巴关上:
tool/result;turn/end,结局标 interrupted。时间戳复用最后一条真实事件的,绝不发明未来时间。
| 情况 | 占位措辞 |
|---|---|
| 工具记录了启动但结果没落盘 | 结果未知;只有只读或幂等操作才可以重试,有副作用的要先核实外部状态或问用户 —— 原文强调 「Do not retry blindly」 |
| 工具压根没启动 | 直接说:需要就重试 |
出处:packages/core/session/src/repair.ts L91–124。
这两句措辞的区别看似很小,其实是在替模型做安全判断。分不清这两种情况,模型就会盲目重试一个可能已经执行了一半的写操作。
会话还活着时,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;控制器生命周期跟着任务走 |
aborted;Claude Code 的会话恢复与中断记录细节未在已核对书稿章节展开,保留未知项。Promise.race 半路丢弃(会留僵尸工作)?droppedUnrun 交代?Promise.race 半路丢弃,留下僵尸工作。同一个轮次,取消发生在两个不同时刻:
问题一:分别写出日志从 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 条。
结论:合成顺序不是随意的,严格对应日志不变式 —— 先补内层边界,再补外层边界。
quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。取消是一根显式的线:每轮一个 AbortController,cancel 清 inbox 再 abort,中断的轮次照样写 turn/end { aborted },取消权在 turn/end 发布前收回、绝不跨轮。
崩溃恢复不截断:冷加载给悬空工具补错误占位、合成 turn/end { interrupted } 配平,崩溃前已落盘的每一条事件都保留。体面告别和意外身亡,日志里一眼可辨。
*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*