[控场AI]
· 8 分钟阅读· 4,148 字

构建TypeScript结构化输出校验门:让LLM的JSON真正可用

构建TypeScript结构化输出校验门:让LLM的JSON真正可用

用TypeScript构建Schema校验门,以解析、校验、一次修复与拒绝四步把大模型结构化输出从概率性演示变为可靠产品。

大模型的结构化输出在单次测试中看似可靠,但在高并发生产环境中坏结构是一个「比率」而非偶发事件。本文以一个包含三字段路由决策的微型示例,拆解了如何用纯TypeScript构建一道「Schema校验门」:解析模型返回、按schema逐字段校验、失败时将具体错误信息回传模型修复一次,最终接受或拒绝。门的健康状况由pass@1、pass@repair、reject rate三个指标量化,并对照预设阈值做ship/hold决策。文章同时诚实地列出了四处边界:厂商的约束解码无法替代应用层决策、微型校验器不支持复杂schema特性、修复可能编造语义错误的合法输出、以及fixture数量过少不能反映真实分布。核心论点是:结构化输出是概率性采样器与确定性系统之间的契约,schema提供形状,校验器提供证据,拒绝路径提供停止点,缺少任何一环都只是脆弱的演示。

大模型返回了一段JSON,看起来没问题。可当生产环境需要一个数字时,它却给了你 "high"。

这就是结构化输出(Structured Output)的真实困境——它可能通过一次测试,却无法保证每一次都靠谱。本文将基于一段原始教程,拆解如何用纯 TypeScript 构建一个微型「Schema 校验门」,把概率性采样和确定性系统之间的契约落到实处。

为什么结构化输出需要一道「门」

结构化输出正迎来高光时刻,但各家厂商的文档都坦承它并非万无一失。

根据 Anthropic 的结构化输出文档,JSON 输出通过 output_config.format 和严格工具调用实现,但页面明确列出了大家早已熟悉的失败模式:解析错误、字段缺失、类型不一致、以及触发重试的 schema 违规。OpenAI 的结构化输出指南则把这项能力定义为「遵循 schema」而非仅仅「有效的 JSON」,同时点名了拒答(refusal)和 max-token 截断这两类响应可能不匹配 schema 的场景。

更关键的信号来自 Anthropic 推出的 Claude Haiku 5.5——这是面向高并发、成本敏感任务(摘要、分类、路由等短任务)的小模型。原文一针见血地指出:当一个小模型如此频繁地作答时,一次坏结构就不再是偶发事件,而是一个「比率」。

不同的产品,相同的方向:供应商能约束采样,但你的系统仍然必须决定——当结构出错、被截断或被拒答时该怎么办。

Validate, repair once, accept or reject

原文给出了精辟的概括:一个通过了一次的 schema 只是 demo,一个能拒绝坏结构的「门」才是产品。

「约束解码」(Constrained Decoding)是厂商实现结构化输出的核心机制,值得单独说明。在标准采样流程中,模型在每一步从词表中以概率分布选取下一个 token;约束解码则在此之上叠加一个掩码层——在每个位置,只有能使输出仍然符合目标 schema 的 token 才被允许。以 JSON 为例:若当前上下文要求一个数字,则字母、引号等 token 的概率会被强制置零。这种机制在统计上显著降低了格式错误的频率,这也是 OpenAI、Anthropic 等厂商能够声称「遵循 schema」的底层依据。然而约束解码有两处固有盲区:其一,它只能约束 token 序列的合法性,无法约束语义——模型仍然可以在合法格式内输出错误的枚举值或越界的数字;其二,当输出被 max_tokens 截断时,约束层本身也会随之中止,留下一个结构残缺的响应。这正是为什么即便启用了厂商侧的结构化输出,应用层仍然需要一道独立的校验门。

这道门要做什么

示例场景是一个带三个字段的路由决策:

  • action:枚举值,取 route、escalate 或 ignore
  • target:字符串
  • confidence:介于 0 到 1 之间的数字

模拟模型会返回八种回复:合法 JSON、截断的 JSON、错误类型、非法枚举、多余字段、缺失字段、超范围的 confidence,以及一个「怎么修都修不好」的顽固样本。

这道门只做三件事:

  1. 解析(Parse)
  2. 按 schema 校验(Validate)
  3. 若不合法,把错误反馈回去修复一次,然后决定接受或拒绝

需要特别说明的一点诚实之处:这里的「模型」是一张 fixture 表,所有 confidence 数值和通过率都是示例输入。实际使用时,把你的真实厂商调用换进去,保留这道门即可。整个 demo 不需要 API Key、不需要真实模型,只需要 TypeScript。

核心设计:无框架校验与一次修复

Schema 即契约

整个 schema 的定义只有三个字段:不允许额外字段(additionalProperties: false)、枚举封闭、数字有界。原文强调:如果 schema 本身是模糊的,那么门也会是模糊的。

不依赖框架的校验器

校验逻辑没有引入 Zod、也没有 Ajv,仅手写了这个 schema 所需要的检查:对象类型检查、必填字段检查、禁止多余属性、字符串与枚举校验、数字类型与上下界校验。

原文点出了一个容易被忽视的本质:你完全可以之后换成真正的 JSON Schema 库,有意思的部分不是库,而是你拿到 issues 列表之后做什么。没有决策的校验只是日志。

一次修复,然后停下

修复流程被刻意设计得很短:解析 → 校验 → 若有问题,带着错误信息请求一次 → 再校验 → 停。

在真实技术栈里,mockComplete 会变成你的厂商调用,firstIssues 会变成你回传的错误负载。Anthropic 的文档本就把 schema 违规列为重试的原因之一——这里做的,正是把这个重试交由你的框架掌控,而不是在 prompt 里祈祷它成功。

原文有一句金句值得记住:一次修复是策略,无限修复是死循环。一个没有拒绝路径的修复循环,就是一场无尽的道歉。

将错误信息回传给模型要求其自我修正,这一模式在大模型应用领域被称为「自我修复」(Self-Repair)或「反馈驱动重生成」。其有效性来自一个经验性发现:大多数结构错误并非模型能力不足,而是首次生成时的随机性偏差——当模型被明确告知「你的输出缺少字段 X,confidence 的值 1.3 超出了 0-1 的范围」时,它通常能在第二次生成中修正这些具体的、可描述的错误。但这一机制有一个重要前提:错误信息必须足够精确且可操作。模糊的反馈如「输出格式不对,请重试」效果远不如列出具体 issue 的结构化错误清单。此外,「只修复一次」并非保守主义,而是工程理性:每次修复都消耗额外的推理时间和 token 成本,而多轮修复的边际收益在大量实证中被证明迅速递减。超过一次仍未修复的样本,往往是因为 schema 本身歧义、或模型能力确实无法满足约束,此时继续重试只是在浪费预算。

用三个指标决定「能不能上线」

跑完 npx tsx gate.ts 后,程序会输出三个关键指标:

  • pass@1:首次回复就已经符合 schema 的比例
  • pass@repair:经过一次约束性重试后拿到可用对象的比例
  • reject rate:应当拒绝执行的比例

Pass rates and ship gate from the CLI

示例运行结果中,8 个 fixture 里只有 1 个首次通过(pass@1 = 0.13),6 个在修复后变为合法(pass@repair = 0.88),1 个顽固样本始终错误而被拒绝。门设置的发布阈值是 pass@repair ≥ 0.75,于是输出 SHIP。

原文特意提醒:这个 0.75 的阈值只是示例,你应该从真实流量里挑选阈值,而不是从一篇博客里照搬。

整个 demo 的精神被浓缩成一句话:接受符合 schema 的输出,拒绝其余的,并把两者都计数。

pass@k 这一指标体系最初来源于代码生成领域的评估方法——在 OpenAI 的 HumanEval 等基准测试中,pass@1 表示单次生成即通过测试的概率,pass@k 则表示在 k 次独立生成中至少有一次通过的概率。本文将其迁移到结构化输出场景,语义略有调整:pass@1 衡量首次响应即符合 schema 的比例,pass@repair 则衡量经过一次修复后最终可用的累计比例。这种迁移是合理的——两个场景的本质都是「在有限次数的概率性尝试后,得到满足确定性约束的输出」。将这三个指标与一个明确的发布阈值结合,使得上线决策从「感觉差不多能用了」变成一个可重复、可审计的量化判断,这对需要在多个模型版本或供应商之间做横向比较的团队尤为重要。

它在哪里会失效

原文难得地花了大篇幅讨论局限,这对工程实践尤为重要:

厂商的结构化输出不是你的门。 约束解码降低了坏结构的概率,但它不拥有你的重试预算、不负责你的日志、也不替你做停止的决策。保留厂商特性,同时保留这道门。

微型校验器不是完整的 JSON Schema 引擎。 这个检查器覆盖了对象、必填键、枚举、数字边界和 additionalProperties: false,但不支持 $ref、anyOf、对象数组或正则模式。生产级 schema 通常需要真正的库,但模式不变:issues 进来,修复一次,接受或拒绝。

修复可能「编造」一个合法的谎言。 把错误反馈回去,可能让模型凭空发明一个工单里从未出现过的 target。Schema 合法并不等于语义真实。对于高风险字段,应当为门搭配白名单或人工审核环节。

八个 fixture 只是冒烟测试。 这里 0.13 的 pass@1 是 fixture 写法造成的人为结果,在约束性厂商路径上应当高得多。真正的测试矩阵应从生产故障中构建:截断的流、枚举漂移、类型强转、以及你的解析器以前默默忽略的多余键。

更大的启示

结构化输出不是一个 prompt 技巧,而是概率性采样器与确定性系统之间的一纸契约。

完整链路可以这样理解:

  • Schema 提供「形状」
  • 校验器提供「证据」
  • 修复提供一次廉价的「第二次机会」
  • 拒绝路径提供「停止点」
  • 指标提供「发布决策」

当你的结构化输出通过了一次,别满足于此——让它为下一次调用重新赢得信任。这正是把一个脆弱的演示,转化为一个可靠产品的分水岭。

分享:

相关推荐