followup / steer / inject:雙佇列 Inbox
Agent 正在幹活時你想說句話,訊息該排哪條隊、什麼時候被處理。三個 API 共用一個 send(),只差兩個參數。
Agent 跑到一半,使用者突然說話。工程上有三種處理:打斷它重來、排隊等它幹完、悄悄把話塞給它。大多數 harness 只做前兩種。DeepSeek Harness(下稱 DSH)把第三種也做成了正式 API,三種語義共用一個入口 send(),只靠兩個參數區分。
先玩再學。把一個 Turn(輪次,一輪完整工作)想成一班車,Step(步驟,一次模型請求)是一站一站地開。next-turn 佇列是等下一班車的人,next-step 佇列是要插進當前這班的人。Agent 執行時隨時點下面三個按鈕,看訊息落進哪條佇列、在哪一站被接走。底部字幕會解釋每一步在發生什麼。
Agent 空閒,兩條佇列都是空的。點上面的按鈕,或點播放看完整流程。
它解決什麼問題。只有一條訊息佇列的 harness 裡,使用者說話只有兩種命運:打斷,或者排隊。翻車場景很日常:Agent 正在按計劃改十個檔案,改到第三個你發現方向偏了。打斷,前面兩個檔案的活白幹;排隊,只能眼睜睜看它把十個檔案全改錯。你想做的只是補一句話,這兩個選項都要你拿當前進度去換。
思路是什麼。先把迴圈拆成兩層。Turn 是一輪完整工作,Step 是一次模型請求外加它觸發的工具執行,一個 Turn 裡通常有好幾個 Step。拆開的原因很實際:訊息需要一個比整輪更細的投遞點,模型下一次能看到新訊息的機會,就是下一個 Step 的開頭。然後把說話的時機編碼成兩個參數:target 決定排哪條隊(next-turn 等下一班車,next-step 插進當前這班),wakeup 決定要不要叫醒司機(Agent 空閒時是否立刻開工)。三個 API 全是 send() 的參數預設,各自只有三行。
followup下一件事,等這輪幹完再說
steer糾正方向,別推倒重來
inject塞條資訊,別催它幹活
為什麼長期成立。這三種語義是中斷的分類學,和實現語言無關。任何 agent 系統重寫一遍,還是要回答同樣兩個問題:新訊息等當前任務結束,還是插進去?插進去時要不要立刻觸發行動?只要模型呼叫有回合邊界,這套三分法就成立。換 Rust、換 Python 重寫,參數名會變,分類不會。把分類做成 API,使用者補一句話就有了第三種命運,這是把中斷粒度當產品能力來做。
出處:packages/core/agent-loop/src/agent.ts 第 113 至 132 行(send 與三個別名方法)。
它解決什麼問題。佇列有了,下一個問題是誰來取、怎麼取。如果多處程式碼都能從同一條佇列裡讀訊息,兩種事故遲早發生:迴圈取了一次、某個外掛又取一次,同一條訊息進兩遍對話歷史;或者讀了還沒處理完行程崩了,重啟後這條訊息不知去向。翻車場景:崩潰恢復時重放日誌,一條 steer 被消費兩遍,模型收到兩條一模一樣的指令,然後把同一個改動做了兩次。
思路是什麼。DSH 的答案叫 claim(領取)。每個 Step 開始前,迴圈呼叫一次 claim,原子地把 next-step 佇列的全部訊息取走;碰上輪次邊界,再多取一條 next-turn 訊息。注意是一條:連點三次 followup,會得到三個獨立的 Turn。取走這個動作落盤為一條純刪除事件,所以訊息只有兩種狀態:還在佇列裡,或者歸屬某個 Turn,沒有中間態。崩潰後重放日誌,照著刪除事件走,不會重複消費。還有一個容易想錯的點:被 pre-step 外掛拒絕的批次不放回佇列,claim 先於裁決發生,拒絕時不開新 Step,輪次直接以 blocked 收場。
為什麼長期成立。領取制是訊息佇列幾十年的老共識。資料庫裡叫 SELECT FOR UPDATE,SQS 裡叫可見性超時,本質都是把讀取和佔有合成一個原子動作。只要系統同時滿足兩個條件,多個潛在消費者、崩潰後要能恢復,領取制就是標準答案。DSH 只是把這個共識搬進了 agent 迴圈,原始碼換幾個版本,這個取法不會變。
出處:packages/core/agent/src/inbox.ts 第 71 至 78 行(claim 本體);packages/core/agent-loop/src/agent.ts 第 229 行(每步開頭呼叫)、第 266 至 269 行(reject 分支,被拒批次不回隊)。
它解決什麼問題。你按 Esc 中止了當前活動,緊接著又發一條 steer。steer 的語義是插進當前 Turn 的下一個 Step,但這個 Turn 正在死掉,它的下一個 Step 永遠不會到來。如果照原樣入隊,只有兩種壞結局:訊息永遠躺在佇列裡沒人接,會話卡死;或者強行插進一個正在收尾的回合,等於插進一個正在死掉的回合,行為沒法預測。
思路是什麼。send() 在入隊之前先看一眼現場:這條訊息要求喚醒,而當前活動已經被中止?那就把投遞目標改寫成 next-turn,喚醒請求先上閂記著,等被中止的活動善後完畢、狀態收斂到空閒,再重放喚醒,開一班全新的車。判斷發生在入隊之前,所以佇列裡從頭到尾不會出現一條註定沒人接的訊息。inject 不要求喚醒,不受這個降級影響,照常排進 next-step,等新一班車的第一站被順路接走。
為什麼長期成立。這是併發系統的通用命題:事件到達時,它的目標正在死亡。答案也是通用的:別追一個正在退出的執行體,把事件重新排到下一個穩定邊界。作業系統給正在退出的行程遞訊號、Actor 系統給正在停機的 Actor 發訊息,處理套路都一樣。DSH 把這個套路放在了 send() 的入口處,位置會隨重構變,判斷本身不會。
出處:packages/core/agent-loop/src/agent.ts 第 113 至 120 行(wakingAfterAbort 判斷與目標改寫)、第 164 至 193 行(wakeDriver 的上閂與重放)。另外兩處主迴圈行為:第 299 行(next-step 佇列非空時 Turn 不關閉,繼續開新 Step)、第 324 至 329 行(佇列空了就收工,還有存貨則換一個 AbortController 繼續下一輪)。
三家都有排隊,差別在插話的粒度。
DeepSeek Harness雙佇列 + 三語義
兩條持久佇列,三個語義共用 send() 一個入口。插話但不打斷的 inject 是獨立的正式 API:投遞、不喚醒、不打斷,等下一個自然的 Step 邊界被領取。
Claude Code打斷當前流 + 訊息排隊
使用者打斷走生成器終止:Ctrl+C 觸發 .return(),巢狀的生成器一起收尾。執行中的輸入進排隊命令流,等當前流結束後再消費,沒有步級插話。出處:claude-code-sourcemap-main/study/chapters/01-architecture.md。完整排程原始碼未公開,結論基於已公開證據的推斷。
Grok Build單佇列 + CancellationToken
每個 Session 是獨立執行緒上的 Actor(見站內 Session Actor 課),取消走 CancellationToken 協作式收尾。佇列只有一條:條目按 position 排序等待,running_prompt_id 標記正在執行的那條。沒有對應 inject 的中途插話語義。出處:crates/codegen/xai-prompt-queue/src/types.rs 第 44 至 56 行。
對比下來,只有 DSH 把插話但不打斷、也不用等整輪結束的 inject 做成了公開 API,訊息在當前 Turn 的下一個 Step 就能被模型看到。
推演一次錯過時機的 inject
Agent 正在 Turn 3 的 Step 2 流式輸出,外掛呼叫 inject()。請推演:這條訊息最早在哪個時刻被領取?如果 Step 2 本來是本輪最後一步,它會被丟掉,還是把 Turn 續命一步?如果 Agent 已經空閒,它要等到什麼時候才被消費?推完回到上面的演示裡驗證。
send() 的參數預設。claim 是原子交接:訊息要麼在佇列,要麼歸屬某個 Turn,被拒不回隊。中斷後的喚醒輸入一律改排 next-turn,因為死掉的 Turn 不再有下一個 Step。