手搓 AI Agent

第 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) 注意 roleuser 这很反直觉——工具结果怎么会是"用户说的话"?但想通就顺了:结果是你告诉模型的——"你点的菜,好了,喏"。这段对话里从头到尾只有两个角色,凡是你发给它的,都是 usertool_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 } }
]

看一眼 addinput:模型按你的 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 最重要的设计哲学。


小结

这一篇我们手动走完了一次完整的工具调用。

  1. 工具没有给模型任何新能力:它能做的仍然只有生成文本。tools 是你递的菜单,tool_use 是它下的单——真正动手的,从头到尾是你的代码
  2. 一套协议,三个动作:模型的回复完整拼回历史(tool_use 块不能丢);结果装进 tool_result 块、放在一条 user 消息里喂回去;tool_use_id 对账,下一条消息必须把单还清。
  3. tool_result 只是对话数据:你喂什么它信什么。结果的真实性、执行的安全性,都由执行者——你——负责。

回头看你刚才干的活:发请求 → 看到 tool_use → 执行 → 喂回 → 再发。问题稍微复杂一点,这套动作就得三遍五遍地重复——你已经在人肉扮演一个循环了。把这个动作交给代码,让它自己转起来,一个真正会干活的 agent 就成形了。下一篇,我们就写这个循环。

On this page