Agent 工程体系 · 第 9/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent 工具 Schema:命名、描述、参数、枚举和可发现性

Agent 的工具调用不是“模型输出一段 JSON,程序再把它转成函数调用”这么简单。模型能否选对工具、填对参数、避免危险操作,首先取决于工具如何被声明。

这里的 工具 Schema,指描述工具能力、输入结构和取值约束的机器可读契约。它通常以 JSON Schema 或其受限子集表达,并由模型侧、编排器和工具执行器共同消费。

一个完整的工具调用链至少包含四个角色:

  1. 模型:根据用户目标选择工具并生成调用参数。
  2. Schema:告诉模型“有哪些工具、什么时候用、参数长什么样”。
  3. 工具执行器:解析、校验、授权并实际执行调用。
  4. 外部系统:数据库、HTTP 服务、文件系统或业务 API。

OpenAI 的 Function Calling 将工具定义为模型可以使用的功能,工具输入通常由 JSON Schema 描述;MCP 则通过 tools/list 暴露工具列表,并为每个工具提供名称、描述和 inputSchema。(developers.openai.com)

因此,Schema 不是注释,也不是仅供 IDE 补全的类型声明。它是模型决策空间的一部分:

工具选择质量f(工具命名,工具描述,参数语义,值域约束,可发现性)\text{工具选择质量} \approx f(\text{工具命名}, \text{工具描述}, \text{参数语义}, \text{值域约束}, \text{可发现性})

如果 Schema 只保证“JSON 能解析”,却没有表达业务语义,模型仍然可能稳定地产生错误调用。


一、先建立工具 Schema 的完整边界

以一个“查询订单”的工具为例:

{
  "name": "commerce_get_order",
  "description": "根据订单号查询当前用户有权访问的订单状态、金额和物流摘要。仅用于查询,不会修改订单。",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "订单号,例如 ORD-20260901-001。不要填写商品名称、手机号或自然语言描述。"
      },
      "include_items": {
        "type": ["boolean", "null"],
        "description": "是否返回订单中的商品明细。传 null 表示使用系统默认值。"
      }
    },
    "required": ["order_id", "include_items"],
    "additionalProperties": false
  },
  "strict": true
}

这个定义包含五个不同层次:

层次 作用
name 工具的机器可调用标识
description 工具的能力边界、适用场景和副作用
parameters 输入对象的结构
参数 description 每个字段的业务含义和填写方式
enumtyperequired 输入值域和结构约束

其中,name 解决“调用谁”,description 解决“什么时候调用”,参数 Schema 解决“传什么”,枚举解决“只能传哪些离散值”,可发现性解决“模型是否能在当前上下文中看到并理解这个工具”。

这几个概念不能互相替代:

  • 名字清楚但没有描述,模型不知道适用边界。
  • 描述详细但参数宽松,模型可能填出无法执行的值。
  • 参数严格但工具没有被加载,模型根本无法选择它。
  • 工具已被发现但名称冲突,模型可能选择错误的同名能力。

二、命名:名称是路由键,不是营销标题

2.1 工具名称的两个使用者

工具名称至少同时服务于两个系统:

  • 模型用它区分工具;
  • 执行器用它查找处理函数。

因此工具名称必须同时满足:

name可稳定生成的字符串可唯一路由的标识可读的语义标签\text{name} \in \text{可稳定生成的字符串} \cap \text{可唯一路由的标识} \cap \text{可读的语义标签}

名称不是给人看的展示标题。它更接近 RPC 方法名或 API 路由。

推荐使用:

<领域>_<动作>_<对象>

例如:

crm_get_customer
crm_list_open_orders
billing_create_refund
shipping_get_tracking

OpenAI 的文档也建议使用 namespace 将相关工具按领域分组,例如 crmbillingshipping,以帮助模型在多个系统之间区分相似能力。(developers.openai.com)

2.2 动作必须表达真实副作用

下面两组名称看起来都能工作,但语义风险不同:

order_update
order_cancel
order_delete
order_preview_cancellation
order_submit_cancellation
order_confirm_cancellation

order_update 把多个可能的业务动作压缩成了一个模糊入口。模型可能把“修改收货地址”“取消订单”和“修改备注”都映射到同一个工具。

如果工具会产生外部副作用,动作名应尽量直接表达副作用:

  • create:创建资源;
  • update:修改资源;
  • delete:删除资源;
  • send:发送消息;
  • refund:退款;
  • approve:审批;
  • preview:只计算或预览,不提交;
  • confirm:确认已经展示给用户的动作。

不能把有副作用的工具伪装成查询工具:

get_order

内部却执行:

def get_order(order_id):
    mark_order_as_viewed(order_id)
    trigger_customer_notification(order_id)
    return load_order(order_id)

这会破坏模型对工具行为的预测。工具名称和描述应当让模型能够区分:

read-onlystate-changing\text{read-only} \neq \text{state-changing}

2.3 名称冲突会造成“语义路由错误”

假设工具列表同时存在:

get_customer

以及:

get_customer

但分别来自 CRM 和客服系统。

如果协议层允许名称重复,执行器必须额外依赖 server、namespace 或工具来源进行路由;如果协议层或客户端假设名称全局唯一,则后注册的工具可能覆盖先注册的工具。

更稳妥的形式是:

crm_get_customer
support_get_customer

错误示例:

do_it
handle_request
query
process

这些名称缺少对象和动作,模型需要从描述中猜测全部语义,工具数量一多,选择错误会显著增加。


三、描述:不是“介绍”,而是模型的决策规则

3.1 工具描述回答四个问题

一个可用的工具描述至少需要回答:

  1. 它做什么?
  2. 什么时候应该使用?
  3. 什么时候不应该使用?
  4. 它是否产生副作用?

推荐结构:

[动作对象] + [适用条件] + [关键输入] + [返回内容] + [副作用/限制]

例如:

根据订单号查询当前用户有权访问的订单状态、金额和物流摘要。
仅用于读取;不会取消、修改或支付订单。
当用户询问订单进度、订单状态或物流信息时使用。
如果用户只提供商品名称而没有订单号,先向用户索要订单号。

相比之下,下面的描述信息不足:

查询订单

它没有说明:

  • 是否只能查询当前用户的订单;
  • 是否支持商品名称;
  • 是否返回物流;
  • 是否会改变状态;
  • 缺少订单号时是否可以调用;
  • 是否适合处理取消订单。

3.2 描述中的“何时不用”很重要

工具选择本质上是一个分类问题。对于用户请求 xx,模型需要在工具集合 TT 中选择:

t=argmaxtTP(tx,schema)t^* = \arg\max_{t \in T} P(t \mid x, \text{schema})

描述不仅提高目标工具的匹配概率,也应该降低相邻工具的匹配概率。

例如有两个工具:

{
  "name": "calendar_find_free_slots",
  "description": "查找参与者共同空闲的时间段。只读,不会创建或修改日历事件。"
}
{
  "name": "calendar_create_event",
  "description": "创建日历事件并邀请参与者。会产生外部副作用;只有在用户明确确认时间、标题和参与者后使用。"
}

如果第一个工具只写“查询日历”,第二个工具只写“创建事件”,模型仍可能在用户说“帮我安排明天下午三点开会”时直接创建事件。描述中的副作用条件可以把“查找时间”和“提交事件”分成两个不同阶段。

3.3 描述不能承担硬约束

以下写法是常见误解:

{
  "name": "billing_refund",
  "description": "金额必须小于等于 1000 元"
}

如果 Schema 没有表达金额类型和范围,执行器仍然可能收到:

{
  "amount": 999999
}

描述是自然语言提示,不是可靠的业务验证。硬约束应同时出现在 Schema 和执行器中:

{
  "amount": {
    "type": "number",
    "minimum": 0.01,
    "maximum": 1000,
    "description": "退款金额,单位为人民币元。"
  }
}

即使模型遵守了 Schema,执行器仍需根据真实账户余额、订单状态和授权主体再次校验。Schema 约束的是输入形状,不等于授权,也不等于业务规则的完整实现。


四、参数 Schema:从 JSON 形状到业务语义

4.1 根节点通常是对象

工具调用参数通常是一个 JSON 对象:

{
  "type": "object",
  "properties": {},
  "required": [],
  "additionalProperties": false
}

它表达的是:

aObjecta \in \text{Object}

其中 aa 是一次工具调用的参数对象。

例如:

{
  "type": "object",
  "properties": {
    "query": {
      "type": "string",
      "minLength": 1
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50
    }
  },
  "required": ["query", "limit"],
  "additionalProperties": false
}

合法输入:

{
  "query": "退款政策",
  "limit": 10
}

非法输入:

{
  "query": "",
  "limit": 100,
  "debug": true
}

失败原因分别是:

  1. query 不满足 minLength: 1
  2. limit 超过 maximum: 50
  3. debug 不在 properties 中,且不允许额外字段。

4.2 required 表达结构必需,不表达业务必填的全部含义

在普通 JSON Schema 中,字段是否出现在对象中由 required 决定:

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

表示 units 可以缺失。

但在 OpenAI Function Calling 的 strict mode 中,要求 properties 中的所有字段都标记为 required,可选语义可以通过允许 null 表达。additionalProperties 也需要设为 false。(developers.openai.com)

因此:

{
  "type": "object",
  "properties": {
    "units": {
      "type": ["string", "null"],
      "enum": ["celsius", "fahrenheit"]
    }
  },
  "required": ["units"],
  "additionalProperties": false
}

表达的是:

  • units 这个键必须出现;
  • 值可以是 "celsius"
  • 值可以是 "fahrenheit"
  • 值也可以是 null
  • 但不能省略该键。

这是“字段存在但值可为空”,不是“字段可以省略”。

错误写法:

{
  "properties": {
    "units": {
      "type": ["string", "null"],
      "enum": ["celsius", "fahrenheit"]
    }
  },
  "required": []
}

在普通 JSON Schema 中它可能有效,但在要求 strict mode 的调用接口中可能被拒绝,或者被降级为非严格调用。OpenAI 文档明确区分了严格和 best-effort 调用,并说明不同 API 的默认行为可能不同。(developers.openai.com)

4.3 additionalProperties: false 的意义

如果没有额外字段限制:

{
  "type": "object",
  "properties": {
    "order_id": {
      "type": "string"
    }
  }
}

以下输入可能仍通过基础结构校验:

{
  "order_id": "ORD-001",
  "is_admin": true,
  "force": true
}

执行器如果直接把整个对象传给下游系统,可能出现:

  • 下游 SDK 忽略未知字段;
  • 下游 API 将未知字段解释为特殊选项;
  • 业务代码未来新增字段后意外激活旧调用;
  • 攻击者利用“看似无害”的额外参数绕过边界。

所以严格工具调用一般采用:

"additionalProperties": false

但要注意,它只禁止 Schema 中未声明的键,并不自动阻止参数值中的恶意字符串。例如:

{
  "path": "../../etc/passwd"
}

字段名合法,不代表业务语义安全。


五、枚举:把开放文本压缩为有限状态空间

5.1 枚举的形式化含义

enum 表示字段值属于有限集合:

vE={e1,e2,,en}v \in E = \{e_1, e_2, \ldots, e_n\}

例如:

{
  "status": {
    "type": "string",
    "enum": ["pending", "paid", "cancelled"],
    "description": "订单状态过滤条件。"
  }
}

允许值集合是:

{"pending", "paid", "cancelled"}

以下值都不合法:

"Pending"
"已支付"
"success"
"all"

除非它们被明确加入枚举集合。

5.2 枚举值应使用稳定机器值

推荐:

{
  "sort": {
    "type": "string",
    "enum": ["created_at_asc", "created_at_desc"],
    "description": "结果排序方式。"
  }
}

不推荐:

{
  "sort": {
    "type": "string",
    "enum": ["按创建时间升序", "按创建时间降序"]
  }
}

机器值应该:

  • 稳定;
  • 无歧义;
  • 与后端状态机一致;
  • 不依赖语言;
  • 不包含容易混淆的同义词。

展示文本可以放在描述中,或由应用层做本地化映射:

SORT_LABELS = {
    "created_at_asc": "按创建时间升序",
    "created_at_desc": "按创建时间降序",
}

5.3 不要把枚举集合做得过大

枚举不是把所有可能文本罗列出来:

{
  "city": {
    "type": "string",
    "enum": [
      "杭州",
      "上海",
      "北京",
      "广州",
      "深圳",
      "..."
    ]
  }
}

城市数量可能动态变化。把动态数据硬编码到 Schema 会带来:

  • Schema 体积膨胀;
  • 工具定义频繁变化;
  • 模型上下文成本增加;
  • 旧 Schema 与真实数据不一致。

更合理的拆分是:

search_city(query: string)
create_delivery(order_id: string, city_id: string)

第一个工具负责发现动态资源,第二个工具只接受稳定的 city_id

5.4 枚举不能代替跨字段约束

以下 Schema:

{
  "type": "object",
  "properties": {
    "payment_method": {
      "type": "string",
      "enum": ["card", "bank_transfer"]
    },
    "card_number": {
      "type": ["string", "null"]
    }
  },
  "required": ["payment_method", "card_number"],
  "additionalProperties": false
}

允许这种逻辑上矛盾的输入:

{
  "payment_method": "card",
  "card_number": null
}

也允许:

{
  "payment_method": "bank_transfer",
  "card_number": "4111111111111111"
}

如果使用的 Schema 方言和调用平台支持条件组合,可以用 oneOf 表达互斥结构:

{
  "type": "object",
  "oneOf": [
    {
      "properties": {
        "payment_method": {
          "const": "card"
        },
        "card_number": {
          "type": "string",
          "minLength": 12
        }
      },
      "required": ["payment_method", "card_number"],
      "additionalProperties": false
    },
    {
      "properties": {
        "payment_method": {
          "const": "bank_transfer"
        },
        "card_number": {
          "type": "null"
        }
      },
      "required": ["payment_method", "card_number"],
      "additionalProperties": false
    }
  ]
}

但不能假定所有模型接口都完整支持 JSON Schema。OpenAI 文档明确说明 strict mode 只支持 JSON Schema 的一部分。(developers.openai.com)

生产中常见取舍是:

  1. Schema 表达模型和接口都稳定支持的约束;
  2. 执行器补充跨字段业务校验;
  3. 对无法表达的条件返回结构化错误,而不是静默修正。

六、可发现性:工具存在不等于模型能使用

6.1 可发现性的定义

可发现性是指模型在需要完成某个目标时,能够在当前上下文中看到、区分并正确理解某个工具的能力。

它包含三个条件:

D(t)=V(t)S(t)C(t)D(t) = V(t) \land S(t) \land C(t)

其中:

  • V(t)V(t):工具对模型可见;
  • S(t)S(t):工具描述和 Schema 足以区分它;
  • C(t)C(t):工具当前可调用,例如权限、租户、环境和生命周期都满足。

只有名称和描述写得好,但工具没有传给模型,V(t)=0V(t)=0,模型仍不可能调用它。

6.2 全量暴露与按需发现

当工具数量较少时,可以把全部工具定义放进模型请求:

用户请求
  ↓
加载全部工具 Schema
  ↓
模型选择工具
  ↓
执行器调用

工具数量较多时,全量暴露会造成:

  • 上下文变长;
  • 相似工具增多;
  • 描述之间发生竞争;
  • Schema 修改影响整个请求;
  • 工具选择不稳定。

OpenAI 的工具搜索能力允许延迟加载较少使用的工具;官方文档说明,在工具很多或 Schema 很大时,可以先暴露工具搜索入口,再按需加载工具,且该能力只适用于支持它的模型版本。(developers.openai.com)

按需发现的基本流程是:

sequenceDiagram
    participant U as 用户
    participant M as 模型
    participant D as 工具目录
    participant X as 执行器
    participant S as 外部系统

    U->>M: 提出目标
    M->>D: 搜索相关工具
    D-->>M: 返回名称、描述和 Schema
    M->>X: 生成工具调用
    X->>X: 解析、校验、授权
    X->>S: 执行业务操作
    S-->>X: 返回结果或错误
    X-->>M: 工具结果
    M-->>U: 生成最终回答

关键点是:工具搜索本身只是发现机制,不是授权机制。目录返回了工具,不代表当前用户被允许执行它。

6.3 MCP 的工具发现模型

在 MCP 中,客户端可以通过 tools/list 请求服务器提供的工具列表。返回结果包含工具数组,并且支持分页游标;工具调用参数应符合工具的 inputSchema。(modelcontextprotocol.io)

抽象后的发现响应类似:

{
  "tools": [
    {
      "name": "crm_get_customer",
      "description": "根据客户 ID 查询当前租户内可访问的客户资料。",
      "inputSchema": {
        "type": "object",
        "properties": {
          "customer_id": {
            "type": "string"
          }
        },
        "required": ["customer_id"],
        "additionalProperties": false
      }
    }
  ],
  "nextCursor": "opaque-next-page-token"
}

这里需要区分两个层次:

  • MCP 的 tools/list 是协议层的工具发现;
  • Agent 运行时是否把这些工具交给模型,是宿主应用的编排决策。

一个 MCP Server 可以暴露 100 个工具,但宿主应用只给模型加载其中 5 个。反过来,宿主应用也可能把多个 MCP Server 的工具合并后再做 namespace 处理。

6.4 动态工具列表必须有缓存失效策略

工具发现通常不是每一轮都重新获取。否则模型请求会反复承担发现开销。

可以为工具目录维护:

(server_id, tenant_id, permission_scope, schema_version)

作为缓存键,而不能只用:

(server_id)

因为工具列表可能依赖:

  • 当前租户;
  • 当前用户;
  • OAuth scope;
  • 环境;
  • 功能开关;
  • 工具版本。

如果权限变化后仍复用旧缓存,模型可能看到用户已经无权使用的工具。即使执行器最终拒绝调用,也会导致不必要的错误回合,并可能暴露工具名称和业务能力。

MCP 的新版本为列表和资源读取结果增加了 ttlMscacheScope,帮助客户端判断缓存时长和缓存是否可以跨用户共享。(blog.modelcontextprotocol.io)


七、一个完整工具定义:从错误版本到严格版本

假设需求是:根据订单号查询物流信息,并允许指定返回语言。

7.1 错误版本

{
  "name": "query",
  "description": "查询物流",
  "parameters": {
    "type": "object",
    "properties": {
      "id": {
        "type": "string"
      },
      "lang": {
        "type": "string"
      }
    }
  }
}

这个版本有五类问题:

  1. query 无法区分订单查询、客户查询和商品查询;
  2. id 没有说明是订单号、物流单号还是数据库 ID;
  3. lang 没有取值范围;
  4. 没有 required,模型可能遗漏关键参数;
  5. 没有 additionalProperties: false,额外字段可能进入执行器。

7.2 改进版本

{
  "type": "function",
  "name": "shipping_get_tracking",
  "description": "根据订单号查询当前用户有权访问的物流状态、承运商和最近一条轨迹。仅用于读取,不会修改订单或联系承运商。当用户询问订单配送进度、物流状态或预计送达时间时使用。",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {
        "type": "string",
        "description": "平台订单号,例如 ORD-20260901-001。不要填写物流单号、商品名称或手机号。",
        "pattern": "^ORD-[0-9]{8}-[0-9]{3,}$"
      },
      "language": {
        "type": ["string", "null"],
        "enum": ["zh-CN", "en-US"],
        "description": "物流状态的返回语言。传 null 表示使用用户会话语言。"
      }
    },
    "required": ["order_id", "language"],
    "additionalProperties": false
  },
  "strict": true
}

这里的每条约束都有明确作用:

  • shipping_get_tracking 消除工具路由歧义;
  • 工具描述区分“查询物流”和“修改订单”;
  • order_id 的描述避免把物流单号传进来;
  • pattern 约束订单号的基本格式;
  • language 使用枚举限制离散取值;
  • null 表达可选语义,同时满足 strict mode 对字段存在性的要求;
  • additionalProperties: false 阻止未声明字段;
  • strict: true 要求调用更可靠地符合 Schema,而不是仅做 best-effort 生成。(developers.openai.com)

7.3 执行器仍然必须二次校验

模型返回:

{
  "order_id": "ORD-20260901-001",
  "language": "zh-CN"
}

执行器至少要经过以下状态:

stateDiagram-v2
    [*] --> Received: 收到工具调用
    Received --> Decoded: JSON 解码成功
    Received --> Rejected: JSON 非法
    Decoded --> SchemaValidated: Schema 校验成功
    Decoded --> Rejected: 类型/枚举/额外字段错误
    SchemaValidated --> Authorized: 身份和权限校验成功
    SchemaValidated --> Rejected: 授权失败
    Authorized --> Executing: 调用物流系统
    Executing --> Succeeded: 返回结果
    Executing --> Failed: 超时/网络/业务错误
    Succeeded --> [*]
    Failed --> [*]
    Rejected --> [*]

Schema 校验只证明:

参数结构合法

它不能证明:

订单存在
订单属于当前用户
当前用户有权查看物流
订单已发货
物流服务可用

因此执行器不能写成:

handler(**json.loads(arguments))

而应当在边界处重新验证:

def execute_shipping_get_tracking(arguments, user):
    args = decode_json(arguments)
    validate_schema(args, TRACKING_SCHEMA)

    order = order_repository.get(args["order_id"])
    if order is None:
        return tool_error("ORDER_NOT_FOUND", "订单不存在")

    if order.owner_id != user.id:
        return tool_error("FORBIDDEN", "无权访问该订单")

    return tracking_service.get_tracking(
        order_id=order.id,
        language=args["language"] or user.locale,
    )

这里 validate_schema 防止结构错误,业务查询和权限判断防止语义越权。两者缺一不可。


八、参数描述必须解释“值的语义”,而不仅是类型

下面两个字段在 JSON Schema 层面完全相同:

{
  "type": "integer"
}

但业务语义可能完全不同:

{
  "timeout": {
    "type": "integer",
    "description": "超时时间,单位为秒,范围 1 到 30。"
  }
}
{
  "timeout": {
    "type": "integer",
    "description": "任务截止时间距当前时间的秒数,不是 HTTP 请求超时。"
  }
}

如果只写类型,模型可能把毫秒当秒,或者把绝对时间戳当成相对时长。

参数描述应该明确:

  • 单位:秒、毫秒、元、分、件;
  • 标识类型:订单号、资源 ID、用户名还是展示名称;
  • 时间基准:UTC、用户时区还是服务器时区;
  • 是否允许空值;
  • 默认值如何解释;
  • 字符串是否需要编码;
  • 是否支持自然语言;
  • 是否有格式示例;
  • 不应传什么。

例如:

{
  "start_time": {
    "type": "string",
    "format": "date-time",
    "description": "查询起始时间,必须使用 ISO 8601 格式并带时区,例如 2026-09-01T00:00:00+08:00。不要传 Unix 时间戳。"
  }
}

示例不是装饰。对于格式复杂但合法空间很大的字段,示例可以显著缩小模型的解释空间。


九、Schema 演化:新增字段不一定向后兼容

Schema 版本变化至少影响三方:

  1. 生成调用的模型;
  2. 校验调用的执行器;
  3. 处理工具结果的编排器。

9.1 新增必填字段是破坏性变更

旧版本:

{
  "required": ["order_id"]
}

新版本:

{
  "required": ["order_id", "region"]
}

旧模型仍然可能只生成:

{
  "order_id": "ORD-001"
}

结果是调用失败。

更兼容的做法是先新增可为空字段:

{
  "properties": {
    "region": {
      "type": ["string", "null"],
      "enum": ["cn-east", "cn-south"]
    }
  },
  "required": ["order_id", "region"]
}

执行器对 null 使用默认路由,待所有调用方完成迁移后,再考虑收紧约束。

9.2 删除枚举值也是破坏性变更

旧版本:

"enum": ["pending", "paid", "cancelled"]

新版本删除 "pending" 后,旧缓存、重试消息或延迟任务仍可能携带该值。

如果状态已经不再支持,更安全的迁移方式是:

  1. 保留旧值一段兼容窗口;
  2. 描述中标记不再生成;
  3. 执行器把旧值映射到明确错误或迁移状态;
  4. 等待旧调用完全消失后再删除。

9.3 名称重命名不是普通重构

将:

billing_refund

改成:

billing_create_refund

会影响:

  • 模型工具选择;
  • 执行器路由表;
  • 日志和指标聚合;
  • 录制回放数据;
  • 审计规则;
  • 用户确认策略。

可以采用双注册迁移:

billing_refund              # 旧名称,标记 deprecated
billing_create_refund       # 新名称

旧名称的执行器内部转发到新实现,但不要同时让模型长期看到两个语义相同的工具,否则会增加选择竞争。迁移完成后,从模型可见列表中移除旧名称,但保留一段时间的后端兼容路由。


十、并行工具调用会放大 Schema 的副作用风险

某些支持的模型可能在一次响应中产生多个工具调用;OpenAI 文档说明可以使用 parallel_tool_calls: false 禁止这种行为,使一轮中最多产生零个或一个工具调用。(developers.openai.com)

如果工具都是只读操作,并行调用通常比较简单:

get_weather(Hangzhou)
get_weather(Shanghai)
get_weather(Beijing)

但以下工具不能简单并行:

debit_account()
create_invoice()
send_email()
cancel_order()

原因不是 Schema 无法表达,而是工具之间存在状态依赖:

S0扣款S1开票S2发邮件S3S_0 \xrightarrow{\text{扣款}} S_1 \xrightarrow{\text{开票}} S_2 \xrightarrow{\text{发邮件}} S_3

如果模型同时生成三个调用,执行器必须知道:

  • 是否允许并发;
  • 是否需要顺序;
  • 前一步失败后是否执行后一步;
  • 是否允许重试;
  • 是否具有幂等键;
  • 用户是否需要在副作用前确认。

因此 Schema 中可以通过描述提示模型:

仅在扣款已成功且用户确认发票信息后调用。
不能与 payment_debit 并行执行。

但真正的顺序控制必须由编排器和执行器保证,而不能依赖模型遵守自然语言。对于高风险副作用工具,关闭并行调用通常比事后补救更容易验证。


十一、错误的 Schema 设计会如何失败

11.1 把多个动作合并为一个工具

{
  "name": "manage_order",
  "description": "管理订单",
  "parameters": {
    "type": "object",
    "properties": {
      "action": {
        "type": "string"
      },
      "order_id": {
        "type": "string"
      },
      "payload": {}
    }
  }
}

问题在于:

  • action 的可能值不受约束;
  • 不同动作需要不同参数;
  • payload 失去结构;
  • 模型需要自行推断参数组合;
  • 执行器容易出现“先解析,再猜业务”的代码。

应拆成多个工具:

order_get
order_preview_cancel
order_submit_cancel
order_update_address

工具数量增加了,但每个工具的决策边界更清晰。

11.2 把动态查询做成巨大枚举

{
  "product_id": {
    "type": "string",
    "enum": ["p001", "p002", "p003", "..."]
  }
}

这会让 Schema 成为过期的商品缓存。动态资源应通过搜索或列表工具发现,不能要求工具定义实时承载业务数据库。

11.3 只使用自然语言约束

{
  "amount": {
    "type": "number",
    "description": "请填写合理的金额,不能太大。"
  }
}

“合理”“不能太大”不是可验证条件。应该把可计算条件写成:

{
  "amount": {
    "type": "number",
    "minimum": 0.01,
    "maximum": 1000
  }
}

无法形式化的部分,再由执行器做业务校验。

11.4 把用户身份放进模型参数

错误设计:

{
  "name": "get_account",
  "parameters": {
    "type": "object",
    "properties": {
      "user_id": {
        "type": "string"
      }
    },
    "required": ["user_id"]
  }
}

如果身份已经由会话、令牌或执行上下文确定,就不应让模型决定 user_id。否则模型可能根据用户文本填入另一个人的 ID。

更安全的设计是:

{
  "name": "get_my_account",
  "parameters": {
    "type": "object",
    "properties": {},
    "required": [],
    "additionalProperties": false
  }
}

执行器从可信上下文取得用户身份:

def get_my_account(args, auth_context):
    return account_service.get(auth_context.subject_id)

这体现了一个重要边界:

模型可控参数所有业务输入\text{模型可控参数} \neq \text{所有业务输入}

身份、租户、权限和审计主体通常应来自可信执行上下文,而不是模型生成的 JSON。


十二、如何验证一个工具 Schema 是否真的可用

可以把验证分成四层。

12.1 结构验证

检查:

  • 根节点是否为对象;
  • properties 是否为对象;
  • required 是否引用已声明字段;
  • 字段类型是否有效;
  • enum 中的值是否符合类型;
  • strict mode 所需的约束是否完整;
  • 是否禁止未声明字段。

12.2 语义验证

逐个回答:

  • 工具名是否包含领域和动作;
  • 描述是否说明适用和不适用场景;
  • 参数名是否能区分 ID、名称和自然语言;
  • 单位和时区是否明确;
  • 副作用是否显式;
  • 是否存在两个功能高度重叠的工具。

12.3 对抗性输入验证

至少测试:

{}
{"order_id": null}
{"order_id": "ORD-001", "unexpected": true}
{"order_id": "../../etc/passwd"}
{"order_id": "ORD-001", "language": "中文"}

这些输入分别检验缺字段、类型错误、额外字段、路径注入式值和枚举越界。

12.4 模型选择验证

构造覆盖相邻能力的用户请求:

帮我查订单 ORD-001 的物流。
帮我取消订单 ORD-001。
帮我找出最近三个月还没发货的订单。
我想知道 ORD-001 能不能取消,但先不要真的取消。

预期分别应路由到:

shipping_get_tracking
order_submit_cancel
order_list_unshipped
order_preview_cancel

如果模型在这些边界请求中经常混淆,优先检查名称和描述是否表达了动作差异,而不是立刻增加更多系统提示。


十三、规范保证、实现行为和工程建议要分开

规范或接口保证

这类内容来自协议或 API 约束,例如:

  • 工具定义包含名称、描述和参数 Schema;
  • MCP 通过 tools/list 暴露工具;
  • MCP 工具调用参数应符合 inputSchema
  • OpenAI strict mode 对 additionalPropertiesrequired 有明确要求;
  • 某些模型和配置支持并行工具调用。(developers.openai.com)

常见实现行为

这类内容并非所有平台都保证:

  • 模型通常会利用工具描述进行选择;
  • 更具体的描述通常比泛化描述更容易区分相邻工具;
  • 工具数量增大后,全量 Schema 往往更难管理;
  • 动态工具列表通常需要缓存和失效机制。

这些行为应通过评测验证,而不是当作协议承诺。

工程建议

这类内容属于系统设计取舍:

  • 使用 namespace 避免跨系统名称冲突;
  • 对高风险工具关闭并行调用;
  • 让身份和租户来自可信上下文;
  • 把硬约束放进 Schema,把授权和业务状态放进执行器;
  • 为 Schema、名称和枚举设计兼容迁移策略。

它们不是单靠 Schema 就能实现的能力。


结语:Schema 是模型与系统之间的可计算边界

一个成熟的 Agent 工具 Schema 不是“函数签名加几行描述”,而是同时承担了四项工作:

  1. 命名建立稳定的工具路由;
  2. 描述定义模型的选择语境;
  3. 参数和枚举压缩输入空间;
  4. 可发现性决定工具是否在正确的时间、以正确的权限进入模型上下文。

可以用下面的判定式检查工具设计是否完整:

可用工具=可识别可校验可授权可执行\text{可用工具} = \text{可识别} \land \text{可校验} \land \text{可授权} \land \text{可执行}

Schema 主要解决前两项的一部分;授权、超时、幂等、错误信封和状态一致性,则必须由工具执行器继续完成。

所以,工具 Schema 的目标不是让模型“看起来会调用函数”,而是让模型在一个明确、有限、可验证的行动空间中做选择。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。