← 全部文章

Tool Schema 设计:为什么你的 Agent 总是调用错工具?

Tool Schema 不是函数类型声明,而是模型选择工具、生成参数和理解边界时共同依赖的操作说明书。

Tool Schema 设计:为什么你的 Agent 总是调用错工具?
文章目录
  1. 导语
  2. 一、先看一个看似能用的坏 Schema
  3. 二、工具名应该表达一个清晰动作
  4. 名字不要承担全部解释
  5. 三、Description 要写选择边界,不是宣传语
  6. 四、参数名要让模型知道“该填什么”
  7. 五、required、默认值和 null 不是一回事
  8. 六、Enum、范围和格式是可执行边界
  9. 七、为什么建议关闭额外字段
  10. 八、一个工具应该做多大一件事
  11. 九、不要让模型填写系统已经知道的信息
  12. 十、Schema 需要怎样测试
  13. 选择测试
  14. 参数测试
  15. 权限测试
  16. 十一、前端和后端如何共同使用 Schema
  17. 十二、用一组工具检查是否理解
  18. 十三、这一篇真正要记住的事
  19. 下一篇预告
  20. 参考资料

导语

上一篇,我们走完了一次 Tool Calling:应用提供工具定义,模型返回 Tool Call,Runtime 校验并执行,再把结果送回模型。

其中有一个环节被刻意略过了:模型怎样知道该选哪个工具,参数又该怎么填?

假设一个开发 Agent 同时拥有两个工具:

search_code
search_logs

用户说:

帮我找出 INVALID_SIGNATURE 是从哪里产生的。

它应该搜代码,还是搜运行日志?如果工具描述都只写“搜索内容”,模型只能猜。

Tool Schema 就是模型看到的操作说明书。它用工具名、用途描述和参数约束,回答三件事:

  1. 这个工具是做什么的;
  2. 什么情况下应该用它;
  3. 调用时需要提供哪些结构化参数。

Schema 设计不好,强模型也可能选错工具、漏传参数或扩大查询范围。本文会用一组“代码搜索与日志搜索”工具,完整演示怎样把模糊能力改造成清晰接口。


一、先看一个看似能用的坏 Schema

const searchTool = {
  name: 'search',
  description: '搜索信息',
  parameters: {
    type: 'object',
    properties: {
      input: { type: 'string' }
    }
  }
}

这份定义没有语法错误,却几乎没有帮助模型做决策。

模型不知道:

  • 搜索的是源码、日志、文档还是互联网;
  • input 是关键词、正则表达式还是一段自然语言;
  • 搜索范围在哪里;
  • 参数是不是必填;
  • 结果为空时意味着什么。

更麻烦的是,如果工具实现允许 input 同时表达动作和参数,它就会成为一个万能入口:

search({ input: "在生产日志里找错误并顺便删除重复记录" })

Runtime 很难对这种自由文本做可靠校验和细粒度授权。

一个能通过 JSON Schema 校验的定义,不一定是一个适合模型使用的 Tool Schema。


二、工具名应该表达一个清晰动作

工具名是模型做选择时最先看到的信号之一。

相比:

github
data_tool
handle
execute

下面这些名字更容易形成稳定边界:

get_pull_request
search_application_logs
read_file
create_review_draft

实用的命名习惯是“动词 + 对象”:

  • get:获取一个已知对象;
  • list:列出一组对象;
  • search:根据条件查找未知位置;
  • create:创建新资源;
  • update:修改已有资源;
  • delete:删除资源。

这不是为了追求英文整齐,而是让动作的副作用和返回预期更明显。

例如 get_order 与 update_order 应该分开。前者可以自动读取,后者可能需要确认。如果合成 order_tool,权限系统还要再次解析参数才能判断风险。

名字不要承担全部解释

不要为了写清边界,造出过长的名字:

search_staging_application_logs_by_exact_error_code_only

工具名负责识别,完整条件交给 description 和参数 schema。三者要协作,而不是把所有信息压进名字。


三、Description 要写选择边界,不是宣传语

下面这种描述信息量很低:

强大、智能、快速地搜索日志。

模型真正需要知道的是:什么时候用、能查什么、不能做什么。

例如:

查询指定环境中的应用运行日志。
适用于根据时间范围、服务名、请求 ID 或错误码定位运行时问题。
不用于搜索源码;搜索源码请使用 search_code。
该工具只读,不会修改日志或服务状态。

这段描述提供了四类信号:

  • 能力:查询应用日志;
  • 触发场景:定位运行时问题;
  • 反边界:不搜索源码;
  • 副作用:只读。

当两个工具容易混淆时,互相写清“不要在什么情况下使用”很有效。但不必给每个工具堆十条反例;只有真实存在选择冲突时才添加。


四、参数名要让模型知道“该填什么”

继续改造日志工具:

const searchApplicationLogs = {
  name: 'search_application_logs',
  description: [
    '查询指定环境中的应用运行日志。',
    '用于根据服务、时间范围、请求 ID 或错误码定位运行时问题。',
    '不用于搜索源码;搜索源码请使用 search_code。',
    '该工具只读。'
  ].join(' '),
  parameters: {
    type: 'object',
    properties: {
      environment: {
        type: 'string',
        enum: ['staging', 'production'],
        description: '要查询的部署环境'
      },
      service: {
        type: 'string',
        description: '服务名,例如 auth-api'
      },
      query: {
        type: 'string',
        minLength: 1,
        description: '日志查询表达式,可包含错误码或请求 ID'
      },
      startTime: {
        type: 'string',
        format: 'date-time',
        description: '查询起始时间,ISO 8601 格式'
      },
      endTime: {
        type: 'string',
        format: 'date-time',
        description: '查询结束时间,ISO 8601 格式'
      },
      limit: {
        type: 'integer',
        minimum: 1,
        maximum: 200,
        default: 50,
        description: '最多返回多少条日志'
      }
    },
    required: [
      'environment',
      'service',
      'query',
      'startTime',
      'endTime'
    ],
    additionalProperties: false
  }
}

这里每个字段只表达一个概念。

不要把几个概念塞进一个字符串:

{
  "filter": "staging auth-api last 30 minutes limit 50"
}

这种格式看起来省字段,实际上把解析工作重新推给模型或工具实现。拆成结构化参数后,Runtime 才能检查时间范围、环境和最大返回条数。


五、required、默认值和 null 不是一回事

JSON Schema 中,在 properties 里声明字段,并不自动代表它必填。必填字段要放入 required。

{
  "properties": {
    "service": { "type": "string" }
  },
  "required": ["service"]
}

还要区分三种状态:

字段缺失
字段存在但值为 null
字段存在且使用默认语义

如果 schema 只允许 string,传入 null 并不等同于省略字段。

默认值也不要只写在描述里。更稳妥的做法是:

  • schema 用 default 告诉读者和工具系统推荐值;
  • Runtime 或工具实现真正补齐默认值;
  • 日志记录补齐后的最终参数。

不同校验器不一定会自动应用 default,不能假设“写了 default 就一定改写输入”。


六、Enum、范围和格式是可执行边界

能枚举的值,不要让模型自由拼写:

{
  "environment": {
    "type": "string",
    "enum": ["staging", "production"]
  }
}

相比任意字符串,它可以拦截:

prod
online
正式环境
production-eu-secret

数值也应该有合理范围:

{
  "limit": {
    "type": "integer",
    "minimum": 1,
    "maximum": 200
  }
}

否则模型可能请求返回十万条日志,既慢又占满上下文。

格式约束能表达日期时间、URI 等常见结构,但要确认你使用的校验器是否实际启用了对应 format 检查。Schema 是契约,Runtime 使用的验证行为才是最终事实。


七、为什么建议关闭额外字段

JSON Schema 默认允许未声明的额外属性。也就是说,只写 properties 时,这类输入可能仍会通过:

{
  "service": "auth-api",
  "query": "INVALID_SIGNATURE",
  "deleteAfterRead": true
}

对于边界明确的工具,通常可以设置:

{
  "additionalProperties": false
}

这样模型多传字段时,Runtime 会明确拒绝,而不是静默忽略或把未知字段传给下游。

不过复杂 schema 使用 allOf 等组合关键字时,additionalProperties 的作用域容易产生意外。不要机械添加后就结束;要用真实样例验证合法和非法输入。


八、一个工具应该做多大一件事

工具太大,模型难选择,权限也难控制:

github({ action, payload })

工具太碎,模型又需要在几十个近似动作中犹豫:

get_issue_title
get_issue_body
get_issue_author
get_issue_labels

更合适的边界通常对应一个可理解、可授权、可测试的业务动作:

get_issue
list_issue_comments
create_issue_comment
update_issue_labels

判断是否应该拆分,可以问四个问题:

  1. 不同动作的副作用是否不同?
  2. 是否需要不同权限或确认策略?
  3. 参数和错误类型是否明显不同?
  4. 模型是否经常只需要其中一部分能力?

任意两项差异很大时,拆开通常更清晰。

但这不是绝对公式。最终要用真实任务集测试,而不是只凭接口美感判断。


九、不要让模型填写系统已经知道的信息

假设用户已在产品里打开仓库 acme/web-app,服务端也知道当前账号和组织。

这时工具参数未必需要再次暴露:

{
  "userId": "?",
  "tenantId": "?",
  "accessToken": "?",
  "owner": "?",
  "repo": "?"
}

可信信息应从执行上下文注入:

async function execute(
  args: SearchCodeArgs,
  context: ToolContext
) {
  return codeSearch.search({
    tenantId: context.tenantId,
    repository: context.currentRepository,
    query: args.query
  })
}

这样既减少模型出错,也避免它把一次会话里的资源标识带到另一位用户或另一个租户。

原则是:模型只填写完成语义动作所需、且确实需要它判断的参数;身份、凭证和已确定的资源上下文由应用提供。


十、Schema 需要怎样测试

Schema 不是写完看着合理就结束。至少要准备三组测试。

选择测试

给模型一组真实用户请求,检查它是否选对工具:

“找出这段函数在哪里被调用” → search_code
“查 request_id=abc 的线上错误” → search_application_logs

还要加入容易混淆的反例。

参数测试

检查正常、边界和非法参数:

缺少必填字段
时间范围颠倒
limit 超过上限
多出未知字段
environment 使用未允许值

JSON Schema 负责结构约束;像“startTime 必须早于 endTime”这样的跨字段业务规则,通常仍需要额外代码验证。

权限测试

同一份合法参数,在不同用户和环境下可能得到不同结果:

开发者查询 staging → 允许
开发者查询 production → 需要额外权限
外部用户查询内部服务 → 拒绝

Schema 不能替代授权测试。


十一、前端和后端如何共同使用 Schema

后端把 Schema 用于模型提示、参数校验和工具路由。前端也可以从同一份元数据中获得帮助:

  • 展示即将执行的工具名称;
  • 把关键参数转成确认摘要;
  • 为人工修正参数生成表单;
  • 在提交前进行基础校验。

但不要直接把原始 Schema 无脑渲染给用户。query 对模型可能很清楚,对普通用户却需要显示成“日志查询条件”。产品层可以维护安全、可本地化的展示元数据。

同一份底层契约可以服务多端,但模型描述、开发者文档和用户界面不必使用完全相同的文案。


十二、用一组工具检查是否理解

现在设计一个订单查询 Agent,已有三个工具:

get_order
list_fulfillment_events
cancel_order

试着回答:

  1. 为什么不能合成一个 order_tool?
  2. cancel_order 的描述应该怎样明确副作用?
  3. 订单所属租户应该由模型传入吗?
  4. reason 是必填、可空还是可省略?依据是什么?
  5. 哪些非法参数可以由 JSON Schema 拒绝,哪些仍需业务代码检查?

如果你能根据权限、动作边界和业务语义回答,而不只是堆更多字段,就已经掌握了 Schema 设计的核心。


十三、这一篇真正要记住的事

Tool Schema 不只是“把 TypeScript 类型翻译成 JSON”。它同时服务三个目标:

  • 帮模型在相似能力中选对工具;
  • 把自然语言意图约束成可校验参数;
  • 给 Runtime 留下权限、测试和审计的清晰边界。

设计时优先检查:

名字是否表达明确动作
描述是否说明使用场景与反边界
字段是否一项一义
必填、枚举、范围和额外字段是否受控
可信身份是否来自服务端上下文
读写动作是否因权限和副作用而拆分

Schema 解决的是“模型怎样提出一份清楚的调用请求”。下一篇继续看另一半:工具执行完后,怎样把结果返回给模型,才不会让它误判、浪费上下文或陷入重试。


下一篇预告

下一篇:Tool Result 设计:Agent 如何看懂工具返回?


参考资料