给正在干活的 Agent 插一句话:steering 的原理与实现

摘要:Agent 跑起来之后,你突然想起漏说了一个点。以前只能等它做完,或者停掉重来;现在主流 Agent 多了一条路------不停,直接说,它下一步就能读到。这个能力通常叫 steering。本文从 turn 与 step 的粒度讲清它的原理、实现,以及它和 followup、inject 的区别。

一、一个很具体的痛点

让 Agent 去修一个 bug,它已经开始跑测试了,你突然想起来:这个仓库包的构建命令不是 pnpm build,是 yarn build。

以前的交互只有两条路:

  1. 等它做完再说。可能白跑十分钟,甚至因为构建失败绕一大圈。
  2. 停掉它,重新说一遍。它刚读的文件、刚跑出来的日志、刚改到一半的代码,全部作废。

现在多了一条:不用停,直接补一句,它在下一个步骤就能读到,并且把它和当前进展一起考虑。

这个能力在业界通常叫 steering(中途转向 / 实时插话),也有人叫 mid-turn steering、interrupt-free steering。名字不统一,但底层机制是同一件事。

二、先搞清楚一个 turn 里有什么

要理解 steering,先得把两个词分开:turn 和 step。

  • turn(轮次):从"用户发来一条消息"到"Agent 停下来等下一句"的完整过程。
  • step(步骤):turn 内部的一次循环------组装请求 → 模型返回工具调用 → 执行工具 → 带着结果再请求。
sequenceDiagram participant U as 人 participant R as Agent 运行时 participant M as 模型 participant T as 工具 U->>R: 初始任务 R->>M: step 1 请求 M->>T: 调用工具(跑构建) Note over R: 收到插话,放入 nextStep 队列 T-->>M: 构建失败,返回日志 Note over R,M: step 边界:插话并入历史 R->>M: step 2 请求(已包含插话) M->>T: 改用 yarn build M-->>U: 调整后的结果

关键点:turn 不是原子的,step 才是原子单位。

模型两次请求之间天然存在一个空档:工具已经跑完、结果已经拿到、下一次请求还没发出。这个位置插入一条消息,既不破坏当前模型调用的完整性,也不需要丢弃任何已有状态。

以前做不到,是因为早期实现把整个 turn 当成一个不可分割的操作:run() 一路跑到结束才返回,中间没有可观测、可写入的边界。要改动只能 cancel 整个 turn,于是推理轨迹、工具调用记录、后台任务全部被丢掉。

三、三种"递话"方式

一旦运行时有了 inbox(收件箱)和边界,就能区分出三种语义:

flowchart LR A[外部输入<br/>评论 / 面板 / CI / 通知] --> B[inbox] B -->|steer| C[当前 turn 的下一个 step] B -->|followup| D[下一个 turn] B -->|inject| E[下一个被接纳的 step<br/>不唤醒]
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 两个边界钩子。

相关推荐
jerrywus15 分钟前
让 AI 帮你部署 Vercel,报错了也不用手动搬日志
前端·agent·mcp
柯南466821 分钟前
【AI工程师精讲】32:Text2SQL:让 AI 查数据库,难点八成不在生成 SQL 上
人工智能·ai编程
Trae_陈冀22 分钟前
写详细设计总堆代码没逻辑?我做了个自动生成工具,3套模板+规范内置,输出直接过评审
架构·agent
丘山录24 分钟前
本地部署 PLaMo,llama.cpp 完整教程,不用显卡
人工智能
播播资源26 分钟前
同账户实测 11 个多模态模型:图像 0.03 元起、竖屏视频 8 元一条,最贵和最便宜差 267 倍
大数据·人工智能·深度学习
Linye30 分钟前
AI 越跑越偏?从 500 行臃肿规则到 80 行核心约束:多 Agent 项目中的「规则瘦身与防腐」四步排查法
人工智能·架构
Eyfcom33 分钟前
〖AI物流落地 06〗AI Copilot 在 TMS 调度系统中的应用:Text-to-SQL、意图拆解与自动调参
人工智能·物流系统·快递系统·跨境物流快递系统·ai物流落地
域限奇点访客33 分钟前
怎么让AI稳定输出可解析的问卷结构?schema约束、失败重试与降级策略
人工智能