让 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() 排错时特别有用,直接告诉你哪个字段挂了。上面那次 amount 变 total 的事故,它一眼就能定位到 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 之外的方案的吗,评论区蹲一个对比。