LLM 的结构化输出不是一次性解析问题,而是一份会持续演进的数据契约。本文用 Node.js 实现带版本号的 Schema、兼容性检查、历史数据迁移和回放测试,重点处理旧数据重放、语义变化与发布回滚。
先把输出当成数据契约
很多 LLM 应用会把提示词、JSON 解析和业务逻辑放在同一个调用函数里。初期这样做很快,但当模型输出从 v1 变成 v2,风险通常不在 JSON.parse:JSON 可能完全合法,字段类型也可能正确,业务含义却已经变化。
例如,第一版输出用 priority: "high" 表示高优先级,第二版改成 priority: 3。如果只验证字段是否存在,旧数据会被当作新数据处理。另一个常见问题是历史请求无法重放:当前提示词已经变化,模型再次生成的结果和当时的结果不同,排查线上问题时就失去了参照物。
因此,一份可演进的输出契约至少应该记录:
| 信息 | 作用 |
|---|---|
schemaVersion | 判断输出属于哪一版契约 |
| 原始模型输出 | 支持审计和问题重放 |
| 解析后的结构 | 避免每次重新调用模型 |
| 提示词版本 | 解释模型行为为什么变化 |
| 模型标识 | 区分模型或配置升级造成的影响 |
Schema 版本、提示词版本和业务代码版本不必绑定成同一个数字,但应该分别记录。Schema 负责描述数据形状和语义边界,提示词版本负责描述生成方式,业务代码版本负责描述消费逻辑。
用 Node.js 定义版本化 Schema
下面的示例不依赖第三方库,使用 Node.js 内置模块实现一套小型契约层。真实项目可以把 validate 替换成 JSON Schema 或项目已有的校验库,但版本判断、迁移和回放的边界仍然应该保留。
这里规定:v1 的 priority 是字符串,v2 使用数值等级,并新增 customerId。v2 允许通过迁移得到,但不能把任意未知字符串静默转换成数字。
// schema-demo.js
'use strict';
const assert = require('node:assert/strict');
const schemas = {
1: {
version: 1,
validate(value) {
return value &&
value.schemaVersion === 1 &&
typeof value.title === 'string' &&
typeof value.priority === 'string' &&
['low', 'medium', 'high'].includes(value.priority) &&
Array.isArray(value.actions) &&
value.actions.every(item => typeof item === 'string');
}
},
2: {
version: 2,
validate(value) {
return value &&
value.schemaVersion === 2 &&
typeof value.title === 'string' &&
Number.isInteger(value.priority) &&
value.priority >= 1 && value.priority <= 3 &&
typeof value.customerId === 'string' &&
Array.isArray(value.actions) &&
value.actions.every(item => typeof item === 'string');
}
}
};
function validate(value) {
const schema = schemas[value?.schemaVersion];
if (!schema) throw new Error('Unsupported schema version');
if (!schema.validate(value)) throw new Error('Schema validation failed');
return value;
}
function migrateV1ToV2(value) {
validate(value);
const priorityMap = { low: 1, medium: 2, high: 3 };
return {
schemaVersion: 2,
title: value.title,
priority: priorityMap[value.priority],
customerId: value.customerId ?? 'unknown',
actions: value.actions
};
}
function toCurrent(value) {
validate(value);
if (value.schemaVersion === 2) return value;
if (value.schemaVersion === 1) return migrateV1ToV2(value);
throw new Error(`No migration for v${value.schemaVersion}`);
}
const oldResult = {
schemaVersion: 1,
title: '无法登录',
priority: 'high',
actions: ['检查密码重置邮件']
};
const currentResult = toCurrent(oldResult);
assert.deepEqual(currentResult, {
schemaVersion: 2,
title: '无法登录',
priority: 3,
customerId: 'unknown',
actions: ['检查密码重置邮件']
});
console.log(JSON.stringify(currentResult, null, 2));
运行方式:
node schema-demo.js
迁移函数应该是显式的、可测试的,并且尽量保持幂等:同一份 v1 数据重复迁移,结果应该一致。对于没有可靠默认值的新增字段,应返回迁移错误或进入人工处理队列,而不是使用一个看似合理但可能改变业务含义的默认值。
在发布前检查兼容性
兼容性不是一句“字段都还在”就能说明的。可以先定义项目采用的规则,再把规则固化成检查器。下面的检查器针对本例执行几条保守规则:旧字段不能删除,字段类型不能直接改变,新增字段必须声明默认值或迁移策略。
function checkCompatibility(oldSchema, newSchema) {
const errors = [];
const oldFields = oldSchema.fields;
const newFields = newSchema.fields;
for (const [name, oldField] of Object.entries(oldFields)) {
const nextField = newFields[name];
if (!nextField) {
errors.push(`removed field: ${name}`);
continue;
}
if (nextField.type !== oldField.type) {
errors.push(`changed type: ${name}`);
}
}
for (const [name, nextField] of Object.entries(newFields)) {
if (!oldFields[name] && nextField.required &&
nextField.default === undefined && !newSchema.migration) {
errors.push(`required field without migration/default: ${name}`);
}
}
return errors;
}
const v1Contract = {
fields: {
title: { type: 'string', required: true },
priority: { type: 'string', required: true },
actions: { type: 'array', required: true }
}
};
const v2Contract = {
migration: 'v1-to-v2',
fields: {
title: { type: 'string', required: true },
priority: { type: 'integer', required: true },
customerId: { type: 'string', required: true },
actions: { type: 'array', required: true }
}
};
console.log(checkCompatibility(v1Contract, v2Contract));
这个结果会报告 priority 类型发生变化。即使项目提供了迁移函数,也不应该把这种变化标记为完全兼容,而应要求版本升级,并在发布流程中验证迁移覆盖率。一个实用的判断表如下:
| 变化 | 建议 |
|---|---|
| 新增可选字段 | 通常可兼容 |
| 新增必填字段 | 需要默认值或迁移 |
| 删除字段 | 先确认所有消费者已停止依赖 |
| 字段改名 | 保留旧字段并迁移,或升级主版本 |
| 类型变化 | 默认视为不兼容 |
| 枚举值增加 | 检查消费者是否能处理未知值 |
| 枚举值含义变化 | 视为语义不兼容 |
只做形状检查还不够。比如 status: "closed" 改成 status: "resolved",类型没有变化,但统计口径、通知流程和权限判断都可能变化。因此 Schema 评审还需要包含字段语义、枚举说明和业务不变量。
保存原始结果并支持迁移回放
线上记录不要只保存业务代码最终使用的对象。建议保存一次模型调用的完整输入输出,至少包括请求标识、提示词版本、模型标识、Schema 版本、原始文本、解析结果和错误信息。敏感字段需要按照数据保留和脱敏策略处理。
回放测试的目标不是重新调用模型,而是使用已经保存的历史样本验证:旧结果能否被当前代码读取,迁移后是否满足新 Schema,关键业务决策是否保持一致。下面是一个不依赖文件系统的最小测试例子,真实项目可以把样本放入 JSON Lines 文件或测试目录。
const fixtures = [
{
id: 'case-001',
promptVersion: 'support-v1',
rawOutput: '{"schemaVersion":1,"title":"无法登录","priority":"high","actions":["检查密码重置邮件"]}',
parsed: {
schemaVersion: 1,
title: '无法登录',
priority: 'high',
actions: ['检查密码重置邮件']
}
}
];
function replay(fixtures) {
return fixtures.map(fixture => {
const migrated = toCurrent(fixture.parsed);
assert.equal(migrated.schemaVersion, 2);
assert.equal(typeof migrated.title, 'string');
assert.ok(migrated.priority >= 1 && migrated.priority <= 3);
return { id: fixture.id, ok: true };
});
}
console.log(replay(fixtures));
回放断言应该覆盖业务行为,而不是只检查迁移后的字段。例如高优先级是否仍然进入紧急队列、缺少 customerId 是否会阻止自动执行、未知枚举值是否进入人工审核。对于这些规则,可以把当前业务函数接入回放测试,并对结果做快照或显式断言。
还要区分两种回放:数据回放和模型回放。数据回放使用固定的历史输出,适合验证解析、迁移和业务逻辑;模型回放重新请求模型,受模型版本、采样参数和服务端变化影响,适合评估提示词改动,但不能替代确定性的历史回放。
发布与回滚策略
推荐把一次升级拆成几个阶段。第一阶段先发布能够读取 v1 和 v2 的代码,同时继续写入 v1,观察迁移和新逻辑在生产样本上的结果。第二阶段切换提示词和输出 Schema,写入 v2,但保留旧数据读取能力。第三阶段确认历史窗口内的回放通过,再停止生成 v1。
回滚时最容易犯的错误是只回滚业务代码,却继续接收新版本输出。更稳妥的做法是让读取端在一段时间内支持两个版本,并把生成端的 Schema 版本通过配置或发布开关控制。回滚生成端后,已经写入的 v2 数据仍然应由旧代码之外的兼容读取层处理,或者先迁移到旧版本能够消费的形态。
可以把状态记录设计为:
received -> validated -> migrated -> processed
\-> rejected -> manual_review
每一步都记录版本、错误原因和幂等键。迁移和业务处理应避免重复副作用,例如发送通知前先用 requestId 和业务动作建立唯一约束。这样重放测试、消息重试和发布回滚不会重复创建订单或发送消息。
提示词也要进入版本管理。提交提示词时同时更新 Schema 说明、样本、迁移代码和回放结果。不要只在提示词中写“请返回 JSON”;应明确字段类型、允许值、缺失字段的处理方式,以及模型无法确定时应该使用的状态。即便模型平台提供结构化输出或 JSON Schema 约束,业务侧仍需要保留版本校验,因为平台约束解决的是输出形状,不会替你判断业务语义和历史兼容性。
总结
本文的核心做法可以归纳为:
- 给每份结构化输出增加明确的
schemaVersion,同时记录提示词版本、模型标识和原始输出。 - 把字段类型、必填性、枚举值和语义变化纳入兼容性检查;发现类型或含义变化时,显式升级版本。
- 使用逐版本迁移函数把旧数据转换到当前版本,迁移失败就拒绝处理或进入人工审核,不静默吞错。
- 保存历史样本,优先做不依赖模型调用的确定性回放测试,验证迁移结果和业务不变量。
- 发布时保留双版本读取能力,回滚时控制生成端版本,并通过幂等设计避免重复副作用。
LLM 输出的可靠性不只取决于模型能否生成合法 JSON,还取决于团队是否把它当作长期维护的数据契约。版本化、迁移、兼容检查和回放测试组合起来,才能让提示词和业务代码的迭代保持可解释、可验证,也保留真正可执行的回滚路径。
评论