大模型(LLM)很强大,但它本质上是一个「只会根据上下文预测下一个 token」的文本模型。它能对话、能写作、能推理,却有几件根本做不到的事:拿不到实时数据、访问不了你的私有系统、也无法真正产生副作用。Tool(工具) 就是用来补齐这些能力的关键机制。这篇文章从「是什么、为什么、怎么实现、怎么调用」四个角度,把 tool 讲透。
一、什么是 tool
一句话:tool 是交给模型调用的一个函数。
但这里有个必须澄清的认知误区:模型并不会亲自执行这个函数。
模型的角色只是「决策」——它根据用户的问题,判断该不该用工具、用哪个工具、参数填什么,然后把结论输出成一段结构化的调用请求(tool_calls)。真正干活的,是你自己的程序。程序拿到请求后执行对应函数,再把执行结果作为一条消息回传给模型,模型基于结果继续推理,直到给出最终答案。
所以一次工具调用的本质,是模型与程序之间的分工协作:
用户提问
└─> 模型决策:我要调用 get_weather(city="北京")
└─> 你的程序执行:得到「北京今天晴,25℃」
└─> 结果回传给模型
└─> 模型给出最终答案
可以这样理解两个角色:
- 模型负责「想」:理解意图、决定用不用工具、生成参数、结合结果作答;
- 程序负责「做」:真正执行函数、访问外部世界、控制副作用。
这种分工带来一个很重要的好处:执行过程完全在程序这一侧,因此可测试、可审计、可拦截、可加权限校验——而不是把外部能力「烧」进模型里。
二、为什么需要 tool
模型自身有四个典型的短板,工具正是针对它们设计的:
1. 知识有截止日期,拿不到实时数据。 模型只知道训练时见过的内容,问它「现在北京几度」「今天美元汇率多少」,它要么不知道,要么一本正经地编造(幻觉)。接一个天气 API、汇率接口,就能给出真实答案。
2. 访问不了你的私有数据。 公司的订单数据库、内部工单系统、你本地的文件——这些模型从未见过。把它们包装成工具,模型才能在回答时引用。
3. 不能产生副作用。 模型只能「说」,不能「做」。下单、发邮件、写文件、调用支付接口这些真实动作,必须由程序在受控的前提下执行。
4. 不擅长精确计算与确定性逻辑。 大数乘法、日期推算、严格的格式转换,交给专门的函数比让模型「心算」可靠得多。
换一个角度看:模型擅长的是理解与推理(听懂你要什么、把结果组织成人话),而数据的获取、精确的执行交给工具。工具让模型从「一个知道很多但封闭的脑子」,变成一个「能感知外部世界、能动手做事的智能体(Agent)」。
三、怎么实现 tool
LangChain 提供了 tool(func, fields) 辅助函数来定义工具。它接收一个普通函数和一段元信息,自动把参数模式转成模型能读懂的 JSON Schema,返回一个工具对象。
3.1 一个工具由四部分组成
| 组成 | 给谁看 | 作用 |
|---|---|---|
name | 模型 | 工具名,模型靠它指定「调用谁」,需唯一 |
description | 模型 | 说明能做什么,模型靠它判断「什么时候用」 |
schema | 模型 | 参数形状(类型、必填、含义),模型靠它生成合法参数 |
func | 程序 | 真正的实现逻辑,普通 JS 函数 |
前三个决定模型「知不知道、会不会用」这个工具;最后一个由你的程序执行。
3.2 定义结构化工具(最常用)
import { tool } from "langchain";
import { z } from "zod";
const getWeather = tool(
({ city }) => `${city} 今天晴,气温 25℃`, // func:真正的实现
{
name: "get_weather", // name
description: "查询指定城市今天的天气", // description
schema: z.object({ // schema
city: z.string().describe("要查询的城市名,例如 北京"),
}),
}
);
z.object 定义参数结构,.describe() 写上字段含义。LangChain 会把它转成模型实际看到的 JSON Schema:
{
"type": "object",
"properties": {
"city": { "type": "string", "description": "要查询的城市名,例如 北京" }
},
"required": ["city"]
}
这份 JSON Schema 才是模型真正看到的东西。模型靠字段名和 description 来生成参数——描述写得越清楚,模型传参就越准。
3.3 多参数工具
const getExchangeRate = tool(
({ from, to, amount }) =>
`${amount} ${from} ≈ ${(amount * 7.2).toFixed(2)} ${to}`,
{
name: "get_exchange_rate",
description: "查询两种货币之间的汇率,并把给定金额换算过去",
schema: z.object({
from: z.string().describe("原货币代码,例如 USD"),
to: z.string().describe("目标货币代码,例如 CNY"),
amount: z.number().describe("要换算的金额"),
}),
}
);
3.4 只吃一个字符串的工具
const echo = tool((input) => `你说的是:${input}`, {
name: "echo",
description: "把用户说的话原样回显,用于测试",
schema: z.string().describe("任意一段文本"),
});
这类工具的参数会被包装成 { input: string } 暴露给模型。
3.5 工具可以脱离模型单独调用
工具对象本身是一个 Runnable,能像普通函数一样直接 invoke,非常适合写单元测试、调试实现:
await getWeather.invoke({ city: "北京" });
// "北京 今天晴,气温 25℃"
注意:传入的是普通对象({ city: "北京" }),不是 JSON 字符串;LangChain 会按 schema 校验并解析参数。
3.6 实现工具的注意事项
name要自解释:get_weather远好于gw;description写清「做什么 + 何时用」:模型完全靠它做选择,含糊会导致该用时不用、不该用时乱用;schema用.describe()标注单位与格式:避免模型瞎猜;func要能容错:外部 API 可能失败,返回的错误信息最好也能被模型读懂,方便它换策略。
四、tool 的调用过程
一次完整的工具调用,是一个「模型决策 → 程序执行 → 结果回传 → 再决策」的循环。分三步。
第 1 步:把工具绑定给模型
模型事先并不知道有哪些工具可用,需要注册:
const model = await initChatModel("deepseek:deepseek-chat", { apiKey });
const modelWithTools = model.bindTools([getWeather, getExchangeRate]);
第 2 步:模型返回带 tool_calls 的 AIMessage
const messages = [
new SystemMessage("你是助手,需要实时信息时必须调用工具,不要凭空编造。"),
new HumanMessage("北京今天天气怎么样?"),
];
const aiResp = await modelWithTools.invoke(messages);
// aiResp.content === "" (这一轮通常没有正文)
// aiResp.tool_calls === [
// { name: "get_weather", args: { city: "北京" }, id: "call_xxx", type: "tool_call" }
// ]
关键点:模型只是提出了请求,并没有执行。tool_calls 是一份「调用清单」,可能包含一个或多个工具(也可并行调用多个)。
第 3 步:程序执行工具,用 ToolMessage 回传
messages.push(aiResp);
for (const call of aiResp.tool_calls) {
const result = await toolMap.get(call.name).invoke(call.args);
messages.push(
new ToolMessage({
content: String(result),
tool_call_id: call.id, // 必须对应上具体是哪次调用
name: call.name,
})
);
}
然后再次调用模型。这次历史里多了工具结果,模型会基于它给出最终答案:
const finalResp = await modelWithTools.invoke(messages);
console.log(finalResp.content);
如果模型还想调用别的工具,就继续循环;直到某次返回没有 tool_calls,说明它拿到了足够信息,循环结束。
消息历史的演化
一次调用结束后,消息类型序列是:
system -> human -> ai -> tool -> ai
如果一轮里并行调用了两个工具:
system -> human -> ai -> tool -> tool -> ai
也就是说,一次工具调用其实是三段式:AI 提出请求 → Tool 返回结果 → AI 给出答案。这也解释了为什么每个 ToolMessage 都必须带 tool_call_id:一次对话里可能有多个(甚至并行)调用,得靠 id 把「结果」和「请求」一一对上号。
自动化:createAgent
上面的 for 循环就是 Agent(智能体)的核心。LangChain 用 createAgent 把「绑定工具 → 执行工具 → 回传结果 → 再调用」整个循环封装了起来:
import { createAgent } from "langchain";
const agent = createAgent({
model: "deepseek:deepseek-chat",
tools: [getWeather, getExchangeRate],
systemPrompt: "你是助手,需要实时信息时必须调用工具,不要凭空编造。",
});
const res = await agent.invoke({
messages: [{ role: "user", content: "上海今天天气怎么样?" }],
});
console.log(res.messages.at(-1).content);
res.messages 的类型序列同样是 human -> ai -> tool -> ai。生产环境直接用 createAgent;而理解手动循环,是为了知道它内部到底发生了什么。
五、小结
tool 是连接「模型推理」和「外部能力」的桥梁,用来弥补模型在实时数据、私有数据、副作用操作、精确计算上的短板。
实现一个工具,就是写一个普通函数并配上一段元信息:name(叫什么)、description(能干什么、何时用)、schema(参数长什么样,用 zod 描述)、func(怎么实现)。
调用过程是一个分工明确的循环:模型决策(输出 tool_calls)→ 程序执行(运行对应函数)→ 结果回传(包成 ToolMessage,用 tool_call_id 关联)→ 模型再决策,直到不再请求工具、给出最终答案。
记住三点:name / description / schema 是写给模型看的,决定它会不会用、用得对不对;func 是给你执行的,可脱离模型单独测试;手动循环帮你理解原理,createAgent 用于生产。