本文通过Node.js和本地模型qwen3-1.7b,实现了ReAct(推理-行动-观察)循环的多工具调用,无需任何Agent SDK。文章详细解析了OpenAI tools协议和Agent框架的底层原理,通过手写完整的工具调用循环,摊开框架封装的底层协议细节。核心内容涵盖多工具调用的必要性、工具调用的本质是"乒乓球"循环、ReAct循环的实战解析、手写协议的核心实现以及并行与顺序执行模式的对比。本文旨在帮助程序员和小白深入理解大模型的工具调用机制,为使用LangChain、@openai/agents等框架打下坚实基础。
用 Node.js + 本地模型(qwen3-1.7b)实现 ReAct(推理-行动-观察)循环的多工具调用,不用任何 Agent SDK,手写完整的工具调用循环,把框架封装掉的底层协议细节全部摊开。
写作目的:不为造轮子,而是吃透 OpenAI tools 协议与 Agent 框架的底层原理——看懂这套“乒乓球”循环后,LangChain、@openai/agents 等框架的工具调用部分对你就再也没有黑盒。
一、为什么需要多工具
单个工具只能解决单一问题:查天气、查新闻、看时间各管一摊。但真实需求往往是复合的:
- “北京和上海今天哪里更热?” → 要同时调两次天气工具(并行)
- “帮我看看今天 AI 新闻里第一条的正文” → 要先查新闻拿链接,再读网页(顺序依赖)
这就是多工具调用要解决的两类核心场景:彼此独立的工具并行跑、有依赖关系的工具串行跑。而这两种模式,在 OpenAI tools 协议里其实是同一套机制的不同表现——先看懂协议,两种模式就都通了。
二、工具调用的本质:一场"乒乓球"循环
模型自己不能执行代码,它只会"说"想调什么工具。真正的执行在本地。一次带工具的问答,实际是这样往返多次:
工具调用循环流程
协议层面的四个关键点:
工具清单放在请求的
tools参数里,每个工具用 JSON Schema 描述名字、用途、参数结构——这是模型"选工具、填参数"的唯一依据模型决定调工具时,响应的 message 里带
tool_calls数组:工具名 + JSON 字符串参数。注意参数是字符串,要本地JSON.parse工具执行结果以
{ role: "tool", tool_call_id, content }消息回写,tool_call_id把结果和那次调用配对带
tool_calls的 assistant 消息必须原样回写进 messages,再带上 tool 结果重新请求,直到模型不再返回tool_calls
一次完整问答的消息数组最终长这样:
[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 循环:推理-行动-观察
ReAct Loop(AI Agent,Reason + Act)
论文:ReAct: Synergizing Reasoning and Acting in Language Models (2022),Yao et al.
ReAct = Reasoning(Thought) + Acting(Action),是大模型 Agent 最经典的循环范式,交替做思考、调用工具、接收环境观察,直到任务结束。
🔁 循环流程(核心三步,反复迭代)
Thought(思考):LLM 基于当前上下文,分析现状、推理下一步策略,写明思考过程。
Action(行动):模型输出工具调用指令(搜索 / 查库 / 计算器等),交给外部环境执行。
Observation(观察):外部环境返回执行结果,把结果追加到上下文。
回到第一步,继续循环;直到模型判断任务完成,输出 Final Answer 终止循环。
3.1 实例:论文原貌
用户提问:“帮我看看今天 AI 新闻里第一条的正文。”按 ReAct 论文的经典格式,模型的思考过程是这样:
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(本地实现):
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(正文)增量都能实时转发给页面:
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 工具执行:错误也要作为结果返回
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 工具调用循环(全文核心)
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并行执行:
const results = await Promise.all(message.tool_calls.map((call) => executeToolCall(call)));- 总耗时 ≈ 最慢的那个工具,而串行是所有工具耗时之和。天气接口单次约 1~2 秒,两个工具并行能省下一半等待
- 回写结果时顺序无所谓,模型靠
tool_call_id配对,不靠位置
5.2 顺序:跨轮次的工具依赖链
问"看看今天 AI 新闻第一条的正文",模型不可能一轮完成——它必须先拿到链接。于是协议自然形成跨轮次的串行链:
第1轮:模型返回 tool_calls=[get_news(topic=人工智能)] → 本地执行,结果(含每条新闻的 link)回写 第2轮:模型从上一轮结果中提取第一条的 link, 返回 tool_calls=[read_url(url=那个链接)] → 本地执行,网页内容回写 第3轮:模型不再调工具,整理正文给出最终回答注意这里的关键:第二次的参数是模型自己从上一轮 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 只有四条短句:
你是一个会使用工具的中文助手。 1. **涉及天气、新闻、当前时间等实时信息,必须先调用对应工具,禁止凭空编造。** 2. **需要看某条新闻的正文时,先用 get_news 获取链接,再用 read_url 读取该链接。** 3. **拿到工具结果后,用简洁的中文整理出回答。** 4. **不需要工具的问题直接回答。**经验总结:
短:小模型上下文理解力有限,指令越短遵从度越高
先判断再行动:第 4 条明确"不需要工具就直接回答",避免什么问题都去调工具
显式写依赖链:第 2 条直接告诉模型 get_news → read_url 的先后关系,顺序执行的成功率明显提升
大模型的知识又多又杂,到底怎么学?
做这行久了,被问最多的问题就一个:大模型的知识又多又杂,我到底先学啥?翻了一堆资料,看啥都像重点,结果啥都没学进去。今天把我自己一路摸出来的顺序捋一遍,零基础、应届生、转行的都适用。跟着走,至少方向不乱、不瞎忙,学完拿得出手。
第一阶段(10天):初阶应用
这一阶段就一个目标:把大模型真正"玩明白",不是只会聊天那种,而是能调教它、能用代码把大模型和业务接起来。
- 大模型 AI 能干什么?
- 大模型是怎样获得「智能」的?
- 用好 AI 的核心心法
- 大模型应用业务架构
- 大模型应用技术架构
- 代码示例:向 GPT-3.5 灌入新知识
- 提示工程的意义和核心思想
- Prompt 典型构成
- 指令调优方法论
- 思维链和思维树
- Prompt 攻击和防范
- …
第二阶段(30天):高阶应用
这阶段正式进实战,目标很实在:做出两个能拿出手的东西——一个私有知识库,一个 agent 对话机器人。
为什么要做 RAG
搭建一个简单的 ChatPDF
检索的基础概念
什么是向量表示(Embeddings)
向量数据库与向量检索
基于向量检索的 RAG
搭建 RAG 系统的扩展知识
混合检索与 RAG-Fusion 简介
向量模型本地部署
…
走完这 30 天,Python 或 JavaScript 背景的基本能独立做一套 RAG 应用,简历上也敢写"大模型应用开发"了。
第三阶段(约20天):动手微调
学到这,你已经开始 “造” 模型了 —— 通过微调,搞出垂直领域的专用小模型。
- 为什么要做 RAG
- 什么是模型
- 什么是模型训练
- 求解器 & 损失函数简介
- 小实验2:手写一个简单的神经网络并训练它
- 什么是训练/预训练/微调/轻量化微调
- Transformer结构简介
- 轻量化微调
- 实验数据集的构建
- …
恭喜你,如果学到这里,你基本可以找到一份大模型 AI相关的工作,自己也能训练 GPT 了!通过微调,训练自己的垂直大模型,能独立训练开源多模态大模型,掌握更多技术方案。
到此为止,大概2个月的时间。你从 “只会用” 变成 “能造” 的 AI 小子。那么你还想往下探索吗?
想要入局AI大模型赛道、抢占行业红利的朋友,微信扫描下方CSDN官方认证二维码,即可100%免费领取全套学习资料!
第四阶段(20天):商业闭环,部署落地
技术学完,落地才算数。这阶段带你搞明白大模型怎么真正跑起来、用起来。
- 硬件选型
- 带你了解全球大模型
- 使用国产大模型服务
- 搭建 OpenAI 代理
- 热身:基于阿里云 PAI 部署 Stable Diffusion
- 在本地计算机运行大模型
- 大模型的私有化部署
- 基于 vLLM 部署大模型
- 案例:如何优雅地在阿里云私有部署开源大模型
- 部署一套开源 LLM 项目
- 内容安全
- 互联网信息服务算法备案
- …
想要入坑大模型的朋友,给大家准备了一份2026 年最新 AI 大模型全套学习资料:
1、大模型系统化学习路线
2、大模型学习视频教程
3、大模型学习书籍&电子文档
4、AI大模型最新行业报告
5、大模型项目实战&配套源码
6、2026大模型大厂面试真题
7、为什么要学习大模型?
我国在A大模型领域面临人才短缺,数量与质量均落后于发达国家。2023年,人才缺口已超百万,凸显培养不足。随着AI技术飞速发展,预计到2025年,这一缺口将急剧扩大至400万,严重制约我国AI产业的创新步伐。加强人才培养,优化教育体系,国际合作并进是破解困局、推动AI发展的关键。
8、适合谁学?
- 传统后端 / Java / 前端开发,想转型 AI 应用
- 大学生、应届生,想拿更好的 offer
- 产品经理、运营,想武装职业竞争力
- 技术负责人,想给团队落地提效
学习是反人性的,但回报是真金白银。技术会更新,赛道会切换,但只要你先动手,机会就永远站在你这边。
9、这些资料真的有用吗?
这份资料由我和鲁为民博士(北京清华大学学士和美国加州理工学院博士)共同整理,现任上海殷泊信息科技CEO,其创立的MoPaaS云平台获Forrester全球’强劲表现者’认证,服务航天科工、国家电网等1000+企业,以第一作者在IEEE Transactions发表论文50+篇,获NASA JPL火星探测系统强化学习专利等35项中美专利。本套AI大模型课程由清华大学-加州理工双料博士、吴文俊人工智能奖得主鲁为民教授领衔研发。
资料内容涵盖了从入门到进阶的各类视频教程和实战项目,无论你是小白还是有些技术基础的技术人员,这份资料都绝对能帮助你提升薪资待遇,转行大模型岗位。
想要入局AI大模型赛道、抢占行业红利的朋友,微信扫描下方CSDN官方认证二维码,即可100%免费领取全套学习资料!
好了,就写到这。路线是死的,人是活的,卡住了随时来问我。觉得有用就点个收藏,别让它进收藏夹吃灰——动起来比啥都强。