写作归档

影子模式与灰度验证

类型
文章
日期
2026/10/16
阅读
5 分钟
字数
1,329

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 },
  executionCtx: ExecutionContext
) {
  // 主路径:用旧版本的结果作为正式回复
  const mainResult = await trace.span(
    'memory_retrieval_v1',
    { query },
    () => retrieveMemoriesV1(env, query, sessionId)
  )

  if (config.shadowEnabled) {
    // 影子调用不进入正式回复,但必须交给 waitUntil 托管
    // 否则请求结束后,Workers 可能直接取消这个浮动 Promise
    const shadowTask = 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)
    })

    executionCtx.waitUntil(shadowTask)
  }

  return mainResult
}

示例中的影子调用没有使用 await,因为它不应该阻塞主路径;但也不能只写一个没有托管的 .then()。executionCtx.waitUntil() 会让 Workers 在响应返回后继续执行这项任务。waitUntil() 适合日志、对比记录等可以在时限内完成的工作;如果影子任务需要可靠重试或可能超过时限,应改为投递到 Queue,而不是依赖浮动 Promise。**Workers **waitUntil()** 文档**

示例为了突出流程直接把 query 放进了 Trace。生产环境应沿用上一篇的做法,只记录经过脱敏、截断或哈希处理的摘要,不能默认保存完整用户消息。

使用影子模式时,需要严格保证它不会影响正式处理流程:不阻塞正式回复、不修改状态、不写正式业务数据、出错也不能抛到主流程、只做比较和记录

影子模式可以用来比较新旧节点本身的差异,但是,它不能直接回答这两个问题:用户最终体验有没有变好?实际回复质量有没有提升?

3、A/B 分桶

当影子模式确认新旧节点的结果确实存在差异后,才适合把新版本接入一小部分真实流量。

A/B 分桶会把用户分成两组:A 组是继续使用旧版本的对照组,B 组是使用新版本的实验组。比较两组指标后,才能判断新版本带来的变化究竟是改善还是退化。

分桶首先要解决稳定性问题,也就是保证同一个用户的每次请求都进入同一组。这里可以使用确定性哈希。

如果每次请求都通过 Math.random() 重新选择分组,会出现几个问题:

  • 用户这次请求可能进实验组,下一次请求又回到对照组
  • 同一个用户在一次会话里,前半段体验的是旧版本,后半段体验的是新版本
  • 如果这个节点本身会影响上下文、记忆召回、情绪状态或回复风格,用户感受到的行为就会来回跳变
  • 实验数据会失真,无法区分指标变化究竟来自版本差异,还是同一个用户被反复切组

A/B 分桶需要先保证稳定,再保证随机。随机用于让总体样本尽量均匀,稳定则用于保证同一个实验单位不会在不同分组之间来回切换。

AI 伴侣的用户体验会跨多轮对话逐渐积累,因此我们可以按照 userId 分组。这里,我们使用确定性哈希来保证同一个用户始终落在同一组。

确定性哈希可以分成两个步骤理解:

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

只要输入不变,得到的桶位就不会变化。例如 user_123 + memory_retrieval_v2 永远落在同一个桶里,这个桶如果属于实验组,那这个用户在整个实验期都走实验组;另一个用户 user_456 会因为哈希结果不同,大概率

订阅后解锁剩余内容

已免费试看前 30%,订阅小册即可阅读全文。

订阅小册
0 条

还没有评论。

发表评论