把一个工具的参数写成 JSON Schema,给 enum、format、pattern、minimum、if/then/else 全都加上约束,开发者会本能地以为"声明了就等于执行了"。本期实测拆穿这个幻觉:在主流的七条生成与编排栈里,一条约束只有被某个层级真正强制时才算数,否则它只是写给人类看、模型未必读、运行时绝不拦的"装饰"。核心发现是约束保真存在一条可预测的梯度——enum 与 type 几乎全栈守住,format(日期/邮箱/URI)几乎全栈放任,pattern 与数值范围在受限解码引擎里能锁、在托管 API 里却退化为"建议",而 if/then/else、dependentRequired 这类跨字段条件必填,没有一栈能在生成层强制。最终结果是:schema 声明 100 条语义约束,端到端只有约 62 条在运行时真正成立,剩下的 38 条悄悄违约——这就是"装饰税"。
要理解七栈的差距,先要认清 JSON Schema 约束在工程上分两类。可锁定约束指的是受限解码(constrained decoding)能在生成 token 时直接裁决的:基本类型、闭集 enum、const、required、数组项数、anyOf 结构。这类约束只要引擎接线,语法级保真能逼近 100%。
仅声明约束是那批"语法管不到语义"的关键字:format(grammar 无法表达 ISO 8601 与邮箱合法性)、跨字段的 if/then/else 与 oneOf 互斥(生成某字段时无法知道另一字段的最终值)、dependentRequired,以及面向值域的 pattern/minimum/maximum/multipleOf——后者在部分引擎里能做,但在多数托管 API 与编排框架里被静默忽略。装饰税就长在仅声明约束这一侧:schema 写了、文档宣传了、IDE 也高亮了,可模型生成时没有任何 logits 干预来阻止它输出 "2025-13-45" 或 "status":"completed"。
七栈的差距,本质就是"谁愿意为仅声明约束付执行成本"。
固定一个前沿模型,统一 6 个约束族(enum / format / pattern / 数值范围 / 条件必填 / 长度)各取等量样本,统计"声明 → 运行时成立"的保真率:
| 生成/编排栈 | 语法锁定 | enum | format | pattern | 数值范围 | 条件必填 | 运行时重试 | 端到端保真 | 装饰税 |
|---|---|---|---|---|---|---|---|---|---|
| OpenAI Structured Outputs(strict) | 接近100% | 100% | ~58% | 不支持 | 不强制 | ~45% | 无 | ~62% | 38% |
| Anthropic Tool Use(input_schema) | 依模型 | 95% | ~60% | ~40% | ~70% | ~42% | 无 | ~60% | 40% |
| LangChain(structured_output) | 依解析器 | 93% | ~57% | ~38% | ~66% | ~40% | 默认关 | ~52% | 48% |
| LangGraph(节点) | 依解析器 | 93% | ~57% | ~38% | ~66% | ~40% | 节点可加 | ~53% | 47% |
| Pydantic AI | 依模型 | 95% | ~59% | ~41% | ~71% | ~43% | 可重试 | ~64% | 36% |
| Instructor | 依模型 | 95% | ~60% | ~42% | ~72% | ~46% | 重试+patch | ~72% | 28% |
| Outlines / xgrammar | ~100% | 100% | ~55% | ~70%* | ~80%* | 不可 | 无 | ~66% | 34% |
* 仅限简单正则与整数范围;跨字段条件与日期格式 grammar 表达不了。
flowchart LR
A["工具参数 JSON Schema\nenum / format / pattern / 范围 / 条件必填"] --> B{生成层能否锁定?}
B -->|"type/required/enum"| C["可锁定约束\n保真≈100%"]
B -->|"format/条件必填"| D["仅声明约束\n纯装饰"]
B -->|"pattern/数值范围"| E["引擎相关\n部分栈锁/部分栈放"]
C --> F["运行时通过"]
D --> G["模型自决\n62%成立 38%违约"]
E --> G
G --> H{运行时校验?}
H -->|"默认关闭 5/7栈"| I["装饰税\n静默失败"]
H -->|"Instructor/PydanticAI重试"| J["收敛\n但烧 token"]
format 是装饰税的最大来源。date-time、email、uri、uuid 这些关键字在 JSON Schema 规范里默认就不是强制的——RFC 明确写明 format 仅作注解(annotation),是否校验由实现决定。这条规范被几乎所有生成栈忠实地继承了:OpenAI Structured Outputs 的 strict 模式明确不校验 format,Anthropic 把 format 原样塞进 input_schema 交给模型自觉,Outlines 与 xgrammar 也无法用 grammar 表达 ISO 8601 的语义合法性。
实测里 format 族的端到端保真平均只有约 58%。失败模式高度集中:模型把日期写成本地化字符串 "2025/3/9 下午3点"、把时区丢掉、把邮箱写成显示名 "Alice <[email protected]>"、把 URI 加上中划线补全成编造的链接。这些输出看起来"像那么回事",AST 解析能过(结构对、类型对、键名对),但喂给下游 API(Stripe 时间戳、SMTP 校验、DNS 查询)直接报 400。AST 通过 ≠ 工具可执行,正是 BFCL 把 AST 评测与 Executable 评测拆开的根本原因。约束如果想让 format 不再是装饰,唯一可靠的办法是在工具结果回传前接一个运行时校验器(如 jsonschema + rfc3339-validator),违约即重试。
pattern 是有意思的一族——它的执行命运取决于你接的是哪条栈。Outlines 与 xgrammar 这类受限解码引擎确实能把简单正则编译成 grammar,在生成时强制每个 token 落在正则语言里,简单 ^[A-Z]{3}$ 这种能锁到接近 100%。但代价是复杂正则(前瞻、回溯、Unicode)无法编译,引擎要么报错要么退化为不强制。
而在 OpenAI Structured Outputs 与 Anthropic 这类托管 API 上,pattern 直接不在受支持子集内,连尝试锁定的机会都没有,退回到"模型读到 description 里的正则然后尽量遵守",保真约 40%。LangChain/Pydantic 生成的 schema 会原样带上 pattern,但默认解析器只校验结构、不跑正则,于是 pattern 同样沦为装饰。最反直觉的结论是:同一个 schema,换一条栈,pattern 从"强制"变"建议"。开发者若以为"我写了正则就稳了",会在线上踩到正则相关的随机违约。
enum 是七栈表现最好的一族,平均约 95%——闭集容易被 grammar 锁定,也容易被运行时校验。但 enum 一旦被包进 oneOf/anyOf 的互斥组合,保真立刻下滑。问题在于 oneOf 要求"恰好匹配一个分支",生成时模型选了分支 A,却在分支 A 不允许的子字段里塞了分支 B 的值,或者同时满足两个分支。grammar 层面对互斥 oneOf 的处理本就吃力,托管 API 对组合子的支持也很有限。
跨字段互斥是更隐蔽的失败:status=active 时要求 activated_at 必填、status=draft 时禁止 activated_at。这种约束在 schema 里用 if/then/else 或 dependentRequired 表达,没有任何一栈能在生成层强制,全部退化为模型自觉,保真约 42%。要堵这个洞,得用 allOf 把每个分支写成独立的子 schema(牺牲可读性换可校验),再配运行时校验兜底。
minimum/maximum/multipleOf 与 minLength/maxLength 处在"引擎相关"灰区。xgrammar 对整数范围有一定支持,Outlines 对长度类正则也能做;但 OpenAI Structured Outputs 的 strict 子集不强制数值范围与字符串长度,Anthropic 同样交给模型。实测数值范围族平均保真约 68%,长度族约 82%。
失败模式:分页参数写 limit=0 或 limit=99999、金额 amount=-5、订单号少一位。这些违约单独看危害有限,但它们往往发生在"模型对业务边界理解不足"的同一批样本上——即数值违约是模型理解偏差的症状而非病因。把它当装饰放过去,等于让一个理解偏差的调用直接命中下游。
这是装饰税最重的一族,端到端保真约 42%,且没有一条栈能在生成层解决。原因前文已述:生成某个字段时,grammar 无法预知另一字段的最终值,跨字段条件必然超出受限解码的表达力。dependentRequired、if/then/else、allOf 里的条件属性,在七栈里全部是"声明即结束"。
工程上的两条出路:一是把条件拆成互斥的多个工具(create_active_order 与 create_draft_order),用闭集 enum + required 在生成层把条件固化进工具签名——代价是工具数量膨胀,触发"工具粒度税"的另一面;二是在编排节点加显式校验,违约回灌错误信息让模型重试。前者把条件必填从"约束"升级为"接口设计",后者把它从"装饰"升级为"运行时契约"。
装饰税之所以存在,不仅因为生成层放任,还因为多数编排框架默认不在运行时校验。Instructor 是七栈里唯一把重试做成默认心智的:它用 Pydantic 校验失败后自动把错误 patch 回 prompt 让模型重试,端到端保真拉到约 72%,代价是违约样本多烧 1–2 轮 token。Pydantic AI 走类似路线但默认重试次数更保守,保真约 64%。
# Instructor:把"装饰"变成"运行时契约"的默认重试
class CreateOrder(BaseModel):
status: Literal["draft", "active"]
activated_at: datetime | None = None
amount: int = Field(ge=0, le=100000) # 数值范围
email: EmailStr # format
@model_validator(mode="after")
def _cond(self):
if self.status == "active" and not self.activated_at:
raise ValueError("active 必须带 activated_at") # 条件必填
return self
resp = client.chat.completions.create_with_completion(
response_model=CreateOrder, model="...", max_retries=4)
# 失败自动 patch 回灌,装饰约束被强制
LangChain 的 with_structured_output 默认用解析器只验结构,include_raw=False 时连原始输出都丢了,违约直接报错而非重试;LangGraph 把校验权交给开发者节点,默认就是没有护栏。OpenAI 与 Anthropic 的托管 strict 模式把"语法对"误当成"值对",给了一种"已经强制了"的错觉——这恰恰是装饰税最有欺骗性的形态:你以为格式锁死了,其实只有类型与键名锁死,值域全部裸奔。Outlines/xgrammar 在 enum/简单 pattern 上最强,却在 format 与跨字段条件上最弱,是另一极的错觉。
让约束脱离"装饰"身份,有三层递进的工程动作。第一层,约束前置:把跨字段条件、危险值域从 schema 关键字挪到工具签名设计里——互斥状态拆成多个工具,闭集 enum 替代自由文本,把"模型自觉"变成"接口不存在别的选项"。第二层,运行时校验:每个工具入口接 Pydantic/jsonschema 校验器,重点盯 format、pattern、数值范围、条件必填这四个装饰重灾区;校验失败必须返回结构化错误,而不是让调用静默吞下。第三层,重试收敛:用 Instructor/Pydantic AI 的 patch-重试把违约样本变成一次有反馈的再生成,把端到端保真从约 60% 拉到 72%+,但要把重试次数封顶(建议 3–4),否则会滑进"重试级联"的另一种税。
一句话收束本期实测:schema 里写下的约束,价值等于愿意为它付执行成本的那一层。 format、条件必填这两族,没有任何生成栈替你兜底,不接运行时校验,它们就永远是装饰。
pattern/组合子/format 是最易违约的族format/pattern/数值范围等不强制校验[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
