DeepSeek Harness · 會話與循環

Esc 之後發生了什麼:取消、崩潰恢復與重入

每輪一個取消信號,中斷也要把賬記平,kill -9 之後重啓還能接着跑,已落盤的勞動成果一條不丟。

課程目標讀完你能説清三件事:按下 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 } 配平,崩潰前已落盤的每一條事件都保留。體面告別和意外身亡,日誌裏一眼可辨。