章节06 / 14
本文目录12 节
Tool Calling:模型提出动作,应用执行动作
掌握 Tool Calling 的底层逻辑与安全边界。本课将剖析 JSON Schema 的编写、各大模型厂商的工具调用通信循环、参数防御性校验以及应用层的安全执行权控制,并交付一个带完整可观测日志的任务创建闭环。
- 理解大语言模型(LLM)的 Chat Completion 基础 API 调用
- 具备基本的 TypeScript 或 JavaScript 异步编程基础
- 了解 JSON 数据的结构特征
- 理解 Tool Calling 的双向通信循环,明晰“模型提出动作、应用执行动作”的安全隔离边界
- 能够使用 Zod 和 JSON Schema 编写可被模型精准识别的工具定义
- 掌握参数校验失败时的自动纠错重试机制与高危操作的“人工确认”拦截设计
- 交付一个包含完整执行日志、入参校验和错误恢复的本地工具调用闭环程序
精准控权的 Tool Calling:如何把 AI 变成安全的行动派
很多人在初学 Agent 或是工具调用时,容易产生一种直观的误解,认为大语言模型可以直接连接数据库、发送邮件或是在你的服务器上执行 Shell 脚本。这种误解很容易导致系统设计出现严重的安全漏洞。
本课将带你厘清 Tool Calling(工具调用)的本质,让你看清模型与应用程序之间的真实边界,并通过规范的代码与严密的流程控制,写出一个能够安全可控地执行外部动作的 AI 应用。
执行权留给应用层:打破“模型在执行代码”的迷思
本节操作锚点:围绕“执行权留给应用层:打破“模型在执行代码”的迷思”记录步骤、样例、诊断、风险、检查清单和验收结果。
不要误以为模型拥有魔法般的执行力。无论是 OpenAI 的 GPT、Anthropic 的 Claude,还是 Google 的 Gemini,它们本质上都只是“概率预测器”和“决策提出者”,而非“代码执行器”。
根据 OpenAI 的 Function calling 官方文档说明,模型在识别到用户意图符合工具定义时,并不会直接去调用外部系统的 API,它只是返回了一段结构化的 JSON 数据,其中包含了模型想要调用的工具名称以及模型推导出来的参数。此时,网络请求挂起,模型陷入“等待(Suspended)”状态。
真正去解析这段 JSON、校验参数、执行 fetch 请求、读写数据库、并把结果拼接成特定格式再喂回给模型的,全部是你的应用程序(运行在 Node.js、Python 或 Go 环境下的后端代码)。
这意味着:
- 安全边界完全由你掌控:模型只是写了一份“申请书”,批不批准、怎么执行、执行时的权限控制,全部由你的后端代码说了算。
- 应用层必须承担校验职责:永远不要直接将模型生成的参数盲目传入敏感系统,因为模型随时可能发生幻觉,生成不存在的 ID 或越权的指令。
JSON Schema 合约:如何教模型认清你的 API
本节操作锚点:围绕“JSONSchema合约:如何教模型认清你的AP”记录步骤、样例、诊断、风险、检查清单和验收结果。
为了让模型在正确的时候提出调用工具的申请,你需要给它提供一份“说明书”。这份说明书遵循 JSON Schema 规范。
根据 JSON Schema 官方文档的定义,Schema 是一种用于描述 JSON 数据结构的声明式语言。模型在进行推理时,会阅读你传入的 Schema,以此来理解工具的作用、需要的参数类型以及哪些参数是必填项。
下面是一份符合规范的 JSON Schema 声明,用于定义一个“创建任务”的工具:
{
"name": "create_task",
"description": "在项目管理系统中创建一个新任务。仅当用户明确要求创建、添加或记录待办事项时使用。",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "任务的简短标题,例如:‘撰写下周周报’"
},
"priority": {
"type": "string",
"enum": ["high", "medium", "low"],
"description": "任务紧急程度,默认为 medium"
},
"due_date": {
"type": "string",
"format": "date",
"description": "截止日期,格式必须为 YYYY-MM-DD"
}
},
"required": ["title"]
}
}
编写这段“说明书”时,有两个不容忽视的细节:
- 描述(description)是给模型读的 Prompt:这里的描述需要写得极其具体。如果你的描述写得含糊不清,模型就可能在不需要的时候滥用工具,或者无法正确推导出参数。
- 严格限制枚举(enum):如果你的系统只支持特定几个选项,务必在 Schema 中限制
enum。这可以大幅度降低模型信口开河的几率。
解析不同厂商的工具通信循环
本节操作锚点:围绕“解析不同厂商的工具通信循环”记录步骤、样例、诊断、风险、检查清单和验收结果。
当你在底层直接对接不同的模型厂商时,你会发现它们在协议命名和消息结构上存在细微的差别,但其核心的“双向通信循环”完全一致。
我们来看一下三大主流厂商在协议设计上的对应关系:
| 阶段 | OpenAI (Function calling) | Anthropic (Tool use) | Gemini (Function calling) |
|---|---|---|---|
| 1. 声明工具 | 传入 tools 数组,每个元素包含 type: "function" 和 function 描述。 | 传入 tools 数组,直接定义每个 tool 的 name, description, input_schema。 | 传入 tools 参数,包含 functionDeclarations 列表。 |
| 2. 模型提出调用 | 响应中包含 tool_calls 数组,每个元素有独一无二的 id,以及 function.arguments。 | 响应中包含 content 数组,其中类型为 tool_use 的节点含有 id 和 input。 | 响应中包含 functionCalls 数组,每个元素有 name 和 args。 |
| 3. 应用回传结果 | 发送一条 role: "tool" 的消息,必须带上对应的 tool_call_id。 | 发送一条 role: "user" 且 content 类型为 tool_result 的消息,必须对应 tool_use_id。 | 发送一条 role: "function" 的消息,在 parts 中回传包含 response 的结构体。 |
Anthropic Tool use 官方文档明确指出,在模型返回 tool_use 之后,应用层必须向模型回传一个 tool_result 类型的消息。这就推导出一个重要的设计原则:工具调用不是单向的单次请求,而是一个严格互锁的多阶段对话循环。你不能跳过结果回传,否则对话上下文将会断裂,模型将无法继续生成后续答复。
这个循环的完整链路如下图所示:
用户: "帮我建一个明天截止的写代码任务"
│
▼
[应用层] 发送用户 Prompt + 工具 Schema
│
▼ (网络请求)
[模型] 分析语义,匹配 Schema,输出 tool_call 请求:
{ name: "create_task", arguments: { title: "写代码", due_date: "2026-05-29" } }
│
▼ (网络响应挂起)
[应用层] 拦截到 tool_call -> 执行参数校验 -> 执行本地代码(写入数据库)-> 拿到 result: { success: true, task_id: 1024 }
│
▼ (新网络请求)
[应用层] 将工具执行结果 `{ role: "tool", tool_call_id: "...", content: "{...}" }` 回传给模型
│
▼
[模型] 阅读执行结果,生成最终人类语言:“我已经为您创建好了任务,ID是1024。”
统一抽象层:基于 Vercel AI SDK 与 Zod 的工具声明
本节操作锚点:围绕“统一抽象层:基于VercelAISDK与Zod的”记录步骤、样例、诊断、风险、检查清单和验收结果。
如果开发多模型适配的应用,可以优先采用类似 Vercel AI SDK 的统一 Tool 抽象层,因为这样能规避不同厂商在 tool_use 协议格式上的微小差异,否则你需要为 OpenAI, Claude 和 Gemini 手写多套复杂的格式转换与消息追加逻辑。
Vercel AI SDK 允许我们使用 zod 来声明参数 Schema,并直接在工具定义中绑定 execute 函数。下面展示如何在 TypeScript 中声明一个查询外部知识库和创建任务的工具:
import { z } from 'zod';
import { tool } from 'ai';
// 模拟的外部系统接口
const db = {
async createTask(title: string, priority: string, dueDate?: string) {
return { id: Math.floor(Math.random() * 10000), title, priority, dueDate, status: 'created' };
},
async searchWiki(query: string) {
if (query.includes('AI')) {
return { content: 'AI应用开发中,Tool Calling 是实现 Agent 架构的核心。' };
}
return { content: '未找到相关资料。' };
}
};
export const tools = {
// 工具一:查询资料
searchWiki: tool({
description: '查询内部 Wiki 知识库,获取相关开发规范与背景资料。',
parameters: z.object({
query: z.string().describe('检索关键词,如 "AI 开发规范"')
}),
execute: async ({ query }) => {
console.log(`[工具执行] 正在检索 Wiki: "${query}"`);
const result = await db.searchWiki(query);
return result;
}
}),
// 工具二:创建任务
createTask: tool({
description: '在项目管理系统中创建一个新任务。',
parameters: z.object({
title: z.string().describe('任务标题'),
priority: z.enum(['high', 'medium', 'low']).default('medium').describe('紧急程度'),
dueDate: z.string().optional().describe('截止日期,格式为 YYYY-MM-DD')
}),
execute: async ({ title, priority, dueDate }) => {
console.log(`[工具执行] 正在创建任务: "${title}", 优先级: ${priority}`);
const result = await db.createTask(title, priority, dueDate);
return result;
}
})
};
在这段代码中,Vercel AI SDK 的 tool 函数帮我们默默完成了两件事:
- 它利用
zod-to-json-schema自动把 Zod 的 Schema 转换成了模型能读懂的 JSON Schema 标准格式。 - 当模型返回工具调用请求时,SDK 会自动提取参数、运行对应的
execute异步函数,并将结果打包好发送给下一轮模型请求,消除了繁琐的手工拼接过程。
防御性编程:失败诊断与模型自我纠错机制
本节操作锚点:围绕“防御性编程:失败诊断与模型自我纠错机制”记录步骤、样例、诊断、风险、检查清单和验收结果。
模型并不总是完美的,有时候它会传递格式错误的日期(例如写成 'tomorrow' 而非 '2026-05-29'),或者漏掉必填字段。如果模型输出的参数无法通过 JSON Schema 校验,应该把校验错误作为新一轮对话的 User Message 喂给模型,让其自我修正,除非重试次数已经达到上限,此时必须直接报错打断流程。
这就是在 Agent 开发中常用的**自我纠错(Self-Correction)**垫片机制。在 Vercel AI SDK 架构下,虽然底层在调用 execute 前会自动进行 Zod 校验,但我们可以通过自定义执行流,抓取到这些校验错误并让模型重试。以下是自定义防御性校验与纠错的流程设计:
import { generateText, ToolExecutionValidationError } from 'ai';
import { openai } from '@ai-sdk/openai'; // 需要提前配置好 OPENAI_API_KEY
async function runAgentWithRetry(prompt: string, maxRetries = 2) {
let currentPrompt = prompt;
let attempts = 0;
while (attempts <= maxRetries) {
try {
const response = await generateText({
model: openai('gpt-4o'),
tools: tools,
prompt: currentPrompt,
maxSteps: 5, // 允许自动进行多轮工具调用循环
});
return response;
} catch (error) {
attempts++;
if (error instanceof Error && error.name === 'ZodError') {
// 捕捉到本地 Zod 校验失败
console.warn(`[参数校验失败] 第 ${attempts} 次尝试失败,正在反馈给模型进行纠错。`);
currentPrompt = `${currentPrompt}\n\n【系统提示】上一次工具调用参数未通过校验,错误信息为:"${error.message}"。请重新检查你的参数格式并再次调用。`;
} else {
// 遇到不可恢复的其他错误(如网络中断、API 凭证失效),不再重试,直接抛出
throw error;
}
}
}
throw new Error('达到最大重试次数,模型未能成功纠正其生成的工具参数。');
}
这种防御性设计的精妙之处在于,我们没有因为一次参数格式错误就彻底崩溃中断,而是给模型一次“看清报错、重新做人”的机会。这大大提升了系统的鲁棒性。
权限控制与安全边界:高危工具的人工确认垫片
本节操作锚点:围绕“权限控制与安全边界:高危工具的人工确认垫片”记录步骤、样例、诊断、风险、检查清单和验收结果。
如果工具涉及写库或转账等高危操作,必须在应用层引入人工确认(Human-in-the-loop)机制,否则一旦模型发生幻觉或遭遇 Prompt 注入攻击,可能会对业务数据造成毁灭性的破坏。
如何优雅地在工具流中加入人工确认环节?我们需要在工具执行前,将执行状态挂起,将工具调用的上下文序列化后持久化到数据库中,等待管理员在前端页面点击“批准”后,再恢复执行。
以下是一个模拟人工确认拦截的伪代码逻辑,展示了如何在不破坏工具生命周期的前提下,实现动作拦截:
import { tool } from 'ai';
import { z } from 'zod';
// 模拟挂起和等待外部确认的信号器
const pendingApprovals = new Map<string, (approved: boolean) => void>();
export const secureTransferTool = tool({
description: '将资金转入指定的银行账户。此操作敏感,必须经过人工审核。',
parameters: z.object({
amount: z.number().positive().describe('转账金额'),
accountNumber: z.string().describe('目标账户')
}),
execute: async ({ amount, accountNumber }) => {
const approvalId = `req_${Math.random().toString(36).substr(2, 9)}`;
console.log(`\n⚠️ [高危操作拦截] 检测到转账请求!`);
console.log(`- 目标账户: ${accountNumber}`);
console.log(`- 转账金额: ¥${amount}`);
console.log(`- 审批 ID : ${approvalId}`);
console.log(`[系统提示] 请在控制台中输入 "yes" 同意转账,或输入 "no" 拒绝:`);
// 挂起 execute 的异步流程,等待外部输入信号
const approved = await new Promise<boolean>((resolve) => {
pendingApprovals.set(approvalId, resolve);
// 在控制台中模拟人工交互输入
process.stdin.once('data', (data) => {
const input = data.toString().trim().toLowerCase();
if (input === 'yes' || input === 'y') {
resolve(true);
} else {
resolve(false);
}
});
});
pendingApprovals.delete(approvalId);
if (!approved) {
throw new Error('用户拒绝了该转账操作,转账已被安全拦截。');
}
// 只有在获批后,才会真正调用底层写库/转账接口
return { status: 'success', transactionId: `tx_${Date.now()}`, message: '转账成功' };
}
});
在构建 Web 应用时,你应该将这个控制台输入替换为 Webhook 或者是基于 WebSocket 的长连接通知。应用的核心逻辑是:工具调用的 execute 方法在 Promise 解决之前绝不返回,利用底层的等待机制天然卡住模型的推理回路,直到安全验证通过。
练习与交付:实现带可观测日志的任务创建闭环
本节操作锚点:围绕“练习与交付:实现带可观测日志的任务创建闭环”记录步骤、样例、诊断、风险、检查清单和验收结果。
现在,轮到你亲自动手实现一个可以在本地运行的工具调用闭环系统了。本次练习的交付目标是编写一个完整的 TypeScript 脚本,能够让模型根据用户的自然语言输入,自主决定调用“查询知识库(Wiki)”和“创建待办任务”工具,并输出清晰明了的可观测执行日志。
交付规范与预期输出
- 依赖配置文件 (
package.json):
{
"name": "tool-calling-demo",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@ai-sdk/openai": "^1.0.0",
"ai": "^3.0.0",
"zod": "^3.22.0"
}
}
- 主程序 (
index.ts):
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { tools } from './tools.js'; // 导入我们前面写好的 tools 定义
async function main() {
// 模拟输入:一句话同时触发 Wiki 检索和任务创建
const userInput = "根据 Wiki 里关于 AI 的开发规范,帮我建一个今天截止的‘编写 AI 模块代码’任务,优先级定为高。";
console.log(`[User]: ${userInput}\n`);
console.log(`--- [开始执行可观测 Trace] ---`);
const response = await generateText({
model: openai('gpt-4o'), // 确保已在环境变量中注入了 OPENAI_API_KEY
tools: tools,
prompt: userInput,
maxSteps: 5, // 开启多步调用,允许模型先查 Wiki,拿到结果后再建任务
onStepFinish({ text, toolCalls, toolResults, finishReason }) {
// 每一步推理/执行结束时的钩子,这是可观测性的关键
console.log(`\n[Step Finish Log]`);
if (text) {
console.log(`> 模型输出: "${text}"`);
}
if (toolCalls && toolCalls.length > 0) {
console.log(`> 模型提议调用工具:`);
toolCalls.forEach(call => {
console.log(` - 工具名: ${call.toolName}`);
console.log(` - 提取参数: ${JSON.stringify(call.args)}`);
});
}
if (toolResults && toolResults.length > 0) {
console.log(`> 应用程序执行工具结果:`);
toolResults.forEach(res => {
console.log(` - 工具: ${res.toolName}`);
console.log(` - 结果: ${JSON.stringify(res.result)}`);
});
}
console.log(`> 终止状态: ${finishReason}`);
}
});
console.log(`\n--- [Trace 结束] ---`);
console.log(`\n[AI 最终答复]: ${response.text}`);
}
main().catch(console.error);
验收自测路径
请在你的开发终端执行以下步骤以进行验收:
- 确保你的环境中配置了正确的环境变量:
export OPENAI_API_KEY="your-key"。 - 运行脚本:
node --loader ts-node/esm index.ts。 - 合格的日志输出格式(必须包含两个不同的工具调用步骤):
- 步骤一:模型检测到需要“关于 AI 的开发规范”的信息,提议调用
searchWiki,拿到返回内容(“AI应用开发中,Tool Calling 是实现 Agent 架构的核心。”)。 - 步骤二:模型基于 Wiki 返回内容,结合任务截止日期和优先级,提议调用
createTask(参数中title为'编写 AI 模块代码',priority为'high')。 - 最终状态:模型吐出最终人类友好的文本答复。
- 步骤一:模型检测到需要“关于 AI 的开发规范”的信息,提议调用
异常情况诊断
- 问题一:模型直接给出答案,没有调用工具。
- 排查路径:检查
userInput里的措辞。如果你的 prompt 里没有包含让模型感到“未知”的信息(例如 Wiki 里的特定知识),模型可能会依靠自身的预训练知识直接做出回答。应该修改 prompt,确保其明确包含需要查询 Wiki 或对系统发起写操作的意图。
- 排查路径:检查
- 问题二:报
ZodError错误。- 排查路径:模型生成的某些参数(例如日期)没有通过你的 Zod 约束。请打印出
toolCalls的原始参数,对比你的 Zod schema,适当放宽 schema 限制,或在描述(describe)中给出更具体的格式范例(如'due_date 必须是 YYYY-MM-DD 格式,不允许填写 tomorrow')。
- 排查路径:模型生成的某些参数(例如日期)没有通过你的 Zod 约束。请打印出
来源、复核与时效说明
本节操作锚点:围绕“来源、复核与时效说明”记录步骤、样例、诊断、风险、检查清单和验收结果。
本单元内容基于以下来源的官方技术文档进行编写:
- OpenAI Function calling Guide (2026-05-28):规定了模型作为动作提议方,应用作为动作执行方的架构分工。
- Anthropic Tool Use Documentation (2026-05-28):规定了
tool_use与tool_result消息配对的完备性合约。 - Google Gemini API Function Calling (2026-05-28):确认了主流多模型在函数声明与回调生命周期上的一致性。
- Vercel AI SDK Core - Tools (2026-05-28):提供了基于 TypeScript/Zod 的统一包装层与可观测性钩子(
onStepFinish)。 - JSON Schema Draft (2026-05-28):奠定了模型理解结构化输入的基础协议。
复核触发条件:
当 Vercel AI SDK 废弃 tool 声明函数或对 onStepFinish 签名进行不向下兼容的修改时,或者 OpenAI/Anthropic 发布了无需回传 tool_result 的全新单向异步执行协议时,本课内容需要进行复核与更新。
ToolCalling:模型提出动作,应用执行动作:把判断写成可复查证据
这一课的价值在于把模糊经验拆成可以检查的动作,而不是把清单背下来。本课交付物是 一个查询资料/创建任务的工具调用闭环和执行日志格式,它要服务于 AI 应用 的下一步,而不是只证明你读过这一节。
如果你现在还没有真实输入,先用一个最小样例完成 ToolCalling:模型提出动作,因为没有输入就无法判断产物是否可用。 如果本课产物会影响后续交付,应该写明适用条件和不适用条件,否则下一课会把不确定性误当成基础。 如果检查结果只剩一句“看起来可以”,必须补一条反例或失败样例,只有能解释失败原因时,这个判断才站得住。
验收失败时要区分三类原因:理解偏差、执行遗漏、外部条件变化。三类问题对应的修复方式不同。围绕 ToolCalling:模型提出动作 做检查时,至少保留步骤、样例、风险、修复和验收五项。
OpenAI 的《Function calling》说明:支撑工具定义、模型提出调用、应用执行工具和结果回传的边界。;这意味着 ToolCalling:模型提出 不能只写经验结论,要把来源变成检查动作。Anthropic 的《Anthropic Tool use overview》提醒:支撑 tool_use / tool_result 流程、工具执行权留在应用层和代理边界。;因此本课方案必须写清边界。Google AI 的《Gemini API function calling》提供的证据是:支撑 Gemini 函数声明、工具调用、多模型工具抽象和调用结果处理。;所以当前结论按 2026-05-28 的来源状态使用。
复核触发条件要写具体:如果官方 API/SDK、软件工具版本、版权政策、安全合规要求、行业规范、平台发布规则或关键来源页面发生变化,需要重新检查 ToolCalling:模型提出 的步骤、样例、风险和验收清单。
练习验收:把 ToolCalling:模型提出动作 应用到一个自己的任务,交付一份包含输入、步骤、样例、失败诊断、来源证据和复测结果的记录。缺少其中任何一项,都先标记为 needs_review。