结构化输出与 JSON 校验

结构化输出与 JSON 校验

第 02 章我们学会了裸调 LLM,第 03 章学会了流式接收,第 04 章把厂商差异收进了一个 Provider。但现在有个根本问题一直悬着:模型给你的结果是一段字符串。字符串是给人读的,程序要的是能直接消费的数据结构(对象、数组、布尔值)。这一章解决「怎么让模型说出程序听得懂的话」。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:自然语言输出没法被程序消费

设想我们在给 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);    // 碰巧能切出来吗?

这代码能跑,但你心里清楚它有多脆:

  1. 措辞一换就断。 模型这次说「好的!您想在明天上午九点设置一个提醒」,下次可能就是「没问题,我把明早九点的回电话提醒记下了」。正则和 indexOf 全部失守。
  2. 程序没法验证自己解析对了没有。 切出 note = '小王回电话,我会在设置前跟您确认一下。' 这种多出尾巴的垃圾,程序一无所知。
  3. 没有稳定契约。 每次回答的形态都不同,你没法把它当数据用:不能 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 对象并解析出来。我们用两级策略:

  1. 围栏优先:如果文本里有 ```json 代码块,直接取围栏内容,这是最可靠、最常见的形态;
  2. 括号平衡扫描兜底:没有围栏时,从头扫描文本,找第一段「花括号配对完整、且能通过 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,结果完全一致:

模型返回的 textextractJson 的路径结果
{"action":"set_reminder",...}(裸 JSON)无围栏 → 扫描,一次命中解析成功
好的,这是结果:&#96;&#96;&#96;json\n{...}\n&#96;&#96;&#96;(代码块)围栏命中解析成功
我来分析一下……我把它解析成 { ... },这样 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 能产出 nullnull 能通过 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'] }

兜底:重试与「错误也是数据」

提取和校验都写好了,还差最后一环:流水线失败时怎么办。两个原则:

  1. 不抛异常,返回结果对象。 抛异常会让调用方被迫 try/catch,而 Agent 流水线里「模型输出不合法」是常态而非意外——它应该走正常的数据流。我们用判别联合(discriminated union)表达结果:{ ok: true, data }{ ok: false, error },调用方一个 if (result.ok) 就能分支。
  2. 失败了就重试,重试是「再问一次模型」。 提取失败/校验失败时,重新把模型叫出来问一次(这次把失败原因告诉它,它往往能自己改正),最多试几次再放弃。重试把「概率性的成功」变成「工程上可靠的成败」,这是业界成熟做法(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 } 或一个结构化的错误——你的业务代码从此不再面对 undefinedNaN 和正则失守。

常见坑

坑一:只 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、或者干脆答非所问。没有兜底层的代码,上线后会在真实模型的随机性里随机崩溃。校验 + 重试 + 把错误当数据,是每个模型消费点的标配。

坑五:依赖校验库而不知道它在查什么。 zodajv 这些库很成熟,但这一章要求手写,是因为校验的语义required 缺失报什么错、null 怎么处理、错误路径长什么样)必须在你的掌控之中,尤其当模型输出是唯一输入时,你连「报错信息会不会泄露提示词」都要自己把关。等这一章的手写校验器不能满足复杂场景时,再引入库不迟,到那时你也读得懂它的报错。

小结

下一章(ch06)做 Tool Calling 手动全流程,届时你会发现,工具参数用的正是本章的 JSON Schema,而 Anthropic 官方课程的「用工具强制 JSON」技巧(把想要的 JSON 形状声明成工具的 input_schema,让模型为了「调用工具」而产出严格合规的参数)也会在那时水到渠成。先把本章三件套练熟:现在去浏览器里完成本章练习,把 extractJsonvalidateAgainstSchemaparseStructured 亲手补全。

延伸阅读

完成阅读,去做练习 →