《互联网产品的 AI 架构·模型接入与能力边界》7.1 结构化输出的契约与校验

从「回声」客服辅助返回的工单建议 JSON 出发,说明为什么只取 JSON Schema 的一个可审查子集,如何把必填、枚举、范围与多余字段变成可定位的问题码,并给出一个零依赖校验器与一次真实的失败分类,让解析失败从“模型不稳定”变成可测量、可回归的工程输入。

从一段“看起来像 JSON”的回复说起

「回声」的客服工作台有一个辅助功能:坐席打开一条用户会话,模型读上下文后返回一段工单建议,前端用它填充意图、回复草稿、置信度和关联订单号。这个功能的价值不在“生成一段话”,而在“生成一段下游代码能直接消费的数据”。

上线第一周,团队收到的不是“回复写得不好”,而是三类更基础的问题:

  • 模型把 JSON 包在 Markdown 围栏里,JSON.parse 直接抛错。
  • 字段都在,但 orderId 拼成了 order_id,订单面板拿不到单号。
  • confidence 返回了 1.4,前端进度条按百分比渲染,宽度溢出。

这三类问题有一个共同点:模型确实“按格式”回答了,只是它理解的格式和业务代码理解的格式不是同一份。自然语言里的“请返回 JSON”不是契约,它只是建议。结构化输出的第一件事,是把建议变成可校验、可定位、可回归的契约。

本文中的比例、耗时和阈值,除本机测试输出外都是案例假设值,用来演示判断过程。

契约只保留可审查的子集

完整 JSON Schema 很强,强到不适合直接放进这条链路。$ref 会把契约拆到多个文件,allOf 与 oneOf 让失败原因难以解释,正则和远程引用还会引入执行成本与安全边界。客服辅助只需要描述“一个对象有哪些字段、哪些必填、取值在什么范围”,用一个能自己读懂的迷你子集就够了。

子集包含七种节点类型:object、array、string、number、integer、boolean、null。约束只保留五组:required、enum、minimum/maximum、minLength/maxLength、minItems/maxItems,外加一个 additionalProperties 开关。没有正则,没有联合类型,没有默认值。

三个刻意收紧的决定

第一,面向模型的输出在契约里显式写 additionalProperties: false。模型很擅长“顺手补充”一个 tenantId 或 debug 字段,业务代码不该为这些字段兜底。多余字段直接失败,比悄悄忽略更安全。

第二,金额和计数用 integer,置信度用 number。1.5 个订单没有意义,但 0.82 的置信度有意义。类型选错时,校验器应该在字段级别报错,而不是等数据库写入时才炸。

第三,字符串长度按 Unicode 码点计算。中文、emoji 和组合字符在 UTF-16 里的长度不一样,按码点算至少能让“最多 300 字”在产品、前端和后端之间保持同一个口径。

路径就是排障坐标

每个问题都带一个从根出发的路径,例如 $.orderId、$.citations[0]、$["order-id"].id。路径不是给人看的装饰,它同时用于三件事:日志聚合、修复提示的定位、以及测试断言。没有路径的“校验失败”只能让人重新读一遍输出,有路径的失败可以直接指向某个字段。

校验器:先收集,再排序

下面的实现刻意把“收集问题”和“排序问题”分开。收集阶段按 Schema 声明顺序遍历,排序阶段按路径与问题码统一整理,保证同一个输入每次得到同一份报告。这一点对回归测试很重要:报告顺序不稳定,快照测试就会变成噪音。

// file: src/structured/schema.ts
// tier: L1
// verify: node --test test/structured/schema.test.ts

/** 只实现「回声」用得到的 JSON Schema 子集,避免引入第三方校验器。 */
export type SchemaNode =
  | {
      type: "object";
      properties: Record<string, SchemaNode>;
      required?: string[];
      additionalProperties?: boolean;
    }
  | { type: "array"; items: SchemaNode; minItems?: number; maxItems?: number }
  | { type: "string"; enum?: string[]; minLength?: number; maxLength?: number }
  | { type: "number"; minimum?: number; maximum?: number }
  | { type: "integer"; minimum?: number; maximum?: number }
  | { type: "boolean" }
  | { type: "null" };

export type IssueCode =
  | "type"
  | "required"
  | "enum"
  | "range"
  | "length"
  | "additional-property";

export interface SchemaIssue {
  /** 绝对路径,例如 $.order.id 或 $.items[0]。 */
  path: string;
  code: IssueCode;
  expected: string;
  actual: string;
}

export type FailureKind =
  | "json-syntax"
  | "wrong-type"
  | "missing-required"
  | "value-out-of-contract"
  | "unknown-field";

export interface FailureReport {
  stage: "json" | "schema";
  kinds: FailureKind[];
  issues: SchemaIssue[];
  message: string;
}

export type ParseResult =
  | { ok: true; value: unknown }
  | { ok: false; report: FailureReport };

const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function actualType(value: unknown): string {
  if (value === null) return "null";
  if (Array.isArray(value)) return "array";
  return typeof value;
}

/** 长度按 Unicode 码点计算,避免把 emoji 当成两个字符。 */
function codePointLength(value: string): number {
  return Array.from(value).length;
}

function childPath(base: string, key: string): string {
  return IDENTIFIER.test(key) ? `${base}.${key}` : `${base}[${JSON.stringify(key)}]`;
}

function compareText(a: string, b: string): number {
  if (a < b) return -1;
  if (a > b) return 1;
  return 0;
}

function collect(schema: SchemaNode, value: unknown, path: string): SchemaIssue[] {
  const issues: SchemaIssue[] = [];

  if (schema.type === "object") {
    if (!isRecord(value)) {
      return [{ path, code: "type", expected: "object", actual: actualType(value) }];
    }
    for (const key of schema.required ?? []) {
      if (!Object.prototype.hasOwnProperty.call(value, key)) {
        issues.push({
          path: childPath(path, key),
          code: "required",
          expected: "present",
          actual: "missing",
        });
      }
    }
    for (const key of Object.keys(value)) {
      const child = schema.properties[key];
      if (child === undefined) {
        if (schema.additionalProperties === false) {
          issues.push({
            path: childPath(path, key),
            code: "additional-property",
            expected: "defined property",
            actual: "unexpected",
          });
        }
        continue;
      }
      issues.push(...collect(child, value[key], childPath(path, key)));
    }
    return issues;
  }

  if (schema.type === "array") {
    if (!Array.isArray(value)) {
      return [{ path, code: "type", expected: "array", actual: actualType(value) }];
    }
    if (schema.minItems !== undefined && value.length < schema.minItems) {
      issues.push({
        path,
        code: "range",
        expected: `>= ${schema.minItems} items`,
        actual: String(value.length),
      });
    }
    if (schema.maxItems !== undefined && value.length > schema.maxItems) {
      issues.push({
        path,
        code: "range",
        expected: `<= ${schema.maxItems} items`,
        actual: String(value.length),
      });
    }
    value.forEach((item, index) => {
      issues.push(...collect(schema.items, item, `${path}[${index}]`));
    });
    return issues;
  }

  if (schema.type === "string") {
    if (typeof value !== "string") {
      return [{ path, code: "type", expected: "string", actual: actualType(value) }];
    }
    if (schema.enum !== undefined && !schema.enum.includes(value)) {
      issues.push({
        path,
        code: "enum",
        expected: schema.enum.join("|"),
        actual: value,
      });
    }
    const length = codePointLength(value);
    if (schema.minLength !== undefined && length < schema.minLength) {
      issues.push({
        path,
        code: "length",
        expected: `>= ${schema.minLength} chars`,
        actual: String(length),
      });
    }
    if (schema.maxLength !== undefined && length > schema.maxLength) {
      issues.push({
        path,
        code: "length",
        expected: `<= ${schema.maxLength} chars`,
        actual: String(length),
      });
    }
    return issues;
  }

  if (schema.type === "integer" || schema.type === "number") {
    if (
      typeof value !== "number" ||
      !Number.isFinite(value) ||
      (schema.type === "integer" && !Number.isInteger(value))
    ) {
      return [{ path, code: "type", expected: schema.type, actual: actualType(value) }];
    }
    if (schema.minimum !== undefined && value < schema.minimum) {
      issues.push({
        path,
        code: "range",
        expected: `>= ${schema.minimum}`,
        actual: String(value),
      });
    }
    if (schema.maximum !== undefined && value > schema.maximum) {
      issues.push({
        path,
        code: "range",
        expected: `<= ${schema.maximum}`,
        actual: String(value),
      });
    }
    return issues;
  }

  if (schema.type === "boolean") {
    if (typeof value !== "boolean") {
      return [{ path, code: "type", expected: "boolean", actual: actualType(value) }];
    }
    return issues;
  }

  if (value !== null) {
    return [{ path, code: "type", expected: "null", actual: actualType(value) }];
  }
  return issues;
}

/** 校验入口:问题按路径与问题码排序,保证同一输入每次得到同一份报告。 */
export function validate(schema: SchemaNode, value: unknown): SchemaIssue[] {
  const issues = collect(schema, value, "$");
  issues.sort((a, b) => compareText(a.path, b.path) || compareText(a.code, b.code));
  return issues;
}

export function classifyIssue(issue: SchemaIssue): FailureKind {
  if (issue.code === "type") return "wrong-type";
  if (issue.code === "required") return "missing-required";
  if (issue.code === "additional-property") return "unknown-field";
  return "value-out-of-contract";
}

export function classifyIssues(issues: SchemaIssue[]): FailureKind[] {
  const kinds = new Set(issues.map(classifyIssue));
  return [...kinds].sort(compareText);
}

/** 类型错误通常意味着输出被截断或整体走形,重写比局部修复更划算。 */
export function isRepairableKind(kind: FailureKind): boolean {
  return kind !== "wrong-type";
}

function summarize(issues: SchemaIssue[]): string {
  return issues
    .map((issue) => `${issue.path} ${issue.code} expected=${issue.expected} actual=${issue.actual}`)
    .join("; ");
}

export function describeFailure(report: FailureReport): string {
  if (report.stage === "json") return `json syntax error: ${report.message}`;
  return summarize(report.issues);
}

export function parseStructured(schema: SchemaNode, text: string): ParseResult {
  let value: unknown;
  try {
    value = JSON.parse(text);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return {
      ok: false,
      report: { stage: "json", kinds: ["json-syntax"], issues: [], message },
    };
  }
  const issues = validate(schema, value);
  if (issues.length > 0) {
    return {
      ok: false,
      report: {
        stage: "schema",
        kinds: classifyIssues(issues),
        issues,
        message: summarize(issues),
      },
    };
  }
  return { ok: true, value };
}

代码里没有 throw。校验失败不是异常,而是一个正常的返回值。异常只留给“调用方用错了校验器”这类真正的程序错误;模型输出不合契约是预期内的事件,应该走返回值、计数、降级,而不是把整条请求打成 500。

测试与真实输出

测试覆盖十二个场景:完整对象、缺必填、枚举与范围、多余字段、数组下标、整数与小数、码点长度、重复校验的稳定性、失败分类、Markdown 围栏、非标识符键,以及“校验不修改输入”。

node --test test/structured/schema.test.ts

本机一次运行的摘要如下,耗时随机器变化:

ℹ tests 12
ℹ suites 0
ℹ pass 12
ℹ fail 0
ℹ duration_ms 197.620083

测试里有两个容易被忽略的断言。一个是重复校验同一输入必须得到完全相同的报告,另一个是校验前后输入对象的 JSON 字符串不变。前者防止未来加入 Set 或对象遍历后顺序漂移,后者防止某个“顺手补默认值”的实现悄悄改写业务对象。

失败分类决定下一步

问题码回答“哪里错了”,失败分类回答“接下来怎么办”。两者不能混在一起:问题码是校验器的输出,失败分类是修复策略的输入。

失败分类触发问题码是否值得一次修复默认处理
json-syntaxJSON 解析异常是一次修复提示
wrong-typetype否重新生成或降级
missing-requiredrequired是一次修复提示
value-out-of-contractenum、range、length是一次修复提示
unknown-fieldadditional-property是一次修复提示

把 wrong-type 单独列为不可修复,是一个有意的保守选择。整体类型错误通常意味着输出被截断、被包了围栏,或者模型根本没理解任务;这类失败再喂一次“局部修复”提示,往往只是把同一个错误换一种写法。重新生成或直接降级更划算。这个判断会在 7.3 展开。

把契约写进产品流程

校验器只是链路中的一段。要让结构化输出真正稳定,还需要三件事。

Schema 要和提示一起版本化。提示改了字段含义、Schema 没改,校验器会放行一份语义已经错了的数据;Schema 改了、提示没改,模型会持续缺字段。两者应该共用同一个版本号,并在发布记录里一起出现。

线上失败要按“分类 + 路径”聚合,而不是只记一个失败率。missing-required: $.orderId 连续升高,说明提示里的订单号说明不够清楚;unknown-field: $.tenantId 升高,说明模型开始猜测上下文,可能是提示泄漏了不该给它的信息。

契约测试要用真实失败样本。每次线上出现新的失败形态,就把那段原始输出收进测试,断言它被分到预期的类别。校验器不怕失败样本多,怕的是失败样本只存在于某个人的聊天记录里。

小结

结构化输出的核心不是“让模型输出 JSON”,而是让模型输出和业务代码共享同一份可执行的契约。契约要小到能审查,校验要稳定到能回归,失败要分类到能驱动下一步动作。

这一节给出的校验器只做一件事:把一段文本变成“通过的值”或“带路径与问题码的失败报告”。它不负责修复,也不负责决定降级。下一节先换一个方向:当模型不只是返回数据,而是提议调用一个工具时,谁来决定这个提议能不能被执行。

阅读导航:上一节:6.3 提示版本化与发布 · 下一节:7.2 工具调用:谁执行、谁授权 。

常见错误与边界

  • 把“请返回 JSON”当成契约。没有 Schema、没有校验,模型返回什么就只能靠运气。
  • 直接引入完整 JSON Schema 校验器。能力越全,失败原因越难解释,依赖也越难审计。
  • 用 number 表示金额。浮点误差会在对账时变成真实差额,金额和计数一律用整数。
  • 允许多余字段。模型补的 tenantId、debug 一旦进入下游,就会变成权限与日志问题。
  • 校验失败就抛异常。模型输出不合契约是预期事件,应该走返回值与降级路径。
  • 只记录失败率,不记录分类与路径。没有定位信息的指标无法指导提示修改。
  • 用 String.length 数字符。中文与 emoji 的长度口径会在前后端之间打架。
  • 把 Schema 和提示分开版本化。两者语义漂移时,校验器会放行错误数据。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查