工具设计原则
状态:✅ 已补齐(2026-09-13) 一句话定义:工具是 Agent 的手;粒度、命名、参数 schema、错误信息、幂等 决定模型能不能用对。
你为什么要学这个
工具调用是 Agent 能力的核心,但很多工程师只关注「工具能不能跑」,忽略「模型会不会用」。好工具让模型用对 90% 的情况;坏工具让模型反复踩坑——误判参数、盲目重试、不知道何时该停止。
学完应能:判断工具粒度是否合理(一个工具一件事);写出模型能读懂的描述;设计可恢复的错误返回;理解权限标注与幂等的重要性;并回答「为什么万能搜索工具往往更差」。
一、一个工具一件事 vs 巨石工具
1.1 粒度原则:单一职责
反例:万能搜索工具
// 坏设计:一个工具干太多事,参数表长,模型容易错用
interface UniversalSearchParams {
query: string;
searchType: "users" | "orders" | "products" | "tickets";
filters: {
userId?: string;
orderId?: string;
productCategory?: string;
ticketStatus?: "open" | "closed" | "pending";
dateRange?: { start: string; end: string };
};
sortBy?: "date" | "relevance" | "popularity";
limit?: number;
}
问题:
- 参数表太长(10+ 字段),模型容易漏填或乱填
searchType是枚举,但每个枚举值的过滤字段不同(userId只对users有效),模型需要「记忆」这些规则- 错误信息不够具体,模型不知道是哪个字段错了
正例:拆分为单一职责工具
// 好设计:每个工具只干一件事
async function searchUsers(params: {
query?: string;
userId?: string;
limit?: number;
}): Promise<User[]> { /* ... */ }
async function searchOrders(params: {
query?: string;
orderId?: string;
status?: "pending" | "paid" | "shipped";
limit?: number;
}): Promise<Order[]> { /* ... */ }
async function searchProducts(params: {
query?: string;
category?: string;
limit?: number;
}): Promise<Product[]> { /* ... */ }
async function searchTickets(params: {
query?: string;
ticketId?: string;
status?: "open" | "closed" | "pending";
limit?: number;
}): Promise<Ticket[]> { /* ... */ }
好处:
- 参数表短(3-5 字段),模型容易填对
- 每个工具的字段含义清晰,不需要「记忆」复杂的枚举规则
- 错误信息能精确到字段(「
userId必须为 24 字符 UUID」而非「参数无效」)
1.2 粒度判断原则
| 场景 | 粒度 | 理由 |
|---|---|---|
| 数据源相同(都查 MySQL) | 拆分 | 过滤字段、排序规则不同,合在一起模型要记太多规则 |
| 动作相同(都是删除)但资源不同 | 拆分 | deleteUser、deleteOrder、deleteProduct 权限不同 |
| 数据源不同(MySQL + Elastic) | 拆分 | 搜索语义不同(MySQL 精确,Elastic 模糊) |
| 工具调用有依赖(先 A 后 B) | 拆分 | 让模型显式编排,而不是在工具里隐式串联 |
| 只有一个参数且含义相同 | 合并 | getUserById(userId: string) 和 getUser(userId: string) 合并 |
关键直觉:工具的参数表应该是「一眼能看懂的」(5 个字段以内),超过就需要拆分。
二、描述文字怎么写才「模型可读」
2.1 三层描述结构
OpenAI / Anthropic 的 Function Calling 协议中,工具描述包含三层:
{
"name": "searchUsers",
"description": "搜索用户信息。支持按用户 ID 精确查询,或按姓名/邮箱模糊匹配。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词,可以是用户名或邮箱的一部分。不填则返回全部用户。",
},
"userId": {
"type": "string",
"description": "用户 ID(24 字符 UUID)。与 `query` 二选一,优先用此参数精确查询。",
},
"limit": {
"type": "integer",
"description": "返回结果数量限制,默认 10,最大 100。",
},
},
"required": ["limit"], // ← 坏设计!limit 不该是必填
},
}
2.2 描述三原则
原则一:说清楚「干什么」,不说「怎么干」
坏描述:
{
"name": "searchUsers",
"description": "调用 MySQL 的 `SELECT * FROM users` 语句,支持 LIKE 匹配和 UUID 精确查询,默认按 `created_at` 降序排序。",
}
问题:
- 模型不需要知道底层实现(MySQL、表结构、排序规则)
- 这些信息会干扰模型判断「该不该用这个工具」
好描述:
{
"name": "searchUsers",
"description": "搜索用户信息。支持按用户 ID 精确查询,或按姓名/邮箱模糊匹配。",
}
原则二:说明「参数互斥」和「默认值」
坏描述:
{
"name": "searchUsers",
"description": "搜索用户信息。",
"parameters": {
"query": { "type": "string", "description": "搜索关键词" },
"userId": { "type": "string", "description": "用户 ID" },
},
}
问题:
- 模型不知道
query和userId是二选一 - 模型不知道不填会返回全部用户(可能触发大量结果)
好描述:
{
"name": "searchUsers",
"description": "搜索用户信息。支持按用户 ID 精确查询,或按姓名/邮箱模糊匹配。",
"parameters": {
"query": {
"type": "string",
"description": "搜索关键词,可以是用户名或邮箱的一部分。不填则返回全部用户。",
},
"userId": {
"type": "string",
"description": "用户 ID(24 字符 UUID)。与 `query` 二选一,优先用此参数精确查询。",
},
"limit": {
"type": "integer",
"description": "返回结果数量限制,默认 10,最大 100。",
},
},
}
原则三:必填字段要「少」
坏设计:
{
"parameters": {
"required": ["query", "limit", "sortBy"], // ← 太多必填
},
}
问题:
- 模型遇到必填字段时会「强行凑值」(不填
limit就填 1,不填sortBy就填date) - 强行凑的值往往不符合业务逻辑(
limit=1结果太少,sortBy=date结果不准)
好设计:
{
"parameters": {
"required": [], // ← 没有必填字段
"limit": { "description": "返回结果数量限制,默认 10,最大 100。" },
"sortBy": { "description": "排序方式,可选 `date`、`relevance`,默认 `relevance`。" },
},
}
直觉:必填字段越少,模型越不会强行凑值——宁可让工具返回全部结果(让前端分页),也不要让模型瞎填。
三、错误返回:让模型可恢复,而不是堆栈砸脸
3.1 三类错误
| 错误类型 | 模型能恢复吗 | 应如何返回 |
|---|---|---|
| 参数错误(格式不对、必填未填) | ✅ 能,改参数重试 | 结构化错误信息 + 正确示例 |
| 权限错误(没权限执行) | ✅ 能,换个工具或提示用户 | 说明权限等级 + 建议 |
| 业务错误(用户不存在、余额不足) | ✅ 能,让模型解释给用户 | 业务层面的解释 |
| 系统错误(数据库挂了) | ❌ 不能,降级或人工介入 | 返回友好提示 + 建议稍后重试 |
3.2 结构化错误返回
坏设计:堆栈砸脸
// 工具直接抛出异常
throw new Error("Invalid userId: must be 24 characters");
问题:
- 模型收到的是纯文本,无法解析「哪个字段错了」
- 模型不知道「正确的格式是什么」
好设计:结构化错误
interface ToolResponse<T = any> {
success: boolean;
data?: T;
error?: {
type: "invalid_params" | "permission_denied" | "not_found" | "rate_limit";
field?: string; // 哪个字段错了
message: string; // 人类可读的错误信息
hint?: string; // 给模型的建议
};
}
// 参数错误
return {
success: false,
error: {
type: "invalid_params",
field: "userId",
message: "用户 ID 必须为 24 字符 UUID 格式",
hint: "例如:5f8d0d55b54764421b7156d4,或先调用 searchUsers 获取正确 ID",
},
};
// 权限错误
return {
success: false,
error: {
type: "permission_denied",
message: "您没有权限删除此订单",
hint: "仅订单所有者或管理员可删除,请改用 requestOrderDeletion 工具发起删除申请",
},
};
// 业务错误
return {
success: false,
error: {
type: "not_found",
field: "orderId",
message: "订单不存在或已删除",
hint: "请确认订单 ID 是否正确,或调用 searchOrders 搜索订单",
},
};
3.3 让模型自纠
模型收到结构化错误后,通过 system prompt 引导自纠:
// System Prompt 的一部分
{
role: "system",
content: `你是一个智能助手,可以调用工具完成任务。
工具调用失败时,请根据错误类型采取不同策略:
- invalid_params:检查错误信息中的 field 和 hint,修改参数后重试
- permission_denied:提示用户权限不足,或建议使用其他工具
- not_found:提示用户资源不存在,或先调用搜索工具
- rate_limit:提示用户稍后重试
错误信息格式:
{
"type": "invalid_params",
"field": "userId",
"message": "用户 ID 必须为 24 字符 UUID 格式",
"hint": "例如:5f8d0d55b54764421b7156d4"
}`,
}
四、权限与副作用标注
4.1 权限标注:让模型知道「能不能用」
OpenAI 协议:工具定义时不支持权限标注,需要通过描述传递
{
"name": "deleteUser",
"description": "删除用户账户。⚠️ 仅管理员可使用,普通用户请改用 deactivateUser。",
"parameters": { /* ... */ },
}
Anthropic 协议:支持 requires_approval 和 risk_level
{
"name": "deleteUser",
"description": "删除用户账户。",
"parameters": { /* ... */ },
"requires_approval": true, // ← 需要人工确认
"risk_level": "L4", // ← 风险等级,触发 HITL
}
MCP 协议:支持 dangerous 标注
{
"name": "deleteUser",
"description": "删除用户账户。",
"parameters": { /* ... */ },
"dangerous": true, // ← 标记为危险操作
}
4.2 副作用标注:让模型知道「会有影响」
有副作用的操作:
- 删除、更新、下单、转账、发送邮件
- 工具返回后,数据库状态改变
无副作用的操作:
- 查询、搜索、读取
- 工具返回后,数据库状态不变
标注方式:
{
"name": "updateOrder",
"description": "更新订单信息。此操作有副作用:会修改订单状态和数据库记录。",
"parameters": { /* ... */ },
}
为什么重要:模型需要知道「哪些操作可以重试,哪些不行」
- 无副作用操作:失败可重试(
searchUsers失败可重试 3 次) - 有副作用操作:失败不能重试(
updateOrder失败需人工介入)
五、与 MCP / Function Calling 的对应
5.1 三层协议映射
| 层级 | MCP 协议 | OpenAI Function Calling | Anthropic Tool Use |
|---|---|---|---|
| 工具定义 | Tool schema |
function schema |
tool schema |
| 描述 | description |
description |
description |
| 参数 | inputSchema (JSON Schema) |
parameters (JSON Schema) |
input_schema (JSON Schema) |
| 权限标注 | dangerous |
通过 description |
requires_approval、risk_level |
| 调用返回 | ToolResult |
tool message |
tool_result message |
5.2 JSON Schema 规范
所有协议都遵循 JSON Schema,核心字段:
{
"type": "object", // 必填
"properties": { // 参数定义
"userId": {
"type": "string",
"format": "uuid", // ← 格式校验
"minLength": 24,
"maxLength": 24,
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
},
},
"required": [], // 必填字段
}
5.3 跨协议工具定义抽象
// 统一的工具定义
interface UnifiedToolDefinition {
name: string;
description: string;
parameters: JSONSchema;
riskLevel?: "L1" | "L2" | "L3" | "L4" | "L5"; // 通用
dangerous?: boolean; // MCP / Anthropic
requiresApproval?: boolean; // Anthropic
}
// 转换为 OpenAI 格式
function toOpenAI(tool: UnifiedToolDefinition) {
return {
type: "function",
function: {
name: tool.name,
description: tool.dangerous ? `⚠️ 危险操作:${tool.description}` : tool.description,
parameters: tool.parameters,
},
};
}
// 转换为 Anthropic 格式
function toAnthropic(tool: UnifiedToolDefinition) {
return {
name: tool.name,
description: tool.description,
input_schema: tool.parameters,
requires_approval: tool.riskLevel === "L4" || tool.riskLevel === "L5",
risk_level: tool.riskLevel,
};
}
// 转换为 MCP 格式
function toMCP(tool: UnifiedToolDefinition) {
return {
name: tool.name,
description: tool.description,
inputSchema: tool.parameters,
dangerous: tool.riskLevel === "L4" || tool.riskLevel === "L5",
};
}
六、命名、参数与类型设计
6.1 命名规范
| 工具类型 | 命名模式 | 示例 |
|---|---|---|
| 查询 | search{资源} |
searchUsers、searchOrders |
| 获取单条 | get{资源} |
getUser、getOrder |
| 创建 | create{资源} |
createUser、createOrder |
| 更新 | update{资源} |
updateUser、updateOrder |
| 删除 | delete{资源} |
deleteUser、deleteOrder |
| 发送 | send{资源} |
sendEmail、sendNotification |
| 取消 | cancel{资源} |
cancelOrder、cancelSubscription |
反例:
{
"name": "getUserData", // ← 不够具体,data 是什么?
"name": "queryUser", // ← 动词混用,统一用 search
"name": "deleteUserById", // ← 方法名里不要加 ByXxx,参数里已有 userId
}
正例:
{
"name": "getUser", // ← 明确,返回单个用户
"name": "searchUsers", // ← 明确,返回用户列表
"name": "deleteUser", // ← 明确,删除用户
}
6.2 参数命名
| 参数类型 | 命名模式 | 示例 |
|---|---|---|
| ID | {资源}Id |
userId、orderId |
| 名称 | {资源}Name |
userName、productName |
| 查询关键词 | query |
query(通用) |
| 筛选字段 | filter{字段} |
filterStatus、filterDateRange |
| 分页 | page、limit、offset |
page、limit |
| 排序 | sortBy、sortOrder |
sortBy、sortOrder |
反例:
{
"uid": { "type": "string" }, // ← 不够明确
"name": { "type": "string" }, // ← 不够明确,是用户名还是商品名?
"search": { "type": "string" }, // ← 动词混用,统一用 query
"size": { "type": "integer" }, // ← 不够明确,是页大小还是记录数?
}
正例:
{
"userId": { "type": "string" }, // ← 明确
"userName": { "type": "string" }, // ← 明确
"query": { "type": "string" }, // ← 明确
"limit": { "type": "integer" }, // ← 明确
}
6.3 类型设计
必填类型:
| 业务场景 | 类型 | 示例 |
|---|---|---|
| ID | string + format: uuid |
"5f8d0d55b54764421b7156d4" |
| 金额 | number + minimum: 0 |
99.99 |
| 数量 | integer + minimum: 1 |
10 |
| 日期 | string + format: date-time |
"2026-09-13T12:00:00Z" |
| 布尔 | boolean |
true、false |
| 枚举 | string + enum |
"pending"、"paid"、"shipped" |
反例:
{
"userId": { "type": "string" }, // ← 没说明格式,模型可能填任意字符串
"price": { "type": "number" }, // ← 没限制最小值,模型可能填负数
"quantity": { "type": "integer" }, // ← 没限制最小值,模型可能填 0 或负数
"status": { "type": "string" }, // ← 没说明枚举值,模型可能乱填
}
正例:
{
"userId": {
"type": "string",
"format": "uuid",
"description": "24 字符 UUID,例如:5f8d0d55b54764421b7156d4。",
},
"price": {
"type": "number",
"minimum": 0,
"description": "订单金额(元),必须 ≥ 0。",
},
"quantity": {
"type": "integer",
"minimum": 1,
"description": "商品数量,必须 ≥ 1。",
},
"status": {
"type": "string",
"enum": ["pending", "paid", "shipped", "cancelled"],
"description": "订单状态。",
},
}
七、幂等性设计
7.1 幂等的定义
幂等操作:多次执行结果相同,不会产生副作用。
| 操作 | 幂等吗 | 理由 |
|---|---|---|
getUser(userId: string) |
✅ 是 | 查询操作,不改变数据库 |
searchUsers(query: string) |
✅ 是 | 查询操作,不改变数据库 |
createUser(params) |
❌ 否 | 每次创建新用户 |
updateUser(userId, params) |
✅ 是 | 多次更新到同一状态 |
deleteUser(userId) |
✅ 是 | 删除后已无数据,再删不变 |
sendEmail(to, subject, body) |
❌ 否 | 每次发送新邮件 |
7.2 幂等键设计
**非幂等操作(创建、发送)**必须支持幂等键,防止重试时重复执行。
{
"name": "createOrder",
"description": "创建订单。此操作有副作用:会扣减库存并创建订单记录。幂等键防止重复创建。",
"parameters": {
"idempotencyKey": {
"type": "string",
"description": "幂等键(UUID),相同键的重复请求会返回第一次创建的订单,不会重复创建。建议用客户端生成的 UUID。",
},
"productId": { "type": "string" },
"quantity": { "type": "integer" },
},
}
服务端实现:
// 幂等键存储(Redis)
const idempotencyKeys = new Map<string, { result: Order; timestamp: number }>();
async function createOrder(params: {
idempotencyKey: string;
productId: string;
quantity: number;
}): Promise<Order> {
// 检查幂等键
const cached = idempotencyKeys.get(params.idempotencyKey);
if (cached) {
return cached.result; // ← 返回缓存结果,不重复执行
}
// 执行创建逻辑
const order = await createOrderInDB(params);
// 缓存结果(24 小时过期)
idempotencyKeys.set(params.idempotencyKey, { result: order, timestamp: Date.now() });
return order;
}
前端调用:
// 每次创建订单生成新的幂等键
const idempotencyKey = uuidv4();
const order = await createOrder({ idempotencyKey, productId, quantity });
7.3 幂等键放在哪一层?
| 层级 | 放在哪 | 理由 |
|---|---|---|
| 客户端 | 生成 | 唯一标识一次业务操作,跨请求保持一致 |
| 网关 | 检查 | 先查幂等键,命中则直接返回,不调用下游 |
| 服务端 | 校验 & 缓存 | 最后校验,防止绕过网关直连 |
| 数据库 | 唯一索引 | 幂等键作为唯一索引,防止并发重复插入 |
最佳实践:
- 客户端生成幂等键(UUID)
- 网关先查幂等键(Redis),命中直接返回
- 服务端最后校验幂等键,执行业务逻辑
- 数据库用幂等键做唯一索引兜底
八、端到端落地示例
6.1 坏工具改造前后对比
改造前:万能搜索工具
{
"name": "universalSearch",
"description": "搜索所有资源,包括用户、订单、产品、工单。",
"parameters": {
"searchType": {
"type": "string",
"enum": ["users", "orders", "products", "tickets"],
},
"query": { "type": "string" },
"filters": {
"type": "object",
"properties": {
"userId": { "type": "string" }, // 只对 users 有效
"orderId": { "type": "string" }, // 只对 orders 有效
"status": { "type": "string" }, // 各资源 status 含义不同
},
},
},
}
问题:
- 模型调用成功率:62%
- 参数错误率:31%
- 平均重试次数:2.3 次
改造后:拆分为 4 个单一工具
[
{
"name": "searchUsers",
"description": "搜索用户信息。支持按用户 ID 精确查询,或按姓名/邮箱模糊匹配。",
"parameters": {
"query": { "type": "string", "description": "搜索关键词,可以是用户名或邮箱的一部分。" },
"userId": { "type": "string", "description": "24 字符 UUID,与 query 二选一。" },
"limit": { "type": "integer", "description": "返回数量,默认 10。" },
},
},
{
"name": "searchOrders",
"description": "搜索订单信息。支持按订单 ID 精确查询,或按商品名称模糊匹配。",
"parameters": {
"query": { "type": "string", "description": "搜索关键词,可以是商品名称或订单号。" },
"orderId": { "type": "string", "description": "24 字符 UUID,与 query 二选一。" },
"status": {
"type": "string",
"enum": ["pending", "paid", "shipped", "cancelled"],
"description": "订单状态筛选。",
},
"limit": { "type": "integer", "description": "返回数量,默认 10。" },
},
},
// ... searchProducts, searchTickets
]
改造后指标:
- 模型调用成功率:94%
- 参数错误率:6%
- 平均重试次数:0.8 次
6.2 工具验收清单
- 粒度:每个工具参数表 ≤ 5 个字段
- 命名:动词 + 名词(
searchUsers、deleteOrder) - 描述:说清楚「干什么」,不说「怎么干」
- 必填字段:尽量少(0-1 个)
- 参数互斥:在描述中说明「二选一」
- 默认值:在描述中说明「默认 10」
- 错误返回:结构化,包含
type、field、hint - 权限标注:危险操作在描述中加
⚠️ - 副作用标注:有副作用操作在描述中说明
学习要点(应能回答)
- 为什么「万能搜索工具」往往更差? 参数表太长(10+ 字段),模型容易漏填或乱填;不同资源的过滤字段不同,模型需要「记忆」复杂规则;错误信息不够具体,模型不知道是哪个字段错了。拆分为单一职责工具后,参数表短(3-5 字段),模型容易填对。
- 工具失败时返回什么字段最有用? 结构化错误信息,包含
type(错误类型)、field(哪个字段错了)、message(人类可读信息)、hint(给模型的建议)。这样模型能根据type采取不同策略,根据field修改参数,根据hint生成正确示例。
已有相关文档(先读这些)
参考资料
- OpenAI Function Calling 文档:工具定义最佳实践
- Anthropic Tool Use 文档:
requires_approval与risk_level - MCP 协议规范:
dangerous标注 - JSON Schema 规范:参数校验标准
评论
评论加载中…