06.07实操更新于 2026年9月17日

实操:做一个 Tool Agent

理解城市 → 调用工具 → 读取 JSON → 回答。

06.07 · 课时讲解

实操:做一个 Tool Agent

工具定义交给模型,模型请求调用,程序执行回填,模型读结果回答。

1 / 11

一句话理解:一个 Tool Agent 的核心只有四步:把工具定义交给模型 → 模型请求调用 → 程序执行并回填 JSON → 模型读结果并回答。亲手写一遍,前面几课的概念就全部串起来了。

这是工具调用模块的实操课,对应实战项目里的第一个项目「天气 Agent」。你会用不到 150 行 TypeScript 做出一个命令行 Agent:它能理解“杭州明天会下雨吗”,自己调用天气工具,读取 JSON,再用自然语言回答。

完成后你会真切理解:工具定义怎么影响模型行为、tool_use 与 tool_result 怎么对应、循环怎么停止、错误怎么回传。这些正是评审任何 Agent 方案时要问的问题。

你将做出什么

$ npx tsx agent.ts "杭州明天会下雨吗?要不要带伞?"
→ 调用 get_weather {"city":"杭州","days":2}
← 成功 {"city":"中国 · 浙江 · 杭州","current":{"temperature_c":26.1, ...
杭州明天(9 月 18 日)以阴天为主,最高 31.8°C、最低 23.2°C,降水概率约 2%,基本不会下雨,可以不带伞。

(共 2 轮,stop_reason=end_turn)

整体流程:

天气 Agent 的循环:调用模型、判断是否需要工具、执行并回填,直到输出回答或达到 6 轮上限 用户问题 循环:最多 6 轮(MAX_STEPS) ① 调用模型 带上 messages 与 tools ② 需要调用工具? stop_reason = tool_use 是 ③ 执行 get_weather 组装 tool_result ④ 追加到 messages 助手消息 + 工具结果 达到 6 轮仍未结束:强制停止 否 输出回答 循环结束
图 1 · Agent Loop 的最小形态:模型不再请求工具时,循环结束

天气数据使用 Open-Meteo 的免费接口(地理编码 + 天气预报),不需要注册和 API Key,适合练习。

整个程序由下面几个部分组成:

天气 Agent 的系统结构:命令行、agent.ts 中的循环与工具函数、Claude API 与 Open-Meteo 接口 命令行 npx tsx agent.ts agent.ts(不到 150 行) ask():Agent Loop 最多 6 轮,拼消息与回填 runTool → getWeather 校验参数,返回精简 JSON Claude API 模型 + 工具定义 Open-Meteo 地理编码 + 天气预报 API Key 从环境变量读取;天气接口免注册、无需 Key
图 2 · 天气 Agent 的全部组成:一个循环、一个工具函数、两个外部接口

准备工作

  • Node.js 20 或更高版本。
  • 一个 Claude API Key(在 Claude Console 创建),不要把它写进代码或提交到仓库。
  • 基础:会在终端运行命令;读过 调用模型 API 与 Function Calling 原理 更好。
mkdir weather-agent && cd weather-agent
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk tsx
export ANTHROPIC_API_KEY="你的 Key"

tsx 可以直接运行 TypeScript,不需要编译步骤。Windows PowerShell 用 $env:ANTHROPIC_API_KEY="你的 Key" 设置环境变量。

第 1 步:定义工具

新建 agent.ts,先写工具定义。注意描述回答了“做什么、何时用、何时不用、返回什么、出错怎么办”,参数有范围约束,这些都是 工具定义三要素 的内容。

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(); // 自动读取环境变量 ANTHROPIC_API_KEY
const MODEL = process.env.MODEL ?? "claude-sonnet-5";
const MAX_STEPS = 6;

const tools: Anthropic.Tool[] = [
  {
    name: "get_weather",
    description:
      "查询某个城市的实时天气与未来几天的预报。返回城市全称、当前气温、湿度、风速与天气状况," +
      "以及每天的最高/最低气温、最大降水概率和天气状况。" +
      "适用于用户询问某地现在或未来 1–7 天的天气、会不会下雨、要不要带伞、穿什么。" +
      "不提供历史天气和空气质量。找不到城市时返回 error,此时应向用户确认城市名称。",
    input_schema: {
      type: "object",
      properties: {
        city: {
          type: "string",
          description: "城市名称,中文或英文均可,例如“杭州”“Tokyo”。用户没有给出城市时不要猜测,先追问。",
        },
        days: {
          type: "integer",
          minimum: 1,
          maximum: 7,
          description: "需要的预报天数,包含今天。只问今天填 1,问明天填 2,默认 3。",
        },
      },
      required: ["city"],
      additionalProperties: false,
    },
  },
];

第 2 步:实现工具

接着写真正干活的函数。两个设计要点:程序对参数 再校验一次(不完全信任模型),返回 精简、带语义 的字段而不是原始响应。

const WEATHER_TEXT: Record<number, string> = {
  0: "晴", 1: "大致晴朗", 2: "局部多云", 3: "阴", 45: "雾", 48: "雾凇",
  51: "小毛毛雨", 53: "毛毛雨", 55: "强毛毛雨", 61: "小雨", 63: "中雨", 65: "大雨",
  71: "小雪", 73: "中雪", 75: "大雪", 80: "小阵雨", 81: "阵雨", 82: "强阵雨",
  95: "雷暴", 96: "雷暴伴冰雹", 99: "强雷暴伴冰雹",
};
const describe = (code: number) => WEATHER_TEXT[code] ?? `天气代码 ${code}`;

type WeatherInput = { city: string; days?: number };
type ToolOutput = Record<string, unknown>;

async function getWeather({ city, days = 3 }: WeatherInput): Promise<ToolOutput> {
  const n = Math.min(Math.max(Math.round(days), 1), 7); // 程序兜底校验

  const geoUrl =
    "https://geocoding-api.open-meteo.com/v1/search?count=1&language=zh&format=json&name=" +
    encodeURIComponent(city);
  const geoRes = await fetch(geoUrl);
  if (!geoRes.ok) return { error: `地理编码服务暂时不可用(HTTP ${geoRes.status}),请告知用户稍后再试。` };
  const geo = (await geoRes.json()) as {
    results?: { name: string; admin1?: string; country?: string; latitude: number; longitude: number }[];
  };
  const place = geo.results?.[0];
  if (!place) return { error: `找不到城市“${city}”。请向用户确认城市名称,或换成更完整的写法,例如“浙江杭州”。` };

  const params = new URLSearchParams({
    latitude: String(place.latitude),
    longitude: String(place.longitude),
    current: "temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m",
    daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max",
    forecast_days: String(n),
    timezone: "auto",
  });
  const wxRes = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
  if (!wxRes.ok) return { error: `天气服务暂时不可用(HTTP ${wxRes.status}),请告知用户稍后再试。` };
  const wx = (await wxRes.json()) as any;

  return {
    city: [place.country, place.admin1, place.name].filter(Boolean).join(" · "),
    current: {
      temperature_c: wx.current.temperature_2m,
      humidity_pct: wx.current.relative_humidity_2m,
      wind_kmh: wx.current.wind_speed_10m,
      condition: describe(wx.current.weather_code),
    },
    daily: wx.daily.time.map((date: string, i: number) => ({
      date,
      condition: describe(wx.daily.weather_code[i]),
      max_c: wx.daily.temperature_2m_max[i],
      min_c: wx.daily.temperature_2m_min[i],
      rain_chance_pct: wx.daily.precipitation_probability_max[i],
    })),
  };
}

async function runTool(name: string, input: unknown): Promise<ToolOutput> {
  switch (name) {
    case "get_weather":
      return getWeather(input as WeatherInput);
    default:
      return { error: `未知工具:${name}` };
  }
}

第 3 步:写 Agent Loop

最后是循环本身。它严格遵循 Claude API 的规则:把模型返回的 content 原样作为助手消息追加;每个 tool_use 都回一个 tool_result,用 tool_use_id 对应,并且全部放在同一条用户消息里。

async function ask(question: string) {
  const today = new Date().toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" });
  const system =
    `你是一个天气助手。今天是 ${today}。` +
    "回答天气相关问题前必须调用工具获取数据,不要凭记忆回答。回答时说明城市和日期,简洁给出建议。";
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];

  for (let step = 1; step <= MAX_STEPS; step++) {
    const response = await client.messages.create({
      model: MODEL,
      max_tokens: 4096,
      system,
      tools,
      messages,
    });
    messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason !== "tool_use") {
      const text = response.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("\n");
      console.log(`\n${text}\n\n(共 ${step} 轮,stop_reason=${response.stop_reason})`);
      return;
    }

    const results: Anthropic.ToolResultBlockParam[] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;
      console.log(`→ 调用 ${block.name} ${JSON.stringify(block.input)}`);
      try {
        const output = await runTool(block.name, block.input);
        const isError = "error" in output;
        console.log(`← ${isError ? "失败" : "成功"} ${JSON.stringify(output).slice(0, 100)}...`);
        results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(output), is_error: isError });
      } catch (err) {
        results.push({
          type: "tool_result",
          tool_use_id: block.id,
          content: `工具执行异常:${String(err)}。请告知用户稍后重试。`,
          is_error: true,
        });
      }
    }
    messages.push({ role: "user", content: results });
  }
  console.log(`已达到最大轮数 ${MAX_STEPS},停止。`);
}

const question = process.argv.slice(2).join(" ") || "杭州明天会下雨吗?要不要带伞?";
ask(question).catch((err) => {
  console.error(err);
  process.exit(1);
});

MAX_STEPS 是最简单的停止条件,防止模型反复调用陷入死循环。生产环境还需要预算与超时,见 停止条件。模型名可以通过环境变量 MODEL 切换,可用的模型 ID 以 Claude 官方模型列表为准。

第 4 步:运行与验证

依次运行下面 5 个问题,对照预期结果检查 Agent 的行为:

npx tsx agent.ts "杭州明天会下雨吗?要不要带伞?"
npx tsx agent.ts "对比一下北京和上海今天的气温"
npx tsx agent.ts "火星城今天天气怎么样"
npx tsx agent.ts "我这边冷不冷?"
npx tsx agent.ts "帮我写一首关于秋天的短诗"
问题 预期行为 观察点
杭州明天 调用 1 次,days 为 2,回答明天的降水概率 模型是否正确把“明天”换算成天数
北京和上海 同一轮发出 2 个 get_weather 调用,再给出对比 并行调用与结果对应
火星城 工具返回 error,模型请用户确认城市名 错误信息是否引导了下一步
我这边 不调用工具,直接追问城市 描述里“不要猜测”是否生效
写诗 不调用工具,直接回答 模型会不会滥用工具

模型输出有随机性,措辞每次不同属于正常情况。重点看 调用了什么、参数对不对、出错后怎么处理。

排错提示

现象 可能原因 处理
401 或 authentication_error 没有设置或设错 ANTHROPIC_API_KEY 在同一个终端窗口里重新 export,确认没有多余空格
404 或模型不存在 MODEL 写错,或账号无权使用该模型 对照官方模型列表,换一个可用的模型 ID
400:tool_use ids were found without tool_result blocks immediately after 漏回了某个 tool_result,或在它前面插入了别的消息 确认每个 tool_use 都有对应结果,且结果紧跟助手消息
400:提示 tool_result 需要排在前面 在同一条用户消息里把文字放在了 tool_result 之前 把所有 tool_result 放在 content 数组最前面
模型不调用工具,凭空回答 系统指令或描述不够明确 强化“必须先调用工具”,或对这一步使用 tool_choice 强制调用
fetch failed 网络无法访问 Open-Meteo 检查代理与网络;工具会以 is_error 形式把异常交给模型
SyntaxError: Cannot use import statement 没有设置 "type": "module" 执行 npm pkg set type=module
回答里日期错位 模型不知道今天是哪天 确认系统指令里注入了当天日期

进阶挑战

完成基础版本后,任选一两项继续:

  1. 加第二个工具:接入一个汇率接口,做 get_exchange_rate,观察模型如何在两个工具之间选择。
  2. 开启严格模式:在工具定义上加 strict: true,再故意用含糊的问题测试参数质量。
  3. 加 Trace:把每轮的输入 Token、输出 Token、工具耗时打印出来,估算单次问答成本(见 Tracing)。
  4. 换成订单场景:用本地 JSON 文件模拟订单库,把 get_weather 换成 get_order 与 get_payment,这就是「订单售后诊断 Agent」的雏形。
  5. 做成 MCP Server:把 get_weather 搬进 MCP Server,让 Claude Code 等 Host 也能调用,见 实操:写一个 MCP Server。

练习

用 30–60 分钟完成并记录:

  1. 跑通第 4 步的 5 个问题,把每个问题的调用次数、参数与最终回答填进表格。
  2. 故意把工具描述改成一句“查天气”,删掉参数描述,重跑 5 个问题,记录行为发生了哪些变化。
  3. 写 3 句结论:工具定义里哪一句话对模型行为影响最大?

延伸阅读

下一步

你已经写出了一个能用工具的 Agent,但工具只活在这一个程序里。下一模块从「为什么需要 MCP」开始,讲怎么让同一套工具被任何 Agent 产品复用。

试试:上下文、MCP、沙箱、Prompt 注入、外链