用zod在运行时兜住AI返回的JSON

让 AI 返回结构化 JSON 这事,写 demo 的时候很爽,上生产那天就开始挨打。

我们有个功能让模型把用户的一段自然语言解析成表单字段,约定它返回固定结构的 JSON。前端拿到直接 JSON.parse 然后 data.amount.toFixed(2)。前两周岁月静好,某天线上报错:Cannot read properties of undefined。模型那次返回里 amount 字段没了,它自作主张换成了 total

模型的输出是概率性的。你给它再严格的 prompt,它也有小概率不照做。TypeScript 那套类型断言在编译期信誓旦旦,运行时一点忙帮不上------as ParsedForm 只是你对它撒的谎。

编译期类型 + 运行时校验,一份 schema 搞定

我用 zod 把这事统一了。schema 既是运行时校验器,又能反推出 TS 类型,不用两头维护:

css 复制代码
import { z } from 'zod'

const ParsedFormSchema = z.object({
  intent: z.enum(['create', 'update', 'query']),
  amount: z.number().nonnegative(),
  currency: z.string().length(3).default('CNY'),
  date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  tags: z.array(z.string()).max(10).default([]),
})

type ParsedForm = z.infer<typeof ParsedFormSchema> // 类型从 schema 来,不手写

z.infer 这步是关键。类型和校验逻辑同源,改 schema 类型自动跟着变,杜绝了「类型写了 number、实际没校验」的对不上。

解析失败要给得出兜底

safeParse 不抛异常,返回成功失败的判别联合,逼你必须处理失败分支:

typescript 复制代码
function parseAIResult(raw: string): ParsedForm | null {
  let json: unknown
  try {
    json = JSON.parse(raw)
  } catch {
    return null // 模型有时会在 JSON 外面包 ```json 代码块,先清洗
  }
  const result = ParsedFormSchema.safeParse(json)
  if (!result.success) {
    console.warn('AI 输出不符合 schema', result.error.flatten())
    return null
  }
  return result.data
}

result.error.flatten() 排错时特别有用,直接告诉你哪个字段挂了。上面那次 amounttotal 的事故,它一眼就能定位到 amount: Required

模型爱在 JSON 外面套壳

实战里最高频的不是字段错,是模型把 JSON 包在 markdown 代码块里返回,前面还带句「好的,这是解析结果:」。直接 parse 必炸。我加了层清洗:

sql 复制代码
function extractJSON(raw: string): string {
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/)
  if (fenced) return fenced[1].trim()
  const start = raw.indexOf('{')
  const end = raw.lastIndexOf('}')
  return start >= 0 && end > start ? raw.slice(start, end + 1) : raw
}

这个清洗函数我们组现在每个对接 AI 的项目都复制一份。不优雅,但 work。

用 coerce 容忍小毛病

模型偶尔把数字返回成字符串 "100"。早期我对这种直接判失败,后来发现太严了------明明语义对的,何必。用 z.coerce 做温和转换:

scss 复制代码
const Amount = z.coerce.number().nonnegative()
// "100" -> 100,但 "abc" 仍然失败

这里有个取舍:coerce 用多了,schema 的「严格保证」就松了。我的原则是只对数字、布尔、日期这种有明确转换规则的字段开 coerce,枚举和关键业务字段绝不放松。这条线我们团队讨论了挺久,最后定的就是「能无歧义转的才转」。

一个仍在忍受的缺点

zod 的校验在超大对象上有性能开销,我们一个嵌套很深的 schema 单次 parse 大概 3-5ms。量大的批处理场景里这个不能忽略。我试过 valibot 更快但生态没 zod 全,纠结之后还是留在 zod,体积和速度的账先记着,等真成瓶颈再说。

类型安全这事,在 AI 接口面前不能只靠 TS。运行时这道闸,迟早得补。


说回来,能容忍模型偶尔抽风、又不至于翻车,前提是后端模型本身足够稳定可靠。我们用的是讯飞 这类 MaaS 提供的现成模型 API,结构化输出的成功率比自己瞎调高不少,前端兜底的活儿也轻一些。

你们怎么校验 AI 的结构化输出?有用 zod 之外的方案的吗,评论区蹲一个对比。

相关推荐
George3752 小时前
第一章:本体论是什么(以及它不是什么)
人工智能
贵慜_Derek2 小时前
《从零实现 Agent 系统》连载 32|闭集 IE 与小模型:分类、意图与字段抽取
人工智能·架构·agent
IT_陈寒2 小时前
Java 并行流把我坑惨了,这6小时加班值了
前端·人工智能·后端
火山引擎开发者社区3 小时前
告别长期密码:火山引擎云数据库 MySQL IAM 鉴权全解析
人工智能
火山引擎开发者社区3 小时前
从仓库维护者到架构师|首个大规模真实仓库长程任务 SWE 数据集 DeNovoSWE 发布,火山引擎云沙箱提供支撑
人工智能
火山引擎开发者社区9 小时前
火山 DTS 正式支持 MySQL 同步到 Milvus , 解决业务库到向量库最后一公里
人工智能
火山引擎开发者社区10 小时前
@开发者,提前解锁 FORCE 原动力大会五大看点,限时赢取门票福利
人工智能
火山引擎开发者社区10 小时前
这个 Skill 让 Agent 从会理解到会执行,补齐移动 APP 执行最后一公里
人工智能