专题篇章 · 拆开 DeepSeek Harness

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

每轮一个 AbortSignal,中断也要写回日志,崩溃重启还能接着跑

本页解决的问题

先给结论

「Esc 之后发生了什么:取消、崩溃恢复与重入」要解决的关键问题是什么?

每轮一个 AbortSignal,中断也要写回日志,崩溃重启还能接着跑

判断标准

让这个结论先证明自己值得留下。 把这一页当成决策工具,而不是需要背下来的定义。把概念连到一个真实任务、一个可观察结果,以及一个能改变你判断的失败上。

下一步

写下一个问题:试完这个方法后,你能用什么证据回答它?

常见误区

结论听起来很完整,却没有检查最关键的假设。

课程目标读完你能说清三件事:按下 Esc 之后取消信号怎么从一个 AbortController 传遍模型流和工具执行,为什么取消权只管当前这一轮;被中断的轮次为什么也要写一条 turn/end 回日志;以及进程被 kill -9 之后,重启的 DSH 靠什么把半截会话救回来、又保住了哪些东西。
交互演示 · 中断演练场

下面是一条正在干活的轮次:模型流式输出中,还叫了一个长工具。左边是会话日志,右边是运行状态。三个情景按钮对应三种事故:按 Esc、kill -9 后重启、以及一个假想的截断式恢复做对照。点播放,字幕会告诉你每一步日志里多了什么、少了什么。

会话事件日志(仅追加 · 落盘即持久)
运行状态
进程运行中
本轮取消信号未创建
Inbox 排队消息0 条
日志统计会显示在这里:哪些事件保住了,哪些丢了。
选一个情景点播放,或滚动到此处自动播放情景 A。
演示为教学化模拟:事件条目做了简化,机制对应 packages/core/agent-loop/src/agent.ts(取消与 turn/end 落日志)与 packages/core/session/src/repair.ts(崩溃恢复合成 closer)。情景 C 的截断式恢复是教学假设,DSH 未实现该行为,用来对照数据损失。
设计思路一 · 取消权跟着轮次走

它解决什么问题

想象取消做成一个全局开关:agent 身上挂一个布尔标志位,谁都能设,各处代码自己抽空看一眼。翻车迟早发生:上一轮注册的某个超时回调半夜苏醒,顺手把正在跑的新一轮取消了;或者取消用 Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态,成了僵尸工作。

取消这件事,难点是停得干净、只停该停的。这两样,全局开关都给不了。

思路是什么

DSH 的取消是一根显式传递的线,一头拴在轮次上,另一头拴在每个正在干活的边界上。驱动器每次醒来干活,新建一个 AbortController(取消控制器);一轮跑完、队列里还有活,就再换一个新的。任何时刻最多只有一个控制器有效,取消权的生命周期和轮次一样长。

Esc 只是拉了一下这根线。界面把按键翻译成 agent.cancel({ kind: 'user' }),子 agent 被父级打断则是 { kind: 'parent' },取消自带身份。cancel 的入口小到可以背下来,就两个动作:默认先把 inbox 清空,排队没跑的消息全部作废,想保住排队工作就传 keepInbox,只中断当前活动;然后对当前控制器 abort(cause)。空闲时调 cancel 是空操作,不会给未来的工作预埋取消状态。

同一个 signal 显式地发给 pre-step、提示词组装、模型请求、流式读取、工具执行、审批这些环节,连 bash 工具都能顺着它杀掉整个进程组。传递是协作式的:循环在每个 await 边界前后检查中断,不用 Promise.race 半路丢弃一个还在跑的 Promise,所以不会有僵尸工作偷偷改状态。

一根显式的线:从 Esc 到每个正在干活的边界 Esc 按下 翻译成带身份的 cancel cancel 只做两件事 清 inbox,再 abort(cause) 本轮唯一 signal 随轮次生灭 模型流停止读取 工具与 bash 进程组 组装与审批收手 turn/end 发布之前收回取消权,下一轮换全新的 signal
信号是显式参数,不靠全局状态;每个边界在 await 前后自己检查,协作式收手。

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

出处:每轮新建 AbortController 在 packages/core/agent-loop/src/agent.ts 第 187 行,跑完换新在第 325 行,cancel 入口(可选清 inbox,再 abort 带类型化 cause)在第 134 至 140 行;取消权不跨轮泄漏的设计记录见项目 Agent Note 2026-07-16。

为什么长期成立

显式令牌加作用域绑定,是结构化并发的通则:Go 的 context、.NET 的 CancellationToken 走的都是这条路。令牌由创建者负责收回,活不过自己的作用域,越权自然无从谈起。只要系统里同时有流式 IO 和外部进程要停,换个语言重写,这根显式的线还是得有。

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

它解决什么问题

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

思路是什么

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

于是日志里的死法只有两种,各有专属签名。aborted 由循环亲手写下:有人调了 cancel,轮次有序收尾。interrupted 循环从来不发,它只在崩溃恢复时由持久化后端合成,是唯一一个非循环出品的结局。看一眼 reason,就知道这轮的结束方式。

两个配套细节。日志只存粗粒度的 aborted,不存是谁按的,user 还是 parent 属于运行时信息,回放不需要也不该知道。被清掉的排队消息没有任何 turn/end 描述它,foldConsumedWork 单遍扫日志,靠 inbox 记录里的 outcome: 'canceled' 算出 droppedUnrun,UI 才能如实说有活被扔了、没跑。

体面告别和意外身亡,日志里一眼可辨。

出处:catch 定结局在 packages/core/agent-loop/src/agent.ts 第 302 至 305 行,finally 写 turn/end 在第 316 至 323 行;droppedUnrun 的折叠在 consumed-work.ts 第 87 行。

为什么长期成立

所有退出路径都写终态,是一切拿日志当权威状态的系统的底线,数据库事务日志的 commit 和 abort 记录同理。异常路径和正常路径出同样的账,重建现场的人才不用猜。模型换代、语言重写,这条纪律都不变。

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

它解决什么问题

取消好歹有 finally 善后,kill -9 连善后的机会都没有。进程死掉的瞬间,日志停在半截:turn/start 开着,某个工具调用记了 tool/call,却永远等不到 tool/result。重启后冷加载这份日志,摆在面前的是一道选择题:把没写完的轮次删掉,还是补齐?

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

思路是什么

DSH 选补齐。恢复逻辑生成几条确定性的合成事件把尾巴关上:先给每个悬空的工具调用补一条错误占位的 tool/result,再关掉开着的 step,最后合成 turn/end,结局标 interrupted。顺序有讲究:step 还开着就写 turn/end 违反日志不变式,所以先补 step 的边界,再补 turn 的。

已落盘的真实事件一条不动,只在尾部补三条合成事件 kill -9 轮次开始 用户消息 模型输出 工具启动 补占位结果 补步骤收尾 补轮次终局 接着跑 崩溃前已持久化 · 全部保留 恢复时合成 · 终局标 interrupted
合成事件的时间戳复用最后一条真实事件的,绝不发明未来时间。

给悬空工具调用补的那条占位结果,措辞分两种情况。工具记录了启动但结果没落盘的,占位文本告诉模型结果未知,只有只读或幂等操作才可以重试,有副作用的要先核实外部状态或问用户,原文强调 「Do not retry blindly」。工具压根没启动的,直接说需要就重试。

「它不会截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 turn/end { reason: { kind: 'interrupted' } } 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。」

docs/subsystems/persistence.zh.md · 崩溃恢复保留被中断的轮次

还有一条容易忽略的边界:这套修复只对冷会话生效。会话还活着的时候,load 会等权威内存快照落盘、只在日志配平时返回,活跃轮次没闭合就直接拒绝,绝不给一个正在跑的轮次插入合成边界。写盘那头也有讲究:持久化插件批量落盘,循环在领取下一轮之前用 session/flush 做检查点,把顺序和写盘错误都看在眼里。

出处:占位结果的两种措辞在 packages/core/session/src/repair.ts 第 91 至 124 行,closer 的合成顺序(先 step 后 turn)在第 126 至 132 行;只修冷会话在 docs/subsystems/persistence.zh.md 第 17 行,flush 检查点见同文档对应小节。

为什么长期成立

追加式日志加读取时修复,就是数据库 WAL(预写日志)恢复的思路:崩溃后不改写历史,只补足让状态机能继续走的最小事件。只要权威状态放在事件日志里,恢复逻辑重写多少遍都长这样。反过来说,敢截断日志的系统,等于默认单轮工作便宜到可以随便扔,这个假设在长任务时代不成立。

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

Grok Build

进程级 · 专门的崩溃 crate

Grok 有一个专职 crate xai-crash-handler:用 sigaction 接住 SIGSEGV 和 SIGBUS,崩溃现场只用信号安全操作把二进制快照写进 last-crash.bin,还顺手用预计算的转义序列把终端恢复原状;下次启动再解析符号、生成崩溃报告,保留最近 5 份(crate README 与 handler.rs)。

它回答的问题是进程怎么死的、终端别留烂摊子。DSH 的 repair.ts 回答的是会话日志怎么活下来。两家各占一层,正面对比下 DSH 的独特点在于把恢复语义做进了持久化契约:load 的接口注释直接承诺补齐中断尾部、不改写已提交事件。在已核对的 Grok Build 材料中未见等价的会话日志配平机制,这一条基于已公开源码。

Claude Code

任务级 · AbortController 挂在 task 上

后台 agent 的取消走 killAsyncAgent:从任务状态里取出 abortController 调 abort,把任务标成 killed(restored-src 的 LocalAgentTask.tsx 第 283 至 298 行,书稿第 6 章引用)。控制器的生命周期跟着任务走,一个任务一个。

DSH 把粒度再切细一档:控制器跟着轮次走,同一个 agent 的下一轮自动拿新信号,旧回调想越权都拿不到把手。另外 DSH 明确不持久化取消原因,durable 日志只留 aborted;Claude Code 的会话恢复与中断记录细节未在已核对的书稿章节展开,这里保留未知项。

课堂练习
01

推演两个时刻的日志差异

同一个轮次,取消发生在两个不同时刻:a)模型流式输出到一半;b)bash 工具正在执行。

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

问题二:把这两种情况的事故换成 kill -9,重启后 repair 分别要合成几条 closer?提示:流式中断时没有悬空的 tool/call;工具执行中断时有一条记录了启动的 tool/call,对应结果未知的占位文本。

Takeaway:取消是一根显式的线:每轮一个 AbortController,cancel 清 inbox 再 abort,中断的轮次照样写 turn/end { aborted },取消权在 turn/end 发布前收回、绝不跨轮。崩溃恢复不截断:冷加载给悬空工具补错误占位、合成 turn/end { interrupted } 配平,崩溃前已落盘的每一条事件都保留。体面告别和意外身亡,日志里一眼可辨。

「交互演示 · 中断演练场」的能力藏在每次交接里

「下面是一条正在干活的轮次:模型流式输出中,还叫了一个长工具。左边是会话日志,右边是运行状态。三个情景按钮对应三种事故:按 Esc、kill -9 后重启、以及一个假想的截断式恢复做对照。点播放,字幕会告诉你每一步日志里多了什么、少了什么」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。

先写清状态,再增加能力

从「想象取消做成一个全局开关:agent 身上挂一个布尔标志位,谁都能设,各处代码自己抽空看一眼。翻车迟早发生:上一轮注册的某个超时回调半夜苏醒,顺手把正在跑的新一轮取消了;或者取消用 Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态,成了僵尸工作」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。

成功路径不能代表系统可靠

用「问题二:把这两种情况的事故换成 kill -9,重启后 repair 分别要合成几条 closer?提示:流式中断时没有悬空的 tool/call;工具执行中断时有一条记录了启动的 tool/call,对应结果未知的占位文本」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。

从「交互演示 · 中断演练场」走到「它解决什么问题」

「交互演示 · 中断演练场」先把问题落在「下面是一条正在干活的轮次:模型流式输出中,还叫了一个长工具。左边是会话日志,右边是运行状态。三个情景按钮对应三种事故:按 Esc、kill -9 后重启、以及一个假想的截断式恢复做对照。点播放,字幕会告诉你每一步日志里多了什么、少了什么」上;到了「它解决什么问题」,讨论继续推进到「想象取消做成一个全局开关:agent 身上挂一个布尔标志位,谁都能设,各处代码自己抽空看一眼。翻车迟早发生:上一轮注册的某个超时回调半夜苏醒,顺手把正在跑的新一轮取消了;或者取消用 Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态,成了僵尸工作」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。

把这条判断带到下一个场景

分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。

  • 「交互演示 · 中断演练场」:下面是一条正在干活的轮次:模型流式输出中,还叫了一个长工具。左边是会话日志,右边是运行状态。三个情景按钮对应三种事故:按 Esc、kill -9 后重启、以及一个假想的截断式恢复做对照。点播放,字幕会告诉你每一步日志里多了什么、少了什么
  • 「它解决什么问题」:想象取消做成一个全局开关:agent 身上挂一个布尔标志位,谁都能设,各处代码自己抽空看一眼。翻车迟早发生:上一轮注册的某个超时回调半夜苏醒,顺手把正在跑的新一轮取消了;或者取消用 Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态,成了僵尸工作
  • 「最后的要点」:出处:每轮新建 AbortController 在 packages/core/agent-loop/src/agent.ts 第 187 行,跑完换新在第 325 行,cancel 入口(可选清 inbox,再 abort 带类型化 cause)在第 134 至 140 行;取消权不跨轮泄漏的设计记录见项目 Agent Note 2026-07-16

最后的「最后的要点」把讨论落到「出处:每轮新建 AbortController 在 packages/core/agent-loop/src/agent.ts 第 187 行,跑完换新在第 325 行,cancel 入口(可选清 inbox,再 abort 带类型化 cause)在第 134 至 140 行;取消权不跨轮泄漏的设计记录见项目 Agent Note 2026-07-16」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。

标记为已学完 阅读进度会自动记录
← 上一篇下一篇 →

继续阅读

同一条线上的下一篇。

文章讨论

读到这里,留下一个判断。

把刚想明白的地方、还没想通的问题,留给下一位一起学习的人。

正在讨论 Esc 之后发生了什么:取消、崩溃恢复与重入 拆开 DeepSeek Harness
3条讨论文章讨论 · 与共学社区同步
在共学社区查看
AM
Asha Morgan内容编辑
观点实践记录

我把这篇文章里的一个判断改写成了今天可以验证的小实验。比记住结论更有用的是,知道下一步要观察什么。

文章讨论7 有帮助
LH
Lin Harper独立开发者
观点观点

读完以后我先回头找它成立的条件,而不是直接把方法搬进项目。这个顺序让后面的取舍清楚很多。

文章讨论5 有帮助
KM
Kiki Moore产品运营
问题问题

如果把这个判断放到真实工作里,最先需要补的约束是什么?我想知道从阅读到第一次实践之间,哪一步最值得先做。

文章讨论4 有帮助