摘要:Agent 跑起来之后,你突然想起漏说了一个点。以前只能等它做完,或者停掉重来;现在主流 Agent 多了一条路------不停,直接说,它下一步就能读到。这个能力通常叫 steering。本文从 turn 与 step 的粒度讲清它的原理、实现,以及它和 followup、inject 的区别。
一、一个很具体的痛点
让 Agent 去修一个 bug,它已经开始跑测试了,你突然想起来:这个仓库包的构建命令不是 pnpm build,是 yarn build。
以前的交互只有两条路:
- 等它做完再说。可能白跑十分钟,甚至因为构建失败绕一大圈。
- 停掉它,重新说一遍。它刚读的文件、刚跑出来的日志、刚改到一半的代码,全部作废。
现在多了一条:不用停,直接补一句,它在下一个步骤就能读到,并且把它和当前进展一起考虑。
这个能力在业界通常叫 steering(中途转向 / 实时插话),也有人叫 mid-turn steering、interrupt-free steering。名字不统一,但底层机制是同一件事。
二、先搞清楚一个 turn 里有什么
要理解 steering,先得把两个词分开:turn 和 step。
- turn(轮次):从"用户发来一条消息"到"Agent 停下来等下一句"的完整过程。
- step(步骤):turn 内部的一次循环------组装请求 → 模型返回工具调用 → 执行工具 → 带着结果再请求。
关键点:turn 不是原子的,step 才是原子单位。
模型两次请求之间天然存在一个空档:工具已经跑完、结果已经拿到、下一次请求还没发出。这个位置插入一条消息,既不破坏当前模型调用的完整性,也不需要丢弃任何已有状态。
以前做不到,是因为早期实现把整个 turn 当成一个不可分割的操作:run() 一路跑到结束才返回,中间没有可观测、可写入的边界。要改动只能 cancel 整个 turn,于是推理轨迹、工具调用记录、后台任务全部被丢掉。
三、三种"递话"方式
一旦运行时有了 inbox(收件箱)和边界,就能区分出三种语义:
| steer | followup | inject | |
|---|---|---|---|
| 落地位置 | 当前 turn 的下一个 step | 下一个 turn | 下一个被接纳的 step |
| 是否唤醒驱动器 | 是 | 是 | 否 |
| 是否延长当前 turn | 是 | 否 | 是(但不主动发起) |
| 消息在历史中的位置 | 并入当前轮的中间 | 新一轮的起点 | 上下文,非任务指令 |
| 典型用途 | 人中途补充、纠偏 | 新任务、新派工 | 背景知识、策略变更通知 |
其中两个细节容易被忽略:
第一,followup 不是"追加到当前任务",而是"开启下一轮"。 它会成为新 turn 里唯一的普通消息------模型会把它当成一次全新对话的起点,重新从当前状态理解任务。所以"新派工"用 followup 是对的,"补充说明"用 followup 就会丢上下文。
第二,steer 是并进当前轮的中间。 模型在下一个 step 能看到这一轮已经发生的工具调用和结果,因此天然知道"你说的是刚才那件事"。
代码上很直白(以开源的 dsh-agent 为例):
ts
// 排到下一轮,作为新 turn 的起点
handle.agent.followup({ content: [{ type: 'text', text: '接着处理下个 issue' }], source: { kind: 'user' } })
// 插到当前 turn 的下一个 step
handle.agent.steer({ content: [{ type: 'text', text: '这个仓库用 yarn,不是 pnpm' }], source: { kind: 'my-plugin' } })
// 只进历史,不唤醒;适合"策略变了"这类上下文
handle.agent.inject({ content: [{ type: 'text', text: '...' }], source: { kind: 'notice' } })
inject 存在的意义是"我知道你正在忙,别打断你,但下次你醒着的时候该看到这个"。比如运行期间策略配置变了,不需要立刻让 Agent 停下手里的活,只要保证它下一次决策时能看到新规则。
四、实现要点
语义清楚了,实现还有几个坑。
4.1 消息必须带 ID
网络重试、进程重启、上游重复投递都可能让同一条消息进两次历史。做法是给每条消息一个确定性的 requestId(比如 input:<ticket>:<trigger>),投递前比对历史与待处理队列,已存在就跳过。
这一步看起来是防御性代码,实际上决定了整个机制能不能用在生产:steering 的价值在于"不打断",而一旦重复投递导致 Agent 把同一件事做两遍,就比打断更糟。
4.2 只在边界提交
消息写进 inbox 不等于立刻可见。真正让它进入模型视野的是 step 开始前的钩子(pre-step):在这里批量取出 inbox 中待处理的消息,合并进本次请求。
这个钩子同时也是一个拒绝点:如果一条 steering 让即将执行的 step 变得不安全(比如权限已被收回),可以拒绝这一步。被拒的 steering 留在 inbox 里,不会丢,等下一次唤醒再处理。
4.3 让"该结束了"再跑一步
有个微妙的时序问题:你的插话到达时,Agent 恰好已经做完所有工具调用、准备结束本轮。这时会走 turn-stopping 边界------监听器可以再 steer 一条消息,运行时重新读 inbox,发现有新内容,就再跑一个 step,而不是关闭 turn。
这条机制让"最后一刻赶到"的补充也不丢,代价是 turn 的结束时机变成了数据驱动,而不是固定步数。
4.4 cancel 和 steer 是两条路
cancel() 默认会清空 inbox,连排队待发的 steer 消息一起丢弃(除非显式 keepInbox)。也就是说:
- 插话 = 往 inbox 写,不打断;
- 打断 = cancel,清空在途工作。
这两个动作不能混用。想要"停一下但别丢我排的话",必须显式保留。很多早期实现让人误以为"插话 = 先停下再说",其实是把两个能力混成了一个。
五、真正难的地方在工程,不在语义
5.1 长工具调用会把边界"拉长"
steering 只能在 step 边界落地。如果当前 step 正在跑一个五分钟的构建,你的插话就得等五分钟------机制没错,体验很差。
常见做法是:前台命令超过若干秒就自动转到后台,让工具调用立即返回、循环回到模型。这样 Agent 能在下一个 step 读到插话,同时后台任务继续跑,结果稍后再取。
5.2 顺序与并发
同一个会话内的消息必须串行,否则模型会看到乱序的上下文。但"人发的消息"往往需要优先------典型设计是给它单独留一个槽位:即使普通任务的并发位已满,人的插话也能立刻进入。
5.3 持久化与已读状态
待投递的消息要落盘,进程重启后仍能送达;「已写入 session 历史」和「已读并处理」是两种状态,混在一起就会出现"重启后重复回答"或者"消息凭空消失"。
5.4 上下文成本
steering 是追加式写入:历史只增不改,会话前缀稳定,对 KV cache 友好。相反,反复 cancel 再重建会话会让前缀失效,每次都重新计费。这也是 steering 在长任务上更划算的原因之一。
六、同类技术与常见叫法
| 类型 | 行为 | 典型叫法 |
|---|---|---|
| 排队式 | 当前轮结束再送,作为新一轮 | queued message、followup |
| 边界插入式 | 当前轮内下一个 step 插入 | steering、interjection、mid-turn input |
| 暂停等待式 | 流程主动挂起,等人回答后恢复 | human-in-the-loop、interrupt、checkpoint resume |
| 注入式 | 只补上下文,不触发执行 | inject、notice、additional context |
前三种经常被混为一谈,但解决的问题不同:
- HITL 中断回答的是「我需要你做个决定」------流程必须停下来等。
- steering 回答的是「我改主意了,但你别停」------流程继续,方向微调。
- followup 回答的是「这件事结束了,做下一件」。
一个成熟的 Agent 产品通常三者都有,只是入口不同:审批弹窗是 HITL,聊天框里"顺便补一句"是 steering,新开一个任务就是 followup。
七、怎么判断该用哪种
- 补充的是当前任务的一部分(约束、细节、纠正误解)→ steering
- 任务已经结束,或明显是另一件事 → followup / 新任务
- Agent 需要你的决策才能继续(授权、二选一、验收)→ HITL 中断
- 只是背景信息变了(配置、规则、环境)→ inject
判断标准其实就一句话:这条消息是否属于当前这一轮的推理脉络。属于就 steer,不属于就 followup。
八、结语
steering 不是什么玄学的新能力。它只是把执行循环的粒度从 turn 降到了 step,并在边界处开放了一个可写的 inbox。
代价是必须认真处理幂等、持久化、顺序、以及长任务把边界拉长的问题;收益是人和 Agent 的协作从「回合制」变成了「可以随时喊一声」。
如果你在做 Agent 框架,这是少数几个「语义简单、但极大影响使用体验」的设计点,值得优先做对。
本文的实现细节参考开源的 DeepSeek Harness agent loop(dsh-agent):followup / steer / inject 三个入口,以及 pre-step、turn-stopping 两个边界钩子。