DeepSeek Harness · 会话与循环

followup / steer / inject:双队列 Inbox

Agent 正在干活时你想说句话,消息该排哪条队、什么时候被处理。三个 API 共用一个 send(),只差两个参数。

课程目标读完本课,你能说清三件事:followup、steer、inject 差在哪两个参数;消息进了队列之后,在哪个时刻被取走;中断之后再说话,为什么会被强制改排到下一轮。

Agent 跑到一半,用户突然说话。工程上有三种处理:打断它重来、排队等它干完、悄悄把话塞给它。大多数 harness 只做前两种。DeepSeek Harness(下称 DSH)把第三种也做成了正式 API,三种语义共用一个入口 send(),只靠两个参数区分。

交互演示 · 排队模拟器

先玩再学。把一个 Turn(轮次,一轮完整工作)想成一班车,Step(步骤,一次模型请求)是一站一站地开。next-turn 队列是等下一班车的人,next-step 队列是要插进当前这班的人。Agent 运行时随时点下面三个按钮,看消息落进哪条队列、在哪一站被接走。底部字幕会解释每一步在发生什么。

空闲

Agent 空闲,两条队列都是空的。点上面的按钮,或点播放看完整流程。

事件记录(模拟)
试一下:Agent 空闲时点 inject,消息会一直留在队列里,这就是不唤醒的含义。时间轴为教学化模拟。出处:packages/core/agent-loop/src/agent.ts 第 113 至 132 行。
思路一 · 插话要分三种,粒度是产品能力

它解决什么问题。只有一条消息队列的 harness 里,用户说话只有两种命运:打断,或者排队。翻车场景很日常:Agent 正在按计划改十个文件,改到第三个你发现方向偏了。打断,前面两个文件的活白干;排队,只能眼睁睁看它把十个文件全改错。你想做的只是补一句话,这两个选项都要你拿当前进度去换。

思路是什么。先把循环拆成两层。Turn 是一轮完整工作,Step 是一次模型请求外加它触发的工具执行,一个 Turn 里通常有好几个 Step。拆开的原因很实际:消息需要一个比整轮更细的投递点,模型下一次能看到新消息的机会,就是下一个 Step 的开头。然后把说话的时机编码成两个参数:target 决定排哪条队(next-turn 等下一班车,next-step 插进当前这班),wakeup 决定要不要叫醒司机(Agent 空闲时是否立刻开工)。三个 API 全是 send() 的参数预设,各自只有三行。

followup = 排队 + 叫醒  ·  steer = 插队 + 叫醒  ·  inject = 插队 + 不吭声

followup下一件事,等这轮干完再说

什么时候用这句话和当前任务无关,是下一个任务。不想打扰正在进行的活。
进哪条队next-turn 队列。等当前 Turn 完整跑完,下一班车发车时才被接走,一班只接一条。
唤不唤醒唤醒。Agent 睡着也会被叫起来,马上开一班新车。
生活化例句:「这个改完之后,帮我再把测试补上。」

steer纠正方向,别推倒重来

什么时候用想纠正正在进行的活,又不想牺牲已有进度。方向偏了、要补个约束,都用它。
进哪条队next-step 队列。当前这一步跑完,下一步开头就被模型看到,全部一次取走。
唤不唤醒唤醒。Agent 空闲时也能直接把它叫起来。
生活化例句:「等等,配置文件用 YAML 写,别用 JSON。」

inject塞条信息,别催它干活

什么时候用插件想给模型塞一条环境提醒,又不想因此触发一轮空跑。给人的补充是 steer,给环境的旁白是 inject。
进哪条队next-step 队列,和 steer 同一条。下一个自然到站时被顺路接走。
唤不唤醒不唤醒。Agent 睡着就一直躺在队列里,等别的事把它叫醒时一起处理。
生活化例句:「顺便说一句,用户刚把分支切到 main 了。」

为什么长期成立。这三种语义是中断的分类学,和实现语言无关。任何 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 收场。

send(消息, target, wakeup) next-turn 队列 每轮只取 1 条 next-step 队列 每步全部取走 claim() 原子领取 Step 开始
教学化结构图。取走落盘为纯删除事件:消息要么在队列里,要么归属某个 Turn,没有中间态。

为什么长期成立。领取制是消息队列几十年的老共识。数据库里叫 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,等新一班车的第一站被顺路接走。

一条要唤醒的消息 steer 或 followup 到达 入队前检查 当前活动被中止了吗 没有 中止了 按原目标入队 steer 照常进 next-step 改投 next-turn 唤醒上闩,收敛后开全新 Turn
教学化结构图。降级判断发生在入队之前,队列里不会出现注定没人接的消息。

为什么长期成立。这是并发系统的通用命题:事件到达时,它的目标正在死亡。答案也是通用的:别追一个正在退出的执行体,把事件重新排到下一个稳定边界。操作系统给正在退出的进程递信号、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 就能被模型看到。

课堂练习
01

推演一次错过时机的 inject

Agent 正在 Turn 3 的 Step 2 流式输出,插件调用 inject()。请推演:这条消息最早在哪个时刻被领取?如果 Step 2 本来是本轮最后一步,它会被丢掉,还是把 Turn 续命一步?如果 Agent 已经空闲,它要等到什么时候才被消费?推完回到上面的演示里验证。

Takeaway:DSH 用 next-turn / next-step 两条持久队列把说话的时机编码成数据,三个 API 只是 send() 的参数预设。claim 是原子交接:消息要么在队列,要么归属某个 Turn,被拒不回队。中断后的唤醒输入一律改排 next-turn,因为死掉的 Turn 不再有下一个 Step。