实操:做一个 Tool Agent
理解城市 → 调用工具 → 读取 JSON → 回答。
一句话理解:一个 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)
整体流程:
天气数据使用 Open-Meteo 的免费接口(地理编码 + 天气预报),不需要注册和 API Key,适合练习。
整个程序由下面几个部分组成:
准备工作
- 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 |
| 回答里日期错位 | 模型不知道今天是哪天 | 确认系统指令里注入了当天日期 |
进阶挑战
完成基础版本后,任选一两项继续:
- 加第二个工具:接入一个汇率接口,做
get_exchange_rate,观察模型如何在两个工具之间选择。 - 开启严格模式:在工具定义上加
strict: true,再故意用含糊的问题测试参数质量。 - 加 Trace:把每轮的输入 Token、输出 Token、工具耗时打印出来,估算单次问答成本(见 Tracing)。
- 换成订单场景:用本地 JSON 文件模拟订单库,把
get_weather换成get_order与get_payment,这就是「订单售后诊断 Agent」的雏形。 - 做成 MCP Server:把
get_weather搬进 MCP Server,让 Claude Code 等 Host 也能调用,见 实操:写一个 MCP Server。
练习
用 30–60 分钟完成并记录:
- 跑通第 4 步的 5 个问题,把每个问题的调用次数、参数与最终回答填进表格。
- 故意把工具描述改成一句“查天气”,删掉参数描述,重跑 5 个问题,记录行为发生了哪些变化。
- 写 3 句结论:工具定义里哪一句话对模型行为影响最大?
延伸阅读
- Tool use with Claude:官方完整往返示例,含多种语言。
- Handle tool calls:
tool_result的格式要求与错误处理。 - Parallel tool use:一轮多个调用时的处理方式。
下一步
你已经写出了一个能用工具的 Agent,但工具只活在这一个程序里。下一模块从「为什么需要 MCP」开始,讲怎么让同一套工具被任何 Agent 产品复用。