作者:前端转 AI 深度实践者
【省流助手/核心观点】:Tool Calling 不是把几个函数名丢给模型就完事了。函数名只能告诉模型“有什么工具”,但不能说明“什么时候用、怎么传参、哪些字段必填、风险有多高、能不能自动执行”。真正可维护的 Agent 工程,需要给每个工具写一份 Schema:描述用途、约束参数、标记风险、控制确认流程。对前端开发者来说,这就像给 API 写 TypeScript 类型、接口文档、表单校验和权限边界。
第 23 篇我们讲了 Agent 的第一块积木:Tool Calling。
简单说,就是让模型不再硬编答案,而是学会提出工具调用意图。
用户问:
帮我查一下订单 A1001 到哪了。
模型不应该直接编:
订单正在配送中。
而应该输出:
{
"toolName": "getOrderStatus",
"args": {
"orderId": "A1001"
}
}
然后程序真正去查订单系统。
这一步很关键,它把 AI 应用从“会聊天”推进到“能办事”。
但只做到这里还不够。
因为模型可能会:
- 选错工具。
- 少传参数。
- 把数字传成字符串。
- 把枚举值写错。
- 调用根本不存在的工具。
- 请求执行一个高风险操作。
所以这篇文章继续往下走一步:
Tool Calling 不能只靠函数名,还要给工具写 Schema。
这个 Schema,就是工具说明书。
1. 痛点:只注册函数,对程序够用,对模型不够用
先看一个很常见的工具注册表:
const tools = {
getOrderStatus,
calculateRefund,
searchPolicy
};
这对程序来说确实能跑。
程序看到 getOrderStatus,就能找到对应函数执行。
但对模型来说,它只看到几个名字。
这些名字没有完整说明:
getOrderStatus是查物流,还是查订单详情?calculateRefund是预估退款,还是直接发起退款?searchPolicy是查公司制度,还是查售后政策?- 每个工具需要哪些参数?
- 参数类型是什么?
- 哪些参数允许哪些枚举值?
- 哪些工具只是查询,哪些工具会改变系统状态?
这就像你给新同事一个接口列表:
/order
/refund
/policy
然后让他自己猜每个接口怎么用。
他可能猜对,也很容易猜错。
Agent 系统一样。工具名不是工具契约,工具名只是入口。
2. 错误做法:模型输出什么,程序就执行什么
很多人第一次写 Tool Calling,会不自觉地相信模型输出:
async function unsafeRunTool(toolCall: {
toolName: string;
args: Record<string, unknown>;
}) {
const tool = tools[toolCall.toolName as keyof typeof tools];
return tool(toolCall.args);
}
这段代码最大的问题是:没有任何边界。
模型可能传错类型:
{
"toolName": "getOrderStatus",
"args": {
"orderId": 1001
}
}
但你的工具需要的是字符串:
async function getOrderStatus(args: { orderId: string }) {
// ...
}
模型也可能少传参数:
{
"toolName": "calculateRefund",
"args": {
"reason": "damaged"
}
}
但退款计算至少需要 orderId。
它还可能传错枚举值:
{
"toolName": "calculateRefund",
"args": {
"orderId": "A1001",
"reason": "随便退一下"
}
}
如果你直接执行,错误会在很深的业务代码里爆炸。更糟的是,高风险工具可能被误触发。
所以你需要工具 Schema。
3. 正确做法:Schema = 接口文档 + 类型定义 + 安全边界
一个更可靠的工具定义,应该同时包含 handler 和 schema。
type ToolRisk = "low" | "medium" | "high";
type ToolSchema = {
name: string;
description: string;
risk: ToolRisk;
requiresConfirmation: boolean;
parameters: {
type: "object";
required: string[];
properties: Record<
string,
{
type: "string" | "number" | "boolean";
description: string;
enum?: string[];
pattern?: string;
}
>;
};
};
type ToolDefinition = {
schema: ToolSchema;
handler: (args: Record<string, unknown>) => Promise<unknown>;
};
比如 getOrderStatus 可以这样写:
const getOrderStatusTool: ToolDefinition = {
schema: {
name: "getOrderStatus",
description:
"查询订单物流状态。适用于用户询问订单是否发货、是否签收、预计何时送达。不用于退款、取消订单或修改地址。",
risk: "low",
requiresConfirmation: false,
parameters: {
type: "object",
required: ["orderId"],
properties: {
orderId: {
type: "string",
description: "订单编号,例如 A1001",
pattern: "^A\d{4}$"
}
}
}
},
handler: async (args) => {
const orderId = args.orderId;
if (typeof orderId !== "string") {
throw new Error("orderId 必须是字符串");
}
return {
orderId,
status: "shipping",
eta: "2026-04-28"
};
}
};
这份 Schema 至少告诉系统 5 件事:
- 工具是做什么的。
- 什么时候该用这个工具。
- 参数有哪些,哪些必填。
- 参数类型和格式是什么。
- 工具有没有风险,能不能直接执行。
如果你是前端开发者,可以把它理解成:
Tool Schema = API 文档 + TypeScript 类型 + 表单校验规则 + 权限提示
没有 Schema 的 Tool Calling,就像没有类型定义的接口联调。能跑,但迟早会在边界条件上摔跤。
4. 好的 description,会显著减少选错工具
很多人写工具描述,会写得很短:
查询订单。
这当然比没有好,但还不够。
更好的描述要包含适用场景和不适用场景:
查询订单物流状态。适用于用户询问订单是否发货、是否签收、预计何时送达。不用于退款、取消订单或修改地址。
这个描述告诉模型:
- 用户问物流,选它。
- 用户问退款,不要选它。
- 用户问取消订单,也不要选它。
工具描述不是写给人看的注释而已。
它会影响模型的选择质量。你可以把工具描述理解成一种挂在工具上的 Prompt。
5. 参数校验:模型输出的 args 本质上是不可信输入
args 是模型生成的,所以它应该被当成用户输入处理。
一个最小参数校验器可以这样写:
function validateArgs(schema: ToolSchema, args: Record<string, unknown>) {
const errors: string[] = [];
const { required, properties } = schema.parameters;
for (const key of required) {
if (!(key in args)) {
errors.push(`缺少必填参数:${key}`);
}
}
for (const [key, rules] of Object.entries(properties)) {
const value = args[key];
if (value === undefined) continue;
if (rules.type === "string" && typeof value !== "string") {
errors.push(`参数 ${key} 应该是 string`);
continue;
}
if (rules.type === "number" && typeof value !== "number") {
errors.push(`参数 ${key} 应该是 number`);
continue;
}
if (rules.type === "boolean" && typeof value !== "boolean") {
errors.push(`参数 ${key} 应该是 boolean`);
continue;
}
if (rules.enum && !rules.enum.includes(String(value))) {
errors.push(`参数 ${key} 必须是 ${rules.enum.join(", ")} 之一`);
}
if (
rules.pattern &&
typeof value === "string" &&
!new RegExp(rules.pattern).test(value)
) {
errors.push(`参数 ${key} 格式不合法`);
}
}
return errors;
}
这段代码看起来不酷,但它是 Agent 稳定性的地基。
AI 工程里很多真正有价值的代码,都不是“让模型更聪明”,而是让系统在模型不稳定时也不会乱跑。
6. 调度器要像网关,而不是传声筒
没有 Schema 校验时,调度器像一个传声筒:
模型说调用什么,我就调用什么。
更好的调度器应该像网关:
模型提出调用意图
-> 检查工具是否存在
-> 检查参数是否合法
-> 检查风险等级
-> 检查是否需要确认
-> 决定是否执行
一个升级版 runTool 可以这样写:
type ToolCall = {
toolName: string;
args: Record<string, unknown>;
};
type ToolRunResult =
| {
ok: true;
toolName: string;
data: unknown;
}
| {
ok: false;
toolName?: string;
errorType:
| "unknown_tool"
| "invalid_arguments"
| "confirmation_required"
| "tool_error";
message: string;
errors?: string[];
};
const toolRegistry: Record<string, ToolDefinition> = {
getOrderStatus: getOrderStatusTool
};
async function runTool(toolCall: ToolCall): Promise<ToolRunResult> {
const tool = toolRegistry[toolCall.toolName];
if (!tool) {
return {
ok: false,
toolName: toolCall.toolName,
errorType: "unknown_tool",
message: `未知工具:${toolCall.toolName}`
};
}
const errors = validateArgs(tool.schema, toolCall.args ?? {});
if (errors.length > 0) {
return {
ok: false,
toolName: tool.schema.name,
errorType: "invalid_arguments",
message: "工具参数不合法",
errors
};
}
if (tool.schema.requiresConfirmation) {
return {
ok: false,
toolName: tool.schema.name,
errorType: "confirmation_required",
message: "该工具属于高风险操作,需要用户确认后才能执行"
};
}
try {
const data = await tool.handler(toolCall.args);
return {
ok: true,
toolName: tool.schema.name,
data
};
} catch (error) {
return {
ok: false,
toolName: tool.schema.name,
errorType: "tool_error",
message: error instanceof Error ? error.message : "工具执行失败"
};
}
}
这段逻辑让系统多了几道闸门。
这些闸门不是为了为难模型,而是为了保护用户、业务和团队。
7. 查询类工具和写入类工具必须分开看
Tool Calling 里最需要警惕的是副作用。
查询类工具通常风险较低:
- 查订单状态。
- 查制度文档。
- 查天气。
- 查库存。
它们只是读取信息,不改变系统状态。
写入类工具就不一样了:
- 取消订单。
- 发起退款。
- 修改地址。
- 发送邮件。
- 删除数据。
- 修改用户权限。
这些操作会改变真实业务状态。
如果模型误触发,后果可能很麻烦。
所以你应该给工具标记风险:
const cancelOrderTool: ToolDefinition = {
schema: {
name: "cancelOrder",
description:
"取消指定订单。仅当用户明确要求取消订单时使用。不用于查询订单状态或咨询退款政策。",
risk: "high",
requiresConfirmation: true,
parameters: {
type: "object",
required: ["orderId", "reason"],
properties: {
orderId: {
type: "string",
description: "订单编号,例如 A1001",
pattern: "^A\d{4}$"
},
reason: {
type: "string",
description: "取消原因",
enum: ["user_request", "wrong_address", "duplicate_order"]
}
}
}
},
handler: async (args) => {
return {
cancelled: true,
orderId: args.orderId
};
}
};
当模型试图调用高风险工具时,系统不应该立刻执行,而应该返回确认态。
这不是“不智能”,这是负责任。
AI 工程里有一个很实用的原则:
能查的,可以宽一点;能改的,必须严一点。
8. 生产环境避坑指南
1. Schema 要和真实 handler 同步维护
最危险的情况是:Schema 说需要 orderId,handler 实际读的是 id。
这类错不会总是立刻暴露,但会让模型、前端和后端一起困惑。
建议把 Schema 和 handler 放在同一个文件或同一个模块里维护,不要散落在不同仓库里。
2. 不要用模糊工具名
坏名字:
doUserThing
handleOrder
processData
好名字:
getOrderStatus
calculateRefundEstimate
searchPolicyDocs
createSupportTicketDraft
名字越清楚,模型越容易选对,团队也越容易维护。
3. description 要写“不适用场景”
很多工具选错,不是因为模型不知道它能做什么,而是不知道它不能做什么。
描述里最好写清楚:
- 适用于什么问题。
- 不适用于什么问题。
- 和相似工具的区别是什么。
4. 高风险工具必须程序层拦截
不要只在 Prompt 里写“谨慎使用”。
Prompt 是软约束。权限校验、二次确认、审计日志才是硬约束。
涉及删除、支付、发送、修改权限、批量操作的工具,都应该默认需要确认。
5. 结构化错误要返回给模型和日志
不要只返回:
调用失败。
更好的错误是:
{
"ok": false,
"errorType": "invalid_arguments",
"message": "工具参数不合法",
"errors": ["缺少必填参数:orderId"]
}
这样模型可以尝试修正参数,开发者也能快速定位问题。
9. 常见误区
误区 1:函数名写清楚就够了
不够。函数名只能表达一小部分语义,不能替代参数规则、风险等级和适用边界。
误区 2:模型很聪明,参数错了它会自己修
有时会,但不能依赖。工程系统要把错误显式返回,而不是期待模型每次都猜对。
误区 3:高风险工具只要 Prompt 写“谨慎使用”就行
不行。Prompt 是软约束,权限和确认是硬约束。涉及副作用的工具必须在程序层把关。
误区 4:Schema 越复杂越好
也不是。初期 Schema 要清晰、够用、容易维护。复杂度应该来自真实问题,而不是一开始就堆满规则。
10. 给前端开发者的落地清单
如果你正在设计 Agent 工具,可以从这份清单开始:
- 每个工具必须有清晰名字。
- 每个工具必须有 description,包含适用和不适用场景。
- 每个工具必须声明必填参数。
- 每个参数必须有类型、描述和必要的格式约束。
- 枚举参数必须列出允许值。
- 工具必须标记风险等级。
- 高风险工具默认需要确认。
- 调度器必须返回结构化错误。
- 工具调用必须记录 traceId。
- 工具 Schema 要和代码一起维护。
这份清单不花哨,但很实用。它会让你的 Agent 不只是演示时聪明,而是在真实用户、真实业务、真实错误里也能站得住。
结语
Tool Calling 的第一步,是让模型知道有哪些工具。
Tool Calling 的第二步,是让系统知道这些工具该怎么被安全、稳定、可维护地使用。
这就是 Schema 的价值。
它像一份工具说明书,也像一份协作契约:告诉模型什么时候该用,告诉程序怎么校验,告诉团队风险在哪里。
如果说第 23 篇让我们把 Agent 的“手”接上去,那么第 24 篇就是给这只手标上边界、权限和刹车。
真正可靠的 Agent,不是模型想做什么就做什么。
真正可靠的 Agent,是模型提出建议,程序负责把关,工具在清晰边界内执行。