一段先讲心智模型
写 tool schema 的时候,大多数人当成在写 API 文档——给后端同事看的。这个心智在 LLM 时代是错的。
LLM 在每次决定"调哪个工具、传什么参数"的时候,唯一能看到的就是你写的 schema。它没有源码、没有测试、没有 changelog。schema 写糊了,它就猜;猜错了,就反复修复浪费 token;修不好,用户就崩。
下面 7 条来自真实项目的反例-正例对照。
1. description 写"什么时候用",不是"做什么"
反例:
{
"name": "list_users",
"description": "获取用户列表"
}
正例:
{
"name": "list_users",
"description": "当用户问'有哪些用户'/'多少人注册了'/'最近谁加入'/'按公司筛用户'这类问题时调用。返回当前租户下的用户清单,可按 status / created_at / company_id 过滤。不要用于查询单个用户详情——那种用 get_user。"
}
差别在哪?正例告诉 LLM 触发场景的语言模式和与相邻工具的边界。LLM 在多个相似工具里选困难症,靠的是 description 里的负面提示。
2. 必填和可选严格区分
不要给一堆可选参数让 LLM 猜该传哪个。它一定会全传,或者全不传,没有中间状态。
反例:搜索接口 12 个参数全 optional。LLM 看不出哪些是核心、哪些是 advanced。
正例:核心 2 个 required(query、limit),其余塞进 filters: object 嵌套对象,并在 description 里说"高级过滤,绝大多数场景不需要"。
3. enum 优于 string
反例:
{ "priority": { "type": "string", "description": "优先级" } }
LLM 会传 "高"、"high"、"P0"、"urgent"、"important"——五种全见过。
正例:
{
"priority": {
"type": "string",
"enum": ["urgent", "normal", "low"],
"description": "urgent 用于阻塞性问题;normal 是默认;low 用于改进类需求"
}
}
加 enum 的同时给每个值的语义。LLM 会按语义匹配场景,不会瞎挑。
4. type: object 嵌套时给 example
JSON Schema 的 examples 字段是 LLM 学习嵌套结构的最强信号——比 properties 描述强。
{
"filters": {
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["active", "archived"] },
"created_after": { "type": "string", "format": "date-time" }
},
"examples": [
{ "status": "active", "created_after": "2026-01-01T00:00:00Z" },
{ "status": "archived" }
]
}
}
我们做过一个对照:同一个嵌套对象 schema,加 examples 后参数错误率从 18% 掉到 4%。这不是玄学,是 LLM 训练数据里见过太多 "examples 字段 = 真实输入长这样" 的模式。
5. 不要重名 tool
反例:同一个 toolset 里有 get_user 和 fetch_user,前者按 id 查,后者按 email 查。LLM 会反复在两者之间犹豫,甚至每次随机挑。
正例:合并成 get_user,参数里 lookup_by: "id" | "email"。或者起反差大的名字 get_user_by_id / find_user_by_email,让 LLM 一眼分清。
写 tool 命名的时候我有个土办法:把所有工具名列出来,盖住 description,自己能不能从名字判断什么时候用哪个。如果你都判断不出来,LLM 也不行。
6. 返回值结构稳定
反例:成功时返字符串 "OK",失败时返 { error: "..." }。LLM 拿到结果不知道怎么处理后续——是当作文本拼回答,还是当作 JSON 解析。
正例:永远返同一个结构。
type ToolResult<T> =
| { ok: true; data: T }
| { ok: false; error: ErrorCode; hint: string }
ok 字段是 LLM 决策分支的锚点。在 description 里明确写"调用后先检查 ok 字段"。
7. 错误返回也要结构化
反例:"user not found"
正例:
{
"ok": false,
"error": "USER_NOT_FOUND",
"hint": "确认 user_id 是否正确,或调 list_users 查可用 id",
"retryable": false
}
三个字段都有用:
error是机器码,LLM 能据此分类(NOT_FOUND vs RATE_LIMITED 后续行为完全不同)hint是给 LLM 的下一步建议retryable告诉 LLM 这个错值不值得重试
我们在第 6 篇周刊(5 轮自动修复模式)里讲过,错误是修复链路的输入。错误返回的质量直接决定修复链路的成本。
一段完整 schema 例子
把上面 7 条揉到一起:
const listUsers = {
name: 'list_users',
description:
"当用户问'有哪些用户'/'多少注册'/'按公司筛用户'时调。" +
'不要用于查单个用户详情(那种用 get_user)。' +
'返回值检查 ok 字段,ok=true 取 data,ok=false 看 hint。',
parameters: {
type: 'object',
required: ['query', 'limit'],
properties: {
query: {
type: 'string',
description: '用户名 / email 模糊匹配,留空返回全部',
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
description: '默认 20。LLM 不要主动调到 100 除非用户明确要"全部"',
},
filters: {
type: 'object',
description: '高级过滤。绝大多数场景不需要',
properties: {
status: { type: 'string', enum: ['active', 'archived'] },
created_after: { type: 'string', format: 'date-time' },
},
examples: [{ status: 'active' }],
},
},
},
}
MCP 是什么
最近 Anthropic 推的 Model Context Protocol(MCP)把工具协议标准化了——schema 思路和上面 7 条一致,但格式更规范、跨厂商互通。Claude、Cursor、Windsurf 都开始原生支持。如果你在做内部 toolset,直接按 MCP 写,未来切换 LLM 厂商不用重写。
但 MCP 的标准化解决的是"格式"问题,不是"质量"问题。description 写糊、enum 漏掉、example 缺失,再标准的协议也救不了。
通用性
这些经验对 OpenAI、Claude、Gemini、Qwen 都通用。底层逻辑很简单:这些 LLM 都从你给的 schema 学决策。它们对 schema 的"理解力"差异比你以为的小,差异主要在生成长度、推理深度。schema 的好坏对所有家族都成立。
最后一个真心建议:写完 schema,自己拿一段用户提问 + schema 喂给 ChatGPT,看它怎么填参数。它填错的地方,就是你 schema 的 bug。这个土办法比 unit test 还管用。