ARTICLE · 1235509
DeepSeek-Harness 源码专题(3):正在干的 agent 递条子,它凭什么不停手
摘要:给在跑的 agent 送输入有三条路:followup 排下一回合、steer 在 step 边界插进去、inject 静默注入不唤醒。steer 不打断流式请求,日志 append-only 可重放;硬停走 cancel:abort 贯穿每个 await 点,未启动的工具补合成错误结果,call/result 永远成对。
接着上两篇的会话。47 个 deprecated 调用清到一半,模型这一轮发起了全量测试,bash 要跑几分钟。用户盯着 web 端的输出流,发现它下一步打算顺手改 fixtures 里的快照文件,而那些快照是故意保持旧行为的。用户敲下一句话递进去:「fixtures 里的快照别动。」
这句话送出去之后发生了什么?很多人的直觉是「打断它」。这篇的问题就是:正在干的 agent,收到一句话,凭什么不停手?
先给结论:不停。这句话被排进一个叫 next-step 的队列,当前测试跑完、下一次模型请求组装之前,它才会作为一条新的 user message 出现在请求里。dsh 把「给 agent 送输入」这件事拆成了三条定向路,「干预正在运行」只是其中一条,而且它是软的。真正的硬停是另一条独立通道。我们把三条路走完,再回答为什么软是对的选择。
一、三条路,只有队列和唤醒两个开关
三个 API 是同一个 send 的特化(agent.ts:122-132):
followup(input: UserMessage): void { this.send(input, 'next-turn', true) } steer(input: UserMessage): void { this.send(input, 'next-step', true) } inject(input: UserMessage): void { this.send(input, 'next-step', false) }这段是送输入的三路网关(agent.ts:122-132)。两个开关拼出三种语义:队列选 next-turn 还是 next-step,唤醒选 true 还是 false。followup 排队等下一个回合,steer 在下一个 step 边界插队且会唤醒空闲 agent,inject 同样进 next-step 但不唤醒,纯粹的静默补上下文。
send 本体里有一行改投逻辑值得看(agent.ts:113-120):
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) if (wakeup) this.wakeDriver(wakingAfterAbort) }这段是送输入的底座(agent.ts:113-120)。注意看 wakingAfterAbort:一条要唤醒的消息如果撞上已经 abort 的活动,会被改投进 next-turn 队列,因为唤醒输入不能加入一个正在终止的活动,只能开启下一回合。而且这个判定在插入消息之前完成,注释写了理由:splice 的观察者里可能重入一次 cancel,先分类可以防止它被改判。递条子的语义在落盘之前就已经定死。
二、条子先落盘,再进内存
消息排队不是内存里的临时状态。Inbox 是一个双队列投影,构造时从会话日志重放全部 agent/inbox/spliced 事件(inbox.ts:32-40);每次变更先持久化提交,然后才改内存(inbox.ts:186-187):
const splice = { target, start: actualStart, ...(actualDeleteCount === 0 ? {} : { removedCount: actualDeleteCount }), inserted, ...(outcome === undefined ? {} : { outcome }), } this.validate(splice) const event = this.session.append('agent/inbox/spliced', splice) const removed = inbox.splice(actualStart, actualDeleteCount, ...event.data.inserted) // …(removed 的通知发布与返回略)这段是一次队列变更的提交(inbox.ts:178-191 核心)。注意看顺序:session.append 在前,内存 inbox.splice 在后。进程在两条语句之间被杀,重启后重放日志,队列原样重建,条子不丢。validate(inbox.ts:203-219)还卡了一条身份唯一:同一条消息的 id 不允许在挂起状态里出现两次。
队列本身是可编辑的。我们看 append、prepend、replace、remove 四个操作(inbox.ts:86-126),每个都是一条 splice 事件。这就是 web 端「排队消息可改写、可撤回、可从排队升级为插话」的能力底座——全部动作写进日志,审计都能追上。
三、step 边界:条子在哪儿被消费
队列里的消息在 turn 主循环的每轮迭代顶部被取走。preStep 第一件事就是 claim(agent.ts:229):
claim(target: InboxTarget, turn: number): UserMessage[] { const claimed = this.mutate('next-step', 0, this.nextStep.length, [], false) if (target === 'next-turn') { claimed.push(...this.mutate('next-turn', 0, 1, [], false)) } for (const message of claimed) this.notifications.claimed(message, turn) return claimed }这段是 step 边界的批量领取(inbox.ts:71-78)。next-step 队列被一次性整体取走,target 是 next-turn 时再补领一条排队的回合消息。被 claim 的消息随后作为 user/message 正式写入会话(agent.ts:282-284),成为模型可见历史——落日志时带着 surfaceOp 标记,tp01 拆过的那套表面机制,插话也是普通公民。
所以 steer 的生效点是:当前 step 的流式响应和全部工具调用完整结束后,下一次请求组装之前。正在飞行的那次请求会跑完并落盘,用户的条子出现在紧随其后的请求里。
还有一个收尾处的精巧交互(agent.ts:294-300):
signal.throwIfAborted() if (turnEnds && this.inbox.nextStep.length === 0) { await this.dispatch.serial('agent/turn-stopping', { turn, signal }) signal.throwIfAborted() } if (turnEnds && this.inbox.nextStep.length === 0) break target = 'next-step'这段是 turn 的收尾判断(agent.ts:294-300)。注意看条件:turn 已经到了自然终点(模型不再调工具),但 next-step 队列非空时,不退出,继续下一步把插话消费掉。用户在你收尾时递了条子,你得先看完再走。第二道检查之前还串行过了 agent/turn-stopping 钩子点,Stop 钩子 deny 能强制续跑,用的正是 steer 通道,§六 会见到。
四、排序不需要机制,因为只有一个队列
中途进来的输入不止用户条子:工具执行完可能附带 additionalContexts,插件也可能注入消息。它们怎么排队?不需要额外机制,所有输入都走同一个 next-step 队列的队尾追加(agent.ts:414-417):
const { concluded } = await executeToolCalls( this.loopCtx, turn, step, toolCalls, signal, context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), )这段是工具结果的上下文入队(agent.ts:414-417)。注意看回调:splice('next-step', this.inbox.nextStep.length, 0, [context]),位置是队尾。用户 steer 也是队尾 append,先到先得,到达次序就是消费次序。回到我们的案例:测试 bash 跑完时附带一个上下文块,排在用户那条「fixtures 别动」后面,claim 时两条一起被取走,按序写进下一次请求。
五、硬打断是另一条路:cancel 与 abort
软干预有个边界:它最坏要等当前 step 跑完,长工具调用期间递的条子,工具不收尾就看不到。真需要立刻停下的时候走 cancel(agent.ts:134-140):
cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { this.inbox.clear() if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) }这段是硬干预的入口(agent.ts:134-140)。默认清空两个队列、撤销唤醒请求,然后 abort 当前活动的 AbortController。abort signal 贯穿循环的每个 await 点:turn 循环每轮开头(agent.ts:264)、preStep 内两处(:231、:241)、step 的流式循环里每个 chunk(:347-349)都 throwIfAborted。
abort 之后还有两个收尾动作保证日志自洽。《你取消了工具调用,日志里反而多了一条记录》拆过的那件事,这里看实现(tool-calls.ts:248-258):模型已经发出但还没启动的工具调用,补写一对合成的 call/result,错误信息是 「tool call aborted before dispatch」(内容文本带 Error: 前缀),带 isError 标记。取消不能让 call 悬空,重放时 call/result 必须成对完整。turnEnds 被置为 {kind:'aborted', reason}(agent.ts:303-305),finally 里统一落一条 turn/end(:319)。
web 端有个细节:用户点取消时走的是 agent.cancel({kind:'user'}, {keepInbox: true})(api-proxy.ts:2560),队列保留。取消了,但排着的下一轮话还在,重新发起时不用重敲。
六、steer 不只是人类通道
三条路拆完,最后看这个机制的复用面,它比「用户插话」宽得多。
web 端 prompt RPC 按 mode 分流(api-proxy.ts:2425-2428):mode === 'steer' ? agent.steer(message) : agent.followup(message),客户端契约写得很直白(session.ts:36-42):'queue' appends a turn; 'steer' interrupts the running one。对仍在排队的消息,updateQueue 支持 edit、remove、steer 三种动作,把一条排队消息升级为 steer 有硬守卫(api-proxy.ts:2531-2536):必须还在 next-turn 队列且 agent 正在 running,否则返回 steer-unavailable,当前回合不再接受引导。
插件侧,hooks-claude-code 把 Claude Code 的 Stop 钩子桥成了 steer(hooks index.ts:266-275):
// A blocking Stop hook steers at the stopping boundary, which makes the // machine observe pending input and run another step. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => { const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation. const text = merged.reason ?? 'continue: blocked by Stop hook' agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE })) } })这段是 Stop 钩子的续跑通道(hooks index.ts:267-277)。钩子 decision 是 deny 时,往 next-step 队列 steer 一条续跑消息,主循环在收尾判断处看到非空队列,turn 就走不下去,得再跑一步。steer 在这里是机器的续命原语,人类插话只是搭了同一班车。
子代理那边更明显。子代理结束时若要唤醒忙碌的父级,走的是 steer 不是 followup,注释把账算得很清楚(continuation.ts:1494-1503):Inbox.claim() 在一个边界取走整个 next-step 批量,几个子代理同时结束,父级只花一个 step 就能消化全部结果,而不是每个子代理各占一个 turn。 steer 的批处理语义是整套机器的基本节拍。
七、对账
| 问题 | 答案 | 落点 |
|---|---|---|
| 递条子打不打断 | 不打断,step 边界生效 | preStep claim(agent.ts:229) |
| 条子会不会丢 | 先落盘后入内存,重放重建 | inbox.ts:186-187 |
| 收尾时递条子呢 | nextStep 非空则 turn 不收尾 | agent.ts:295-299 |
| 排序怎么保证 | 全部中途输入共用一个队尾 | agent.ts:414-417 |
| 真要立刻停 | cancel:清队列 + abort + 合成结果 | agent.ts:134-140;tool-calls.ts:248-258 |
| 谁能 steer | 用户、Stop 钩子、子代理 | hooks :267-277;continuation.ts:1494-1503 |
边界也说清。steer 的代价是即时性:条子最坏等当前 step(含长工具)跑完才被看到,心急的取消之后重发也解决不了工具级的问题,那种场景该走 cancel。三条路共享一个 send 和一个 Inbox,没有三套机制,但语义上覆盖了「等它做完、马上插话、只补上下文」的全部人类意图,这套最小集本身就是设计。
结语
回到那条测试。bash 跑了四分半,用户的「fixtures 别动」在 next-step 队列里躺了全程,日志里它是一条 agent/inbox/spliced 事件,重启也丢不了。测试收尾,claim 一次性取走条子和工具的上下文块,两条 user message 按序写进会话,下一次请求的组装材料里,fixtures 警告排在最前面。模型看到之后绕开了快照目录,turn 正常走完。
不停手不是迟钝,是这个系统的立场:正在飞行的请求不追砍,每一条都完整落盘,重放时能精确回答「模型当时到底看到了什么」。专题下一篇切 session replay 与 mock LLM——把这条日志管道拧到底,测试里假装一个模型有多难,下一篇见。
你往正在跑的 agent 手里塞过话吗,它当场理你了吗?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































