手写 ReAct 循环:搞懂 Agent 工具调用

手写 ReAct 循环:搞懂 Agent 工具调用

用 Node.js + 本地模型(qwen3-1.7b)实现 ReAct(推理-行动-观察)循环的多工具调用,不用任何 Agent SDK,手写完整的工具调用循环,把框架封装掉的底层协议细节全部摊开。

写作目的:不为造轮子,而是吃透 OpenAI tools 协议与 Agent 框架的底层原理------看懂这套"乒乓球"循环后,LangChain、@openai/agents 等框架的工具调用部分对你就再也没有黑盒。

一、为什么需要多工具

单个工具只能解决单一问题:查天气、查新闻、看时间各管一摊。但真实需求往往是复合的:

  • "北京和上海今天哪里更热?" → 要同时调两次天气工具(并行)
  • "帮我看看今天 AI 新闻里第一条的正文" → 要先 查新闻拿链接,再读网页(顺序依赖)

这就是多工具调用要解决的两类核心场景:彼此独立的工具并行跑 、有依赖关系的工具串行跑。而这两种模式,在 OpenAI tools 协议里其实是同一套机制的不同表现------先看懂协议,两种模式就都通了。

二、工具调用的本质:一场"乒乓球"循环

模型自己不能执行代码,它只会"说"想调什么工具。真正的执行在本地。一次带工具的问答,实际是这样往返多次:

协议层面的四个关键点:

  1. 工具清单 放在请求的 tools 参数里,每个工具用 JSON Schema 描述名字、用途、参数结构------这是模型"选工具、填参数"的唯一依据
  2. 模型决定调工具时,响应的 message 里带 tool_calls 数组:工具名 + JSON 字符串参数 。注意参数是字符串,要本地 JSON.parse
  3. 工具执行结果以 { role: "tool", tool_call_id, content } 消息回写,tool_call_id 把结果和那次调用配对
  4. 带 tool_calls 的 assistant 消息必须原样回写进 messages ,再带上 tool 结果重新请求,直到模型不再返回 tool_calls

一次完整问答的消息数组最终长这样:

scss 复制代码
[system, user, assistant(tool_calls=[A, B]),   ← 模型第1轮:要调 A 和 B
 tool(tool_call_id=A), tool(tool_call_id=B),
 assistant(tool_calls=[C]),      ← 模型第2轮:再调 C
 tool(tool_call_id=C),
 assistant(content="最终回答")]   ← 循环结束

三、ReAct 实战:用一个例子拆解"推理-行动-观察"

上一节讲的是协议格式,这一节回答"模型凭什么知道下一步干什么"------答案是 ReAct (Reasoning + Acting,出自 Yao et al. 2022 论文):让模型交替进行推理(Thought)和行动(Action),每次行动后**观察(Observation)**结果,再继续推理,直到任务完成。这个循环就是所有 Agent 框架的心脏。

ReAct Loop(AI Agent,Reason + Act)

论文:ReAct: Synergizing Reasoning and Acting in Language Models (2022),Yao et al.

ReAct = Reasoning(Thought) + Acting(Action),是大模型 Agent 最经典的循环范式,交替做思考、调用工具、接收环境观察,直到任务结束。

🔁 循环流程(核心三步,反复迭代)
  1. Thought(思考):LLM 基于当前上下文,分析现状、推理下一步策略,写明思考过程。
  2. Action(行动):模型输出工具调用指令(搜索 / 查库 / 计算器等),交给外部环境执行。
  3. Observation(观察):外部环境返回执行结果,把结果追加到上下文。

回到第一步,继续循环;直到模型判断任务完成,输出 Final Answer 终止循环。

3.1 实例:论文原貌

用户提问:"帮我看看今天 AI 新闻里第一条的正文。"按 ReAct 论文的经典格式,模型的思考过程是这样:

css 复制代码
Thought 1: 用户要看 AI 新闻正文,但没给链接。
           我需要先用 get_news 拿到新闻列表。
Action 1:  get_news(topic="人工智能")
Observation 1: [{"title":"国产大模型...","link":"https://www.thepaper.cn/newsDetail_forward_123"}, ...共5条]

Thought 2: 拿到列表了,第一条的链接是 newsDetail_forward_123,
           用 read_url 读它的正文。
Action 2:  read_url(url="https://www.thepaper.cn/newsDetail_forward_123")
Observation 2: {"title":"国产大模型...","summary":"今日,某实验室发布..."}

Thought 3: 正文已拿到,可以回答用户了,不需要再调工具。
Final Answer: 今天 AI 新闻第一条是《国产大模型...》,主要内容是......

注意两个体现 ReAct 精髓的细节:

  • Action 2 的 url 不是用户给的,是模型从 Observation 1 里自己读出来的------"观察→推理→新行动"正是 ReAct 能处理依赖链任务的原因
  • Thought 3 决定"不再行动"------什么时候停下,同样是推理出来的

3.2 ReAct 与 tools 协议的对应关系

在 OpenAI tools 协议里,论文中显式的 Thought 被收进了模型内部(开 enable_thinking 可以看到),Action / Observation 则有了结构化的载体:

ReAct 概念 论文形式 tools 协议中的对应
Thought(推理) 显式文本,写在 prompt 里 收进模型内部,每轮请求前的"想"
Action(行动) Action: 工具名[参数] 文本 tool_calls 数组(工具名 + JSON 参数)
Observation(观察) 工具输出拼回 prompt role: "tool" 消息回写进 messages
Final Answer Final Answer: 文本 不带 tool_calls 的 assistant 消息

也就是说:runAgent 的每一次 for 循环,就是 ReAct 的一次"Thought → Action → Observation" 。下一节的 runAgent 代码就是它的工程落地。

3.3 实例走查:循环每轮发生了什么

把这个例子放进 runAgent 循环,逐轮追踪 messages 数组:

轮次 Thought(模型内部推理) Action(返回的 tool_calls) Observation(回写的 tool 消息)
1 要正文得先有链接 get_news({"topic":"人工智能"}) 5 条新闻,含 link
2 第一条的 link 可用 read_url({"url":"https://..."}) 网页标题 + 正文摘要
3 信息够了,直接回答 无 → 循环结束 ---

这正是第二节消息数组展开后的真实内容------"查列表 → 读详情"是 ReAct 最常见的依赖链形态。

3.4 从实例得到的两条工程结论

  • Observation 的质量决定 Thought 的质量 :get_news 返回了结构化的 title/link 字段,模型才能准确提取 url;如果工具返回一段含糊文本,模型就容易填错参数。工具输出的设计,本质是在为模型的下一步推理"供料"
  • 推理不可见 ≠ 推理不存在:tools 协议隐藏了 Thought,但模型每轮都在基于全部 messages 做决策。这也是为什么错误信息也要回写给模型(详见第四节)------它同样是 Observation,是下一轮推理的输入

四、手写协议:multi-tool.js 核心实现

4.1 工具定义:声明写得越具体,小模型越不容易选错

工具集抽在 utils/tools.js,每个工具 = definition(给模型看的声明)+ execute(本地实现):

javascript 复制代码
export const getWeather = {
    definition: {
        type: "function",
        function: {
            name: "get_weather",
            description: "查询指定城市的实时天气,包括温度、体感温度、湿度、风力",
            parameters: {
                type: "object",
                properties: {
                    city: { type: "string", description: "城市名称,如:北京、上海" },
                },
                required: ["city"],
            },
        },
    },
    execute: async ({ city }) => { /* fetch wttr.in,返回 JSON 字符串 */ },
};

// 工具注册表:整体传给模型,按 name 查找执行
export const TOOLS = [getWeather, getNews, getTime, readUrl];
export const TOOL_MAP = Object.fromEntries(TOOLS.map((t) => [t.definition.function.name, t]));

本项目注册了四个工具,其中 read_url 是依赖型工具 ------它的 url 参数必须来自 get_news 的返回结果,这正是顺序执行的素材。

4.2 模型请求:流式 + 思考可视化

初版用非流式(一次拿到完整 JSON,解析 tool_calls 简单可靠),但页面看不到打字效果,也看不到思考过程。改成流式后,每轮的 reasoning_content(思考)和 content(正文)增量都能实时转发给页面:

javascript 复制代码
async function callModelStream(messages, { onReasoning, onContent } = {}) {
    const response = await fetch(`${BASE_URL}/chat/completions`, {
        method: "POST",
        headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.DASHSCOPE_API_KEY}` },
        body: JSON.stringify({
            model: MODEL,
            messages,
            tools: TOOLS.map((t) => t.definition),
            tool_choice: "auto",      // 模型自主决定是否调工具
            stream: true,
            temperature: 0.3,
            max_tokens: 4096,         // thinking 会占输出预算,太小会导致 content 为空
            enable_thinking: true,    // 开启后才有 reasoning_content
        }),
    });

    let content = "";
    const toolCalls = []; // 按 delta.index 累积的 tool_calls
    await parseSSEStream(response, (data) => {
        const delta = data.choices?.[0]?.delta ?? {};
        if (delta.reasoning_content) onReasoning?.(delta.reasoning_content);
        if (delta.content) {
            // 工具轮的 content 常只是换行等空白副产品:纯空白不转发页面,避免打乱展示顺序
            if (delta.content.trim() || content.trim()) onContent?.(delta.content);
            content += delta.content;
        }
        // tool_calls 分片到达:每片带 index,type/id/name 只在首片,arguments 分多片
        for (const tc of delta.tool_calls ?? []) {
            const acc = (toolCalls[tc.index] ??= { id: "", type: "function", function: { name: "", arguments: "" } });
            if (tc.id) acc.id = tc.id;
            if (tc.type) acc.type = tc.type;
            if (tc.function?.name) acc.function.name += tc.function.name;
            if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
        }
    });

    // 拼成与非流式同构的完整 message,循环逻辑无需感知流式细节
    return { role: "assistant", content, ...(toolCalls.length ? { tool_calls: toolCalls } : {}) };
}

三个设计要点:

  • 流式下无法提前知道本轮是"调工具"还是"最终回答" :只能先转发增量、流结束后看 tool_calls 是否累积出内容再决定循环去留。所以返回值拼成与非流式同构的 message,上层循环逻辑一行不用改
  • tool_calls 分片累积是流式的核心难点 :每个分片带 index,type/id/name 只在首片出现,arguments 被切成多片,必须按 index 逐片拼接(坑 5 详述)
  • tool_choice: "auto" :由模型自己判断要不要调工具。其他可选值:"none" 禁止调工具、"required" 必须调一个、指定具体函数名则强制调该工具

4.3 工具执行:错误也要作为结果返回

javascript 复制代码
async function executeToolCall(call) {
    const tool = TOOL_MAP[call.function.name];
    if (!tool) return `未找到工具 ${call.function.name}`;
    let args;
    try {
        args = JSON.parse(call.function.arguments || "{}"); // 参数是 JSON 字符串
    } catch {
        return `工具参数不是合法 JSON: ${call.function.arguments}`;
    }
    try {
        return String(await tool.execute(args));
    } catch (e) {
        return `工具执行失败: ${e.message}`;
    }
}

设计原则:execute 永远返回字符串,失败时返回错误描述而不是抛异常。因为结果是回写给模型看的------模型读到"参数不是合法 JSON"就能在下一轮自行修正参数;而如果抛异常中断流程,模型连纠错的机会都没有。

4.4 工具调用循环(全文核心)

javascript 复制代码
const MAX_TOOL_ROUNDS = 5; // 循环上限,防止模型反复调工具陷入死循环

async function runAgent(userMessage, { onToolCall, onToolResult, onReasoning, onContent } = {}) {
    const messages = [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: userMessage },
    ];

    for (let round = 1; round <= MAX_TOOL_ROUNDS; round++) {
        const message = await callModelStream(messages, { onReasoning, onContent });

        // 无 tool_calls → 本轮增量就是最终回答(已流式推给页面),循环结束
        if (!message.tool_calls?.length) {
            return message.content ?? "";
        }

        messages.push(message); // ★ 原样回写带 tool_calls 的 assistant 消息

        // 同一轮的多个 tool_calls 彼此独立 → 并行执行;
        // 回调顺序:调用行几乎同时发出(并行启动),结果行按完成先后发出,如实反映并行
        const results = await Promise.all(message.tool_calls.map(async (call) => {
            onToolCall?.(`调用工具 ${call.function.name}(${call.function.arguments})`);
            const result = await executeToolCall(call);
            onToolResult?.(`工具结果 ${call.function.name} → ${result}`);
            return { callId: call.id, result };
        }));

        // 逐个回写结果,靠 tool_call_id 与调用配对
        for (const { callId, result } of results) {
            messages.push({ role: "tool", tool_call_id: callId, content: result });
        }
    }
    return `工具调用超过 ${MAX_TOOL_ROUNDS} 轮,未能完成任务`;
}

循环的终止条件只有一个:模型的回复里不再带 tool_calls 。MAX_TOOL_ROUNDS 是保险丝------小模型偶尔会拿着同样的参数反复调同一个工具,没有上限就会一直烧下去。

五、并行 vs 顺序:两种执行模式

5.1 并行:同一轮的多个 tool_calls

问"北京和上海今天天气怎么样",模型会在同一次回复 里返回两个 tool_calls。协议语义上它们彼此独立,所以本地用 Promise.all 并行执行:

javascript 复制代码
const results = await Promise.all(message.tool_calls.map((call) => executeToolCall(call)));
  • 总耗时 ≈ 最慢的那个工具,而串行是所有工具耗时之和。天气接口单次约 1~2 秒,两个工具并行能省下一半等待
  • 回写结果时顺序无所谓,模型靠 tool_call_id 配对,不靠位置
sequenceDiagram participant C as 客户端 participant M as 模型 C->>M: 请求(&#34;北京上海天气&#34;) M-->>C: tool_calls=[get_weather(北京), get_weather(上海)] par 并行执行 C->>C: get_weather(北京) ≈1.5s and C->>C: get_weather(上海) ≈1.5s end C->>M: 回写两个 tool 结果 M-->>C: 最终回答(总耗时 ≈1.5s + 两次模型推理)

5.2 顺序:跨轮次的工具依赖链

问"看看今天 AI 新闻第一条的正文",模型不可能一轮完成------它必须先拿到链接。于是协议自然形成跨轮次的串行链:

ini 复制代码
第1轮:模型返回 tool_calls=[get_news(topic=人工智能)]
       → 本地执行,结果(含每条新闻的 link)回写
第2轮:模型从上一轮结果中提取第一条的 link,
       返回 tool_calls=[read_url(url=那个链接)]
       → 本地执行,网页内容回写
第3轮:模型不再调工具,整理正文给出最终回答
sequenceDiagram participant C as 客户端 participant M as 模型 C->>M: 请求(&#34;AI新闻第一条正文&#34;) M-->>C: tool_calls=[get_news(人工智能)] C->>M: tool 结果(含新闻链接列表) M-->>C: tool_calls=[read_url(第1条的链接)] C->>M: tool 结果(网页正文) M-->>C: 最终回答

注意这里的关键:第二次的参数是模型自己从上一轮 tool 结果里"读"出来填的。依赖链不是代码写死的,而是模型基于上下文推理出来的------这也是 system prompt 里那句"先用 get_news 获取链接,再用 read_url 读取"的作用:给小模型指路。

5.3 对比总结

并行执行 顺序执行
触发方式 模型一轮返回多个 tool_calls 模型多轮、每轮返回一个(或多个)tool_calls
参数来源 都来自用户问题,彼此独立 后一个依赖前一个的结果
本地实现 Promise.all 同时跑 天然串行:上一轮结果回写后才有下一轮
耗时 ≈ 最慢工具 + N 次模型推理 所有工具之和 + N 次模型推理
典型场景 多城市天气、多关键词新闻 查列表 → 读详情、搜索 → 追问
谁决定 模型自主决定(tool_choice: auto) 模型基于上下文推理 + prompt 引导

一句话记忆:并行发生在"轮内",顺序发生在"轮间"。轮内能不能并行由模型决定(它一次给几个 tool_calls),轮间的顺序由数据依赖决定。

六、给小模型的 system prompt 设计

大模型看工具声明就能用得八九不离十,小模型需要更直白的指令。本项目的 prompt 只有四条短句:

markdown 复制代码
你是一个会使用工具的中文助手。
1. 涉及天气、新闻、当前时间等实时信息,必须先调用对应工具,禁止凭空编造。
2. 需要看某条新闻的正文时,先用 get_news 获取链接,再用 read_url 读取该链接。
3. 拿到工具结果后,用简洁的中文整理出回答。
4. 不需要工具的问题直接回答。

经验总结:

  • 短:小模型上下文理解力有限,指令越短遵从度越高
  • 先判断再行动:第 4 条明确"不需要工具就直接回答",避免什么问题都去调工具
  • 显式写依赖链:第 2 条直接告诉模型 get_news→read_url 的先后关系,顺序执行的成功率明显提升

延伸阅读

  • ReAct 原始论文 (Yao et al., 2022, ICLR 2023):ReAct: Synergizing Reasoning and Acting in Language Models ------本文的理论源头,首次提出 Thought → Action → Observation 交替循环。arxiv.org/abs/2210.03...
  • Chain-of-Thought 论文 (Wei et al., 2022, NeurIPS):Chain-of-Thought Prompting Elicits Reasoning in Large Language Models ------思维链奠基之作,ReAct 的推理部分本质上即 CoT 与工具调用的结合。arxiv.org/abs/2201.11...
  • OpenAI Function Calling 官方文档 :OpenAI tools 协议的事实标准定义,涵盖 tools 参数结构、tool_calls 响应格式、tool_choice 取值、流式分片机制等全部细节。platform.openai.com/docs/guides...
  • LangChain :最流行的开源 Agent 框架,AgentExecutor 封装了本文手写的 ReAct 循环;langgraph 支持更复杂的多 Agent 状态流转。github.com/langchain-a...
  • @openai/agents SDK :OpenAI 官方轻量级 Agent SDK,将工具循环、并行执行等底层细节封装在 Agent.run() 中,适合生产环境快速落地。github.com/openai/open...
  • tool_choice 的四种取值 :auto(模型自主)/ none(禁用)/ required(必须调用其一)/ 指定函数对象(强制调某个工具)。生产里做"意图强制分流"时很有用
  • Agent 协议的三层格局:模型↔工具层已收敛为 OpenAI tools 事实标准(各家兼容服务直接支持);工具接入层 MCP(Model Context Protocol)正在成为跨 Agent 复用工具的标准;而 Agent↔前端的 SSE 事件格式仍是各家自造(本文的 reasoning/tool/content 事件也是),AG-UI、ACP 等新标准正在试图统一这层
相关推荐
sell-cloud2 小时前
Prompt Engineering要凉了?Fable 5.5能“猜“意图,但银行正在封杀AI Agent——企业AI的真正护城河是什么
人工智能·prompt
Navicat中国3 小时前
Navicat 18 内置 AI 助手让数据库开发从繁琐操作迈向智能协作
大数据·人工智能·数据库开发
4SAPI4 小时前
多模型请求路由怎么设计?成本、速度和能力如何平衡
人工智能·python·gateway·php
魔乐社区4 小时前
翼芯・智算测试与适配优化专区,让国产算力“选得准、跑得通、用得好“
人工智能·开源·大模型
XCDoris6 小时前
郴州logo设计机构
大数据·人工智能·python
Goboy6 小时前
一个人带三个Agent,我用全新TRAE从0到1搭了个资讯周刊站
人工智能·trae
俊哥V6 小时前
每日 AI 研究简报 · 2026-10-19
人工智能·ai
AI_AGENT_DEV_AI6 小时前
AI英语教培智能体的开发
人工智能
物联网软硬件开发-轨物科技6 小时前
【技术白皮书】光伏清检一体化机器人系统解决方案技术白皮书
大数据·运维·网络·人工智能·机器人
海宇数据6 小时前
零信任架构实战:基于海宇网上踪迹欺诈行为识别构建自动化信贷资信评估网关
运维·人工智能·架构·自动化