
一、Tool Calling
基础概念
tool 是什么
tool 是把一个普通函数包装成"模型可调用的可执行单元"的机制。它由两部分组成:执行回调(真正干活的函数)+ 元信息(name / description / schema)。模型看不到函数体,只能看到元信息里声明的名字、用途和参数结构,据此决定要不要调、调哪个、传什么参。
这可以类比 API 接口声明------调用方只认签名和文档,不关心内部实现。模型的"调用"也不是真的执行函数,而是生成一段结构化的 tool_call(带名字和参数),再由执行环境真正落地。
tool 在 Agent 当中作用
tool 是 Agent "行动"的唯一出口。纯 LLM 只能生成文本,接了 tool 之后,模型能把意图翻译成一次结构化的函数调用,再由执行环境真正落地(查数据库、发请求、写文件)。这让 Agent 从"只会说"变成"能做",是 ReAct 循环里 Acting 那一环的载体。
没有 tool,Agent 的答案只能来自训练数据里的知识,一旦涉及实时信息、私有数据、外部副作用就无能为力。有了 tool,模型可以查天气、读文件、调 API,把外部世界的真实结果纳入推理。
1.1、tool 定义工具函数
第一个参数:执行回调函数
工具的回调函数决定了执行结果返回给模型的形式,分三种:
- 返回字符串
- 返回对象
- 返回 command(async 异步处理 Command)
第二个参数:Tool 的 meta 信息
- name
- description
- schema:执行回调时的参数
typescript
import { tool, ToolMessage, type ToolRuntime } from "langchain";
import { Command } from "@langchain/langgraph";
import * as z from "zod";
// 返回字符串
const getWeather = tool(({ city }) => `It is currently sunny in ${city}.`, {
name: "get_weather",
description: "Get weather for a city.",
schema: z.object({ city: z.string() }),
});
// 返回对象
const getWeatherData = tool(
({ city }) => ({
city,
temperature_c: 22,
conditions: "sunny",
}),
{
name: "get_weather_data",
description: "Get structured weather data for a city.",
schema: z.object({ city: z.string() }),
},
);
// 异步返回 Command
const setLanguage = tool(
async ({ language }, config: ToolRuntime) => {
return new Command({
update: {
preferredLanguage: language,
messages: [
new ToolMessage({
content: `Language set to ${language}.`,
tool_call_id: config.toolCallId,
}),
],
},
});
},
{
name: "set_language",
description: "Set the preferred response language.",
schema: z.object({ language: z.string() }),
},
);
三种返回形式各有用途。返回字符串最简单,适合"查了天气给一段话"的场景;返回对象适合下游要结构化消费的结果;返回 Command 则给了工具改写整个图状态的能力,能顺手更新 preferredLanguage 这类自定义字段,而不仅是回一条 ToolMessage。
name 和 description 是模型选工具的唯一依据,要写得直白:工具做什么、什么时候该用、参数填什么。schema 用 Zod 声明参数结构,模型会照着它填参,同时它也是运行时的参数校验边界。
1.2、Agent 注册与调用 Tools
createAgent 时传入 tools 数组(由每个 tool 组成),就能让 Agent 根据每个 Tool 的 description 自动识别该调哪个。
createAgent 是 LangGraph 提供的高阶工厂------把 LLM 和一组 tools 交给它,它返回一个编译好的可执行 Agent。模型在每轮推理时会"看到"所有工具的 name + description + schema,自主决定本轮要不要调用、调哪个、传什么参数。
javascript
import { createAgent } from '@langchain/langgraph';
import { ChatAnthropic } from '@langchain/anthropic';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
// 1. 定义一组业务工具
const getWeather = tool(
async ({ city }) => {
// 真实场景这里会调天气 API
const data = await fetchWeather(city);
return `${city} 今天 ${data.temp}°C,${data.condition}`;
},
{
name: 'get_weather',
description: '查询指定城市的当前天气。输入城市名(中文或英文)。',
schema: z.object({
city: z.string().describe('城市名称,如 "北京" 或 "Beijing"'),
}),
},
);
const getTime = tool(
async () => {
return `现在是 ${new Date().toLocaleTimeString('zh-CN')}`;
},
{
name: 'get_time',
description: '获取当前时间。',
schema: z.object({}),
},
);
// 2. 用 createAgent 把 LLM 和 tools 组装成 Agent
const agent = createAgent({
llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
tools: [getWeather, getTime],
prompt: '你是一个有用的助手,可以查天气和时间。回答用中文。',
});
// 3. 调用 Agent------它会自动决定调哪个工具、传什么参数
const result = await agent.invoke({
messages: [
{ role: 'user', content: '北京和东京现在几点?天气怎么样?' },
],
});
// Agent 内部会经历多轮:
// 轮1: 模型决定调 get_time → 拿到时间
// 轮2: 模型决定调 get_weather(北京) → 拿到北京天气
// 轮3: 模型决定调 get_weather(东京) → 拿到东京天气
// 轮4: 模型综合所有结果,输出最终自然语言回答
console.log(result.messages[result.messages.length - 1].content);
// "现在是 14:30。北京 22°C 晴天,东京 18°C 多云。"
这里不需要写 if (用户问天气) 调天气工具 这类硬编码逻辑。模型靠阅读每个 tool 的 description 自主决策,所以 description 直接决定工具被调用的方式与频率。好的 description 要交代清楚:这个工具做什么、什么时候该用、参数填什么。
二、Agent Loop
2.1、概念
Agent 的本质是一个循环:模型按"推理 → 调用工具 → 看结果 → 继续推理"的节奏推进,直到它认为可以给出最终回答。
ReAct
ReAct = Reasoning + Acting,是 Agent 的经典心智模型------模型先推理("我需要做什么"),再行动,然后根据行动结果继续推理。
ReAct 代理把自然语言处理(NLP)和强化学习(RL)结合,能在执行任务前进行内部思考和推理,从而优化行动策略。它靠多步骤处理能力分解任务、规划执行,适用于任务管理、客服支持、数据分析这类复杂场景。ReAct 的独特之处在于内部对话机制------像人一样分析问题、做出决策,并在动态环境里适应变化,展现出较强的自主性。
Tool Agent Loop
Tool Agent Loop 是 ReAct 的进一步高阶处理,结合 LLM 的决策判断是否要继续调用相关 Tools:如果返回的是纯文案,或者本轮没有 tool_call,就说明这一轮 LLM 调用已经得出了结论,不再需要循环询问。
2.2、结束 Loop 的判断处理条件
| 情况概述 | 硬停/软停 | 触发条件 |
|---|---|---|
| 无工具调用 | ✅ 正常结束 | LLM 本轮无 tool_call(主路径) |
| 总循环超时 | ⏱ 超时 | 总循环超时:Date.now() - loopStart > LOOP_TIMEOUT_MS(2h 长任务) |
| 用户中止 | ⏹ 中断 | 用户点 stop(abortSignal.abort) |
| adapter 流报错 | ⚠️ 异常 | 本轮收到 error chunk(errored=true) |
| 达到轮次上限 | 🔁 耗尽硬停兜底 | turn > MAX_TURNS(100) |
| 同一工具多次同参调用 | 不停,注入参数纠正 | 同 (tool, argsHash) ≥ 3 次 |
| 接近或快超 token | 不停,进行上下文压缩 | input_tokens/contextWindow ≥ 75% / 90% |
这张表里两类"停"要分清:正常结束和异常中止是硬停,直接终止循环;后面三行属于软停------不直接停,而是注入纠正或压缩上下文,让循环能继续走下去。设计循环终止条件时,这几条兜底缺一不可,否则一旦模型陷入"反复调同一个工具"或 token 悄悄爆掉,整个 Agent 就卡死了。
2.3、简单的完整的例子
把前面定义的工具、注册、Loop 串起来,下面是一个从零搭建到运行的完整例子------用户问"帮我查上海天气,然后根据天气推荐穿搭",Agent 会自主规划调用顺序:
php
import { createAgent } from '@langchain/langgraph';
import { ChatAnthropic } from '@langchain/anthropic';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
// ① 定义两个工具
const getWeather = tool(
async ({ city }) => {
// 模拟天气 API
const db: Record<string, { temp: number; condition: string }> = {
上海: { temp: 20, condition: '多云转小雨' },
};
const w = db[city] ?? { temp: 25, condition: '晴' };
return `${city}:${w.temp}°C,${w.condition}`;
},
{
name: 'get_weather',
description: '查询某城市当前天气,返回温度和天气状况。',
schema: z.object({ city: z.string().describe('城市名') }),
},
);
const suggestOutfit = tool(
({ temp, condition }) => {
if (condition.includes('雨')) return `建议带伞,穿防水外套(${temp}°C 偏凉)`;
if (temp < 10) return '建议穿羽绒服';
if (temp < 20) return '建议穿薄外套';
return '可以穿短袖';
},
{
name: 'suggest_outfit',
description: '根据温度和天气状况推荐穿搭。温度单位是摄氏度。',
schema: z.object({
temp: z.number().describe('摄氏度温度'),
condition: z.string().describe('天气状况,如"晴"、"雨"'),
}),
},
);
// ② 创建 Agent
const agent = createAgent({
llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
tools: [getWeather, suggestOutfit],
prompt: '你是穿搭顾问。先查天气,再根据天气推荐穿搭。',
});
// ③ 运行(用 stream 看清楚 Loop 的每一轮)
const stream = await agent.stream(
{ messages: [{ role: 'user', content: '我在上海,今天穿什么?' }] },
{ streamMode: 'updates', recursive: false },
);
for await (const event of stream) {
// 每轮推理 / 工具调用都会产出一个 event
console.log(JSON.stringify(event, null, 2));
}
// ===== 输出(简化)=====
// 第1轮 event(agent 节点):
// 模型推理 → 决定调 get_weather({ city: "上海" })
// AIMessage.tool_calls = [{ name: "get_weather", args: { city: "上海" } }]
// 第2轮 event(tools 节点):
// 执行 get_weather → 返回 "上海:20°C,多云转小雨"
// ToolMessage.content = "上海:20°C,多云转小雨"
// 第3轮 event(agent 节点):
// 模型看到天气结果 → 决定调 suggest_outfit({ temp: 20, condition: "多云转小雨" })
// 第4轮 event(tools 节点):
// 执行 suggest_outfit → 返回 "建议带伞,穿防水外套(20°C 偏凉)"
// 第5轮 event(agent 节点):
// 模型综合所有信息 → 输出最终自然语言回答,本轮无 tool_call → Loop 结束
// AIMessage.content = "上海今天 20°C,多云转小雨。建议带伞,穿防水外套..."
console.log('--- Agent 执行完毕 ---');
这就是一个完整的 ReAct 循环:推理(要查天气)→ 行动(调天气工具)→ 推理(要查穿搭)→ 行动(调穿搭工具)→ 推理(综合回答,无工具调用,结束)。
值得观察的是,Agent 自主决定了调用顺序------先查天气再推荐穿搭,而不是反过来。这个"规划"能力完全来自模型的推理 + 工具 description,代码里不需要写死调用顺序。这也解释了 Agent 为什么比传统"硬编码工作流"灵活:它能处理代码作者没预料到的组合。
三、Tool Calling 进阶话题
3.1、工具并行调用
模型可以一次返回多个 tool_calls,彼此没有依赖关系(如查天气 + 查时间)。createAgent 会自动并行执行这些工具,省去多轮推理的时间。
php
// 用户问:"北京和东京现在的天气和时间?"
// 模型可能一次返回 2 个工具调用:
// - get_weather({ city: "北京" })
// - get_weather({ city: "东京" })
// - get_time()
// createAgent 内部会自动并行执行它们(不是串行!):
const result = await agent.invoke({
messages: [{ role: 'user', content: '北京和东京现在的天气和时间?' }],
});
// 总耗时 ≈ max(三个工具各自耗时),而不是 sum
并行与串行的判断:模型只对无依赖的调用使用并行。如果工具 A 的输出是工具 B 的输入,模型会自动串行(先 A 再 B)。这种"并行机会识别"是模型自身的能力,不需要在工具里标注。
3.2、工具调用失败处理
工具可能因为网络、参数错误、外部服务故障而失败。三种应对策略:
javascript
import { tool, ToolException } from '@langchain/core/tools';
import { z } from 'zod';
// ① 抛出 ToolException:模型会看到错误并尝试换方案
const flakyTool = tool(
async ({ input }) => {
if (Math.random() < 0.3) {
throw new ToolException(`"${input}" 处理失败,请换种问法`);
}
return `成功:${input}`;
},
{
name: 'flaky_tool',
description: '一个偶尔会失败的工具',
schema: z.object({ input: z.string() }),
},
);
// ② 返回错误字符串(不抛异常):模型把它当正常结果处理
const fallbackTool = tool(
async ({ input }) => {
try {
return await externalAPI(input);
} catch (err) {
return `[工具内部错误] ${err.message},建议重试或换工具`;
}
},
{
name: 'safe_tool',
description: '失败时返回错误信息而不是抛异常',
schema: z.object({ input: z.string() }),
},
);
// ③ 在外层拦截 ToolMessage,做降级处理(推荐)
const wrappedTool = tool(
async (args, config) => {
try {
return await originalTool.invoke(args, config);
} catch (err) {
// 记埋点 + 返回降级结果
console.error(`[tool:${originalTool.name}] 失败:`, err);
return `工具调用失败:${err.message}。请告知用户稍后重试。`;
}
},
originalTool.metadata,
);
错误处理的取舍:
| 策略 | 优点 | 缺点 |
|---|---|---|
| 抛 ToolException | 模型明确知道失败,可自主重试或换方案 | 模型可能无限重试 |
| 返回错误字符串 | 简单,不破坏对话流 | 模型可能误把错误信息当正常结果 |
| 外层拦截降级 | 完全可控,统一埋点 | 需要包装代码 |
本项目 engine/src/tools/ 对所有有副作用的工具都用外层拦截------失败记日志 + 埋点 + 返回用户友好的错误信息,让模型知道可以"建议用户重试",而不是反复尝试同一个工具。
3.3、Tool 元数据高级用法
tool() 的第二个参数除了 name/description/schema,还有几个不常用但很有用的字段:
php
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
// ① returnDirect:跳过模型,直接把工具结果返回给用户
// 适用于"展示型"工具(如计算器、查询天气后立刻显示结果)
const quickLookup = tool(
async ({ city }) => `北京:晴,25°C`,
{
name: 'quick_lookup',
description: '快速查询',
schema: z.object({ city: z.string() }),
returnDirect: true, // ← 关键:工具结果直接返回,不再让模型加工
},
);
// ② responseFormat:自定义工具响应的 schema(默认是字符串)
// 适用于"机器消费"的工具结果
const structuredTool = tool(
async ({ query }) => ({
results: ['item1', 'item2'],
total: 2,
}),
{
name: 'search',
description: '结构化搜索',
schema: z.object({ query: z.string() }),
// 可选:声明返回结构,下游消费者按此解析
},
);
// ③ extra:附加任意元数据,可在 Hook / Middleware 里读取
const tracedTool = tool(
async ({ x }) => x * 2,
{
name: 'multiply_by_two',
description: '乘以 2',
schema: z.object({ x: z.number() }),
extra: {
cost: 'low', // 自定义:成本分级
risk: 'safe', // 自定义:风险等级
owner: 'team-a', // 自定义:归属团队
requiresApproval: false, // 自定义:是否需要审批
},
},
);
extra 字段看着不起眼,实际是接 Hook 和 Middleware 的钩子------审批、限流、成本统计都能从这读取工具的自定义属性,不用再维护一份外部的工具清单。
3.4、动态工具加载
按用户角色、当前任务、上下文动态注入工具子集,能显著提升 Agent 的"工具选择准确率"(少即是多)------不需要一次性塞给 Agent。
php
// 场景:管理员才看"删文件"工具,普通用户只看到"读文件"
const baseTools = [readFileTool, listFilesTool];
function getToolsForUser(userRole: string) {
if (userRole === 'admin') {
return [...baseTools, deleteFileTool, writeFileTool];
}
return baseTools; // 普通用户看不到危险工具
}
const agent = createAgent({
llm: model,
tools: getToolsForUser(currentUser.role),
});
// 也可以根据当前任务动态决定
function getToolsForTask(task: string) {
if (task.includes('代码')) {
return [readFileTool, searchDocsTool, runTestsTool];
}
if (task.includes('部署')) {
return [runCommandTool, deployTool];
}
return baseTools;
}
动态加载的副作用:每次切换工具集,Agent 的"工具提示词"会变,可能让模型困惑("我刚才还能调的工具怎么没了?")。建议在同一会话内稳定工具集,只在会话开始时按角色/任务确定一次。
3.5、Tool Middleware(工具中间件)
和 Web 中间件类似------在工具执行前后插入横切逻辑(日志、限流、参数改写、敏感字段脱敏)。LangGraph 通过 preToolCallHook + postToolCallHook 实现:
ini
const agent = createAgent({
llm: model,
tools: [sendEmailTool, readFileTool],
// 工具执行前的拦截
preToolCallHook: (state) => {
const lastCall = state.messages.at(-1)?.tool_calls?.at(-1);
if (!lastCall) return;
// 横切关注点 1:限流(同一工具 1 秒内最多调 5 次)
rateLimiter.check(lastCall.name);
// 横切关注点 2:参数改写(如自动给邮件加签名)
if (lastCall.name === 'send_email' && !lastCall.args.signature) {
lastCall.args.signature = '------ 来自 Vanguard AI';
}
// 横切关注点 3:敏感字段脱敏(log 时遮蔽密码)
if (lastCall.name === 'read_file') {
console.log(`[tool] ${lastCall.name}`, redactSensitive(lastCall.args));
}
},
// 工具执行后的拦截
postToolCallHook: (state) => {
const lastMsg = state.messages.at(-1);
if (lastMsg?.role !== 'tool') return;
// 横切关注点 4:埋点(统计工具调用)
metrics.increment('tool_call.count', { tool: lastMsg.name });
// 横切关注点 5:结果缓存(相同 query 不重复执行)
cache.set(lastMsg.tool_call_id, lastMsg.content);
},
});
Middleware(Hook)影响所有工具,适合横切关注点;Wrapper(包装函数)只影响单个工具,适合定制化逻辑。两者结合使用效果最佳。
3.6、tool_call_id 生命周期
tool_call_id 是连接"模型决定调工具"和"工具返回结果"的纽带。完整生命周期:
csharp
// 第 1 步:模型决定调工具(在 AIMessage 里生成 tool_calls)
const aiMsg = await model.invoke([...previousMessages]);
// aiMsg.tool_calls = [
// { id: 'call_abc123', name: 'get_weather', args: { city: '北京' } }
// ]
// 第 2 步:执行工具,生成 ToolMessage
// tool_call_id 必须 = aiMsg.tool_calls[].id
const toolResult = await getWeather(aiMsg.tool_calls[0].args);
const toolMsg = new ToolMessage({
content: toolResult, // 工具执行结果
tool_call_id: 'call_abc123', // ← 必须对应上!
name: 'get_weather',
});
// 第 3 步:把 AIMessage + ToolMessage 一起发给模型
const nextResponse = await model.invoke([
...previousMessages,
aiMsg, // 模型的"决定"
toolMsg, // 工具的"结果"
]);
错乱的常见原因:
| 错误 | 现象 | 解决 |
|---|---|---|
tool_call_id 对不上 |
模型报"tool message without tool call" | 严格用 aiMsg.tool_calls[].id 作为 ToolMessage.tool_call_id |
漏发 AIMessage |
模型看不到自己的"决定" | 必须把 AIMessage 和对应的 ToolMessage 成对插入消息列表 |
重复 tool_call_id |
同一调用被算两次 | 每次生成新 tool_call_id,不要复用 |
| 顺序错乱(ToolMessage 在前) | 模型困惑"我没调过这个" | 严格按 AIMessage → ToolMessage → AIMessage 的顺序 |
自动化方案是用 createAgent 而不是手写循环------它自动处理 tool_call_id 配对、消息顺序、并行执行。只有在需要精细控制时才手写。
参考资料:
Agent 开发系列文章
前端转型 Agent 开发 01 之 Agent API 调用(和 Agent 的基础对话):juejin.cn/post/767744...
前端转型 Agent 开发 02 之 Provider 与 Structured Output(规范化模型输入输出):juejin.cn/post/767745...