数据契约与版本演进

本节目标

为触发事件、步骤输入输出和检查点定义可验证合同,并在不破坏运行中实例的前提下演进版本。

为什么“共享一个大字典”会失控

初学者常让所有步骤读写同一个 context。这样做很快,但调用关系隐藏在字段名里:上游把 amount 从整数改成字符串,下游可能在数小时后才失败;某个 LLM 节点写入多余字段,还可能被后续高权限步骤误用。

把数据分成四层更安全:

  1. 原始事件:收到后不可变,用于审计和重新解析。
  2. 规范化领域对象:把多版本输入转换成内部统一模型。
  3. 步骤输入/输出:每条边只传下游真正需要的字段。
  4. 运行元数据:attempt、状态、定义版本、幂等键、结果引用,不和业务数据混写。

一个完整的步骤合同

项目问题示例
输入 schema接受哪些字段和类型?order_id: string, amount_cents: integer
业务前置条件结构合法后还要满足什么?金额大于 0、订单属于当前租户
输出 schema下游可依赖哪些字段?reservation_id, expires_at
错误分类暂时、永久还是业务拒绝?UPSTREAM_BUSY, INVALID_ORDER
副作用是否改变外部世界?预留库存
幂等策略重复执行如何识别同一意图?实例 + 步骤 + 订单 + v1
版本哪个 handler/schema 产生结果?reserve_inventory@2
数据级别能否持久化、记录或发送给模型?内部、机密、受限
可信主体与授权谁在当前时刻获准对哪个资源执行?服务端派生的 actor/tenant、策略版本、资源 ACL
回执与终态下游接受了什么,最后业务结果是什么?receipt_id、outcome: succeeded | failed | unknown

JSON Schema Draft 2020-12 可描述 JSON 结构,$schema 应声明方言。它能检查 required、类型、枚举和额外字段,却无法自行证明“订单属于该用户”或“引用的文档真实存在”;这些仍是业务验证。需要语法与 profile 的前置练习时,先读 JSON Schema 基础契约。

还要避免把几个不同层次的 ID 混成一个 id:

标识回答的问题不能替代什么
event_id + source这是哪一次输入投递?发送方认证;未经意图绑定的业务动作去重
instance_id这是哪个工作流实例?自动成为每个外部副作用的幂等键
operation_id / action_id这一次允许的业务意图是什么?每次执行的 attempt 记录
attempt_id第几次实际执行/领取?幂等结果或最终业务结果
trace_id如何关联诊断信号?身份、租户或授权

高风险动作的合同应把 operation_id 与不可变意图(动作、目标资源、参数摘要、定义/策略版本)绑定。下游的回执只证明它接受或记录了该意图;若调用超时、异步处理或状态未知,工作流仍须通过查询、对账或人工处理得到可验证的 outcome,不能把“收到 202/200”写成“业务已成功”。

事件 envelope 与业务 payload

CloudEvents 的 id/source/type/specversion 属于 envelope。data 是业务 payload,可通过 dataschema 指向其 schema。不要把个人信息、密钥或完整提示塞进可被路由器和日志系统读取的上下文字段;CloudEvents 规范也提醒上下文属性可能被中间件检查和记录。

一个事件可同时有三种版本:

  • CloudEvents 规范版本,例如 specversion: "1.0";
  • 事件类型版本,例如 com.example.order.submitted.v2;
  • data schema 版本。

它们用途不同,不要用一个 version 字段含糊代替。

兼容演进

通常较安全的改动

  • 新增真正可选、且默认语义明确的字段;
  • 扩展下游明确允许忽略的 metadata;
  • 新增错误码,同时保持未知错误的安全回退;
  • 新 handler 同时读取旧检查点,并在内存中显式迁移。

通常是破坏性改动

  • 删除或改名必需字段;
  • 改变类型、单位、时区或字段含义;
  • 把“分”改成“元”却保留字段名;
  • 收紧枚举而不处理已存在值;
  • 让新代码用不同顺序重放旧事件。

“通常”是工程判断,不是 JSON Schema 的自动兼容保证。发布前应以真实旧夹具做 producer/consumer 合同测试。

长流程版本策略

工作流可能等待数天。发布新代码时有三种策略:

  1. 旧实例固定旧定义/worker:简单、资源成本高。
  2. 新 worker 兼容旧历史:需要重放与迁移测试。
  3. 显式迁移实例:迁移函数需版本化、可审计、可回退,危险默认值应转人工处理。

不要让运行中实例自动“跟随 latest”。定义版本、handler 版本和 schema 版本都应写进检查点;恢复时不兼容就明确失败,不能猜测缺失币种、权限或审批含义。

大结果与敏感数据

检查点只保存恢复所需的小元数据。大文档、模型原始响应和二进制文件放对象存储,状态保存对象 ID、source_revision、内容哈希、大小、类型、敏感级别和访问策略。恢复时再次验证引用存在、版本与哈希匹配,且当前服务身份仍被授权读取;URI、旧时 ACL 判定或模型自述都不能代替执行时授权。若源对象已删除、撤销或换版,应进入显式的 unknown/blocked 或人工路径,不能悄悄读取“最新内容”。

日志字段使用 allowlist:记录 instance_id、步骤、错误码和结果指纹,不记录完整凭据、客户文档、提示和工具参数。LLM 节点还要记录允许发送到哪个供应商/地区的字段清单。

合同测试清单

  • 最小合法输入、最大边界和空集合;
  • 缺失 required、错误类型、未知枚举和额外字段;
  • 当前版本、至少一个真实旧版本和未来未知版本;
  • 同一幂等键但载荷不同;
  • 同一 operation_id 的回执、延迟终态和结果未知,是否走正确的查询/对账路径;
  • 未认证或已撤销权限的 actor/tenant,是否在读取对象和执行副作用前 fail closed;
  • Unicode、超长字符串和嵌套深度限制;
  • 敏感字段是否意外进入事件、日志或错误信息;
  • 上游输出能否直接通过每个下游输入合同。

练习

为 parse_invoice -> approve_invoice 写两份简化 schema。v1 只有金额,v2 增加 currency:

  1. 选择 v1 到 v2 是默认币种迁移、拒绝还是人工确认,并说明风险。
  2. 设计一条“同一事件 ID、不同金额”的冲突测试。
  3. 列出允许写入日志的字段和必须只保存引用的字段。
  4. 说明旧审批能否直接用于迁移后金额或币种变化的实例。

自测

  1. schema 验证与业务验证有什么区别?
  2. 为什么原始事件应不可变?
  3. specversion、事件类型版本和 payload schema 版本分别解决什么?
  4. 哪些情况下新增可选字段仍可能破坏消费者?

下一步

继续 条件、并行与汇合。

参考资料