返回机制实验室

W07 · S43—S49 · 总 Day 133139

协议集成:JSON 相同,含义未必相同

把两个版本的金额契约转换为明确的内部语义。

作者准备的学习示例 · 不计真实学习进度 · 不代表生产 / GPU / 真机结果

在仓库根目录运行;只输出合成示例,不写文件、不访问网络

npm run learning:p2 -- w07
跳到完整源码 ↓

核心问题

工具从 amount: 12.34 改成 amount: 1234,两个数都能通过 number 类型检查,却可能差一百倍。协议集成最难的部分常常不是把 JSON 传过去,而是保证双方对单位、币种、身份、动作与版本有相同理解。

对应 S43~S49。本周用金额转换建立语义兼容直觉,不实现 MCP 或 A2A 服务。

1. 外部版本与内部表示分开

本例规定 v1 接受美元字符串 12.34,v2 接受整数最小单位 1234 并显式声明 USD。二者进入内部后都表示 {amountMinor:1234,currency:'USD'}

金额用字符串拆分再转换为整数,避免直接用二进制浮点数乘 100 产生不必要误差。本例限定非负金额、恰好两位小数和 JavaScript 安全整数范围;这不是通用货币库。不同币种的小数位、舍入、负数退款和超大数额需要另外定义。

版本字段不是装饰。未知版本应停止转换或走明确迁移流程,而不是猜测。内部 canonical representation 使业务代码不用同时理解所有外部格式;adapter 负责在边界把差异显式处理。

2. 运行与阅读拒绝原因

npm run learning:p2 -- w07

前两个输入分别来自 v1、v2,应归一为相同对象。v2 中的 12.34 因为不是整数最小单位被拒绝;版本 99 被拒绝。错误是预期的学习输出,不意味着脚本运行失败。

这份函数真正执行了运行时校验,而不是仅写 TypeScript interface。网络、模型输出和历史事件都不会因为接口声明就自动可信。不过它也不是完整 JSON Schema validator:这里手写了一个狭窄合同,允许额外字段但转换时只保留约定字段。

3. 兼容性要看谁在读

场景应检查的问题
新 producer → 旧 consumer新字段被忽略还是导致拒绝?旧端是否误读单位?
旧事件 → 新 consumer历史数据能否通过版本 adapter 重放?
新 consumer 回滚已写入的新状态能否被旧版读取?
重复投递是同一个业务事件,还是新的操作?幂等键是否保持语义?

增加可选字段通常比改变字段含义更容易兼容,但仍取决于读取器是否严格、默认值如何解释。把字段改名而不提供迁移,也可能让旧数据在回放时失效。

MCP 关注模型应用与工具/资源集成,A2A 关注 Agent 之间的任务协作,OpenAPI 描述 HTTP API,AsyncAPI 描述异步接口。它们不能自动推导你的金额单位、授权责任或业务幂等规则。应先确定业务契约,再选择承载方式。

4. 轻量练习

把 v1 输入改为 12.312.345,判断为什么这里选择拒绝而不是猜测舍入。也可以试 currency:'EUR',验证固定 USD 边界。只选一个即可。

深入问题:若外部系统把 amountMinor 放到字符串中以支持超大整数,内部要怎样改变?可能需要 BigInt 或十进制库,同时重新定义 JSON 序列化。这是迁移决策,不是简单扩大一个 number 类型。

5. 专业材料与未来连接

  • OpenAPI 3.1.1 Specification:选 Schema Object 与版本语义,理解结构描述和业务含义的差别。本例固定引用此版本,不宣称它是最新版本。
  • MIT 6.5840:结合 RPC 与状态章节,思考旧请求重试和历史状态读取的问题。

未来 Agent 互操作需要显式任务和权限合同;具身系统会把同样的单位问题放大为米/毫米、弧度/角度、世界坐标/机体坐标。这个金额 adapter 不能直接用于机器人,只是训练“类型正确不代表物理语义正确”的习惯。

配套日课:按需要补充理论

本实验贯穿一周,不要求一天做完。

可运行源码

src/learning/ai-systems/w07-contract-evolution.ts · 构建时直接读取源文件,避免讲义代码与实现各自漂移。

interface Money { amountMinor: number; currency: 'USD' }

export function normalizeMoney(input: unknown): Money {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Object required')
  const x = input as Record<string, unknown>
  let amountMinor: number
  if (x.version === 1 && typeof x.dollars === 'string' && /^\d+\.\d{2}$/.test(x.dollars)) {
    const [whole, fraction] = x.dollars.split('.')
    amountMinor = Number(whole) * 100 + Number(fraction)
  } else if (x.version === 2 && x.currency === 'USD' && typeof x.amountMinor === 'number') {
    amountMinor = x.amountMinor
  } else {
    throw new Error('Unsupported version or currency; v1 requires an exact two-decimal USD string')
  }
  if (!Number.isSafeInteger(amountMinor) || amountMinor < 0) throw new Error('Non-negative safe integer required')
  return { amountMinor, currency: 'USD' }
}

export function run() {
  const inputs: unknown[] = [
    { version: 1, dollars: '12.34' }, { version: 2, amountMinor: 1234, currency: 'USD' },
    { version: 2, amountMinor: 12.34, currency: 'USD' }, { version: 99, amountMinor: 1234, currency: 'USD' },
  ]
  return inputs.map(input => {
    try { return { input, normalized: normalizeMoney(input) } }
    catch (error) { return { input, rejected: error instanceof Error ? error.message : String(error) } }
  })
}