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 或项目已有的校验库,但版本判断、迁移和回放的边界仍然应该保留。

这里规定:v1priority 是字符串,v2 使用数值等级,并新增 customerIdv2 允许通过迁移得到,但不能把任意未知字符串静默转换成数字。

// 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 是否会阻止自动执行、未知枚举值是否进入人工审核。对于这些规则,可以把当前业务函数接入回放测试,并对结果做快照或显式断言。

还要区分两种回放:数据回放和模型回放。数据回放使用固定的历史输出,适合验证解析、迁移和业务逻辑;模型回放重新请求模型,受模型版本、采样参数和服务端变化影响,适合评估提示词改动,但不能替代确定性的历史回放。

发布与回滚策略

推荐把一次升级拆成几个阶段。第一阶段先发布能够读取 v1v2 的代码,同时继续写入 v1,观察迁移和新逻辑在生产样本上的结果。第二阶段切换提示词和输出 Schema,写入 v2,但保留旧数据读取能力。第三阶段确认历史窗口内的回放通过,再停止生成 v1

回滚时最容易犯的错误是只回滚业务代码,却继续接收新版本输出。更稳妥的做法是让读取端在一段时间内支持两个版本,并把生成端的 Schema 版本通过配置或发布开关控制。回滚生成端后,已经写入的 v2 数据仍然应由旧代码之外的兼容读取层处理,或者先迁移到旧版本能够消费的形态。

可以把状态记录设计为:

received -> validated -> migrated -> processed
                  \-> rejected -> manual_review

每一步都记录版本、错误原因和幂等键。迁移和业务处理应避免重复副作用,例如发送通知前先用 requestId 和业务动作建立唯一约束。这样重放测试、消息重试和发布回滚不会重复创建订单或发送消息。

提示词也要进入版本管理。提交提示词时同时更新 Schema 说明、样本、迁移代码和回放结果。不要只在提示词中写“请返回 JSON”;应明确字段类型、允许值、缺失字段的处理方式,以及模型无法确定时应该使用的状态。即便模型平台提供结构化输出或 JSON Schema 约束,业务侧仍需要保留版本校验,因为平台约束解决的是输出形状,不会替你判断业务语义和历史兼容性。

总结

本文的核心做法可以归纳为:

  1. 给每份结构化输出增加明确的 schemaVersion,同时记录提示词版本、模型标识和原始输出。
  2. 把字段类型、必填性、枚举值和语义变化纳入兼容性检查;发现类型或含义变化时,显式升级版本。
  3. 使用逐版本迁移函数把旧数据转换到当前版本,迁移失败就拒绝处理或进入人工审核,不静默吞错。
  4. 保存历史样本,优先做不依赖模型调用的确定性回放测试,验证迁移结果和业务不变量。
  5. 发布时保留双版本读取能力,回滚时控制生成端版本,并通过幂等设计避免重复副作用。

LLM 输出的可靠性不只取决于模型能否生成合法 JSON,还取决于团队是否把它当作长期维护的数据契约。版本化、迁移、兼容检查和回放测试组合起来,才能让提示词和业务代码的迭代保持可解释、可验证,也保留真正可执行的回滚路径。