Agent的一次「回合」到底是什么——DeepSeek Harness的Turn、Step与Inbox,以及为什么One send, one turn

做陪伴产品,最常见的场景不是一问一答。

模型还在回,用户又发了一句;语音聊着聊着,用户突然打断;设备那边上报了一条健康数据,要不要现在就提一嘴?没有用户输入的时候,Agent 也要主动发起关心。

这些问题归根到底,是在问同一件事:Agent 的一次"回合"怎么界定,外面进来的输入什么时候、以什么方式进入回合。

DeepSeek Harness(dsh)在这件事上想得非常细。这是 dsh 系列的第二篇,来拆它的 Turn、Step 与 Inbox。

一、step、turn、round

dsh 把 agent 的运行拆成了严格的几个层级:

层级定义
step一次模型请求,加上它引发的工具执行
turn零个或多个 step,一次 inbox 的排空
round包含 turn 的外层策略迭代,比如一个目标驱动的循环
exchange从一次人类输入的 turn,到下一次人类输入的 turn 之间

最核心的是前两个。一个 step 就是"问一次模型,把它要调的工具都执行完";一个 turn 是若干个 step,从开始处理输入,到"什么都不欠了"为止。

什么叫"什么都不欠"?模型不欠回复,工具不欠结果,inbox 里也没有等着下一个 step 处理的输入。三个条件都满足,turn 才关闭。

用数据库的话来类比,turn 是事务,step 是语句。持久化、崩溃恢复、fork 会话,都只在 turn 的边界上做。

二、一个 turn 是怎么走的

一个 turn 的标准流程大致是这样的(简化版):

turn/start
  从 inbox 认领输入
  组装提示词段落和工具 schema
  -> agent/pre-step                 决定:拒绝,或者带着这批消息进入请求
     step/start
     agent/request                   最后调整这次调用的配置
     记录 user/message,派生模型历史
     流式调用模型
     assistant/message
     tool/call -> 执行管线 -> tool/result
     step/end
     工具还需要一次请求,或者有新的 next-step 输入 -> 下一个 step
  -> agent/turn-stopping            turn 即将关闭
turn/end

注意 agent/pre-step 这个位置。它是进入请求的唯一闸门。压缩、计划模式、时间信息、技能提示,所有"在请求发出之前加工上下文"的插件,都挂在这一个点上。外部的任何输入,都不能绕过它直接塞进请求里。

三、Inbox:所有输入的唯一入口

外面进来的输入,不管是用户的消息、后台任务的通知,还是别的插件想补充的上下文,都只能投递到 agent 的 inbox 里。

inbox 有两个队列:

  • next-turn:等着开一个新 turn 来处理;
  • next-step:等着在下一个 step 的边界被带进请求。

在此之上,dsh 定义了三种固定的投递方式:

投递目标队列是否唤醒典型用途
followupnext-turn是普通的用户消息
steernext-step是运行中纠偏
injectnext-step否后台任务完成通知、附加上下文

三者的区别,用一句话说就是:followup 是"等你忙完,开一轮新的",steer 是"别停,下一步就带上这句",inject 是"不用叫醒你,下次干活时顺便看一眼"。

来看 inject 的一个例子。agent 空闲的时候,一个后台跑着的 bash 任务完成了:

  1. 工具插件调用 agent.inject(通知),通知进入 next-step 队列,agent 不会被唤醒,状态保持空闲;
  2. 过了一会儿,用户发了一句"继续",这是一个 followup,agent 被唤醒,开始一个新的 turn;
  3. turn 开始时认领输入:next-step 里的通知,加上 next-turn 里的这条消息;
  4. 模型这一次的请求里,通知和用户的消息一起出现。

后台任务完成了,并不一定值得专门打扰一次,等下一次自然的交互时带上就行。

四、Turn 封闭不变式

上面那个例子,其实来自一个真实的 bug。

在旧的实现里,agent 空闲时收到的后台通知,是直接写进会话日志的,落在了两个 turn 之间。而崩溃恢复的逻辑是:把最后一个没关闭的 turn 用一个"中断"的结束事件封口。结果这条落在 turn 外面的通知,在重载以后被当成了崩溃的残渣,丢掉了。

dsh 的修复不是去改崩溃恢复的逻辑,让它"更宽容"一点,而是立了一条规矩:所有模型可见的输入,都必须写在某个 turn 之内。排队的用户消息写在 turn/start 之后;turn 的编号每次从日志里推导,而不是靠一个私有的计数器;开发模式下,往 turn 外面写这类事件会直接抛错。

这里面有一句话我特别认同:

约束生产者,而不是放宽读者。

一条可以检查的生产端规则,胜过一套复杂的容错扫描。读的那一端越宽容,系统里就越容易藏着各种"差不多对"的数据。

五、One send, one turn

再看另一个决策。用户连着发了两条消息 A 和 B,如果它们恰好同时在队列里,要不要合并进同一个 turn?

直觉上合并更省事,少一次模型请求。但 dsh 的结论是不合并:每一次成功的 send() 都是一个独立的项,至多对应一个 turn。B 的 turn,要等 A 的 turn 结束、并且结果落盘之后才开始。

为什么?因为如果允许合并,turn 的边界就取决于调度的时序,而不是调用者的意图。A 和 B 是一起处理还是分开处理,全看 B 来得够不够快。B 甚至可能在 A 的结果还没落盘时,就进入了同一个请求。同样的输入,跑出不同的结果,这种问题极难排查。

代价是更多的模型请求和更多的持久化。dsh 的态度是,要批处理可以,但只能通过一个显式的、可度量的契约回来,而不是靠时序碰巧合并。

六、终止用数据表达

最后说一下 turn 怎么结束。

turn 即将关闭时,会触发 agent/turn-stopping。如果有插件觉得"还不能停,还有活没干完",它的做法不是在监听者里返回一个"阻止",而是调用 agent.steer() 往 inbox 里追加一条输入。机器重新读一遍 inbox,发现有新的 next-step 输入,就继续下一个 step。

反过来,工具的结果里可以带一个 concludesTurn 标记,表示"这一轮到此为止",提前结束 turn。

两个方向都是用数据来表达,而不是用监听者的顺序。谁先注册、谁后注册,不影响结果。

七、对照我们的陪伴产品

读完这一章,我把我们产品里几个典型场景对照了一遍。

1. 用户插话

我们现在的做法是:用户插话,就取消当前的轮次;取消之后,旧轮次如果还有迟到的消息(比如模型流式输出的尾巴、工具的结果)回来,按轮次号作废,保证状态一致。

dsh 给了另一个选项:steer,不取消,让模型在下一个 step 的边界看到用户新说的话。

两种方式各有适用的场景。语音里用户打断,说明他已经不想听了,这时候应该取消;文字里用户补了一句"对了,还有……",他并没有否定前面的内容,用 steer 把补充带进下一步,可能比整轮作废更自然。这一点值得在产品层面区分一下。

2. 主动关心

主动关心本质上是一个外层策略发起的 turn,属于 round 这一层。按 dsh 的思路,它应该和用户消息一样走 inbox、过 pre-step 的闸门,而不是绕过 inbox 直接拼一个请求发出去。这样它的输入同样会被记录在 turn 之内,回放和排查时和普通对话没有区别。

3. 设备事件

戒指、手表上报的健康数据,正好对应 followup 和 inject 的区别:

  • 心率异常这类需要马上回应的,用 followup,唤醒 agent,开一轮新的;
  • 日常的步数、睡眠数据,用 inject,不打扰用户,等下次聊天时作为上下文带上。

4. 语音断句

语音还有一个特有的问题:用户一句话中间停顿得稍微久一点,VAD 就可能把它切成了两段,变成两次输入。

按 one send, one turn 的原则,这会变成两轮回复,第一轮还是基于半句话的。但我不打算在 inbox 里做"时间窗内自动合并",这正是 dsh 反对的隐式批处理。更好的做法是在输入适配层做显式的合并:有明确的规则(比如语义上没说完、间隔小于某个阈值),合并的决定也记录下来。进了 inbox 的,就是一条完整的输入。

总结

  1. step 是一次模型请求加它的工具执行,turn 是若干个 step、一次 inbox 的排空,turn 是唯一的持久化和崩溃恢复边界;
  2. 所有输入都走 inbox,所有准入都走 agent/pre-step 这一个闸门;
  3. followup 开新的 turn,steer 在下个 step 带上,inject 不唤醒、下次顺便看,三种投递对应三种紧急程度;
  4. 所有模型可见的输入都必须写在 turn 之内,约束生产者,而不是放宽读者;
  5. 一次 send 至多一个 turn,不做隐式批处理;turn 的终止和续跑用数据表达,不依赖监听者的顺序。

相关资料