用 Schema 校验让工具输出可控

当模型输出要进入工具调用或工作流时,schema 不是文档装饰,而是系统契约。

没有 schema,模型和后端之间靠自然语言约定:请输出订单号、金额、原因、操作类型。这个约定在 demo 里看起来能跑,但一旦输入复杂、模型升级、prompt 修改或下游工具变多,字段缺失、类型错误和枚举漂移就会反复出现。

有 schema,也不代表万事大吉。schema 设计得太松,错误会流进工具;schema 设计得太重,模型输出更容易失败;schema 错误信息不可读,修复和重试就很难稳定。

这篇文章聚焦三件事:Zod、JSON Schema 和错误 schema 如何一起让工具输出可控。

场景

假设一个电商后台接入 AI 售后助手。客服输入用户诉求后,模型需要生成一个工具调用计划。

输入:

用户说收到的耳机左耳没有声音,订单号 O-92818,购买 5 天,希望直接退款。

期望输出:

{
  "action": "request_refund",
  "orderId": "O-92818",
  "reasonCode": "defective_item",
  "requestedResolution": "refund",
  "requiresHumanApproval": true,
  "customerFacingSummary": "用户反馈耳机左耳无声,购买 5 天,希望退款。"
}

这个 JSON 会进入售后工具。工具会检查订单归属、商品状态、退款规则和是否需要人工审批。

如果没有 schema,模型可能输出:

{
  "action": "refund_now",
  "order": 92818,
  "reason": "坏了",
  "approval": "no need"
}

人能看懂,但工具不能安全执行。更危险的是,系统如果用宽松解析把它“猜着转成”工具参数,可能绕过审批。

问题类型

schema 校验主要处理三类问题。

第一类是结构问题:字段缺失、字段名不一致、类型错误、嵌套层级错误。

第二类是约束问题:枚举值不合法、字符串过长、金额为负、数组为空、日期格式错误。

第三类是错误表达问题:校验失败后,系统不知道该告诉模型什么、告诉用户什么、记录什么。

问题类型示例应由谁处理
字段契约orderId 缺失,order 代替 orderIdschema 校验
类型契约requiresHumanApproval 输出成 "false"schema 校验
枚举契约action 输出成 refund_nowschema 校验
业务规则订单不属于当前用户,退款金额超限后端业务校验
权限规则当前客服无权直接退款鉴权和审批流
错误恢复应该重试、补问用户还是转人工错误 schema 和工作流

模型适合生成候选结构。schema 负责判断候选结构是否符合工具契约。业务系统负责判断即使结构正确,动作是否允许执行。

风险/收益

schema 校验的收益是把不稳定的语言输出变成可检查的边界。

主要收益包括:

  • 后端只接受明确字段。
  • 工具调用前能拦截错误参数。
  • 前端可以基于类型稳定渲染。
  • 失败可以按字段归因。
  • 重试可以带着具体错误修复。
  • schema 可以成为 prompt、代码和测试之间的共同契约。

成本包括:

  • schema 需要设计和版本管理。
  • schema 变更可能影响 prompt、前端、后端和历史数据。
  • 过严的 schema 会增加失败率。
  • 过松的 schema 会降低保护能力。
  • 错误 schema 和降级流程需要额外设计。

什么时候简单 prompt 足够?

如果输出只用于人工阅读,或者字段不进入工具、不入库、不触发流程,prompt 加示例就可能足够。比如让模型把用户反馈整理成一段客服备注,客服会人工判断。

什么时候需要工程方案?

只要输出会调用工具、写业务系统、影响用户权益、生成可审计记录或进入无人值守流程,就必须有 schema 校验。高风险动作还要有业务规则、权限、人工确认和审计。

Zod、JSON Schema 和错误 schema

Zod 和 JSON Schema 解决的不是完全相同的问题。

Zod 更适合 TypeScript 项目里的运行时校验和类型推导。它让后端、前端或工作流代码可以直接用同一份 schema 校验模型输出,并得到类型安全。

JSON Schema 更适合作为跨语言、跨服务、工具接口和模型结构化输出的通用契约。它可以被很多语言和平台消费,也适合做 API 文档和契约测试。

错误 schema 则用于定义失败时的结构:校验错误如何表达,哪些错误可修复,哪些错误需要用户补充,哪些错误必须转人工。

方案阶梯

第一层:只定义字段说明

最轻量的做法是在 prompt 里写字段说明和示例。

适合低风险、字段少、人工复核的场景。它的成本低,但没有硬约束。后端仍然可能收到不可解析或不符合预期的输出。

第二层:Zod 运行时校验

在 TypeScript 项目里,Zod 是很自然的起点。

示例:

import { z } from "zod";

const RefundPlanSchema = z.object({
  action: z.enum(["request_refund", "replace_item", "ask_for_more_info"]),
  orderId: z.string().regex(/^O-\d+$/),
  reasonCode: z.enum(["defective_item", "wrong_item", "late_delivery", "other"]),
  requestedResolution: z.enum(["refund", "replacement", "unknown"]),
  requiresHumanApproval: z.boolean(),
  customerFacingSummary: z.string().min(1).max(300),
});

Zod 的好处是离业务代码近,可以直接得到解析后的类型,也可以把错误格式化后传给模型修复。

但 Zod 主要服务 TypeScript 运行时。如果多个服务、语言或外部工具都要共享契约,通常需要导出或同步到 JSON Schema。

第三层:JSON Schema 作为工具契约

JSON Schema 适合作为模型输出、工具参数和服务边界之间的通用协议。

示例:

{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "action",
    "orderId",
    "reasonCode",
    "requestedResolution",
    "requiresHumanApproval",
    "customerFacingSummary"
  ],
  "properties": {
    "action": {
      "type": "string",
      "enum": ["request_refund", "replace_item", "ask_for_more_info"]
    },
    "orderId": {
      "type": "string",
      "pattern": "^O-\\d+$"
    },
    "reasonCode": {
      "type": "string",
      "enum": ["defective_item", "wrong_item", "late_delivery", "other"]
    },
    "requestedResolution": {
      "type": "string",
      "enum": ["refund", "replacement", "unknown"]
    },
    "requiresHumanApproval": {
      "type": "boolean"
    },
    "customerFacingSummary": {
      "type": "string",
      "minLength": 1,
      "maxLength": 300
    }
  }
}

additionalProperties: false 很重要。它能阻止模型随手增加 refundAmountinternalNoteapproval: "skip" 这类未被系统设计过的字段。

第四层:业务校验

schema 通过后,还要做业务校验。

例如上面的 orderId 格式正确,不代表:

  • 订单存在。
  • 订单属于当前用户。
  • 商品在可退款窗口内。
  • 当前客服有权限申请退款。
  • 这个原因码允许退款而不是换货。

schema 校验解决“形状正确”,业务校验解决“是否允许”。这两层不能混在一起。

第五层:错误 schema

很多团队只设计成功输出 schema,却没有设计失败输出 schema。

这会导致失败时系统只能记录一段报错字符串,无法自动决定下一步。

一个错误 schema 可以像这样:

{
  "type": "object",
  "required": ["errorType", "retryable", "message", "fieldErrors"],
  "properties": {
    "errorType": {
      "type": "string",
      "enum": ["parse_error", "schema_error", "business_error", "permission_error"]
    },
    "retryable": {
      "type": "boolean"
    },
    "message": {
      "type": "string"
    },
    "fieldErrors": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["path", "code", "expected", "received"],
        "properties": {
          "path": { "type": "string" },
          "code": { "type": "string" },
          "expected": { "type": "string" },
          "received": { "type": "string" }
        }
      }
    }
  }
}

错误 schema 的作用是让系统知道:

  • 哪些错误可以让模型修复。
  • 哪些错误应该重新生成。
  • 哪些错误应该向用户补问。
  • 哪些错误必须转人工。
  • 哪些错误是权限或业务拒绝,不能通过重试解决。

第六层:版本和评估

schema 是接口,接口就会演进。

常见变更包括新增字段、修改枚举、拆分 action、收紧字符串长度、增加业务校验。每次变更都可能影响 prompt、模型输出、前端渲染、后端工具和历史数据。

因此上线后要记录:

  • schema version。
  • prompt version。
  • model version。
  • parse error。
  • schema error。
  • business error。
  • retry count。
  • 最终处理路径。

没有版本和 trace,团队很难判断一次模型升级或 schema 修改到底让稳定性变好了还是变差了。

方案选型表

场景条件推荐方案不推荐方案判断理由额外成本
单个 TS 服务消费输出Zod 校验手写 if 判断所有字段Zod 能同时提供运行时校验和类型推导schema 维护
多语言服务或工具共享契约JSON Schema只写在 prompt 里跨服务需要稳定协议schema 发布和版本管理
输出作为工具参数JSON Schema + 业务校验schema 通过就直接执行形状正确不代表动作允许工具鉴权、范围校验
校验失败后希望自动恢复错误 schema + 修复/重试把错误拼成自然语言日志结构化错误更容易自动决策错误分类设计
高风险操作schema + 业务规则 + 人工确认让模型决定是否执行权益和责任不能交给模型审核、审计、回滚
快速内部草稿prompt + 轻量解析完整工具契约人工会复核,重工程收益有限少量错误提示

反模式

第一种反模式:只校验字段存在,不校验枚举和类型。

字段存在但值不合法,和字段缺失一样危险。action: "refund_now" 如果没有被拦截,可能进入未设计的分支。

第二种反模式:允许额外字段自由通过。

额外字段看起来无害,但会制造隐性功能。下游某段代码如果开始读取这些字段,就绕开了正式契约。

第三种反模式:把业务规则塞进 prompt。

例如“只有购买 7 天内才能退款”可以写进 prompt 辅助模型判断,但真正执行前必须由后端规则校验。prompt 不是权限系统。

第四种反模式:schema 过度复杂。

一个工具调用 schema 里塞入多种 action、几十个互斥字段和多层嵌套,会让模型更难稳定输出。更好的做法通常是先分类 action,再使用对应 action 的小 schema。

第五种反模式:错误信息只给开发者看。

如果错误不能被模型修复、不能被用户理解、不能被运营处理,就只是日志。错误 schema 应该同时服务机器恢复和人工排查。

第六种反模式:schema 变更不做回归。

改一个枚举名、收紧一个正则、删除一个字段,都可能让历史样本大面积失败。schema 变更应该跑评估集,而不是只测当前手头样例。

工程清单

  • 契约边界:schema 约束的是模型输出、工具参数,还是 API 响应。
  • 工具用途:每个字段是否对应真实工具参数、UI 展示或审计需求。
  • Zod:TypeScript 代码是否有运行时校验和类型推导。
  • JSON Schema:跨服务或工具是否有统一 schema 和版本号。
  • 额外字段:是否设置 additionalProperties: false 或等价策略。
  • 枚举:action、状态、原因码是否封闭且有业务含义。
  • 空值:nullunknown、空数组和字段缺失是否区分清楚。
  • 业务校验:订单归属、金额范围、权限、状态流转是否在后端检查。
  • 错误 schema:parse、schema、business、permission 错误是否结构化。
  • 修复策略:哪些字段错误可修复,哪些必须补问或转人工。
  • 版本管理:schema、prompt、model 是否一起记录。
  • 回归评估:schema 变更是否跑历史样本和失败样本。
  • 审计:工具执行前后的参数、结果和人工确认是否可追溯。

小结

schema 校验的价值不只是“防止 JSON 写错”,而是建立模型和工具之间的边界。

Zod 适合在 TypeScript 项目里快速获得运行时校验和类型安全。JSON Schema 适合跨服务、跨语言和工具契约。错误 schema 则把失败从一段日志变成系统可以处理的状态。

简单 prompt 适合人工可复核的低风险输出。只要模型输出要驱动工具、写入系统或影响用户权益,就应该用 schema 做硬边界,再叠加业务校验、权限控制、人工确认和可观测性。