文章演示如何用 Zod 同时生成工具参数描述与 TypeScript 类型,并在约 40 行代码内完成可执行的工具调用。还指出字段描述、参数校验和错误处理是从示例走向生产环境的关键。
系列文章:AI in TypeScript——共 5 本书,从第一次调用 LLM,到让 Agent 在生产环境中运行——五本都在这里。
我的项目:Hermes IDE | GitHub——一款面向开发者的 IDE,适合使用 Claude Code 和其他 AI 编程工具交付软件。
关于我:xgabriel.com | GitHub
Tool calling 经常被描述成某个框架提供的功能,听起来像是需要“引入”的东西,而不是你自己就能写出来的代码。实际上,它不过是大约四十行 TypeScript。理解这四十行代码,才能让你在框架做出意料之外的事情时,有能力调试它。
下面先给出完整实现,然后再讲真实项目中还需要补上的三个地方。
从 Zod 开始,因为它同时服务于两端。
import { z } from "zod";
const GetWeather = z.object({
city: z.string().describe("City name, e.g. 'Berlin'"),
unit: z.enum(["c", "f"]).default("c"),
});
type GetWeather = z.infer<typeof GetWeather>;
.describe() 并不是装饰。它会成为 JSON Schema 中的字段描述,供模型读取。模型最终传入 "Berlin",还是 "Berlin, Germany, Europe",往往就取决于这个描述。
现在,把 tool 定义成数据:
type Tool<S extends z.ZodType> = {
name: string;
description: "string;"
schema: S;
run: (args: z.infer<S>) => Promise<unknown>;
};
function tool<S extends z.ZodType>(t: Tool<S>) {
return t;
}
这个泛型让 run 获得了类型。在 handler 内部,args 的类型是 { city: string; unit: "c" | "f" }——它由 schema 推导而来,不需要重复声明。
const getWeather = tool({
name: "get_weather",
description: ""
"Current weather for a city. Use when the user asks about " +
"weather, temperature, or conditions in a named place.",
schema: GetWeather,
async run({ city, unit }) {
const r = await fetch(`${API}/current?city=${city}&unit=${unit}`);
if (!r.ok) throw new WeatherUnavailable(city, r.status);
return WeatherResult.parse(await r.json());
},
});
这段 description 是写给模型看的,不是写给团队成员看的。你需要说明 tool 能做什么,以及应该在什么时候调用它——其中的“什么时候”,正是防止模型在回答气候历史问题时错误选择这个 tool 的关键。
import zodToJsonSchema from "zod-to-json-schema";
const TOOLS = [getWeather, getForecast] as const;
const defs = TOOLS.map((t) => ({
name: t.name,
description: "t.description,"
input_schema: zodToJsonSchema(t.schema, { target: "openApi3" }),
}));
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
tools: defs,
messages,
});
从 Zod 对象生成 JSON Schema,可以确保只有一个事实来源。如果在 Zod schema 之外再手写一份 JSON Schema,就会出现两套逐渐偏离的定义。这种偏差通常不会立刻暴露,直到模型发送了一个被 validator 拒绝的字段,你才会发现问题。
async function runTurn(messages: MessageParam[]) {
const res = await client.messages.create({
model: "claude-opus-5",
max_tokens: 1024,
tools: defs,
messages,
});
if (res.stop_reason !== "tool_use") return res;
const results: ToolResultBlockParam[] = [];
for (const block of res.content) {
if (block.type !== "tool_use") continue;
const t = TOOLS.find((x) => x.name === block.name);
if (!t) {
results.push(errorResult(block.id, `unknown tool ${block.name}`));
continue;
}
const parsed = t.schema.safeParse(block.input);
if (!parsed.success) {
results.push(errorResult(block.id, format(parsed.error)));
continue;
}
try {
const out = await t.run(parsed.data);
results.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(out),
});
} catch (err) {
results.push(errorResult(block.id, message(err)));
}
}
messages.push({ role: "assistant", content: res.content });
messages.push({ role: "user", content: results });
return runTurn(messages);
}
这就是整个循环。其中有四个关键部分,缺一不可。
判断条件应该是 stop_reason === "tool_use",而不是响应中是否存在 tool block。一个响应可以同时包含文本和 tool call。如果不检查 stop reason,而是根据 content 类型进行判断,最终很可能会把其中的文本丢掉。
每个 tool_use 都必须对应且仅对应一个 tool_result,两者通过 tool_use_id 匹配。漏掉任何一个,下一次请求的格式都会出错。如果按位置而不是按 id 匹配,那么只要模型一次发出两个调用,结果就会被关联到错误的调用上。
两次 message push 都不能少。先加入包含 tool call 的 assistant 消息,再加入包含执行结果的 user 消息。如果只加入结果,模型的上下文里就没有自己曾经发起调用的记录。
错误应该作为结果返回,而不是被 throw 出去。这是最容易被写错的部分——下面会详细说明。

function errorResult(id: string, msg: string): ToolResultBlockParam {
return {
type: "tool_result",
tool_use_id: id,
content: msg,
is_error: true,
};
}
如果 enum 要求的是 "c",模型却传入了 unit: "celsius",那么只要告诉它哪里出了问题,它就可以自行纠正。直接 throw 会终止当前轮次,并让此前已经完成的工作全部丢失。
把 Zod error 格式化成模型可以采取行动的信息:
const format = (e: z.ZodError) =>
"Invalid arguments:\n" +
e.issues.map((i) => `- ${i.path.join(".")}: ${i.message}`).join("\n");
提供路径和错误信息就够了。不要发送完整的序列化 error,因为其中大部分都是噪声;也不要只返回 "invalid input",因为这无法为模型提供任何可操作的信息。
模型可能会生成一个你从未注册过的 tool 名称——可能是它幻觉出来的,也可能是在对话进行期间移除某个 tool 后遗留下来的旧名称。
当 TOOLS.find 返回 undefined 时,必须生成一个错误结果,而不是让程序崩溃。另一种直接访问 record 的写法——TOOLS[block.name].run(...)——会抛出 TypeError: Cannot read properties of undefined,而它的 stack trace 完全不会告诉你模型究竟请求了哪个 tool。
t.run 会调用网络,而网络调用一定可能失败。
人的本能反应往往是让错误继续向上抛出,但这样会把原本可以恢复的情况变成一次彻底失败的请求。如果天气 API 返回 503,模型完全可以应对:它可以告知用户服务暂时不可用,可以尝试查询另一个城市,也可以改用提供缓存预报的 tool。
但绝不能让模型收到你的内部实现细节:
function message(err: unknown): string {
if (err instanceof WeatherUnavailable) {
return `Weather unavailable for ${err.city} (${err.status}).`;
}
logger.error("tool execution failed", { err });
return "The tool failed. Do not retry this call.";
}
对于已知的失败,返回明确且有用的信息。对于未知失败,向模型返回通用字符串,同时在日志中记录完整信息。原始 stack trace 一旦进入 tool_result,就会进入模型的上下文、被模型总结,甚至可能最终被原样引用给用户。
runTurn 会递归执行,而且没有设置上限。只要模型不断调用 tool,这个循环就会一直继续,而每一次迭代都是一次需要付费的请求。
async function runTurn(messages: MessageParam[], depth = 0) {
if (depth > 10) throw new ToolLoopExceeded(depth);
// ...
return runTurn(messages, depth + 1);
}
十次只是一个任意设定的数字,但必须有一个明确的上限。这是第一个 Agent 产生意外账单最常见的原因,而防止它只需要一行代码。

现在,整个循环已经清晰可见,我们也就更容易判断框架的价值。框架可以提供跨进程重启的持久化能力、不同 tool 路径之间的分支控制、人工审批中断、流式传输中间状态,以及基于策略的重试机制。
如果这些能力你都不需要,那么这四十行代码反而是更好的工程方案——依赖更少,而且一块屏幕就能看清完整的控制流。
《AI That Acts》完整构建了 tool 层——包括如何设计模型能够正确使用的 schema、如何划定验证边界、如何把错误结果作为反馈给模型的通道,以及如何通过循环防护避免第一个 Agent 给你带来意外。

有状态 Agent 和 graph 是第四本书的内容。完整系列位于 xgabriel.com/ai-in-typescript。
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。