影子模式与灰度验证

要点

  • 第八篇讲过一个非常重要的设计原则:AI 管线里的节点应该独立、可替换
  • 最安全的验证方式是影子模式
  • 当影子模式证明”新旧结果确实有差异”之后,下一步才是把新版本真正放到一小部分真实流量里
  • 如果 A/B 的结果证明新版本总体更优,也不要立刻切到 100%
  • 很多线上事故不是因为没人会回滚,而是因为”发现异常时已经太晚了”

内容

1. 节点可替换,不等于可以放心替换

第八篇讲过一个非常重要的设计原则:AI 管线里的节点应该独立、可替换。

例如:

  • 记忆检索可以从纯向量检索升级为混合检索
  • 情绪分类可以从规则引擎升级为 LLM 分类
  • Prompt 模板可以从 v2 升级为 v3

但「工程上能换」,不等于「上线时安全」。真正困难的问题是:

这个新节点到底让效果变好了,还是只是换了一种出错方式?

这类问题很难靠单元测试解决,因为:

  • 用户输入空间太大,测不完
  • LLM 输出非确定,不能简单 assertEqual
  • 很多质量变化是渐进的,不会立刻报错

所以节点替换一定要有验证路径,而不是直接全量切换。

2. 第一阶段:影子模式

最安全的验证方式是影子模式。新旧节点同时执行,但只有旧节点参与正式回复;新节点只负责“陪跑”和留下对比数据。

// shadow.ts
async function memoryRetrievalWithShadow(
 
  env: Env,
 
  trace: TraceContext,
 
  query: string,
 
  sessionId: string,
 
  config: { shadowEnabled: boolean }
 
) {
 
  // 主路径:用旧版本的结果作为正式回复
 
  const mainResult = await trace.span(
 
    'memory_retrieval_v1',
 
    { query },
 
    () => retrieveMemoriesV1(env, query, sessionId)
 
  )
 
  if (config.shadowEnabled) {
 
    // 影子调用:故意不 await,让它在后台运行,不阻塞正式回复
 
    // .then() 里做结果对比,.catch() 确保任何错误都不会抛到主流程
 
    trace.span(
 
      'memory_retrieval_v2_shadow',
 
      { query },
 
      () => retrieveMemoriesV2(env, query, sessionId)
 
    ).then(shadowResult => {
 
      // overlap 是两个版本召回结果的重合度,用于判断差异大小
 
      logComparison(env, {
 
        traceId: trace.traceId,
 
        v1Results: mainResult,
 
        v2Results: shadowResult,
 
        overlap: calculateOverlap(mainResult, shadowResult)
 
      })
 
    }).catch(err => {
 
      // 影子模式的错误绝不能影响主流程,静默记录即可
 
      console.error('Shadow comparison failed:', err)
 
    })
 
  }
 
  return mainResult
 
}

上面代码中,影子调用用了 .then() 而不是 await。这个区别对新手来说很关键:如果用 await,程序会等新版本也执行完才继续往下走,用户就要多等一倍的时间;而用 .then() 的意思是"你先在后台慢慢跑,我不等你,先把旧版本的结果返回给用户"。

Drawing canvas 影子模式适合回答的问题是:

  • 新版本和旧版本结果差异大不大
  • 新版本召回条数有没有提升
  • 新版本平均得分有没有提升

它不适合回答的问题是:

  • 用户最终体验有没有变好
  • 实际回复质量有没有提升

因为新版本根本没被真正用于生成回复。

影子模式最关键的一条纪律是:绝不能影响主路径。

  • 不阻塞正式回复
  • 不修改状态
  • 不写正式业务数据
  • 出错也不能抛到主流程(所以影子调用必须有 .catch()
  • 只做比较和记录

3. 第二阶段:A/B 分桶对比

当影子模式证明”新旧结果确实有差异”之后,下一步才是把新版本真正放到一小部分真实流量里。

所谓 A/B 分桶,就是把用户分成两组:A 组(对照组)继续用旧版本,B 组(实验组)用新版本。对比两组的指标,就能判断新版本到底是变好了还是变差了。

关键问题是:怎么保证同一个用户每次请求都落在同一组?答案是用确定性哈希。

这里的“确定性”两个字非常关键,值得单独掰开讲。

先想一下,如果你不用确定性哈希,而是每次请求来了都 Math.random() 一次,会发生什么?

  • 用户这次请求可能进实验组,下一次请求又回到对照组
  • 同一个用户在一次会话里,前半段体验的是旧版本,后半段体验的是新版本
  • 如果这个节点本身会影响上下文、记忆召回、情绪状态或回复风格,用户感受到的行为就会来回跳变
  • 这样测出来的数据也会失真,因为你已经分不清“指标变化是版本差异导致的”,还是“同一个用户被来回切组导致的”

所以 A/B 分桶的第一原则不是“随机”,而是先稳定,再随机

“随机”解决的是总体样本分布要尽量均匀;“稳定”解决的是同一个实验单位不能来回切组。

这里还有一个统计学上很重要但常被忽略的点:实验分组的单位到底是什么?

  • 如果你的目标是比较“用户长期体验”,分组单位通常应该是 userId
  • 如果你的目标是比较“单次会话效果”,分组单位也可以是 sessionId
  • 但一旦你选定了实验单位,这个单位在整个实验期间就必须保持稳定

对于 AI 伴侣场景,通常更推荐按 userId 分组。因为用户体验是跨多轮对话累积出来的。如果同一个用户今天走 v1、明天走 v2,你得到的不是干净的对照实验,而是一种互相污染的混合体验。

确定性哈希的作用,可以理解成两步:

  1. 先把一个稳定标识(比如 userId + experiment)映射成一个稳定整数
  2. 再把这个整数投影到固定桶位里,例如 0-99

只要输入不变,输出就不变。于是:

  • user_123 + memory_retrieval_v2 永远落在同一个桶
  • 这个桶如果属于实验组,那这个用户在整个实验期都走实验组
  • 另一个用户 user_456 会因为哈希结果不同,大概率落到另一组

为什么输入里要拼上 experiment,而不是只对 userId 做哈希?因为不同实验必须相互独立

举个例子:

  • 实验 A:memory_retrieval_v2
  • 实验 B:prompt_template_v3

如果你只对 userId 做哈希,那么某个用户在所有实验里都会永远落在同一边,比如永远是 treatment。这样多个实验之间会高度耦合,你很难判断某次指标提升到底来自检索节点,还是 Prompt 节点。

而把 experiment 拼进去之后:

  • hash("user_123:memory_retrieval_v2")
  • hash("user_123:prompt_template_v3")

这两个结果会不同,同一个用户在不同实验里可能进入不同组。这样实验彼此解耦,分析才干净。

还可以把“分桶”理解成一条 0 到 99 的刻度尺:

  • 先通过哈希把用户映射到某个刻度,例如 7、42、88
  • 如果实验比例是 10%,那 0-9 算 treatment,其余算 control
  • 如果后面要扩到 30%,就把 0-29 作为 treatment

这就是为什么渐进发布也通常复用同一套 hash 逻辑。因为这样做有一个很好的性质:放量时只会新增一批用户进入新版本,不会让已经分好的用户来回跳变。

例如:

  • 用户 A 的桶位是 7
  • 用户 B 的桶位是 18
  • 用户 C 的桶位是 67

当实验比例为 10% 时,只有 A 进入新版本。

当比例扩大到 30% 时,A 仍然在新版本,B 新进入,C 仍然留在旧版本。

整个过程是单调扩张的,不会出现“昨天在实验组,今天又被踢回对照组”的情况。

// ab-test.ts
// 确定性哈希:同一个字符串永远返回同一个数字
 
// 这样同一用户始终落在同一实验组,不会今天走新版明天走旧版
 
function simpleHash(str: string): number {
 
  let hash = 0
 
  for (let i = 0; i < str.length; i++) {
 
    hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0
 
  }
 
  return Math.abs(hash)
 
}
 
// 你不需要理解上面 << 5 这些位运算的细节
 
// 只需要知道:给同一个字符串,这个函数永远返回同一个数字
 
// 根据用户 ID 决定分到哪一组
 
// control = 对照组(旧版本),treatment = 实验组(新版本)
 
function selectVariant(userId: string, experiment: string): 'control' | 'treatment' {
 
  const hash = simpleHash(`${userId}:${experiment}`)
 
  return hash % 100 < 10 ? 'treatment' : 'control'  // 10% 进入实验组
 
}
 
const variant = selectVariant(userId, 'memory_retrieval_v2')
 
// 根据分组决定用哪个版本
 
const isNewVersion = variant === 'treatment'
 
const spanName = isNewVersion ? 'memory_retrieval_v2' : 'memory_retrieval_v1'
 
const retrieveFn = isNewVersion
 
  ? () => retrieveMemoriesV2(env, query, sessionId)
 
  : () => retrieveMemoriesV1(env, query, sessionId)
 
// 用 trace.span() 包装,实验分组信息自然进入 Trace 的 input 字段
 
const memories = await trace.span(
 
  spanName,
 
  { query, experiment: 'memory_retrieval_v2', variant },
 
  retrieveFn
 
)

这里要注意两件事。

第一,同一用户必须稳定落在同一组。这就是 simpleHash 的作用——它对同一个 userId + experiment 组合永远返回同一个数字。这里的 hash % 100 本质上是在做“映射到 100 个桶”的动作,而 &lt; 10 则表示“前 10 个桶是实验组”。如果用随机数分桶,用户今天走旧版、明天走新版,体验会混乱,数据也会失真。

第二,实验分组信息必须进入 Trace。上面的代码把 experimentvariant 写入了 trace.span() 的 input 字段,这样排查时就能看到每条请求走的是哪个版本。如果分组信息没有记录进 Trace,你后面根本没法分析”这条异常请求到底属于对照组还是实验组”。

还有一个工程细节也很重要:确定性哈希只能保证“稳定”和“大体均匀”,但它不是密码学意义上的随机数生成器。对 A/B 分桶来说,这已经足够了,因为我们的目标不是安全,而是低成本地把用户稳定地摊到一组桶里。只要分布没有明显倾斜、样本量足够,实验就可以正常工作。

分桶之后,你就可以开始对比两组的关键指标:

指标ControlTreatment解释
记忆命中率68%75%新版召回更强
平均检索耗时95ms120ms新版更慢
单会话消息数4552用户可能更愿意继续聊

这里有一个很重要的经验:不要只看直接指标。

像“用户单会话消息数”“次日留存”“中途退出率”这种间接指标,很多时候比技术指标更接近真实体验。

4. 第三阶段:渐进式发布

如果 A/B 的结果证明新版本总体更优,也不要立刻切到 100%。

这里要先明确一个关系:渐进式发布不是 A/B 分桶的替代品,而是 A/B 分桶验证通过后的下一步。

前面的 A/B 分桶回答的是:新版本相对旧版本,是否更好。

而渐进式发布要回答的是:这种“更好”,在流量规模扩大之后还能不能继续成立。

这是两个不同层次的问题。一个版本在 10% 流量下表现更优,不代表它在 100% 流量下也一定更优。因为当规模扩大时,很多隐藏问题才会被放大出来,例如:

  • 下游存储在低并发下正常,高并发下开始排队
  • LLM 或向量检索在小流量下延迟稳定,放量后 P95/P99 明显恶化
  • 缓存命中率在局部样本下很好,放量后才暴露穿透问题
  • 某些极低频错误在 1 万次请求里几乎看不见,但到 100 万次时就会持续出现

所以渐进发布本质上不是“慢慢上线”,而是验证实验结论能否从小样本扩展到大样本

更稳妥的做法是分阶段放量:

  • 10%
  • 30%
  • 50%
  • 100%

这里的核心不在于百分比本身,而在于:每一档都要给系统留下观察窗口。

你可以是 5% -> 10% -> 25% -> 50% -> 100%,也可以是 1% -> 5% -> 20% -> 100%。比例怎么切不是最关键的,关键是每次扩大范围前,上一档的指标已经证明“暂时稳定”。

渐进发布为什么通常复用上一节的确定性哈希?因为它必须保留两个性质:

  • 稳定性:已经进入新版本的用户,下一轮放量时不应该被切回旧版本
  • 单调扩张性:放量只会新增一批用户进入新版本,而不是把现有样本重新洗牌

如果每次放量都重新随机一次,会发生很糟糕的事情:

  • 10% 时在实验组的用户,30% 时可能又回到旧版本
  • 你观测到的指标变化,混杂了“流量增加”和“样本重洗”两种影响
  • 对 AI 伴侣这种有记忆、上下文、长期行为的系统来说,数据污染会更严重

因此,渐进发布不能理解成“每次重新随机抽一批人”,而应该理解成:在同一条固定桶位刻度尺上,逐步扩大 treatment 覆盖范围。

沿用上一节 0~99 的桶位模型:

  • 0~9 表示前 10%
  • 0~29 表示前 30%
  • 0~49 表示前 50%

假设:

  • 用户 A 的桶位是 7
  • 用户 B 的桶位是 18
  • 用户 C 的桶位是 67

那么:

  • 在 10% 阶段,只有 A 进入新版本
  • 扩到 30% 时,A 继续留在新版本,B 新进入,C 仍在旧版本
  • 扩到 50% 时,A/B 继续保留,更多桶位落在 0~49 的用户加入

这个过程是“单调扩张”的。门会越开越大,但已经进门的人不会被赶出来。这正是渐进发布能够稳定观测长期行为的前提。

// rollout.ts
interface RolloutConfig {
 
  rolloutPercentage: number  // 0-100,新版本的流量占比
 
}
 
function getNodeVersion(userId: string, config: RolloutConfig): 'v1' | 'v2' {
 
  // 复用同一个 simpleHash,保证同一用户在放量过程中不会来回切换
 
  const hash = simpleHash(userId) % 100
 
  return hash < config.rolloutPercentage ? 'v2' : 'v1'
 
}

这里的 hash < config.rolloutPercentage,本质上仍然是在做“固定桶位截断”。

  • rolloutPercentage = 10 时,前 10 个桶进入新版本
  • rolloutPercentage = 30 时,前 30 个桶进入新版本
  • rolloutPercentage = 50 时,前 50 个桶进入新版本

阈值越来越大,但桶位本身不变。所以放量时新增的是新用户样本,而不是把旧样本全部重新打乱。

这样做的意义不是“慢一点”,而是“给系统留观察窗口”。很多问题不会在 10 个请求里出现,但会在 1000 个请求里出现。更准确地说,渐进发布是在给你两个缓冲:

  • 给系统一个缓冲,不要让潜在问题在全量用户面前一次性爆发
  • 给团队一个缓冲,让你有时间在风险扩大之前观察、定位、回滚

渐进发布阶段你最应该盯住的,是以下几类变化:

  • 错误率有没有上升
  • P95 延迟有没有恶化(95% 请求的耗时上限)
  • 降级率有没有上升(走了 degraded 次优路径的请求占比)
  • 关键业务指标有没有变差(如会话时长、次日留存)

这里还有一个很重要的实践经验:不要只看总体平均值,要同时看存量和增量。

  • 存量:已经在新版本里的那批用户,指标是否持续稳定
  • 增量:这一轮新加入的新用户,是否出现新的异常模式

如果存量用户一直正常,但增量用户一扩大就开始出问题,通常说明问题和“规模”有关,而不是和“版本逻辑”本身完全等价。例如容量瓶颈、地区差异、缓存热点变化、特定人群分布变化等,都可能在这个阶段才暴露出来。

另外,最好继续看分层指标,而不是只看全局平均:

  • 新老用户是否都稳定
  • 高活跃用户和低活跃用户是否都稳定
  • 某些地区、语言、设备类型是否异常
  • 某些关键节点(检索、生成、流式返回、降级链路)是否在放量后出现尖刺

因为全局平均值很容易掩盖局部问题。看起来“整体还好”,不代表某一类关键用户没有明显劣化。

5. 自动回滚:不要把回滚决策交给人临场判断

很多线上事故不是因为没人会回滚,而是因为”发现异常时已经太晚了”。

这里的”回滚”是指:发现新版本有问题时,立刻把流量切回旧版本。手动回滚的问题是反应慢——等团队发现异常、开始讨论、确认操作,可能半小时就过去了。所以更稳健的方案是提前定义自动回滚条件:

// auto-rollback.ts
// 这个函数通常由定时任务每隔几分钟调用一次
 
// Cloudflare Cron Triggers 是 Workers 的定时执行能力,可以配置"每 5 分钟运行一次"这样的规则
 
// 而不是在每个请求里检查——否则回滚检查本身会拖慢主路径
 
async function checkRollbackCondition(env: Env, experiment: string): Promise<boolean> {
 
  // 从上一篇的 KV Metrics 数据中计算两组的错误率
 
  const v1ErrorRate = await getErrorRate(env, experiment, 'control')
 
  const v2ErrorRate = await getErrorRate(env, experiment, 'treatment')
 
  // 实验组错误率超过对照组 2 倍,触发回滚
 
  if (v2ErrorRate > v1ErrorRate * 2) return true
 
  // 检索节点 P95 延迟超过 500ms(正常应在 100ms 以内),触发回滚
 
  const v2P95 = await getLatencyP95(env, experiment, 'treatment')
 
  if (v2P95 > 500) return true
 
  return false
 
}

这里的关键不是阈值具体是多少,而是你要在上线前就明确:

  • 哪些指标触发回滚
  • 回滚比较的是哪一组基线
  • 连续观察多久才算真正异常

否则到线上出问题时,团队很容易陷入讨论:这是短暂抖动,还是真的变差了?要不要再观察半小时?而这半小时往往就是事故扩大的窗口。

6. 一条完整的验证链路

把这三阶段串起来,你会得到一条非常清晰的升级路径:

  1. 影子模式 先确认新旧版本确实有差异,并收集对比数据
  2. A/B 分桶 再确认这种差异是否真的转化成了更好的用户体验
  3. 渐进发布 最后逐步放量,并用自动回滚控制风险

这条链路的本质是:先验证“不同”,再验证“更好”,最后验证“可稳定上线”。

7. 总结

这一篇可以看成是可观测性的最后一环。前两篇解决的是“看见发生了什么”和“定位问题在哪”,这一篇解决的是“当你准备改动系统时,如何避免把问题带到线上”。

真正成熟的 AI 系统,不是敢于频繁改节点,而是能用数据化的验证路径安全地改节点。