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 行(Turn 结束后队列还有存货就继续下一轮)。
三家都有排队,差别在插话的粒度。
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。