跳转到内容

第 4 章 · 用户插话:steer、queue 与 cancel

当 Agent 在执行长任务过程中出现方向偏差时,用户往往希望实时介入纠偏(例如下达指令「暂停探索,优先检查配置文件」)。这条人工插话究竟应当在哪个精确的时钟切片注入上下文?

这绝非一个简单的消息排队问题,而是整个循环控制流中少数几个由人机协同意图而非单纯底层 API 物理约束所决定的关键注入点。上下文压缩由 Token 预算触发,权限拦截由安全策略驱动,而插话调度则直接映射了人类对非确定性 Agent 的实时控制权。

阅读本章前,建议先温习第 2 章 §2.4。本章判断标准二直接建立在 §2.4.4 失败模式四的基础之上;而 §2.4.3 失败模式三(Cancel 路径上的孤儿 tool_call 治理)则是取消语义在系统底层引发的直接并发问题。

用户的实时输入在语义层可严格拆解为三种互斥的模式,见表 4-1。

表 4-1:三种插话语义与注入点

交互语义 用户的真实业务意图 系统底层的物理注入切片
steer(动态插话) 「下一步立刻改换方向」 当前 Turn 的在途工具执行完毕之后、下一次模型流式请求组装之前
queue(排队追问) 「等你把当前手头的事彻底做完再说」 整个 Agent 任务自然停机、会话重归空闲状态之时
cancel(紧急打断) 「立刻停下当前全部操作」 立即向所有在途工具下发 Abort 信号强制中断

本章重点剖析 steer 与 queue 的架构实现。cancel 在此作为基准语义参与对照(其涉及的底层进程中止与孤儿清理机制已在第 2 章 §2.4.3 及第 5 章中详述)。

若在系统设计中混淆了这三种语义,将引发以下三类严重故障:

  1. 将 queue 误实现为 steer:用户原本打算追加后续任务,系统却在当前轮次强行插话,导致原本连贯的多步推理任务被迫腰斩;
  2. 将 steer 误实现为 cancel:粗暴中断正在执行的工具进程,在历史上下文中遗留无应答的孤儿 tool_call,直接诱发后续轮次全量 HTTP 400 崩溃(详见第 2 章 §2.4.3);
  3. 语义逻辑分离但状态标志未同步解耦:系统在 queue 正常完成的路径上错误打上了「上一轮输出被中断」的误导标记,导致模型基于虚假的前提做出不必要的自我反思与重试(详见 §4.4 源码实证)。

4.2 源码对照:两个队列、一张表、一个配置项

Section titled “4.2 源码对照:两个队列、一张表、一个配置项”

4.2.1 pi-mono:双物理队列与解耦取数函数

Section titled “4.2.1 pi-mono:双物理队列与解耦取数函数”

pi-mono 在架构层将 steer 与 followUp(即 queue 语义)实现为两个完全隔离的物理队列:

private readonly steeringQueue: PendingMessageQueue;
private readonly followUpQueue: PendingMessageQueue;
// ...
this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time");
// ...
steer(message: AgentMessage): void { this.steeringQueue.enqueue(message); }
followUp(message: AgentMessage): void { this.followUpQueue.enqueue(message); }

pi-mono/packages/agent/src/agent.ts:176-177, 231-232, 283-290

源码注释精准界定了两者的时序边界:steer 明确标注为「injected after the current assistant turn finishes」,而 followUp 则标注为「run only after the agent would otherwise stop」。

在向主循环暴露接口时,pi-mono 仅提供了 getSteeringMessagesgetFollowUpMessages 两个纯粹的异步取数回调(对应第 2 章表 2-2 中的核心注入点)。主循环内部完全不感知底层队列的物理形态与存储介质——在云端分布式部署中,只需将这两个函数绑定至数据库的 Session Inbox 表,即可在零侵入循环核心的前提下平稳支持跨网络插话。

两个队列均支持可配置的 QueueMode(默认 "one-at-a-time"),用以精细控制单次出队的批量大小。

4.2.2 opencode V2:双序号 Inbox 模型与非对称提升逻辑

Section titled “4.2.2 opencode V2:双序号 Inbox 模型与非对称提升逻辑”

opencode V2 在持久化层设计了 SessionInputTable,通过两个单调递增的序号彻底解耦「消息物理写入」与「模型逻辑可见」:

  • admitted_seq:用户输入落库时分配的物理接入序号;
  • promoted_seq:消息真正被提升并注入模型上下文时分配的序号;值为 NULL 表示当前仍处于等待挂起状态。

对于 steer 与 queue 两种交付模式,opencode 在提升逻辑上展现了高度理性的非对称设计

export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (db, events, sessionID, cutoff) {
const rows = yield* db.select().from(SessionInputTable)
.where(and(
eq(SessionInputTable.session_id, sessionID),
isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"),
lte(SessionInputTable.admitted_seq, cutoff),
))
.orderBy(asc(SessionInputTable.admitted_seq))
.all()
return yield* publish(db, events, sessionID, rows)
})
export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(function* (db, events, sessionID) {
const row = yield* db.select().from(SessionInputTable)
.where(and(
// ... same session_id and promoted_seq filters as above
eq(SessionInputTable.delivery, "queue"),
))
.orderBy(asc(SessionInputTable.admitted_seq))
.limit(1)
.get()
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
})

opencode/packages/core/src/session/input.ts:245-289(有省略:参数类型标注与两处 .pipe(Effect.orDie),完整代码见仓库)

在对比这两个提升函数时,需要重点关注三个核心特征:lte(admitted_seq, cutoff) 确立安全截断水位、.all() 批量拉取与 .limit(1) 单条拉取。

.all() 对比 .limit(1) 构成了非对称设计的灵魂:steer 在下一个安全的 Turn 边界被整批全量提升;而 queue 在会话进入空闲时严格一次仅提升一条

这一非对称性具备充分的系统论依据:用户在插话纠偏时往往连续输入多条短句(「不对」「先看配置」「在 config 目录下」),这些短句共同构成一个完整的纠偏意图,必须作为一个原子上下文整体交付给模型;而 queue 则代表用户追加的后续独立任务,必须严格按单任务隔离执行,确保每一项工作都能被完整推进。

cutoff 水位(取值自 const cutoff = yield* EventV2.latestSequence(db, session.id))确保了并发事务的隔离性:提升操作严格截止于当前 Turn 开始时刻的快照,正在并发写入的新输入将被安全留存至下一个 Turn 边界处理。

4.2.3 craft-agents-oss:基于 Provider 能力的动态分流策略

Section titled “4.2.3 craft-agents-oss:基于 Provider 能力的动态分流策略”

craft-agents-oss 将插话决策抽象为系统配置项 midStreamBehavior,其默认值依据底层 Provider 特征进行智能适配:

  • 对于 anthropic 系列连接 → 默认启用 'queue' 模式;
  • 其余 providerType(含 pi / pi_compat)→ 默认启用 'steer' 模式;实现是一行三元式而不是逐 provider 的分支(craft-agents-oss/packages/shared/src/config/llm-connections.ts:475-477)。

项目架构文档制定了严格的调用准则:

Read everywhere via resolveMidStreamBehavior(connection) — never branch on providerType directly for this decision; legacy connections without the field rely on the resolver’s fallback.

craft-agents-oss/packages/shared/CLAUDE.md:48

将配置推导逻辑完全封装在单一解析函数中,彻底杜绝了将 Provider 判定硬编码散落于系统各处的反模式。

更重要的是,这一分流调度完全发生在 SessionManager.sendMessage 的中间层。底层的 Agent 核心执行器完全保持纯净,'queue' 模式仅需跳过底层的重定向指令,等待当前 Turn 正常结束后再行动作重放。这再次验证了「窄接口」设计在复杂系统编排中的巨大价值。

表 4-2:其余三家的插话取舍

项目名称 核心实现方案 源码位置与出处
Claude Code 队列模式:运行中输入的消息先进入队列,在本轮工具结果回填之后才进入上下文;产品文档称之为消息排队(message queueing) 闭源,无源码引用;依据 Claude Code 文档与本书观察
Roomote 优先走原生 steer;若 Provider 不支持则将排队消息置顶并下发 CancelTask,降级为 Abort + Replay 模式 Roomote/apps/worker/src/sandbox-server/lib/harness-manager.ts:368-431
cloudflare-os V1 阶段显式舍弃(「No steering/follow-up queues. Turn cap 30 via shouldStopAfterTurn.」),规划于 Phase 2 演进 cloudflare-os/plans/pi-impl.md:27

cloudflare-os 的取舍体现了极高的架构克制:在系统早期明确声明功能边界,远比隐式吞没用户消息更为安全。而 Roomote 采用的 Abort + Replay 降级方案则存在潜在副作用(详见 §4.4)。


4.3 判断标准:时序、批量与状态追踪

Section titled “4.3 判断标准:时序、批量与状态追踪”

判断标准一:产品 UI 是否严格区隔三种交互语义

Section titled “判断标准一:产品 UI 是否严格区隔三种交互语义”

当用户在 Agent 推进过程中敲击回车时,系统必须向用户提供明确的确定性预期:

  • Steer(插话):「正在执行的单步会平稳收尾,下一步立即调整航向」;
  • Queue(排队):「当前任务全量推进,完毕后自动启动新指令」;
  • Cancel(终止):「当前操作立即阻断,现场可能发生回滚」。
图 4-1:用户在工具执行途中输入时,三种语义的注入点
图 4-1 用户在工具执行途中输入时,三种语义的注入点
图 4-1 展示了用户在工具执行途中介入时,三种交互语义的精确物理注入时序与拓扑映射。当用户在工具执行中途(★)输入时:Steer 语义等待当前正在执行的工具批次自然结束,并在紧邻的 Turn 边界安全注入,既不破坏在途工具的执行状态,又能即刻扭转下一轮推理方向;Queue 语义则将输入保持在挂起队列中,直至当前完整 Run 彻底结束且会话重归空闲状态后才行激活;Cancel 语义则立即向在途工具发送 Abort 信号强制中断,同时触发孤儿调用清理与状态重置。

判断标准二:插话注入点必须严格锁定在 Turn 边界

Section titled “判断标准二:插话注入点必须严格锁定在 Turn 边界”

在物理组装网络请求时,必须确保:上一批次的所有工具执行结果(tool_result)已经全量、合法地回填进历史数组

如第 2 章 §2.4.4 所述,Anthropic 等模型 API 明确规定:「Tool result blocks 必须紧随其对应的 tool use blocks 出现,严禁在二者之间插入任何常规消息」。违背此规则将导致网关层直接抛出 HTTP 400 或 422 结构校验错误。Steer 消息最早只能在当前 Turn 结束的缝隙中安全并入。

判断标准三:依据语义精准匹配批量提升策略

Section titled “判断标准三:依据语义精准匹配批量提升策略”
  • 对于 Steer(意图纠偏):必须支持整批原子提升(Batch Promotion),使模型能够在单轮推理中完整获取多条连续输入的整体意图;
  • 对于 Queue(任务排队):必须维持单条递进提升(Single-item Promotion),确保每个独立任务获得完整的规划生命周期。

判断标准四:中断状态标志必须严格源自真实的物理中断

Section titled “判断标准四:中断状态标志必须严格源自真实的物理中断”

系统状态中的 wasInterrupted 标志位,当且仅当底层进程真正触发了 forceAbort 或硬中断时方可置位。严禁仅根据代码分支逻辑进行推测性标记,防止模型接收到虚假的中断提示而产生混乱的自省行为。


失败模式一:在 Queue 路径上错误透传中断标记

Section titled “失败模式一:在 Queue 路径上错误透传中断标记”

craft-agents-oss 将此列为系统的最高严重级别不变量:

managed.wasInterrupted is set only on the steer path (where an actual forceAbort happened) — pure 'queue' mode must NOT set it, otherwise the replayed turn injects the “previous response was interrupted and may be incomplete” reminder for a turn that actually completed, confusing the model.

craft-agents-oss/packages/shared/CLAUDE.md:48

若在顺畅执行完毕的 Queue 路径上误打了中断标志,系统将向模型注入虚假的提示词:「你上一次的回复已被用户打断,可能不完整」。模型据此会错误地重新执行已经落盘的代码,甚至对自身此前正确的输出产生怀疑。

失败模式二:仅在服务端重算时间戳导致前端乐观状态错位

Section titled “失败模式二:仅在服务端重算时间戳导致前端乐观状态错位”

在流式响应生成期间创建的插话消息,若直接采用客户端本地时间戳,其渲染顺序将错误地跃居正在生成的 Assistant 消息之前。

当服务端在重放时通过单调时钟重新打标后,必须将权威的时间戳显式同步回前端已挂载的乐观消息实体上,否则在用户刷新页面前后将出现视图剧烈跳变与因果乱序(详见第 14 章 §14.4)。

失败模式三:滥用 Abort + Replay 模拟 Steer 带来的副作用重放

Section titled “失败模式三:滥用 Abort + Replay 模拟 Steer 带来的副作用重放”

当底层系统不支持原生的 Turn 边界 Steer 时,盲目采用「强行 Kill 掉在途任务并全量重跑」的方式,将导致已产生物理副作用的工具(如已执行的文件覆写、已发起的外部 HTTP 请求)在 Replay 过程中被重复触发,进而破坏工作区的确定性状态。

反面证据:在短任务场景中过度设计的插话复杂度

Section titled “反面证据:在短任务场景中过度设计的插话复杂度”

对于平均执行耗时在 10 秒以内的轻量级 Agent,引入复杂的双队列与持久化 Inbox 机制属于过度工程。合理的架构取舍应依据任务的平均耗时进行分级治理:短周期任务可直接采用静默等待策略,长周期自主 Agent 方需完备的插话控制面支持。


4.5.1 双序号持久化 Inbox 数据模型

Section titled “4.5.1 双序号持久化 Inbox 数据模型”
SessionInput {
id: string
session_id: string
delivery: "steer" | "queue" // 入队时锁定的交付语义
admitted_seq: integer // 物理接入单调序列号
promoted_seq: integer | NULL // 逻辑提升序号(NULL 表示挂起中)
content: string
created_at: timestamp
}
// Steer 提升:在 Turn 边界执行,整批提升
promoteSteers(sessionId, cutoffSeq):
rows = SELECT * FROM SessionInput
WHERE session_id = sessionId
AND promoted_seq IS NULL
AND delivery = "steer"
AND admitted_seq <= cutoffSeq
ORDER BY admitted_seq ASC
标记 rows 的 promoted_seq 并发布至上下文
若存在提升记录,重置当前 Step 步数计数器
// Queue 提升:在 Run 彻底结束时执行,单条提升
promoteNextQueued(sessionId):
row = SELECT * FROM SessionInput
WHERE session_id = sessionId
AND promoted_seq IS NULL
AND delivery = "queue"
ORDER BY admitted_seq ASC
LIMIT 1
若存在记录则标记 promoted_seq 并触发新一轮 Run 调度

在交付插话子系统前,必须通过以下五项基础测试:

  1. 排队语义纯净性测试:向队列提交 Queue 消息并等待 Run 自然结束,断言模型绝对不会收到「输出被中断」的伪提示;
  2. 多条插话原子聚合测试:在 Agent 执行期间连续提交 3 条 Steer 消息,断言在下一轮推理中模型单次感知到全部 3 条输入;
  3. 任务排队隔离测试:连续提交 3 条 Queue 消息,断言系统分 3 个独立的 Run 周期逐一推进;
  4. 工具边界安全性测试:在耗时较长的工具执行期间触发 Steer,断言在途工具平稳执行完毕,且插话严格注入在 tool_result 之后;
  5. 乐观状态对账测试:在流式生成中途插话并立即刷新页面,断言历史消息的时钟顺序与内存实时流完全一致。