在实际业务场景中,智能体执行的任务很少是单次工具调用就能解决的。无论是长程留学规划、代码库大规模重构,还是全自动化行业调研,Agent 都需要启动多步循环,交替进行思考与环境交互。
然而,一旦进入耗时数十秒甚至数分钟的长程推演,系统往往会面临一个工程矛盾:执行过程的闭门造车与外部用户动态意图的冲突。
本文将从 ReAct 循环的交互缺陷切入,深入拆解基于分步等待队列与跨轮等待队列的消息调度设计,解析如何在无需强行中断(Abort)的前提下,实现推演过程中的中途插队纠偏(Steering)。
传统长任务执行的交互困境#
在基于 ReAct(推理与行动交替,即让大模型交替输出思考并调用工具拿到结果)的常规实现中,智能体的核心控制流通常是一个封闭的 while 循环:
// 常规封闭式 ReAct 执行循环
while (currentStep < maxSteps) {
const response = await llm.chat(messages);
messages.push({ role: "assistant", content: response.content });
if (response.isFinish) {
break;
}
const observation = await executeTool(response.action);
messages.push({ role: "tool", content: observation });
}在这个模型下,当 Agent 正在执行复杂任务(例如“调研 5 所亚洲顶尖高校计算机硕士并对比学费”)时,如果用户在 Agent 刚查完第 1 所学校时发现需求有变,突然在前端输入了一句:“NUS 已经拿到拒信不用查了,帮我换成只看预算在 20 万以内的学校(如 SMU)”,传统系统通常只有两种极其粗暴的选择:
- 死等当前轮次结束:系统完全不监听外部输入,强行让 Agent 按照旧指令把剩余的 4 所高校查完。代价是消耗大量昂贵的 API Token 与等待时间,产出的却是一堆完全作废的无效结果;
- 强制掐断(Abort):通过前端提供的中断按钮触发
AbortController.abort(),强行杀死正在执行的进程。代价是前面积累的所有有价值推演事实被彻底清空,用户必须重新组织全量提示词推倒重来。
这两种极端方案的根源,在于执行内核将 Turn(用户发起的完整交互轮次)与内部 Step(单步推演微步)强行绑定,缺乏在微步边界接收并消化外部动态消息的通道。
核心设计:分步与跨轮两级消息队列#
要让智能体在推演中随时接受人类指挥,最直观的工程解法不是引入重型调度框架,而是在运行时中引入两级消息队列:
export interface InboxState {
"next-turn": UserMessage[]; // 跨轮等待队列:存放等当前整轮任务完全结束后再处理的消息
"next-step": UserMessage[]; // 分步等待队列:存放希望在下一个单步推演开始前立刻插入的纠偏指令
}这两级队列的分工极其明晰:
- 跨轮等待队列(
next-turn):对应常规的多轮会话排队。当用户在 Agent 忙碌时追加了一个完全不相干的新任务(如“顺便帮我翻译一下这段自我介绍”),该消息会被放入此队列,静待当前任务执行完毕后开启新一轮交互; - 分步等待队列(
next-step):对应中途插队纠偏(Steering,即在程序尚未跑完时,人类介入干预运行方向)。只要用户发出的是针对当前任务的约束调整或方向纠偏,消息就会被投递到分步队列中。
控制流时序与消息注入防线#
有了分步队列,关键问题转化为:外部纠偏消息应该在何时、以何种形式注入到大模型的上下文(messages)中?
1. 严格的时序注入点与平账防线#
大模型对函数调用(Function Calling)的消息协议有强校验约束:如果模型输出了 assistant 角色的工具调用声明,紧随其后的必须是携带对应 ID 的 tool 角色回执。如果因为外部消息插队而打破了这个时序,API 将直接报 400 校验错误崩溃。
因此,中途插队消息的消费时机必须严格锚定在单步工具执行完毕且已平账之后、大模型发起下一步推演之前的空隙:
sequenceDiagram
autonumber
participant U as 外部用户
participant Q as 分步等待队列 (next-step)
participant L as 运行时微内核 (Event Loop)
participant T as 工具执行器 (Tools)
participant M as 大模型 (LLM)
L->>M: 调用模型推理 (Step 1)
M-->>L: 返回工具调用 Action (查 NUS)
L->>T: 执行工具调用
U->>Q: 用户中途插话: "别看 NUS 了,改看 SMU"
T-->>L: 工具执行完成,返回 Observation
Note over L: 回执写入 messages 历史 (完成平账)
rect rgb(240, 248, 255)
Note over L,Q: 单步检查边界
L->>Q: 批量全量提取当前积压的所有分步纠偏消息
Q-->>L: 返回纠偏指令
L->>L: 格式化为 user 角色追加至 messages 历史末尾
end
L->>M: 携带最新纠偏调用模型推理 (Step 2)
Note over M: 模型感知到最新约束,重定向搜索 SMU2. 角色选择与语义加权#
外部注入的消息在身份上天然属于 user(用户角色)。为了避免大模型在面对冗长工具输出时遗漏中途指令,工程上应对其进行轻量的显式语义包装:
- 利用近因偏置(Recency Bias):大模型的自回归生成对上下文中靠后的 Token 天然拥有更高的注意力偏置。将最新纠偏置于历史数组末尾,能自然形成对旧指令的语义重载;
- 显式前缀标识:对内容添加结构化前缀(例如
【用户实时介入/最新要求】: xxx),能让大模型在长上下文检索中迅速识别出最高优先级的约束变更。
边界攻坚:高频并发插队与全量消费#
在实际工程中,工具执行往往涉及网络请求或代码沙箱运行,耗时较长(可能达到数秒)。在这段空隙里,用户可能在前端连续敲下多条补充要求:
- 消息 1:“_别看 NUS 了_”
- 消息 2:“_预算限制在 20 万人民币以内_”
此时,如果收件箱在消费分步队列时采取单条弹出(如仅调用 shift() 取第一条),将引发语义分裂与状态滞后:Agent 在下一步虽然放弃了 NUS,但依然不知道预算限制,可能转去查了一个高达 40 万的昂贵项目,白白浪费一步算力;直到下下一步才读取到预算约束,不得不再次推翻前面的工作。
因此,分步队列在单步迭代时必须执行批量全量提取(Batch Claim):
// 批量全量取出当前积压的所有纠偏消息,避免语义滞后与状态分裂
claimStepMessages(): string[] {
const messages = [...this.nextStepQueue];
this.nextStepQueue = []; // 一次性消费清空
return messages;
}极简实战实现:可落地的分步纠偏微内核#
以下展示基于 TypeScript 实现的完整分步插队微内核。代码完整呈现了三大核心工程机制:
- 分步队列全量批量提取(Batch Claim):单步模型调用前一次性清空并追加所有中途纠偏消息,彻底杜绝语义滞后;
- 严密的条件工具执行与终态收敛:仅在模型声明
toolCalls时才执行外部工具;若模型输出最终文本,则直接正常跳出循环; - 工具异常平账防线(Tool Error Recovery):当网络抖动或外部工具崩溃抛错时,绝不任由异常向上击穿进程,而是就地捕获并转化为
tool角色回执写入上下文,确保每一个call.id100% 严格对账平账,同时让模型能够感知失败原因并自愈。
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface Message {
role: "system" | "user" | "assistant" | "tool";
content: string;
toolCallId?: string;
toolCalls?: ToolCall[];
}
export interface LLMClient {
chat(
messages: Message[]
): Promise<{ thought: string; toolCalls?: ToolCall[] }>;
}
export interface ToolExecutor {
execute(call: ToolCall): Promise<string>;
}
export class ReactLoopInbox {
private nextStepQueue: string[] = [];
append(text: string): void {
this.nextStepQueue.push(text);
}
// 批量全量提取当前步骤的所有插队消息,彻底避免滞后与语义分裂
claimStepMessages(): string[] {
const messages = [...this.nextStepQueue];
this.nextStepQueue = [];
return messages;
}
}
export class AgentRuntime {
public history: Message[] = [];
constructor(
private inbox: ReactLoopInbox,
private llm: LLMClient,
private tools: ToolExecutor
) {}
async runTask(initialPrompt: string, maxSteps = 5): Promise<void> {
this.history = [{ role: "user", content: initialPrompt }];
for (let step = 1; step <= maxSteps; step++) {
// 1. 在模型调用前,批量提取分步插队消息并注入
const pendingSteers = this.inbox.claimStepMessages();
for (const steerText of pendingSteers) {
this.history.push({
role: "user",
content: `【用户实时介入/最新要求】: ${steerText}`,
});
}
// 2. 调用模型推演:大模型基于包含最新纠偏的全量历史做出判断
const decision = await this.llm.chat(this.history);
this.history.push({
role: "assistant",
content: decision.thought,
toolCalls: decision.toolCalls,
});
// 3. 终态检查:若模型未发出工具调用,说明已生成最终答案,正常退出
if (!decision.toolCalls || decision.toolCalls.length === 0) {
break;
}
// 4. 严谨平账执行:无论工具成功还是崩溃抛错,每一个 call.id 必须 100% 回填 tool 回执
for (const call of decision.toolCalls) {
let observation: string;
try {
observation = await this.tools.execute(call);
} catch (err: any) {
// 核心平账防线:工具异常绝不向外抛死整个会话,而是将错误以 tool 角色写回上下文,供模型反思
observation = `【工具执行异常】: ${err.message || "未知错误"}`;
}
this.history.push({
role: "tool",
toolCallId: call.id,
content: observation,
});
}
}
}
}架构权衡与技术演进视角#
引入分步等待队列与中途插队纠偏,标志着智能体控制流从“黑盒批处理”走向“事件驱动的人机协同(HITL,即人机协同,本质就是在程序执行遇到关键分支时让人类动态参与引导)”:
- 确定性与灵活性兼顾:Turn 与 Step 的分层使得长任务可以在微步粒度上精确感知外部世界的动态变化。系统无需为每一次人类干预付出“进程崩溃重建”的高昂代价;
- 算力与 Token 止损:通过及时的方向重定向,系统能够以最小的步数将算力从错误路径拉回正确轨道,避免了长程推演中后期大面积空转带来的资源浪费。
至此,第 2 章关于执行控制流的演进(从 Pipeline、DAG、ReAct 的横向对比,到 Turn-Step 双层微内核,再到中途插队纠偏)已形成完整的闭环。在接下来的第 3 章中,我们将把视野从单个 Agent 的微观控制流,拓展到多个智能体之间的协作拓扑与通信机制。