从一段“看起来像 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-syntax | JSON 解析异常 | 是 | 一次修复提示 |
wrong-type | type | 否 | 重新生成或降级 |
missing-required | required | 是 | 一次修复提示 |
value-out-of-contract | enum、range、length | 是 | 一次修复提示 |
unknown-field | additional-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 和提示分开版本化。两者语义漂移时,校验器会放行错误数据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。