跳到主要内容

你的工具 Schema 验证的是类型,而非单位

· 阅读需 13 分钟
Tian Pan
Software Engineer

一个退款智能体正在处理一笔 42 美元的客户请求。它发出的工具调用是 refund(amount: 4200) —— 等等,这正确吗?如果后端以“分”为单位存储金额,那它完全正确。如果后端以“美元”为单位存储,那么客户刚刚得到了 4,200 美元的退款。两次调用在语法上都是完美的。两者都在微秒级内通过了 JSON Schema 验证。Schema 规定 amount 是一个数字,而 4200 毫无疑问是一个数字。

这是一类单元测试、Schema 验证器和大多数智能体评估(Agent evals)都会忽略的错误:工具调用边界处的单位混淆。模型会根据其训练数据进行数值模式匹配 —— 以美元计价的货币、以秒计价的时间、由周围文本暗示的重量单位 —— 而你的工具则期望的是分、毫秒或公斤。类型系统不会提出任何异议。发票金额只是偏差了 100 倍。

工程领域早有前科。1999 年,“火星气候探测者号”(Mars Climate Orbiter)失事,原因是一个团队的软件以磅力秒为单位输出推进器冲量,而另一个团队的软件则期望牛顿秒。1983 年,“基米尼滑翔机”(Gimli Glider)在飞行途中耗尽燃料,原因是在加油计算中混淆了磅和公斤。在这两个案例中,每个独立的数字在局部看来都是合理的,每个接口契约在类型层面都得到了满足,只有当现实世界产生分歧时,错误才变得显而易见。

现在的新状况是,调用者变成了语言模型。语言模型犯这种错误是“默认行为”,而非偶然。问问你自己,在训练语料库中 amount: 100 意味着什么。在数百万个 Stripe 集成示例中,它代表 1 美元,因为 Stripe 的金额是最小货币单位的整数。在数百万个其他代码片段中,它代表一百美元,因为该变量是一个浮点数。模型已经见过这两种惯例成千上万次,并且会根据局部上下文的引导来做出选择。你的 API 惯例只是众多选项中的一票。

类型约束形状,单位约束含义

JSON Schema 在工具边界确实非常有用。它可以捕获缺失的必填字段、本应是数字的字符串、格式错误的枚举、幻觉出的参数名。约束解码(Constrained decoding)和结构化输出(structured-output)模式已经让“模型输出无效 JSON”基本成为了过去式。

但 Schema 约束的是数据的“形状”,而非其“含义”。{"type": "number"} 会以同样的热情接受 42 和 4200。即使是 minimummaximum 的边界限制也收效甚微,因为以美元计价的字段和以分为单位计价的字段的合法范围几乎完全重合 —— 500 美元的退款和 500 分的退款都是常规数值。这两者只能通过意图来区分,而意图恰恰是 Schema 无法编码的东西。

这正是为什么单位混淆对标准的防御手段具有独特的抵抗力:

  • 类型检查通过。分和美元都是数字。秒和毫秒也都是数字。
  • 范围验证通过。4200 分和 4200 美元都在 amount 的任何合理范围内。
  • 模型很自信。它没有犹豫,也没有要求澄清;它以前见过这种精确的调用形状,只是带有不同的单位惯例。
  • 输出在审核时看起来很正常。人类在扫视工具调用时,会在合理的字段名旁边看到一个合理的数字。

强类型语言在几十年前就通过维度类型(dimensional types)解决了这个问题 —— F# 的度量单位(units of measure),或者 Rust 和 Haskell 中的数量库(quantity libraries),使得 Cents + Dollars 成为一个编译错误。但工具调用边界是两个类型系统之间的一个 JSON 鸿沟:你的后端类型在 Schema 开始的地方结束,而模型的“类型”是来自训练数据的统计关联。边界本身在关键的维度上是无类型的。

为什么模型会猜错,以及何时会猜错

模型并不是粗心大意;它只是在做它一直在做的事情 —— 根据上下文推断最可能的补全。单位错误往往集中在可预测的地方:

货币。美元与分的划分是经典的例子,但情况比二元对立更糟糕。Stripe 风格的 API 使用最小货币单位,对于美元来说是分,但对于日元(JPY)来说是“整日元”,因为日元是一种零小数货币。一个正确学会了“为 Stripe 乘以 100”的智能体,会无声无息地向日本客户多收 100 倍的费用。货币处理中的边缘案例经常让开发者感到困扰;模型继承了所有这些混淆,并根据它们在网络上的出现频率进行了加权。

时间timeout: 30 是 30 秒还是 30 毫秒?Unix 时间戳有秒(经典版)、毫秒(JavaScript 版)和微秒(某些数据库版)之分,这三种形式在训练数据中都大量存在。一个被要求安排“五分钟后”任务的模型可能会输出 300、300000 或 ISO 8601 字符串,具体取决于周围对话更接近哪种惯例。

物理量。公斤与磅、公里与英里、摄氏度与华氏度。一个接受 weight(重量)参数的物流工具,如果刚刚处理了一个美国的运输对话,就会从模型那里得到磅;如果刚刚读了一份欧洲的发票,就会得到公斤。

百分比和比例。15% 的折扣是 0.15 还是 15?这两种惯例无处不在。这类错误很阴险,因为错误因子在一个方向上只有 100 倍,且数值几乎永远不会超出合理范围 —— 一个代表 1500% 的 discount: 15 往往能顺利通过验证。

共同点在于:模型的先验(prior)是由“全局”惯例频率决定的,但你的 API 惯例是一个“局部”事实。每当局部事实属于少数派惯例时 —— 而分、毫秒和辅币单位(minor currency units)在文本中通常都是少数派惯例 —— 模型的默认猜测就是错误的。提示词可能会在三千个 token 之前提到过一次“金额单位为分”;但训练先验已经说过一百万次“美元”了。

将单位放入名称中

最高效的修复方法是不花钱的:重命名参数,让单位出现在模型必须逐字输入的标识符内。

amount 变成 amount_centstimeout 变成 timeout_msweight 变成 weight_kgdiscount 变成 discount_percent,并在描述中明确 0–100 的范围。

这样做是有原因的,值得深入理解。描述字段中写着 "amount, in cents" 是模型读过一次的上下文,等它生成调用时可能已经降低了优先级。但参数名是在 工具调用本身内部 重新生成的 —— 模型必须在输出数值之前立即输出 amount_cents 这个 token。单位提示在决策的准确时刻到达,与所选数字的距离为零。它将记忆问题转化为阅读问题。Anthropic 的工具设计指南也提出了同样的论点,认为 user_id 优于 user:模糊的名称会诱导模型用其先验知识填补空白。

一些改进可以增强这种效果:

  • 尽可能使用带有数量类型的格式。 ISO 8601 持续时间 (PT5M) 和 RFC 3339 时间戳是自描述的;在 2026-07-02T14:30:00Z 中不存在单位歧义。模型无法误解的字符串优于它可能误解的整数。
  • 将货币设为结构化对象。 {"amount_minor": 4200, "currency": "USD"} 强制明确货币决策,并允许你的后端根据每种货币应用正确的辅币单位规则,包括零小数点的货币。
  • 绝不接受纯浮点数表示金额。 整数辅币字段不会产生浮点数带来的 42.004200 之间的困惑,同时也避开了浮点数舍入问题。
  • 消除具有单位歧义的默认值。 timeout: 30 这样的默认值是一个地雷 —— 模型会模仿这种模式并重新解释单位。timeout_ms: 30000 则通过示例传达了约定。

在边界处进行合理性断言

命名降低了错误率,但无法消除它。第二层是边界断言 —— 在工具内部运行的检查。在模型确定一个数值后,不是问 "这是一个数字吗?",而是问 "这个数字 可信 吗?"

Schema 验证询问输入是否格式正确。合理性断言询问输入是否 合理。这种区别很重要,因为单位错误产生的数值往往偏离一个可疑的倍数 —— 100 倍、1000 倍 —— 这是一个可检测的特征:

  • 跨字段一致性。 退款金额不应超过原始费用。计划时间不应是在 40 年后(秒与毫秒的纪元错误恰恰会产生这种结果)。包裹重量应与其声明的运输类别相符。这些检查存在于你的领域逻辑中,任何模型错误都无法在这些检查下悄无声息地逃脱。
  • 带升级机制的数值栅栏,而不只是拒绝。 如果 99% 的退款都在 200 美元以下,那么一笔 4,200 美元的退款不应该是绝对不可能的 —— 它应该需要一个确认步骤或人工审批。分层阈值将 "大额但合法" 的长尾情况转化为审核队列,而不是导致系统停机或事故。
  • 整倍数怀疑。 当一个数值几乎恰好是该字段预期分布的 100 倍或 1000 倍时,"单位混淆" 这一特定假设值得拥有自己的错误信息。

最后一点是边界断言发挥双重作用的地方:拒绝信息会返回到模型的上下文中。"无效金额" 无法教给模型任何东西。而 "amount_cents=4200 将退款 42.00,但客户要求退款42.00,但客户要求退款 42 —— 你是否传递了美元而非美分?此字段接受整数美分" 则让智能体能在下一次尝试中修复调用。在工具边界,你的错误信息是模型在任务进行中唯一的老师,因此要让它们说明单位、显示解释并指出疑似的混淆。

为什么你的评估(Evals)永远抓不到这种错误

Here's the uncomfortable part:即使你的评估仪表盘全是绿色,你仍可能发出 100 倍金额的发票,因为单位错误对标准的智能体评估几乎是不可见的。

大多数工具调用评估检查三件事:模型是否选择了正确的工具,参数是否符合 Schema,以及任务是否完成。一个发生了单位混淆的调用能通过所有这三项。工具是对的,Schema 接受了那个数字,任务也 "完成" 了 —— 退款已发出,只是金额错了。LLM 作为评判者(LLM-as-judge)在审阅记录时,看到一个合理的数字出现在一个合理的字段旁,就会予以批准,因为评判者拥有 导致错误产生的相同训练先验。用同样以美元思考的模型来评判美分当作美元的错误,无异于让狐狸去审计鸡舍。

捕获这类错误需要大多数评估套件所不具备的单位感知断言:

  • 精确值基准(Oracles),而非合理性评分。 测试用例必须确定性地知道正确答案是 4200 美分,并拒绝任何其他数值 —— 包括看起来 自然的 42。
  • 跨单位陷阱案例。 在评估中加入一些故意使用错误约定的任务:用户在面对美分 API 时说 "退还我的 $42",或者在磅 API 中使用欧洲重量,或者在你假设美分的乘法计算中使用日元费用。如果你的评估集从未跨越约定,那么它测试的只是先验知识与 API 一致的理想情况。
  • 生产环境中的分布监控。 规模化的单位 Bug 会产生双峰数值分布 —— 一个集群在正确的量级,另一个在 100 倍处。这种特征在工具调用遥测中很容易检测,但在任何单个追踪中都是不可见的。记录参数,而不仅仅是结果。

投资于此的原因是,在工具调用 Bug 分类中,单位错误具有最糟糕的严重性与可见性比。幻觉出的参数名会立即且大声地报错。而单位错误则会悄无声息地成功并产生真实的资金损失,等到财务部门注意到异常时,智能体可能已经重复执行了几百次。

将每个裸数字视为一个问题

由此产生的准则很简单:在面向智能体的边界上,一个裸数字参数就是一个未回答的问题,而模型会根据其他人的代码库权重,通过猜测来回答它。

所以,要像审计安全漏洞一样审计你的工具签名。每个 number 字段都应采取以下三种处理方式之一:将单位包含在名称中(如 _cents_ms_kg),将值转换为自描述格式(如 ISO 8601、RFC 3339、结构化货币),或者为字段添加带有升级路径的边界断言。涉及金钱、时间或物理量的字段应同时采用这三种方式。然后,在你的评估(evals)中加入陷阱案例——即对话约定与 API 约定不一致的情况——因为这种不一致才是实际的生产环境状况,而不是边缘情况。

25 年前,两个团队软件之间的单位混淆摧毁了一架航天器,当时行业的对策是建立更好的接口契约。智能体时代重新引入了同样的失败模式,但带有一个转折:接口的一侧现在是一个关于其他所有人的软件如何运作的统计模型。你的契约不再仅仅是与调用者签订的,而是与整个训练语料库中对 amount 含义的理解签订的。在编写你的 Schema 时,要假设语料库对你的理解是错误的,因为它很可能确实错了。

References:Let's stay in touch and Follow me for more thoughts and updates