返回机制实验室

W02 · S08—S14 · 总 Day 98104

制品版本:回滚的对象不是一个模型名

用内容指纹和发布指针连接模型、Prompt、数据与策略。

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

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

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

核心问题

模型权重没变,系统回答却变了。可能是 Prompt、检索索引、分词器、工具参数、策略或预处理变了。只记录 model-v1 无法充分说明系统行为,也无法明确该恢复哪一组依赖。

对应 S08~S14。本周把“保存文件”推进到“识别一次运行使用的整组内容”。

1. 内容身份与发布指针是两种东西

内容身份描述字节或规范化值是否相同;发布指针描述当前选择哪个版本。前者应稳定,后者允许受控变更。一个 manifest 引用模型、数据、Prompt、策略等对象,它本身也可以得到一个指纹。

本例用 SHA-256 对规范化 JSON 做散列。键顺序先排序,数组顺序保留。因此 {a:1,b:2}{b:2,a:1} 指纹相同,而调换数组元素会改变指纹。规范化仅覆盖这份示例使用的有限 JSON 值,不宣称实现 RFC 8785 或跨语言签名标准。

特别注意:manifest 中写 model: toy-rules-v1 只是在散列这个名字,不是在验证真实权重。真实制品链还要记录模型文件内容 hash、不可变地址、依赖版本和获取方式,并验证引用对象没有被覆盖。

2. 运行并追踪三次选择

npm run learning:p2 -- w02

v1v2 只有 Prompt 文本不同。promptChangesBundle 应为 true;sameContentDifferentKeyOrder 应为 true。snapshots 存的是两份 manifest 字符串,releaseHistory 按 v1 → v2 → v1 记录选择。最后的 restored 恢复为旧 Prompt。

这展示“指针切回旧内容”的机制,不是线上发布。快照在内存里,进程退出就消失;没有模型被加载,没有正在处理的请求,也没有数据库 schema 迁移。因此不能从这个输出推断线上回滚一定安全。

3. 回滚为什么比恢复旧代码复杂

设新版本生成了不同结构的缓存或持久状态。即使旧代码、模型和 Prompt 都找回来,旧读取器可能不能理解新状态。需要分别考虑静态制品、可变数据、进行中的请求以及已经发生的外部副作用。

对象本例做了什么真实系统还要考虑什么
Manifest内容指纹与字符串快照来源、签名、保留期、依赖可获取性
发布指针三次选择记录并发更新、流量切换、观察窗口
运行状态未实现旧版能否读取新版 checkpoint
副作用未实现已发送的通知或已执行的动作不能靠换指针撤回

散列说明内容身份,不说明内容正确、安全、合法或来自可信作者。版本管理是解释与恢复的条件之一,不是质量证明。

4. 轻量修改

只修改 policy,观察 bundle 是否变化;再把字段顺序调整,观察身份是否稳定。随后在纸上增加 indexSnapshot:如果只回滚模型、不回滚检索索引,回答会不会恢复?把答案限定为你的场景,不给普遍保证。

可选延伸:给发布指针加 expectedPreviousId,讨论为什么比较并交换能发现两个发布者竞争。无需建设 registry 服务。

5. 专业阅读与下一阶段联系

  • MLflow Model Registry:阅读版本、alias 与 lineage 的区别,对照“不可变内容”和“可变引用”。这里没有安装或实现 MLflow。
  • FSDL 2022:选 Experiment Management,把实验输入、运行信息和输出制品分开记录。

后续研究需要固定的不只是随机种子,还有数据、实现、环境与比较条件。具身任务还会引入相机标定、控制频率、仿真器与场景版本。本周建立“完整上下文共同决定结果”的直觉,不声称消除了随机性或硬件差异。

简短笔记可以写:本系统最容易漏掉的一个依赖;它改变时旧结果为何不可复现;恢复时还需要哪种状态信息。

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

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

可运行源码

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

import { createHash } from 'node:crypto'

type Json = null | boolean | number | string | Json[] | { [key: string]: Json }

// A deliberately small canonical form for this lab's JSON values; not RFC 8785.
export function canonical(value: Json): string {
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
  if (value !== null && typeof value === 'object') {
    return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`
  }
  if (typeof value === 'number' && !Number.isFinite(value)) throw new Error('Finite numbers only')
  return JSON.stringify(value)
}

export const fingerprint = (value: Json) => createHash('sha256').update(canonical(value)).digest('hex')

export function run() {
  const v1 = { model: 'toy-rules-v1', prompt: 'cite evidence', dataset: 'synthetic-v1', policy: 'read-only' }
  const v2 = { ...v1, prompt: 'cite evidence and uncertainty' }
  // Strings represent exact manifest contents, not hashes of real model files.
  const snapshots = { [fingerprint(v1)]: canonical(v1), [fingerprint(v2)]: canonical(v2) }
  const releaseHistory = [fingerprint(v1), fingerprint(v2), fingerprint(v1)]
  return {
    manifests: { v1, v2 }, snapshots, releaseHistory,
    sameContentDifferentKeyOrder: fingerprint({ b: 2, a: 1 }) === fingerprint({ a: 1, b: 2 }),
    promptChangesBundle: fingerprint(v1) !== fingerprint(v2),
    restored: JSON.parse(snapshots[releaseHistory[2]]),
    boundary: 'In-memory manifest demo, not a registry, model signature, or deployed rollback.',
  }
}