结构化输出与 Schema 校验:JSON Mode 与手写轻量级类型验证器
本节目标
- 理解为什么 Agent 内部节点通信必须用结构化数据(Structured Outputs)取代脆弱的正则表达式提取
- 掌握用原生 JavaScript 手写零依赖的 Schema 验证器与 JSON Schema 转换器(类 Zod 核心原理)
- 实现带错误定位反馈的结构化输出自愈循环(Self-Healing Retry Loop),将解析成功率提升至 99.9%
为什么自然语言文本解析是 Agent 稳定性的头号杀手?
在 Agent 系统中,模型的输出往往要直接作为下游代码的输入(例如提取任务列表、风险评分、SQL 参数)。如果只靠在 Prompt 里写「请只返回 JSON,不要输出多余文字」,模型在实际运行中仍然经常输出:
- 包裹了 Markdown 代码块(
```json ... ```)加一段寒暄:「好的,这是你要的 JSON:...」 - 漏掉必填字段、把数字写成中文「三十五」、或者夹带未转义的双引号与尾随逗号。
要让 Agent 达到生产级可靠性,必须组合使用三道防线:鲁棒提取器(JSON Extractor) + 运行时 Schema 校验器 + 错误反馈重试闭环。
手写零依赖 Schema 校验与 JSON Schema 生成器
下面的代码实现了一个轻量但完备的声明式 Schema 库 s(对标 Zod 的核心设计),既能一键导出给 LLM 使用的標準 JSON Schema,又能对模型返回的对象执行精确到字段路径(Path)的运行时校验:
export const s = {
string: (opts = {}) => ({
type: 'string',
...opts,
validate(val, path = '$') {
if (typeof val !== 'string') return [`${path}: 期望 string,实际为 ${typeof val}`]
if (opts.enum && !opts.enum.includes(val)) {
return [`${path}: 值 "${val}" 不在允许枚举 [${opts.enum.join(', ')}] 内`]
}
if (opts.minLength && val.length < opts.minLength) {
return [`${path}: 长度不能小于 ${opts.minLength}`]
}
return []
}
}),
number: (opts = {}) => ({
type: 'number',
...opts,
validate(val, path = '$') {
if (typeof val !== 'number' || Number.isNaN(val)) {
return [`${path}: 期望有效 number,实际为 ${JSON.stringify(val)}`]
}
if (opts.min !== undefined && val < opts.min) return [`${path}: 不能小于 ${opts.min}`]
if (opts.max !== undefined && val > opts.max) return [`${path}: 不能大于 ${opts.max}`]
return []
}
}),
array: (itemSchema, opts = {}) => ({
type: 'array',
items: itemSchema,
...opts,
validate(val, path = '$') {
if (!Array.isArray(val)) return [`${path}: 期望 array,实际为 ${typeof val}`]
return val.flatMap((item, i) => itemSchema.validate(item, `${path}[${i}]`))
}
}),
object: (shape, { required = Object.keys(shape) } = {}) => ({
type: 'object',
properties: shape,
required,
additionalProperties: false,
validate(val, path = '$') {
if (typeof val !== 'object' || val === null || Array.isArray(val)) {
return [`${path}: 期望非空 object`]
}
const errs = []
for (const reqKey of required) {
if (!(reqKey in val) || val[reqKey] === undefined) {
errs.push(`${path}.${reqKey}: 缺少必填字段`)
}
}
for (const [k, propSchema] of Object.entries(shape)) {
if (val[k] !== undefined) {
errs.push(...propSchema.validate(val[k], `${path}.${k}`))
}
}
return errs
}
})
}
// 从夹杂 Markdown 围栏或解释文字的模型回复中安全剥离 JSON
export function extractJsonObject(rawText) {
const text = String(rawText || '').trim()
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(text)
const candidate = fenced ? fenced[1].trim() : text
const firstBrace = candidate.search(/[{[]/)
const lastBrace = Math.max(candidate.lastIndexOf('}'), candidate.lastIndexOf(']'))
if (firstBrace === -1 || lastBrace === -1 || lastBrace < firstBrace) {
throw new Error('回复中未找到有效的 JSON 括号边界')
}
return JSON.parse(candidate.slice(firstBrace, lastBrace + 1))
}
// 带自愈反馈的结构化推理调用器
export async function generateStructured({ llm, messages, schema, maxRetries = 3 }) {
const workingMessages = [...messages]
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const res = await llm.chat({
messages: workingMessages,
response_format: { type: 'json_object' }
})
const raw = res.message.content || ''
try {
const parsed = extractJsonObject(raw)
const errors = schema.validate(parsed)
if (errors.length === 0) return parsed
// 校验未通过:把具体哪个字段错、期望什么类型直接反馈给模型重修
workingMessages.push(
{ role: 'assistant', content: raw },
{
role: 'user',
content: `你输出的 JSON 未通过 Schema 校验,错误如下:
${errors.join('
')}
请严格修正上述字段并重新输出合法 JSON。`
}
)
} catch (parseErr) {
workingMessages.push(
{ role: 'assistant', content: raw },
{
role: 'user',
content: `JSON 解析语法错误:${parseErr.message}。请仅输出严格合法的 JSON 字符串。`
}
)
}
}
throw new Error(`结构化输出在重试 ${maxRetries} 次后仍未通过 Schema 校验`)
}课后练习
练习 1:当模型的 JSON 输出未能通过 schema.validate(parsed) 时,为什么我们在重试前要把模型刚才错误的输出(role: 'assistant')和具体的字段错误路径列表(如 $.steps[0].priority: 值 "urgent" 不在允许枚举内)一起追加进 workingMessages?
答案:如果只是简单地用原 Prompt 重新请求一次,模型有很大概率再次犯同样的结构错误;将它上一轮的错误输出连同精确到字段路径的校验报错发回上下文,模型就能直接定位违规字段并做靶向修正,通常只需 1 次重试即可修复。
练习 2:extractJsonObject 为什么要先尝试匹配 ```json 代码围栏并查找首尾 {} / [] 边界,而不是直接对 rawText 调用 JSON.parse?
答案:许多开源模型或未开启严格 Structured Outputs 的接口习惯在 JSON 前后附加说明性话术或 Markdown 代码围栏,直接 JSON.parse(rawText) 会因为首尾的非 JSON 字符抛出语法异常。