第 1 篇 · Tool Use
模型不会执行工具——它只会在回复里「点菜」。菜单你递,单子它下,做菜的是你。这一篇手动走完一次完整的工具调用。
这是《手搓 AI Agent》系列第 1 篇。第 0 篇我们徒手用一次 HTTP 请求和模型对上了话;这一篇给它加上 agent 的第一块拼图——工具调用(tool use)。我们一步一步拆解:亲眼看清"模型调用工具"这六个字里,藏着一个多大的误会。
1.1 模型的先天缺陷
先做个实验。把第 0 篇 hello.ts 里的问题换成:
{ type: "text", text: "现在几点了?" }跑一下。它答不上来——老实一点的回答会承认"无法获取实时时间",请你自己看表;不老实的干脆一本正经地编一个时间给你。多跑几次,两种你都能撞见。
它不是"知识不够",问题比这深:模型能做的事情只有一件——读完你发来的 messages,生成一段文本。 它没有时钟、没有网络、摸不到你的文件系统,连"现在"是什么时候都不知道(训练结束那一刻,它的世界就冻结了)。第 0 篇你已经见过这种无力:它记不住你的名字,得靠你把历史拼回去。今天这个更致命——有些信息根本不在任何历史里,比如此刻几点、你磁盘上有哪些文件、834727 乘 991 等于几。
它只会生成文本,基于这个事实只有一个办法:让它生成一段"我想干什么"的文本,你来替它干,干完把结果告诉它。 这就是工具调用的全部原理。剩下的只是工程问题:这段"我想干什么"长什么格式?结果怎么还给它?——Anthropic 的 Messages API 把这两件事都规定好了,我们一步步走一遍。
1.2 递菜单:tools 参数
怎么让模型知道"有些活你可以让我干"?答案很朴素:在请求体里多发一个字段,内容是一份说明书。
messages 旁边多放一个 tools 数组,每个工具三样东西——叫什么、什么时候用、参数长什么样:
const tools = [
{
name: "get_current_time",
description: "获取当前的日期和时间。当用户问“现在几点”“今天几号”时使用。",
input_schema: { type: "object", properties: {} },
},
];name:工具名。一会儿模型会用它来"点名"。description:给模型看的使用说明。模型全靠读这段话来决定"什么时候该用这个工具",它写得好不好,直接决定工具会不会被正确使用。input_schema:参数的形状。查时间不需要参数,所以properties是空的;1.5 你会见到一个带参数的。
这份说明书怎么随请求发出去?0.7 说过:"调模型"的事,全项目只改 llm.ts 一个文件——第一次兑现来了。给它升三处级:
// llm.ts 升级 ①:ContentBlock 认识两种新块——0.3 预告的"另一种块",就是它们
export type ContentBlock =
| { type: "text"; text: string }
// 模型说"我想调用工具 name,参数是 input"——它是一个"意图",不是执行结果(1.3 见)
| { type: "tool_use"; id: string; name: string; input: any }
// 我们把工具执行结果用这种块还给模型;tool_use_id 用来对上是哪次调用(1.4 见)
| {
type: "tool_result";
tool_use_id: string;
content: string;
is_error?: boolean;
};
// llm.ts 升级 ②:新增"工具说明书"的类型——就是上面 tools 数组里每一项的形状
export type Tool = {
name: string;
description: string;
input_schema: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
};
// llm.ts 升级 ③:callModel 增加第二个参数。不传 tools 时,行为和第 0 篇完全一样
export async function callModel(
messages: Message[],
tools?: Tool[],
): Promise<AssistantResponse> {
// ……函数体只改一处:请求体里有工具才带 tools 字段
// body: JSON.stringify({ model: MODEL, max_tokens: 1024, messages, ...(tools ? { tools } : {}) }),
}升级完,在同一个项目里新建 tool-use.ts,带上菜单把问题发出去:
import { callModel, type Message, type Tool } from "./llm.js"; // 第 0 篇结尾抽好的模型客户端
const tools: Tool[] = [
{
name: "get_current_time",
description: "获取当前的日期和时间。当用户问“现在几点”“今天几号”时使用。",
input_schema: { type: "object", properties: {} },
},
];
const messages: Message[] = [
{ role: "user", content: [{ type: "text", text: "现在几点了?" }] },
];
const first = await callModel(messages, tools);
console.log(JSON.stringify(first, null, 2));发出去之前,把这一步在物理上发生的事想清楚:只是多了一段"说明书文字"随请求发了过去。 模型没有被安装任何插件,你也一行 new Date() 都还没写。菜单只是纸。
1.3 模型下单:tool_use 块
跑一下 npx tsx --env-file=.env tool-use.ts(.env 就是第 0 篇建的那个,接着用)。返回的骨架长这样(id、文案、数字以你跑出来的为准,结构一定是这个):
{
"role": "assistant",
"content": [
{ "type": "text", "text": "我来帮你看一下现在的时间。" },
{
"type": "tool_use",
"id": "call_8f3a0e2b1c",
"name": "get_current_time",
"input": {}
}
],
"stop_reason": "tool_use",
"usage": { "input_tokens": 160, "output_tokens": 25 }
}第 0 篇埋的两处伏笔,在这一坨 JSON 里同时兑现了:
- 0.3 说"很快你会见到另一种块"——就是这个
tool_use块,第 0 篇管它叫"agent 的钥匙"。name点名要调哪个工具;input是模型按你的 schema 填好的参数(这个工具不需要参数,所以是{});id是这次调用的随机编号,各家格式不同,长什么样无所谓,一会儿对账要用。它前面可能还有一个 text 块——有的模型爱先说句"我来查一下"再下单,有的直接下单——所以永远别假设content里只有一种块。 - 0.4 说
stop_reason还有第三个值——tool_use:模型主动停笔,等一个它自己做不到的动作。
顺带看一眼 usage:同样一句短短的问题,input_tokens 比第 0 篇大了一截——说明书也是发给模型的文本,菜单本身就要花 token。工具挂得越多,每次请求的"底价"越高,记下这个体感。
现在,这一篇最重要的认知来了。此刻,没有任何函数被调用,没有任何代码被执行。模型只是生成了一段格式规整的文本,意思是"请帮我调一下 get_current_time",然后停笔,等你。
它在点菜:菜单(tools)是你递的,单子(tool_use)是它下的,而厨房在你家——做菜的只能是你。1.1 说模型唯一会做的事是生成文本,工具调用没有推翻这一点,恰恰是利用了这一点:所谓"模型调用工具",其实是模型生成了一段"想调工具"的结构化文本。从头到尾,它只会点菜。
1.4 你来做菜:执行,然后把结果"喂"回去
接着往 tool-use.ts 里写。三件事:执行工具、把结果包成消息、再发一次。
// (1) 把模型这轮的回复【完整】拼回历史——tool_use 块也在里面,一个都不能丢
messages.push({ role: "assistant", content: first.content });
// (2) 从 content 里找到那张"单子"
const call = first.content.find((b: any) => b.type === "tool_use");
// (3) 执行。所谓"执行工具",就是跑一段你自己的代码——模型碰不到这里
const result = new Date().toLocaleString("zh-CN");
// (4) 把结果装进 tool_result 块,作为一条【新的 user 消息】喂回去
messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: call.id, content: result }],
});
// (5) 带着同一份菜单,再发一次
const second = await callModel(messages, tools);
console.log(JSON.stringify(second, null, 2));逐条拆:
(1) 是新手错得最多的一行。 很多人只把模型说的那句话("我来帮你看一下时间")拼回历史,随手把 tool_use 块扔了。一旦扔了,你下一步喂回去的结果就成了"无主的菜"——服务端不知道它在回应哪张单子,请求直接报错。所以记死:拼回历史的永远是完整的 content,一个块都不许丢。 0.6 说过"原样拼回",现在你知道"原样"两个字有多重了。
(3) 就是普通代码。 这里是 new Date();换成读文件、查数据库、发邮件,性质完全一样——这一行跑在你的进程里,模型碰不到它。说得更露骨一点:你抬手看一眼墙上的挂钟,手打一个 "2026/7/8 20:41:00" 进去,对模型来说毫无区别。
(4) 注意 role 是 user。 这很反直觉——工具结果怎么会是"用户说的话"?但想通就顺了:结果是你告诉模型的——"你点的菜,好了,喏"。这段对话里从头到尾只有两个角色,凡是你发给它的,都是 user。tool_use_id 填上 (2) 那张单子的 id,把菜和单对上号。
(5) 和第一次请求没有任何区别,只是 messages 又长了两条。注意菜单 tools 要接着带——按 Anthropic 的规矩,只要对话里出现过工具块,请求就必须带 tools 字段,否则直接报错。
再跑一次,这回它答上来了(文案、数字是示意):
{
"role": "assistant",
"content": [
{ "type": "text", "text": "现在是 2026 年 7 月 8 日晚上 8 点 41 分。" }
],
"stop_reason": "end_turn",
"usage": { "input_tokens": 210, "output_tokens": 18 }
}stop_reason 回到了 end_turn:菜上齐了,话也说完了。复盘整个来回——模型"用"了一次工具,而所有真正的执行,都发生在你的代码里。
💡 补充 · 亲手骗它一次 把 (3) 改成
const result = "2124/1/1 00:00:00";再跑。模型会一本正经地告诉你现在是 2124 年。 它对tool_result没有任何校验能力——工具结果只是对话里的又一段文本,你喂什么它信什么。 这提醒你两件事:工具的正确性由你负责,模型概不把关;反过来,想让模型"知道"任何它本不知道的事(你的数据库、你的文档),把内容当结果喂进对话就行——很多花哨的做法,底下就是这么一回事。
1.5 一次点两个菜
给菜单加一个带参数的工具:
const tools = [
{
/* get_current_time,同上 */
},
{
name: "add",
description: "把两个数字相加并返回它们的和。需要做加法计算时使用。",
input_schema: {
type: "object",
properties: {
a: { type: "number", description: "第一个加数" },
b: { type: "number", description: "第二个加数" },
},
required: ["a", "b"],
},
},
];问题换成一个两样都要的:
{ type: "text", text: "现在几点了?另外 1234 加 5678 等于多少?" }跑一下第一步。大概率你会看到 content 里同时躺着两张单子:
"content": [
{ "type": "tool_use", "id": "call_aaa", "name": "get_current_time", "input": {} },
{ "type": "tool_use", "id": "call_bbb", "name": "add", "input": { "a": 1234, "b": 5678 } }
]看一眼 add 的 input:模型按你的 input_schema 把参数填好了,两个数字都替你从问题里抠了出来。这就是 JSON Schema 的用处——它是模型填参数用的"表格模板"。
几张单子,规矩只有一条:逐个执行,所有 tool_result 装进同一条 user 消息里一起还,每条结果用自己的 tool_use_id 对号:
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_aaa",
content: "2026/7/8 20:41:00",
},
{ type: "tool_result", tool_use_id: "call_bbb", content: "6912" },
],
});也有的模型习惯一次只点一张单:先要时间,拿到结果之后再点加法。两种都正常,但它对你的代码提了个要求:处理逻辑永远按"数组"写——把 content 里所有 tool_use 块都找出来、都执行、都对账。1.4 用 find 只取第一张单,是为了看清一个来回;真写起来要用循环遍历,配套仓库里的版本就是这么写的。
另外你发现没有:如果模型一张一张地点,你就得"执行 → 喂回 → 再发"地反复好几个来回……先记住这个体感。
1.6 这张单子的两条铁律
铁律一:欠单必还。 模型下了单,紧跟着的下一条 user 消息就必须把每一张单子都用 tool_result 还上——一张不能少,也不能先聊两句别的再还。少还、不还,API 直接报错打回。就算你不想执行某个工具,也得还一条"我没有执行"的 tool_result 交差。
铁律二:失败也要上菜。 工具执行挂了怎么办——参数不对、文件不存在、接口超时?新手的本能是让程序抛异常崩掉。错。把错误信息当成结果还回去,多标一个 is_error:
{ type: "tool_result", tool_use_id: call.id, content: "执行失败:文件不存在", is_error: true }模型看到失败,会自己换个姿势:改参数重试、换个工具、或者告诉用户"这个做不了"。错误是喂给模型的数据,不是砸向程序的异常——这句话今天先记住,下一篇你会看到它成为整个 agent 最重要的设计哲学。
小结
这一篇我们手动走完了一次完整的工具调用。
- 工具没有给模型任何新能力:它能做的仍然只有生成文本。
tools是你递的菜单,tool_use是它下的单——真正动手的,从头到尾是你的代码。 - 一套协议,三个动作:模型的回复完整拼回历史(
tool_use块不能丢);结果装进tool_result块、放在一条user消息里喂回去;tool_use_id对账,下一条消息必须把单还清。 tool_result只是对话数据:你喂什么它信什么。结果的真实性、执行的安全性,都由执行者——你——负责。
回头看你刚才干的活:发请求 → 看到 tool_use → 执行 → 喂回 → 再发。问题稍微复杂一点,这套动作就得三遍五遍地重复——你已经在人肉扮演一个循环了。把这个动作交给代码,让它自己转起来,一个真正会干活的 agent 就成形了。下一篇,我们就写这个循环。