芥末
发布于 2025-09-09 / 0 阅读
0
0

多智能体 ReAct 自主规划的五个工程优化策略

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>

服务端解析时,不要只靠简单字符串截取,至少要做三层校验:

  1. 工具名必须存在于白名单;
  2. arguments 必须能被解析成 JSON;
  3. 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 不只是一个循环调用工具的框架。它更像一个小型任务执行系统:需要任务状态、上下文管理、产物管理、异常控制和最终交付。把这些工程环节补齐后,自主规划才会从“能跑”变成“能稳定交付”。


评论