Skip to content
Charles Shao
Go back

工具 Schema 怎么写:模型眼里只有 JSON

–views

剥离了重型框架的黑盒之后,上一篇我们已经把核心调度循环(Loop)手搓完成:模型输出工具调用意图,代码层执行路由分发,最终把执行结果回灌进 messages 上下文。然而调度循环本身其实没有太多可供调优的余地——真正拉开 Agent 工程能力差距的,是你透传给模型的那份 schema。

大模型既不会去解析你的 Python 源码,也感知不到函数体内的业务逻辑,它能看见的仅仅是挂载在 tools 字段里的那几段 JSON。模型只看见 schema,看不见底层实现:schema 定义含糊,它就产生幻觉、胡乱猜测;参数约束宽松,它就自信地编造脏数据。为了把 schema 设计缺陷带来的灾难性后果压到台面上,本篇依然坚持不引入任何套壳框架——我们复用之前的调度循环,却把工具场景从最简单的「查天气」升级到复杂度更高的「酒店预订」,拆成查酒店与订房间两步。日期格式解析、房型枚举约束、以及「先搜后订」的依赖关系,都会对 schema 的严谨性提出极高要求,后续几篇也会沿着这条实战路线继续深入。

在深入架构细节之前,先把全文的核心行话体系对齐,避免概念混淆:

术语核心定义
schema透传给模型、挂载在 tools 字段内的 JSON 描述
回灌把工具调用的执行结果或异常报错,以 role=tool 的形式追加写入 messages 上下文
hotel_id 白名单本次 search_hotels 真实返回过的 ID 集合,在代码层拦截校验,不对模型暴露
确认拦截写操作,等待一条新的 User 消息授权后,才允许执行状态变更
终止强行中断调度循环,不再回灌、不再重试

核心法则:模型严格按 schema 的边界行事。schema 里没定义的约束,它绝不会自动补全,只会自信地产生幻觉、填入错误数据。

Table of contents

Open Table of contents

一、透视底层:模型到底看见了什么

发起 API 请求时,tools 数组会和 messages 上下文一起被发送到服务端。每一轮调度,模型都会重新解析整份 schema,再决策该调用哪个工具、参数怎么构造。剥开表象,它在底层真正依赖的只有四个核心字段:

字段模型的底层用途缺失或设计不当的后果
name路由分发标识,选中后原样回传命名含糊(如 do_it、query)会让模型无法准确路由分发
description状态机流转的判断依据,决定此刻是否触发缺前置条件会让模型跳过必要依赖、直接执行后续操作
parameters.properties决定参数的键名与数据类型缺 description 或取值范围,模型会基于上下文猜测甚至编造
required / enum / additionalProperties收紧合法取值空间,提供硬性约束约束越宽松,幻觉与编造的空间就越大

需要特别注意的是,数组里工具的先后顺序完全不参与模型的决策逻辑。哪怕你把「订房」强行排在「搜酒店」前面,只要 description 把依赖关系定义得足够清晰,模型依然会遵循逻辑先搜索。

左边是模型看见的四块:name 选中之后原样回传,description 判断此刻该不该用,properties 决定每个参数填什么,required 和 enum 收紧取值。右边是 dispatch 之前的三层:解析层拦 arguments 不是合法 JSON、回灌原文;校验层拦缺字段、不在 enum、日期解析失败,回灌期望是什么;执行层库存没房回灌,鉴权失败和自己的 bug 终止;写操作还要 hotel_id 白名单加确认。

回想上一篇的天气查询案例,整个状态机之所以能顺畅流转,完全仰仗于这句精准的描述:

"description": "按经纬度获取当前天气。没有坐标时先用 search_location 拿到。"

一旦剥离后半句的依赖声明,模型就有极大概率直接为 get_current_weather 编造一组虚假经纬度——此时接口不会抛异常,最终回答看起来也天衣无缝,但底层的地理位置数据已经彻底失真了。

二、精准制导:description 的受众与核心要素

必须先认清一个现实:description 绝不是写给研发同事看的代码注释。解析它的「读者」既没有源码权限,也不懂你们团队的业务黑话,所能依赖的仅仅是当前上下文目标和这份 JSON 声明。因此写 description 时,必须严谨交代清楚三个维度的信息:

  1. 能力边界(干什么):用一句话精准界定工具职责,坚决杜绝「处理酒店相关事务」这类毫无信息量的废话。
  2. 触发时机(何时流转 / 何时拦截):明确指出缺失哪些前置信息时必须先调用其他工具;例如用户仅询价时,必须明确禁止触发预订。
  3. 数据溯源(参数从哪来):例如强制要求 hotel_id 只能来源于 search_hotels 的回调结果,严禁模型用酒店名称模糊冒充。

基于上述原则,订房场景的两个核心工具可以这样设计:

{
    "name": "search_hotels",
    "description": (
        "按城市和入住日期搜索可订酒店。"
        "当用户仅提供城市名且尚未获取 hotel_id 时,必须优先调用此工具。"
        "严禁使用此工具进行下单操作。"
    ),
}
{
    "name": "book_hotel",
    "description": (
        "使用 search_hotels 返回的 hotel_id 执行房间预订。"
        "在未获取合法 hotel_id 时,严禁自行编造,必须先执行搜索。"
        "room_type 参数仅支持 standard / deluxe / suite 三种枚举值。"
    ),
}

实战中有两处反模式最为致命:

三、参数收敛:用硬约束接管模型的决策权

底层机制上,模型生成的 arguments 本质上只是一段纯文本字符串。数据类型、枚举取值、是否夹带非法字段,schema 能在前端收窄预期范围,但最终的兜底防线必须由你的底层代码死守。为了把解析失败的风险压到最低,以下约束必须硬编码进 schema:

"parameters": {
    "type": "object",
    "properties": {
        "hotel_id": {
            "type": "string",
            "description": "必须是 search_hotels 返回的合法 ID,形如 HT-001,严禁传入酒店中文名",
        },
        "room_type": {
            "type": "string",
            "enum": ["standard", "deluxe", "suite"],
            "description": "房型标识,必须严格从枚举列表中选取",
        },
        "check_in": {
            "type": "string",
            "description": "入住日期,必须严格遵循 YYYY-MM-DD 格式,例如 2026-10-01",
        },
        "nights": {
            "type": "integer",
            "description": "入住晚数,限制为 1 到 14 之间的整数",
        },
    },
    "required": ["hotel_id", "room_type", "check_in", "nights"],
    "additionalProperties": False,
}

结合无数次踩坑的血泪史,这里总结几条核心工程经验:

enum 的约束力远优于自由文本。 房型、币种、订单状态这类封闭集合,必须毫不犹豫地采用枚举。若仅声明为 string 再在描述里苦口婆心地劝它「请填 standard 或 deluxe」,它依然有极大概率自信地返回一个中文的 豪华套房。

日期与 ID 必须提供精准的格式规范与示例。 不同底层模型对 JSON Schema 字符串格式约束的遵循程度参差不齐,最稳妥的做法是把 YYYY-MM-DD 的格式要求硬编码进 description,并在代码层做二次 AST 解析或正则校验;一旦校验失败,立即把期望的正确格式回灌给模型,引导其在下一轮调度中自我修正。

必须强制开启 additionalProperties: False。 放任不管,模型极容易在参数里夹带 currency、note 等你根本没声明的幽灵字段,这些脏数据透传给 fn(**args) 时会直接引发致命的 TypeError。与其在业务层用繁琐的异常捕获兜底,不如在 schema 层直接把这些非法字段拒之门外。

所谓的可选参数,必须在业务逻辑上真正可有可无。 一个字段若被声明在 properties 中却未加入 required,模型生成时往往还是会「热心」地填入一个它自行编造的默认值。因此对非核心链路的字段,最干净利落的做法就是彻底从 schema 中剔除。

工具的返回字段同样要基于「下一步是否强依赖」进行无情裁剪。 搜索酒店时若把冗长的设施介绍、高清图片 URL 一股脑吐回去,不仅会让上下文急剧膨胀,还极可能诱导模型把某个无关的展示字段误认成 hotel_id。返回值必须精简到极致,只保留下一步流转强依赖的核心数据:id、name、price、room_types。

四、防线分层:解析与校验的职责隔离

必须明确一点:schema 仅仅是一种软约束。服务端会尽最大努力引导模型按 schema 输出合法 JSON,但它绝对无法保证字段类型绝对正确、枚举值不越界、日期格式完美无缺——真正的硬约束,必须实现在你的 dispatch 路由分发层。工程实践中,把防线划分为三层就足够应对绝大多数场景:

防线层级拦截目标异常处理策略
解析层arguments 无法被解析为合法 JSON 对象截断并回灌原文前 200 个字符,强制模型重新生成
校验层字段缺失、类型异常、枚举越界、日期解析失败或数值超限精准回灌「期望的数据格式是什么」,严禁仅返回空洞的 invalid
执行层业务逻辑异常(如库存不足、酒店 ID 不存在)回灌具体业务报错;若遭遇鉴权失败或系统级 Bug,则直接终止当前调度循环

校验层拦截到异常时,回灌的错误信息必须被构造成模型能理解并据此修正的自然语言:

{"error": "room_type 字段校验失败,必须是 standard / deluxe / suite 之一,当前接收到的非法值为「豪华套房」"}

绝对不要敷衍地抛一个 {"error": "validation failed"}——对无法透视源码的模型而言,这种报错毫无信息量,等同于无效沟通。这里确立一条核心判断准则:凡是模型通过修正参数能自我恢复的异常,一律回灌;凡是改参数也无济于事的系统级错误,立即终止循环。 参数校验失败显然属于前者。

五、链路折叠:能合并的节点,绝不拆分给模型去串联

回顾天气查询案例,整条链路被拆成三轮:搜索坐标 -> 查询天气 -> 汇总终答。若在订房场景下也机械地拆解为「搜城市 -> 搜酒店 -> 查空房 -> 算价 -> 下单」这样冗长的链路,每多一个节点,模型选错工具或填错参数的风险就呈指数级上升——假设单步成功率 95%,五步连乘下来整体成功率将暴跌至约 77%。

触发链路合并的条件非常严苛且具体:中间节点的产出数据仅供模型内部流转使用,且无需向用户暴露。 例如用户输入「西安」时,他根本不关心底层经纬度坐标是多少,此时你完全可以封装一个 get_weather_by_city 工具屏蔽底层细节;同理,当用户指令是「帮我订西安一间标间」时,只要业务规则允许,你完全可以让 book_hotel 直接接收城市名称,在工具内部闭环完成搜索与预订。

然而在以下场景中坚决不能合并:当中间结果需要呈现给用户确认,或涉及核心状态变更的写操作时,搜索与预订必须被严格物理隔离。任何写操作都必须引入确认机制:执行前模型必须先输出终答(如「即将预订 XX 酒店,请确认」),并挂起当前线程,直到接收到一条新的 User 消息授权后,才放行写路径。否则一旦模型产生幻觉订错了酒店,调度循环就已经毫不留情地替它完成了扣款。

架构设计上,砍掉一个冗余工具,往往比绞尽脑汁优化一段 description 要高效得多。 工具箱一旦臃肿,各工具的描述就会互相干扰、抢占模型注意力,反而让路由分发陷入混乱。

六、安全底线:写操作必须引入 hotel_id 白名单机制

search_hotels 是纯粹的只读接口,参数填错最坏也不过是浪费一次 API 调用重新搜索;但 book_hotel 会直接篡改外部系统的核心状态。schema 只能在格式层面拦截,却阻止不了模型在「不该下单的时候强行下单」。为了守住这条安全底线,写操作必须叠加至少三层硬核防护:

  1. 在 description 中严防死守触发条件:必须明确声明,只有用户下达明确的「预订 / 下单」指令时才允许调用;对「有没有房」「价格多少」这类试探性询问,只能触发搜索,严禁越权预订。
  2. 强制校验 hotel_id 白名单:传入的 ID 必须存在于本次 search_hotels 真实返回的结果集中。你必须在代码的内存状态里维护这份白名单,一旦发现越界立即无情拒绝——模型若凭空捏造一个 HT-999 企图蒙混过关,底层逻辑绝对不能照单全收。
  3. 引入人工确认屏障:涉及资金扣款、邮件发送或数据删除的敏感操作,绝对不能在 dispatch 路由层直接放行。必须先让模型输出终答「即将预订 XX 酒店,是否确认?」,随后挂起循环,等接收到一条新的 User 消息授权后,才能真正踏入写操作的执行路径。

请时刻牢记:系统的安全风险永远潜伏在你挂载上去的那些工具中,而不是模型本身。 只要你不把高危工具暴露给它,它再聪明也无法对你的系统造成实质性破坏。

七、排障指南:基于运行时现象调优 schema,而非盲目修改提示词

发现模型输出不符合预期时,很多开发者的第一反应是去改 System Prompt——这看似最省事,实则往往徒劳无功。真正硬核的排障思路是:先扒底层运行轨迹,精准定位它到底错误路由到了哪个工具、在 arguments 里究竟填了什么离谱的参数。

运行时异常现象架构层面的破局点
模型拒绝调用工具,开始自行编造库存和房价幻觉工具 description 未能清晰界定触发时机;需在 System Prompt 追加硬约束:「价格和空房数据必须严格依赖工具回调」
模型跳过搜索前置依赖,直接调用 book_hotel 并捏造非法 hotel_id在 book_hotel 的 description 中强制声明 ID 合法来源;代码层严格校验 hotel_id 白名单
room_type 频繁填入中文或非标近义词schema 中强制引入 enum 约束;校验失败时把合法枚举列表回灌给模型
日期格式混乱,如 10月1日 或 2026/10/1description 中提供精准格式规范与示例;代码层严格按 YYYY-MM-DD 解析与拦截
传入了 schema 中未声明的幽灵字段强制开启 additionalProperties: False;校验拦截时向模型回灌当前合法的键名列表
模型在两个工具间反复横跳,或陷入死循环调用同一工具工具描述存在严重语义重叠,或当前工具箱中缺失了它真正强依赖的那个核心工具
单次搜索回调了十几 KB 的臃肿 JSON 数据无情裁剪返回字段,仅保留下一步流转强依赖的核心数据(id / 价格 / 房型),避免上下文膨胀

完整代码实现

本篇的调度循环逻辑与上一篇保持一致,核心重构点全部集中在 schema 的精细化设计,以及 dispatch 路由分发前的多层校验机制上。完整实战代码已开源至 agent-in-action/part1-handmade-agent/02-agent-tool-design/agent.py。为降低跑通门槛,酒店库存采用了本地 Mock 数据,并未真实接入外部订房接口;底层模型可无缝切换为任何兼容 OpenAI 规范的端点。

实际运行代码时,请重点观察模型是否严格遵循了「先 search_hotels 再 book_hotel」的流转逻辑。此外,你可以尝试在 Prompt 中故意把房型描述为「豪华套房」,看看底层校验机制能否精准拦截,并把合法枚举值成功回灌给模型,从而触发其自我修正。

架构演进的下一步

当 schema 设计足够严谨、模型不再面临「选错工具」的困境时,下一个致命的工程瓶颈就会浮出水面:messages 上下文的无限膨胀。搜索酒店的庞大返回结果、订房的冗长回执,在每一轮调度中都会被原样重发。如何优雅地进行上下文截断与压缩?哪些中间态数据应该被果断丢弃?这将是我们下一篇要攻克的硬核难题。

极客总结

Agent 工具链是否智能,决定性因素在于 schema 的工程设计,而非大模型本身的参数规模。 description 必须精准界定触发时机与数据溯源;enum / required / additionalProperties 必须在底层把合法取值空间彻底收死;一旦校验失败,必须向模型回灌精准的「期望数据格式」。

能够合并的中间节点,绝不要拆分给模型去增加流转风险。对于核心的写操作,除了 schema 层面的软约束,必须在底层代码中强加 hotel_id 白名单与人工确认机制。永远记住:系统的安全风险,始终潜伏在你挂载上去的工具中,而不是模型本身。


–views
Share this post on:

Previous Post
对抗失忆与噪声:上下文裁剪与记忆挂载
Next Post
从零手搓 Agent:带出口的 while 调度循环