结构化输出与 JSON 校验
结构化输出与 JSON 校验
第 02 章我们学会了裸调 LLM,第 03 章学会了流式接收,第 04 章把厂商差异收进了一个 Provider。但现在有个根本问题一直悬着:模型给你的结果是一段字符串。字符串是给人读的,程序要的是能直接消费的数据结构(对象、数组、布尔值)。这一章解决「怎么让模型说出程序听得懂的话」。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出为什么自然语言输出没法被程序直接消费,以及「解析自由文本」为什么是条死路;
- 比较三种约束输出格式的手段(提示词要求 JSON / 包进 markdown 代码块 / 直接返回裸 JSON)的取舍,并明白「约束 ≠ 保证」;
- 手写
extractJson:从任意包裹文本里提取并解析 JSON(围栏优先 + 字符串感知的括号平衡扫描); - 手写一个基于 JSON Schema 最小子集(
type/required/properties)的校验器,把「提取成功但形状不对」的输出挡在门外; - 写一个永不抛异常的兜底入口
parseStructured:解析失败、校验失败都返回结果对象,不会崩溃,并支持「重新问一次模型」的重试。
开场反例:自然语言输出没法被程序消费
设想我们在给 Agent 写一个「意图解析器」:用户说一句话,程序解析出「用户想干什么」,决定 Agent 下一步执行什么动作。你兴冲冲地调用模型,拿到这样的回答:
好的!您想在明天上午九点设置一个提醒,内容是给小王回电话,我会在设置前跟您确认一下。
人眼一看就懂,但程序怎么从这段中文里取出「动作=设置提醒、时间=明天 09:00、内容=给小王回电话」?试试正则:
const reply = '好的!您想在明天上午九点设置一个提醒,内容是给小王回电话,我会在设置前跟您确认一下。';
const action = reply.includes('设置') ? 'set_reminder' : 'unknown';
const time = /(\d+)[点时]/.exec(reply)?.[1]; // "09"
const note = reply.slice(reply.indexOf('给') + 1); // 碰巧能切出来吗?
这代码能跑,但你心里清楚它有多脆:
- 措辞一换就断。 模型这次说「好的!您想在明天上午九点设置一个提醒」,下次可能就是「没问题,我把明早九点的回电话提醒记下了」。正则和
indexOf全部失守。 - 程序没法验证自己解析对了没有。 切出
note = '小王回电话,我会在设置前跟您确认一下。'这种多出尾巴的垃圾,程序一无所知。 - 没有稳定契约。 每次回答的形态都不同,你没法把它当数据用:不能
JSON.parse、不能类型检查、不能存进数据库。
这就是「自由文本输出」的根本问题:模型输出的是它觉得合适的自然语言,而不是你程序需要的形状。要改变这一点,得从「让它输出」这一步就开始约束。
约束输出格式的三种手段
让模型输出结构化数据,业界有层层递进的三招,从最便宜到最可靠:
// 手段一:提示词里口头要求
const prompt1 = `只回答 JSON:{"action": "...", "time": "...", "note": "..."}`;
// 手段二:要求包进 markdown 代码块(提取更可靠)
const prompt2 = `把你的回答包在 \`\`\`json 代码块里,不要有其他文字。`;
// 手段三:直接返回裸 JSON + 严格指令 + 低 temperature
const prompt3 = `只输出裸 JSON,不要任何解释、不要 markdown 围栏,
严格按这个 schema:
{"action": "string", "time": "string", "note": "string", "needsTool": "boolean"}`;
三者的取舍大致是这样:
| 手段 | 做法 | 优点 | 缺点 | 提取难度 |
|---|---|---|---|---|
| 提示词要求 JSON | 在 prompt 里写「只回答 JSON」 | 零成本,一行 prompt | 模型常忍不住加开场白、结束语 | 高(要从废话里抠 JSON) |
| 包进 markdown 代码块 | 要求把 JSON 放进 ```json 围栏 | 模型对代码块格式非常熟练,围栏边界清晰 | 偶尔仍会漏围栏或夹带文字 | 中(找到围栏即可) |
| 直接返回裸 JSON | 严格指令 + temperature=0 | 输出最干净,成功率最高 | 仍不能保证 100% | 低(但兜底仍不可省) |
注意表格里的「提取难度」栏:手段一二三都只是降低了解析成本,没有一项能保证输出必然合法。模型是概率系统,偶尔就是会给你一段带错逗号的 JSON、一段漏了必填字段的 JSON。这正是本教程反复强调的工程观:把 LLM 当成一个会出错的外部组件来对待,永远假设它的输出不可信——无论 prompt 写得多好,都要走「提取 → 校验 → 兜底」这条完整流水线。这一章的三个函数,就是这条流水线的三个工位:
flowchart LR
A[模型输出 text] --> B[extractJson 提取]
B --> C{JSON.parse 成功?}
C -- 否 --> F[兜底:重试 / 报错为数据]
C -- 是 --> D[validateAgainstSchema 校验]
D --> E{符合 schema?}
E -- 否 --> F
E -- 是 --> G[可信数据 data]
那到底用哪招?实践中的建议是:手段三为主,手段二做防御,手段一尽量别单独用。如果模型 API 本身提供「强制 JSON 输出」的参数(OpenAI 的 response_format、Anthropic 后来推出的 Structured Outputs),那是在采样层用约束解码保证输出合法,比提示词可靠得多,但它只是把「失败率降到接近零」,还谈不上「变成零」,且 schema 支持有子集限制,校验层依然要有。更「原生」的一条路是用 工具调用强制 JSON:把想要的 JSON 形状声明成一个工具的 input_schema,模型为了「调用工具」就会产出严格符合 schema 的 arguments。这是 Anthropic 官方课程里著名的结构化输出技巧,等第 06 章讲 Tool Calling 时我们再用它。本章先把手艺练好:无论模型以什么形态吐出来,我们都有办法把它收拾成可信数据。
手写 extractJson:从任意包裹文本里捞 JSON
第一步是提取。你可能会想:模型都按提示词输出 JSON 了,直接 JSON.parse(reply) 不就行了?不行,因为手段一、二产生的输出必然带着壳:开场白、```json 围栏、结尾的客套话。JSON.parse('好的,这是结果:{...}') 会直接抛异常。
extractJson 的职责是:给一段任意文本,找出里面的 JSON 对象并解析出来。我们用两级策略:
- 围栏优先:如果文本里有
```json代码块,直接取围栏内容,这是最可靠、最常见的形态; - 括号平衡扫描兜底:没有围栏时,从头扫描文本,找第一段「花括号配对完整、且能通过
JSON.parse」的片段,用字符串感知的扫描,跳过字符串字面量里可能出现的{和}。
先看策略一:
// 策略一:找 ```json 围栏。没有围栏返回 null,让策略二接手。
function extractFencedBlock(text: string): string | null {
const match = /```(?:json)?\s*\n?([\s\S]*?)```/.exec(text);
return match ? match[1].trim() : null;
}
再看策略二的核心:一个字符串感知的花括号配对扫描。它逐个字符走,记录当前在不在字符串里。字符串里的 {、}、" 都只是普通字符,只有字符串外的 {/} 才算结构:
// 从 start 位置(已知 text[start] === '{')开始,找与它配对的 '}'。
// 返回配对位置的索引;找不到返回 -1。
// 关键:字符串字面量里的括号不算结构(inString 时只认 \" 与转义)。
function scanBalanced(text: string, start: number): number {
let depth = 0;
let inString = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') { i++; continue; } // 跳过转义字符(如 \", \\)
if (ch === '"') inString = false;
continue;
}
if (ch === '"') { inString = true; continue; }
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
注意 if (ch === '\\') { i++; continue; }:\" 里的引号不该结束字符串,\\ 里的反斜杠也不该触发转义,遇到反斜杠直接连下一个字符一起跳过,字符串状态不变。这行是扫描器正确性的关键,常见实现漏掉它,遇到 JSON 里含 \" 或 \\ 就切错边界。
策略二把「找到的每个候选片段」收集起来,从前往后依次 JSON.parse,第一个解析成功的就赢。这样即使散文里先出现一段长得像 JSON、实则非法的花括号片段(比如 {x: 1}),扫描器也会跳过它继续找下一段:
// 策略二:扫描文本,收集所有「配对完整的花括号片段」。
function scanObjectCandidates(text: string): string[] {
const candidates: string[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] !== '{') continue;
const end = scanBalanced(text, i);
if (end === -1) continue; // 这一段配对不完整(可能是被截断的尾巴),跳过
candidates.push(text.slice(i, end + 1));
i = end; // 配对完整的区域不必再扫第二遍
}
return candidates;
}
把两级策略合起来就是 extractJson:
// 从任意模型文本中提取第一个能解析成功的 JSON 对象并返回。
// 找不到可解析的对象时抛错——错误处理交给上层兜底(见 parseStructured)。
function extractJson(text: string): unknown {
const fenced = extractFencedBlock(text);
const candidates = fenced !== null ? [fenced, ...scanObjectCandidates(text)] : scanObjectCandidates(text);
for (const candidate of candidates) {
try {
return JSON.parse(candidate);
} catch {
// 这个候选解析失败,继续试下一个
}
}
throw new Error(`no valid JSON object found in text: ${text.slice(0, 80)}...`);
}
三个输出形态过一遍 extractJson,结果完全一致:
| 模型返回的 text | extractJson 的路径 | 结果 |
|---|---|---|
{"action":"set_reminder",...}(裸 JSON) | 无围栏 → 扫描,一次命中 | 解析成功 |
好的,这是结果:```json\n{...}\n```(代码块) | 围栏命中 | 解析成功 |
我来分析一下……我把它解析成 { ... },这样 Agent 就能执行了。(散文) | 无围栏 → 扫描跳过散文,命中内嵌对象 | 解析成功 |
提取出来的 unknown 还不是数据:「能解析成 JSON」和「形状符合我们的契约」是两回事。接下来上校验器。
基于 JSON Schema 的最小校验器
提取成功只证明「这是一段合法 JSON」,不代表「它是我们想要的形状」。模型完全可能给你 {"action":"set_reminder"}(漏了 needsTool),或 {"needsTool":"是"}(类型错,应该是布尔)。把「形状对不对」的检查写进代码,别赌模型自觉。这一步在 Agent 工程里叫**校验层**,是「永远不信任 LLM 输出」这条原则的具体落点。
业界描述「形状」的标准语言是 JSON Schema:一个描述 JSON 结构的声明式文档。我们只需要它的一个很小的子集:
// 本章的 JSON Schema 最小子集:type / required / properties。
// 有意不支持 minLength、pattern、enum 等进阶关键字——够把模型的输出
// 挡成「可信数据」,又不至于把这一章写成一门 JSON Schema 教程。
interface JsonSchema {
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
properties?: Record<string, JsonSchema>; // 仅 type: 'object' 时使用
required?: string[]; // 仅 type: 'object' 时使用
}
我们解析用户请求用的契约长这样:
const TASK_SCHEMA: JsonSchema = {
type: 'object',
properties: {
action: { type: 'string' },
time: { type: 'string' },
needsTool: { type: 'boolean' },
},
required: ['action', 'needsTool'],
};
校验器本身是一个递归下降的过程:声明是对象就检查「是不是对象 → 必填字段齐不齐 → 逐个属性递归」;声明是基本类型就查 typeof。错误以路径化的字符串数组返回,方便上层展示与重试时精确定位:
interface ValidationResult {
valid: boolean;
errors: string[]; // 如 ["needsTool: missing required field", "time: expected string, got number"]
}
function validateAgainstSchema(value: unknown, schema: JsonSchema): ValidationResult {
const errors: string[] = [];
validateNode(value, schema, '', errors);
return { valid: errors.length === 0, errors };
}
function validateNode(value: unknown, schema: JsonSchema, path: string, errors: string[]): void {
const where = path || 'value';
switch (schema.type) {
case undefined:
return; // 没声明 type 就不约束——子集很小的代价
case 'object': {
// typeof null === 'object',且数组也是 object——两个都得显式排除
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
errors.push(`${where}: expected object, got ${describeValue(value)}`);
return;
}
for (const key of schema.required ?? []) {
if (!(key in value)) {
errors.push(`${path ? path + '.' : ''}${key}: missing required field`);
}
}
for (const [key, subSchema] of Object.entries(schema.properties ?? {})) {
const child = (value as Record<string, unknown>)[key];
if (child !== undefined) {
validateNode(child, subSchema, path ? `${path}.${key}` : key, errors);
}
}
return;
}
case 'array':
if (!Array.isArray(value)) {
errors.push(`${where}: expected array, got ${describeValue(value)}`);
}
return;
default: {
// string / number / boolean:直接比 typeof
if (typeof value !== schema.type) {
errors.push(`${where}: expected ${schema.type}, got ${describeValue(value)}`);
}
}
}
}
function describeValue(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
}
这里有一个 JS 新手(和很多老手)都会踩的坑,代码里已显式处理:typeof null === 'object',typeof [] === 'object'。校验对象时如果只写 typeof value !== 'object',null 和数组都会蒙混过关:必须在条件里把 null 和数组显式排除。这也是「为什么校验器不能省」的又一个注脚:JSON.parse 能产出 null,null 能通过 typeof 检查,最后你的程序在 value.action 处炸掉。
校验器过一遍三个真实输出:
validateAgainstSchema({ action: 'set_reminder', time: '09:00', needsTool: true }, TASK_SCHEMA);
// -> { valid: true, errors: [] }
validateAgainstSchema({ action: 'set_reminder', time: '09:00' }, TASK_SCHEMA);
// -> { valid: false, errors: ['needsTool: missing required field'] }
validateAgainstSchema({ action: 'set_reminder', needsTool: 'yes' }, TASK_SCHEMA);
// -> { valid: false, errors: ['needsTool: expected boolean, got string'] }
兜底:重试与「错误也是数据」
提取和校验都写好了,还差最后一环:流水线失败时怎么办。两个原则:
- 不抛异常,返回结果对象。 抛异常会让调用方被迫 try/catch,而 Agent 流水线里「模型输出不合法」是常态而非意外——它应该走正常的数据流。我们用判别联合(discriminated union)表达结果:
{ ok: true, data }或{ ok: false, error },调用方一个if (result.ok)就能分支。 - 失败了就重试,重试是「再问一次模型」。 提取失败/校验失败时,重新把模型叫出来问一次(这次把失败原因告诉它,它往往能自己改正),最多试几次再放弃。重试把「概率性的成功」变成「工程上可靠的成败」,这是业界成熟做法(pguso 的教程里就是「校验失败 → 最多重试 3 次」)。
parseStructured 把提取、校验、兜底拧成一条流水线。为了让它可测试、不联网,重试被设计成注入式回调:retry 参数收到失败信息,返回一段新文本(在真实系统里,这里会带着失败原因去重新调用模型;在测试里,我们注入一段脚本化的修正文本):
type ParseResult<T> =
| { ok: true; data: T }
| { ok: false; error: string };
interface RetryInfo {
stage: 'extract' | 'validate';
error: string;
}
function parseStructured<T>(
text: string,
schema: JsonSchema,
options: { retry?: (info: RetryInfo) => string | undefined; maxAttempts?: number } = {},
): ParseResult<T> {
const { retry } = options;
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
let current = text;
for (let attempt = 1; ; attempt++) {
// 工位一:提取
let parsed: unknown;
try {
parsed = extractJson(current);
} catch (err) {
// 工位三兜底:还能重试就再问一次,否则把错误当数据返回
if (attempt < maxAttempts && retry) {
const next = retry({ stage: 'extract', error: (err as Error).message });
if (typeof next === 'string') { current = next; continue; }
}
return { ok: false, error: `attempt ${attempt}: ${(err as Error).message}` };
}
// 工位二:校验
const result = validateAgainstSchema(parsed, schema);
if (result.valid) {
return { ok: true, data: parsed as T };
}
if (attempt < maxAttempts && retry) {
const next = retry({ stage: 'validate', error: result.errors.join('; ') });
if (typeof next === 'string') { current = next; continue; }
}
return { ok: false, error: `attempt ${attempt}: validation failed: ${result.errors.join('; ')}` };
}
}
三个典型结局:
// 结局一:一次成功——裸 JSON,直接通过
parseStructured('{"action":"set_reminder","time":"09:00","needsTool":true}', TASK_SCHEMA);
// -> { ok: true, data: { action: 'set_reminder', time: '09:00', needsTool: true } }
// 结局二:提取彻底失败——垃圾文本,重试回调返回 undefined(放弃)
parseStructured('今天天气不错', TASK_SCHEMA);
// -> { ok: false, error: 'attempt 1: no valid JSON object found in text: ...' }
// 结局三:第一轮校验失败,重试补上缺的字段——成功
parseStructured('{"action":"set_reminder","time":"09:00"}', TASK_SCHEMA, {
retry: () => '{"action":"set_reminder","time":"09:00","needsTool":true}',
});
// -> { ok: true, data: { action: 'set_reminder', time: '09:00', needsTool: true } }
parseStructured 的整个决策过程可以画成一张状态图,注意每条失败路径最终都收敛到一个返回值,没有一条异常飞出去:
flowchart TD
A[进入 parseStructured] --> B[attempt = 1]
B --> C[extractJson 提取]
C --> D{提取成功?}
D -- 否 --> E{还能重试?}
E -- 是 --> R[retry 回调拿到新文本] --> B
E -- 否 --> F[返回 ok:false + error]
D -- 是 --> G[validateAgainstSchema 校验]
G --> H{校验通过?}
H -- 是 --> I[返回 ok:true + data]
H -- 否 --> E
到这里,本章三件套就齐了:extractJson 解决「提取」、validateAgainstSchema 解决「把关」、parseStructured 解决「失败怎么体面收场」。不管模型给你裸 JSON、代码块、还是裹在散文里的一段 JSON,整条流水线都能把它变成 { ok: true, data } 或一个结构化的错误——你的业务代码从此不再面对 undefined、NaN 和正则失守。
常见坑
坑一:只 JSON.parse,不做校验。 提取成功 ≠ 形状符合契约。模型给你 {"action":"x"} 或 {"needsTool":"是"} 时 JSON.parse 一声不吭,你的程序直到 result.data.needsTool === true 判断落空才察觉。提取与校验是两个工位,缺一不可。
坑二:括号扫描不感知字符串。 如果扫描器直接数 { },遇到 {"note":"请使用 { 和 } 括号"} 这种字符串里含花括号的合法 JSON 就会切错边界。必须用字符串状态机跳过字符串字面量,还要处理 \" 和 \\ 转义。这也是本章练习会重点考察的一类边界。
坑三:typeof null === 'object'。 校验 type: 'object' 时必须显式排除 null 和数组,否则 JSON.parse('null') 也能过校验,程序随后在 data.action 处崩溃。写校验器第一件事就是处理 null。
坑四:把「prompt 里写了 JSON」当成保证。 提示词约束是「尽力而为」,不是契约。模型偶尔就会输出带错逗号的 JSON、漏 } 的 JSON、或者干脆答非所问。没有兜底层的代码,上线后会在真实模型的随机性里随机崩溃。校验 + 重试 + 把错误当数据,是每个模型消费点的标配。
坑五:依赖校验库而不知道它在查什么。 zod、ajv 这些库很成熟,但这一章要求手写,是因为校验的语义(required 缺失报什么错、null 怎么处理、错误路径长什么样)必须在你的掌控之中,尤其当模型输出是唯一输入时,你连「报错信息会不会泄露提示词」都要自己把关。等这一章的手写校验器不能满足复杂场景时,再引入库不迟,到那时你也读得懂它的报错。
小结
- 自然语言输出没法被程序直接消费:正则与字符串匹配是死路,程序需要稳定契约;
- 三种约束手段(提示词 JSON / markdown 代码块 / 裸 JSON + 严格指令 + 低 temperature)只是降低解析成本,没有一种能保证 100% 合法,「约束 ≠ 保证」;
extractJson:围栏优先 + 字符串感知的括号平衡扫描,从任意包裹文本里提取第一个能解析的 JSON 对象;validateAgainstSchema:手写 JSON Schema 最小子集(type/required/properties)递归校验,返回路径化错误数组;parseStructured:提取 → 校验 → 兜底的一站式入口,永不抛异常,失败返回{ ok: false, error },支持注入式retry重试;- 模型输出是概率的,工程是确定的:提取、校验、兜底三件套,把概率变成可靠。
下一章(ch06)做 Tool Calling 手动全流程,届时你会发现,工具参数用的正是本章的 JSON Schema,而 Anthropic 官方课程的「用工具强制 JSON」技巧(把想要的 JSON 形状声明成工具的 input_schema,让模型为了「调用工具」而产出严格合规的参数)也会在那时水到渠成。先把本章三件套练熟:现在去浏览器里完成本章练习,把 extractJson、validateAgainstSchema、parseStructured 亲手补全。
延伸阅读
- pguso/agents-from-scratch — Lesson 03: Structured Output (JSON contracts):输出契约与「解析 → 校验 → 失败处理」三步信任边界,校验失败最多重试 3 次的参考实现。
- Anthropic Courses — Tool use 03: Forcing JSON with tool use:用工具调用强制模型产出结构化 JSON 的官方课程(第 06 章预告)。
- JSON Schema 官方文档:
type/properties/required等关键字的权威定义,本子集的母集。 - CodeCrafters — Build your own Claude Code, stage 02:用 JSON Schema(
{type, properties, required})声明工具参数,可以看到本章子集在真实工具系统里的原样用法。 - MDN: JSON:
JSON.parse与 JSON 语法边界(字符串、转义、合法值)的权威参考。