ReAct(Reasoning and Acting,推理与行动交替)是一种常见的大模型智能体执行模式:模型先思考当前任务该怎么做,再选择工具或子智能体执行一步,拿到结果后继续推理,直到任务完成。
在多智能体系统里,最常见的结构是“主智能体 + 工具/子智能体”。主智能体负责任务拆解、规划、调度和收敛,工具或子智能体负责完成具体动作,比如搜索资料、生成文档、查询数据库、调用业务接口、生成代码等。
一个典型流程长这样:
flowchart TD
U[用户输入任务] --> M[主智能体理解任务]
M --> P[生成或更新执行计划]
P --> L[大模型推理下一步动作]
L --> A{是否需要调用工具}
A -- 是 --> T[调用工具或子智能体]
T --> O[获得 Observation 执行结果]
O --> C[写入上下文]
C --> L
A -- 否 --> F[生成最终答复]
F --> U
这种模式能处理比单轮问答复杂得多的任务。例如用户要求“调研 RAG 系统并生成一份 PRD”,主智能体可以先检索资料,再整理大纲,然后把大纲交给 PRD(Product Requirements Document,产品需求文档)生成智能体继续扩写。
但在生产环境里,ReAct 自主规划很容易暴露出几个工程问题:
| 问题 | 直接表现 | 根因 |
|---|---|---|
| 工具调用等待时间长 | 用户长时间看不到输出,只看到页面卡住 | Function Calling 通常要等模型完整生成工具调用结构 |
| 上下文越来越长 | 推理变慢、成本升高、模型质量下降 | 每轮工具结果都被完整塞回上下文 |
| 中间产物缺失 | 计划里说要生成大纲,但上下文里没有真正的大纲 | 某些规划步骤没有对应工具承接 |
| 结束回答敷衍 | 最终只说“任务已完成”,没有给出可用结果 | 系统只判断工具调用结束,没有设计最终交付物 |
| 执行过程跑偏或循环 | 一直重复调用同一个工具,或偏离初始目标 | 缺少计划监督和状态管理 |
要让多智能体自主规划真正可用,关键不只是“能调工具”,而是要把工具调用、上下文传递、中间产物、结束条件和过程监督都设计清楚。
1. 用可流式解析的 XML 替代纯 Function Calling
Function Calling(函数调用)或 Tool Calling(工具调用)是大模型应用里最常见的工具调用方案。开发者把工具名、参数结构和描述传给模型,模型返回一个结构化的工具调用对象,系统再解析对象并执行工具。
这种方式的优点很明显:结构稳定,参数容易解析,适合严肃的接口调用。
但在自主规划场景里,它有两个常见问题。
一是流式体验差。很多模型在生成工具调用时,正文 token 很少,真正的工具名和参数要等整个调用结构生成完才能拿到。用户在界面上看到的就是长时间等待。
二是模型兼容性受限。并不是所有模型都支持同一套 Tool Calling 协议。如果平台要接入多种模型,完全依赖原生 Tool Calling 会增加适配成本。
更实用的做法是:让模型用普通文本输出思考过程,同时用 XML 标签包住工具名和参数。这样既能把 Thought 流式展示给用户,又能从文本中解析出 Action。
整体流程如下:
sequenceDiagram
participant U as 用户
participant S as 服务端
participant L as 大模型
participant T as 工具/子智能体
U->>S: 提交复杂任务
S->>L: 发送任务、上下文、工具列表、XML 输出约束
L-->>S: 流式输出 Thought
S-->>U: 实时展示思考过程
L-->>S: 输出 tool_name 与 arguments 标签
S->>S: 解析工具名和 JSON 参数
S->>T: 执行工具
T-->>S: 返回 Observation
S->>L: 带上 Observation 继续推理
提示词可以设计成这种结构:
你是一个可以调用工具完成任务的智能体。
要求:
- 任务没有完成前,可以持续调用工具。
- 不要猜测工具可以获得的信息。
- 每次只能选择一个工具。
- 工具参数必须是合法 JSON。
- 当任务已经完成,使用 <final_answer> 返回最终结果。
可用工具:
{{tools}}
输出格式:
Thought: 说明当前为什么要执行这一步,以及它和整体任务的关系。
Action:
<tool_name>工具名</tool_name>
<arguments>
{
"param1": "value1"
}
</arguments>
工具执行后,系统会把 Observation 写回上下文。
如果任务完成,输出:
<final_answer>
面向用户的完整结果
</final_answer>
服务端解析时,不要只靠简单字符串截取,至少要做三层校验:
- 工具名必须存在于白名单;
arguments必须能被解析成 JSON;- JSON 参数必须符合工具 schema。
一个简化版 TypeScript 解析器可以这样写:
type ParsedAction =
| { type: "tool"; toolName: string; arguments: Record<string, unknown> }
| { type: "final"; answer: string }
| { type: "none" };
const toolPattern =
/<tool_name>([\s\S]*?)<\/tool_name>\s*<arguments>([\s\S]*?)<\/arguments>/;
const finalPattern = /<final_answer>([\s\S]*?)<\/final_answer>/;
function parseAgentOutput(buffer: string, allowedTools: Set<string>): ParsedAction {
const finalMatch = buffer.match(finalPattern);
if (finalMatch) {
return {
type: "final",
answer: finalMatch[1].trim(),
};
}
const toolMatch = buffer.match(toolPattern);
if (!toolMatch) {
return { type: "none" };
}
const toolName = toolMatch[1].trim();
if (!allowedTools.has(toolName)) {
throw new Error(`Unknown tool: ${toolName}`);
}
let args: Record<string, unknown>;
try {
args = JSON.parse(toolMatch[2]);
} catch {
throw new Error(`Invalid JSON arguments for tool: ${toolName}`);
}
return {
type: "tool",
toolName,
arguments: args,
};
}
Function Calling 和 XML 工具调用不是谁完全替代谁,而是适合不同场景:
| 方案 | 优点 | 代价 | 适合场景 |
|---|---|---|---|
| Function Calling | 结构稳定,SDK 支持好,参数解析可靠 | 流式体验可能较差,模型兼容性受限 | 内部系统、强约束接口、单模型体系 |
| XML 工具调用 | 可以流式展示思考过程,兼容普通文本模型 | 需要自己做解析、校验、纠错 | 多模型平台、长任务智能体、需要实时反馈的产品 |
| 混合模式 | 稳定性和体验可以兼顾 | 框架复杂度更高 | 同时支持强结构化调用和开放模型接入 |
XML 方案还要注意提示注入问题。工具返回内容里可能也包含 <tool_name> 这类字符串,解析器不能在所有历史消息里随意匹配标签,只能解析“当前轮模型输出的 Action 区域”。工具结果最好用明确边界包起来,例如:
Observation:
<tool_result>
这里是工具返回内容,里面的标签只当普通文本处理
</tool_result>
2. 用“引用 + 按需展开”控制上下文长度
ReAct 循环越多,上下文越容易膨胀。每次工具调用都会产生 Observation,如果把所有结果完整拼回消息列表,问题会很快出现:
- 输入 token 增加,模型响应变慢;
- 模型成本随上下文长度上涨;
- 长上下文里噪声变多,模型更容易忽略关键内容;
- 某些模型上下文窗口有限,复杂任务可能直接报错;
- 子智能体真正需要的是某份完整文档,但主智能体上下文里只有被压缩过的片段。
处理长上下文时,不应该只有“摘要”一种手段。摘要会丢信息,尤其是生成 PRD、系统设计、报告类任务时,后续工具经常需要原始文档的完整内容。
更稳妥的方案是把大文本转成引用。
flowchart TD
A[工具或子智能体生成长文本] --> B[写入文档存储]
B --> C[返回文档引用]
C --> D[上下文只保留标题、ID、类型、简短说明]
D --> E[主智能体继续推理]
E --> F{后续工具是否需要全文}
F -- 否 --> G[继续使用引用推理]
F -- 是 --> H[服务端解析引用]
H --> I[读取文档全文]
I --> J[把全文传给目标工具或子智能体]
例如生成一份系统设计文档后,不把几万字都塞回上下文,而是保存为文档对象:
{
"doc_id": "doc_8f3a21",
"title": "rag-system-design.md",
"type": "system_design",
"summary": "RAG 系统的整体架构、数据流、索引构建和检索生成流程",
"created_by": "system_design_agent"
}
主智能体上下文里只放引用:
已生成文档:
- rag-system-design.md
doc_id: doc_8f3a21
type: system_design
summary: RAG 系统的整体架构、数据流、索引构建和检索生成流程
当后续 PRD 生成智能体需要这份文档时,主智能体只需要把引用传入工具参数:
{
"input_doc_id": "doc_8f3a21",
"output_format": "prd"
}
服务端在真正调用工具前解析 doc_id,读取全文,再交给目标工具。
这种方式有几个好处:
| 设计点 | 作用 |
|---|---|
| 上下文只保留引用 | 降低每轮推理 token |
| 工具调用前按需展开 | 子智能体仍然可以拿到完整资料 |
| 文档有稳定 ID | 避免标题重复或模型改写标题导致找不到文件 |
| 引用带 summary | 主智能体不用读取全文也能知道文档用途 |
| 文档可追溯 | 用户或系统能回看每个中间产物 |
如果工具返回的是搜索结果,也不建议直接把所有网页片段塞进上下文。搜索结果更适合做“事实保留型压缩”:保留标题、来源、时间、关键事实和 URL,删掉重复表达、广告文本和无关段落。
可以把搜索结果处理成这样的结构:
{
"query": "RAG 系统 PRD 关键功能",
"items": [
{
"title": "RAG 架构设计实践",
"url": "https://example.com/rag-architecture",
"facts": [
"RAG 通常包含文档解析、切分、向量化、索引、召回、重排和生成环节",
"召回质量会受到 chunk 粒度、embedding 模型和重排策略影响"
]
}
]
}
压缩不是越短越好。对多智能体系统来说,真正要控制的是“推理上下文”和“执行上下文”的边界:
| 上下文类型 | 给谁用 | 内容特点 |
|---|---|---|
| 推理上下文 | 主智能体 | 目标、计划、引用、关键状态、必要事实 |
| 执行上下文 | 工具或子智能体 | 完整文档、原始检索结果、业务数据 |
| 展示上下文 | 用户界面 | 思考过程、中间产物、最终结果 |
把三类上下文混在一个消息列表里,是很多 ReAct 系统变慢和变差的根源。
3. 给没有工具承接的步骤配置“通用推理工具”
主智能体通常只负责决定“下一步调用哪个工具,以及参数是什么”。这在工具能力完备时没问题,但现实里工具列表不可能覆盖所有中间步骤。
假设系统里只有两个能力:
- 网络检索工具;
- PRD 生成智能体。
用户输入:
我要写一个关于 RAG 系统的 PRD。先生成大纲,再根据大纲生成完整 PRD。
理想流程应该是:
flowchart LR
A[检索 RAG 资料] --> B[生成 PRD 大纲]
B --> C[基于大纲生成 PRD]
但工具列表里没有“生成大纲工具”。如果框架只允许主智能体调工具,模型可能在第二步说“我已经生成了大纲”,但上下文里并不存在真正的大纲。随后 PRD 生成智能体拿到的输入就会很薄,输出质量自然受影响。
解决办法是内置一个通用推理工具,专门承接那些“不需要外部系统、只需要大模型推理”的中间步骤。
可以把它定义成一个普通工具:
{
"name": "reasoning_artifact_generator",
"description": "当任务中的某个中间产物没有专用工具可以生成,但可以通过已有上下文和大模型推理得到时,使用该工具生成结构化内容。例如大纲、分析框架、评估维度、执行方案、路线规划等。",
"parameters": {
"type": "object",
"properties": {
"artifact_type": {
"type": "string",
"description": "要生成的中间产物类型,例如 outline、analysis、plan、checklist"
},
"task": {
"type": "string",
"description": "要完成的具体中间任务"
},
"context_refs": {
"type": "array",
"items": { "type": "string" },
"description": "可使用的上下文引用或文档 ID"
},
"output_requirements": {
"type": "string",
"description": "输出格式、粒度和约束"
}
},
"required": ["artifact_type", "task", "output_requirements"]
}
}
加入通用推理工具后,流程会变成:
flowchart TD
U[用户要求生成 RAG PRD] --> S[主智能体拆解任务]
S --> R[调用检索工具获取资料]
R --> G[调用通用推理工具生成大纲]
G --> P[调用 PRD 智能体扩写文档]
P --> F[交付完整 PRD]
它的价值不在于“万能”,而在于补齐工具链之间的缝隙。很多复杂任务失败,不是最终工具能力不够,而是中间输入太差。
适合交给通用推理工具的任务包括:
| 中间任务 | 示例 |
|---|---|
| 生成结构 | 报告大纲、PRD 目录、系统设计章节 |
| 信息整理 | 把检索结果整理成需求背景和用户痛点 |
| 分析判断 | 对多个方案做优缺点分析 |
| 计划拆解 | 把目标拆成里程碑、任务清单、验收标准 |
| 参数补全 | 根据上下文生成后续工具所需的结构化输入 |
不适合交给它的任务也要明确:
| 不适合的任务 | 原因 |
|---|---|
| 查询实时数据 | 大模型不能凭空知道最新数据 |
| 执行业务操作 | 需要真实 API 或权限系统 |
| 生成必须可验证的事实 | 应该先检索或查询数据库 |
| 做高风险决策 | 需要规则、审核或人工确认 |
通用推理工具最好也输出可引用的中间产物,而不是只把结果塞进当前轮上下文。这样后续工具可以按 ID 使用它。
4. 用“结束总结工具”强制生成可交付结果
很多 ReAct 系统判断任务结束的方式很简单:模型不再返回工具调用,就认为完成。为了防止死循环,再加一个最大循环次数,例如最多调用 10 次工具。
这种结束条件能让程序停下来,但不一定能给用户一个有用结果。复杂任务执行完后,如果最终答复只有一句“已经帮你完成任务”,用户还要回到过程记录里找真正产物,体验会很差。
结束条件应该从“模型不调工具了”改成“模型调用了一个明确的结束工具,并声明交付内容”。
可以设计一个 finish_task 工具:
{
"name": "finish_task",
"description": "当所有必要步骤已经完成,并且可以向用户交付最终结果时调用。该工具不会继续执行业务动作,而是触发系统整理最终回答。",
"parameters": {
"type": "object",
"properties": {
"completed_goal": {
"type": "string",
"description": "已经完成的用户目标"
},
"artifact_refs": {
"type": "array",
"items": { "type": "string" },
"description": "需要纳入最终回答的文档或中间产物 ID"
},
"key_findings": {
"type": "array",
"items": { "type": "string" },
"description": "需要在最终回答里呈现的关键结论"
},
"final_answer_requirements": {
"type": "string",
"description": "最终回答的格式、长度、章节和语气要求"
},
"remaining_risks": {
"type": "array",
"items": { "type": "string" },
"description": "仍需用户注意的限制、假设或风险"
}
},
"required": ["completed_goal", "artifact_refs", "final_answer_requirements"]
}
}
系统检测到主智能体调用 finish_task 后,不直接把工具参数原样展示给用户,而是再调用一次普通大模型,把所有关键中间产物整理成最终交付物。
flowchart TD
A[主智能体判断任务完成] --> B[调用 finish_task]
B --> C[服务端读取 artifact_refs 对应全文]
C --> D[构造最终回答提示词]
D --> E[大模型生成面向用户的完整结果]
E --> F[返回最终交付物]
最终回答提示词可以强调两点:
你需要基于任务执行过程和中间产物,生成面向用户的最终交付结果。
要求:
- 不要只描述“做了什么”,要直接给出可使用的内容。
- 如果生成了报告、PRD、方案或清单,要把核心内容完整呈现。
- 如果有假设、风险或需要用户确认的点,单独列出。
- 不要让用户回到执行过程里查找关键结果。
这种设计把“任务执行完成”和“最终交付完成”分开了。工具链负责完成动作,结束总结工具负责把动作结果组织成用户真正需要的输出。
5. 用计划监督 MCP 防止跑偏和死循环
ReAct 的每一步都会根据当前 Observation 重新推理,这带来了灵活性,也带来了失控风险。复杂任务执行几轮后,主智能体可能逐渐偏离初始目标,只盯着局部结果继续探索;模型能力较弱时,还可能重复调用同一个工具,形成循环。
要解决这个问题,需要给执行过程引入显式状态:计划是什么,当前做到哪一步,哪些子任务完成了,下一步应该做什么,是否需要用户确认。
MCP(Model Context Protocol,模型上下文协议)可以用来暴露一组计划管理工具,让主智能体在执行过程中持续维护待办清单。一个简单的计划监督服务可以包含这些能力:
| 工具 | 作用 |
|---|---|
create_plan | 根据用户目标创建任务清单 |
update_task_status | 标记任务状态:待执行、执行中、已完成、阻塞 |
get_plan_status | 查询当前计划和完成度 |
set_next_task | 明确下一步要执行的任务 |
request_user_confirmation | 遇到关键选择或权限动作时请求用户确认 |
执行过程可以这样组织:
flowchart TD
U[用户目标] --> CP[create_plan 创建计划]
CP --> N[get_plan_status 获取当前任务]
N --> A[主智能体选择工具执行]
A --> T[工具/子智能体返回结果]
T --> UP[update_task_status 更新状态和证据]
UP --> D{是否还有未完成任务}
D -- 有 --> N
D -- 无 --> F[调用 finish_task 生成最终交付]
A --> Q{是否需要用户确认}
Q -- 是 --> C[request_user_confirmation]
C --> N
计划状态不要只存“完成/未完成”,还要存证据。否则模型可能随口把任务标记为完成。
一个任务项可以这样定义:
{
"task_id": "task_03",
"title": "生成 RAG PRD 大纲",
"status": "completed",
"depends_on": ["task_01", "task_02"],
"evidence": {
"artifact_id": "doc_outline_7a9c",
"summary": "已生成包含背景、目标用户、核心功能、非功能需求和验收指标的大纲"
},
"next_action_hint": "基于该大纲调用 PRD 生成智能体"
}
为了减少循环,还可以加入几个硬规则:
| 规则 | 目的 |
|---|---|
| 相同工具 + 相同参数连续出现时拦截 | 防止无意义重复调用 |
| 每次工具调用后必须更新任务状态 | 让模型把结果和计划对齐 |
| N 轮没有新增证据时停止 | 避免空转 |
| 关键动作前要求用户确认 | 防止执行不可逆操作 |
| 到达最大步数时生成阶段性结果 | 不让任务无休止运行 |
一个简化版执行循环可以这样写:
const MAX_STEPS = 12;
const repeatedActionLimit = 2;
for (let step = 0; step < MAX_STEPS; step++) {
const plan = await planMcp.getPlanStatus(sessionId);
const output = await llmGenerateNextAction({
userGoal,
plan,
context: compactContext,
tools,
});
const action = parseAgentOutput(output, allowedTools);
if (action.type === "final") {
return action.answer;
}
if (action.type !== "tool") {
throw new Error("Agent did not return a valid action");
}
const fingerprint = hash(action.toolName, action.arguments);
if (isRepeated(fingerprint, repeatedActionLimit)) {
await planMcp.updateTaskStatus({
sessionId,
status: "blocked",
reason: "Repeated same tool call without new evidence",
});
break;
}
const observation = await callTool(action.toolName, action.arguments);
await planMcp.updateTaskStatus({
sessionId,
tool: action.toolName,
observationSummary: summarizeObservation(observation),
evidenceRefs: extractArtifactRefs(observation),
});
compactContext = await updateContextWithReference(observation);
}
return await generatePartialResult(sessionId);
计划监督不是为了限制模型,而是给模型一个稳定轨道。自主规划越复杂,越需要把“目标、计划、状态、证据、下一步”显式化。
一套更稳的多智能体 ReAct 架构
把五个策略合在一起,可以得到一套更适合生产环境的 ReAct 多智能体架构:
flowchart TD
U[用户任务] --> G[目标理解与初始规划]
G --> PM[计划监督 MCP]
PM --> L[主智能体推理]
L --> X[XML 流式 Thought/Action]
X --> P[工具解析与参数校验]
P --> R{工具类型}
R -- 业务工具 --> BT[业务 API / 检索 / 数据查询]
R -- 子智能体 --> SA[子智能体执行]
R -- 通用推理 --> GA[生成中间产物]
R -- 结束工具 --> FT[finish_task]
BT --> O[Observation]
SA --> O
GA --> O
O --> DS[文档存储 / 产物存储]
DS --> CR[上下文引用]
CR --> PM
PM --> L
FT --> FR[读取关键产物]
FR --> FA[生成最终交付结果]
FA --> U
核心思路可以概括成五句话:
| 策略 | 解决的问题 |
|---|---|
| XML 流式工具调用 | 缩短用户无反馈等待时间,同时兼容更多模型 |
| 引用式上下文 | 降低主智能体推理负担,又保留工具执行所需原文 |
| 通用推理工具 | 补齐没有专用工具承接的中间步骤 |
| 结束总结工具 | 让任务结束时输出可直接使用的结果 |
| 计划监督 MCP | 让长任务沿着初始目标推进,减少跑偏和循环 |
落地时容易踩的坑
1. XML 解析必须有容错和重试
模型可能输出不完整 JSON、多余解释或错误工具名。解析失败时,不要直接报错给用户,可以让模型基于错误信息修正一次:
上一轮工具调用格式不合法,原因是:arguments 不是合法 JSON。
请只重新输出 Action,不要重复 Thought。
但重试次数要有限,避免格式错误也进入循环。
2. 工具参数不能完全信任模型
即使用 XML 或 Function Calling 得到结构化参数,也要做业务层校验。例如用户 ID、文档 ID、权限范围、枚举值、文件路径都不能直接使用。
3. 文档引用要用稳定 ID,不要只靠标题
模型可能把 rag-prd-outline.md 改写成 RAG PRD 大纲.md。标题适合展示,真正检索应该用 doc_id。
4. 压缩搜索结果时要保留来源
检索增强生成(RAG,Retrieval-Augmented Generation)类任务经常需要引用来源。如果压缩时删掉 URL、发布时间和标题,后续报告会缺少可追溯性。
5. 通用推理工具不能替代真实工具
它适合生成结构、分析、清单和中间草稿,不适合查询实时事实或执行业务动作。只要任务涉及外部真实世界状态,就应该优先调用检索、数据库或业务接口。
6. 结束工具要成为强约束
如果系统同时允许模型直接输出最终回答,又允许调用 finish_task,模型可能绕开结束工具。更稳定的做法是:复杂任务必须通过 finish_task 结束,普通问答才允许直接回答。
7. 计划监督要记录“完成证据”
只记录任务状态会让模型过早收敛。每个完成项都应该绑定观察结果、文档 ID、接口返回或明确结论,方便后续步骤复用,也方便排查问题。
工程实现的取舍
这些优化本质上是在弥补当前大模型能力和产品要求之间的差距。模型上下文窗口变大、Tool Calling 协议更成熟、推理速度更快之后,一些工程补丁会变得没那么重要。但在真实产品里,不能只等待模型升级。
更现实的做法是:
- 用流式 Thought 改善长任务等待体验;
- 用引用式上下文控制 token 和成本;
- 用通用推理工具补齐中间产物;
- 用结束工具保证最终交付质量;
- 用计划监督让自主规划可控、可追踪。
多智能体 ReAct 不只是一个循环调用工具的框架。它更像一个小型任务执行系统:需要任务状态、上下文管理、产物管理、异常控制和最终交付。把这些工程环节补齐后,自主规划才会从“能跑”变成“能稳定交付”。