followup / steer / inject:双队列 Inbox
拆解 next-turn 与 next-step 两条队列的入队、claim 与唤醒语义,对照 Claude Code 的中断模型
本页解决的问题
先给结论「followup / steer / inject:双队列 Inbox」要解决的关键问题是什么?
拆解 next-turn 与 next-step 两条队列的入队、claim 与唤醒语义,对照 Claude Code 的中断模型
让这个结论先证明自己值得留下。 把这一页当成决策工具,而不是需要背下来的定义。把概念连到一个真实任务、一个可观察结果,以及一个能改变你判断的失败上。
写下一个问题:试完这个方法后,你能用什么证据回答它?
结论听起来很完整,却没有检查最关键的假设。
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。
「交互演示 · 排队模拟器」的能力藏在每次交接里
「Agent 跑到一半,用户突然说话。工程上有三种处理:打断它重来、排队等它干完、悄悄把话塞给它。大多数 harness 只做前两种。DeepSeek Harness(下称 DSH)把第三种也做成了正式 API,三种语义共用一个入口 send() ,只靠两个参数区分」说明,Agent 的表现不只由模型决定。模型、上下文、工具、状态、权限和人之间的每次交接,都会改变任务能否继续以及出了问题能否恢复。
先写清状态,再增加能力
从「先玩再学。把一个 Turn (轮次,一轮完整工作)想成一班车, Step (步骤,一次模型请求)是一站一站地开。next-turn 队列是等下一班车的人,next-step 队列是要插进当前这班的人。Agent 运行时随时点下面三个按钮,看消息落进哪条队列、在哪一站被接走。底部字幕会解释每一步在发生什么」出发,可以把流程拆成起始状态、下一步动作、工具返回、状态更新和停止条件。这样调试时找的是第一处丢失信息或权限的位置,而不是笼统地说“模型变笨了”。
成功路径不能代表系统可靠
用「Agent 正在 Turn 3 的 Step 2 流式输出,插件调用 inject() 。请推演:这条消息最早在哪个时刻被领取?如果 Step 2 本来是本轮最后一步,它会被丢掉,还是把 Turn 续命一步?如果 Agent 已经空闲,它要等到什么时候才被消费?推完回到上面的演示里验证」重放一次成功和一次失败,记录每一轮真正传入的上下文、工具结果和负责人;只要第二个人能复述这条链,系统才有可维护性。
从「交互演示 · 排队模拟器」走到「思路一 · 插话要分三种,粒度是产品能力」
「交互演示 · 排队模拟器」先把问题落在「先玩再学。把一个 Turn (轮次,一轮完整工作)想成一班车, Step (步骤,一次模型请求)是一站一站地开。next-turn 队列是等下一班车的人,next-step 队列是要插进当前这班的人。Agent 运行时随时点下面三个按钮,看消息落进哪条队列、在哪一站被接走。底部字幕会解释每一步在发生什么」上;到了「思路一 · 插话要分三种,粒度是产品能力」,讨论继续推进到「它解决什么问题。 只有一条消息队列的 harness 里,用户说话只有两种命运:打断,或者排队。翻车场景很日常:Agent 正在按计划改十个文件,改到第三个你发现方向偏了。打断,前面两个文件的活白干;排队,只能眼睁睁看它把十个文件全改错。你想做的只是补一句话,这两个选项都要你拿当前进度去换」。两段连起来,重点就不只是记住一个结论,而是看清它成立所依赖的条件。
把这条判断带到下一个场景
分析 Agent 时,沿着状态、动作、工具结果和下一步的顺序走一遍;每次交接都要能说明信息从哪里来、由谁确认、失败时停在哪里。
- 「交互演示 · 排队模拟器」:先玩再学。把一个 Turn (轮次,一轮完整工作)想成一班车, Step (步骤,一次模型请求)是一站一站地开。next-turn 队列是等下一班车的人,next-step 队列是要插进当前这班的人。Agent 运行时随时点下面三个按钮,看消息落进哪条队列、在哪一站被接走。底部字幕会解释每一步在发生什么
- 「思路一 · 插话要分三种,粒度是产品能力」:它解决什么问题。 只有一条消息队列的 harness 里,用户说话只有两种命运:打断,或者排队。翻车场景很日常:Agent 正在按计划改十个文件,改到第三个你发现方向偏了。打断,前面两个文件的活白干;排队,只能眼睁睁看它把十个文件全改错。你想做的只是补一句话,这两个选项都要你拿当前进度去换
- 「最后的要点」:思路是什么。 DSH 的答案叫 claim(领取)。每个 Step 开始前,循环调用一次 claim,原子地把 next-step 队列的全部消息取走;碰上轮次边界,再多取一条 next-turn 消息。注意是一条:连点三次 followup,会得到三个独立的 Turn。取走这个动作落盘为一条纯删除事件,所以消息只有两种状态:还在队列里,或者归属某个 Turn,没有中间态。崩溃后重放日志,照着删除事件走,不会重复消费…
最后的「最后的要点」把讨论落到「思路是什么。 DSH 的答案叫 claim(领取)。每个 Step 开始前,循环调用一次 claim,原子地把 next-step 队列的全部消息取走;碰上轮次边界,再多取一条 next-turn 消息。注意是一条:连点三次 followup,会得到三个独立的 Turn。取走这个动作落盘为一条纯删除事件,所以消息只有两种状态:还在队列里,或者归属某个 Turn,没有中间态。崩溃后重放日志,照着删除事件走,不会重复消费…」。回看这条线索时,最值得保留的是:当输入、规模或风险改变,哪些判断需要重新做一遍。
把 followup、steer 和 inject 分开看之后,Inbox 不再像一个普通消息列表,而是两种不同时间关系的入口。这个区分比记 API 名字更重要。
我用“当前生成是否还在运行”作为第一道判断,再决定消息进哪条队列。这样做以后,取消、追加和纠偏的测试用例清楚多了。
如果用户连续快速发送 steer,系统应该合并中间状态,还是严格保留每一次意图?这似乎会影响 UI 如何反馈“已收到”。
还没有这篇文章的讨论。