影子模式与灰度验证
要点
- 第八篇讲过一个非常重要的设计原则: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,你得到的不是干净的对照实验,而是一种互相污染的混合体验。
确定性哈希的作用,可以理解成两步:
- 先把一个稳定标识(比如
userId + experiment)映射成一个稳定整数 - 再把这个整数投影到固定桶位里,例如
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 个桶”的动作,而 < 10 则表示“前 10 个桶是实验组”。如果用随机数分桶,用户今天走旧版、明天走新版,体验会混乱,数据也会失真。
第二,实验分组信息必须进入 Trace。上面的代码把 experiment 和 variant 写入了 trace.span() 的 input 字段,这样排查时就能看到每条请求走的是哪个版本。如果分组信息没有记录进 Trace,你后面根本没法分析”这条异常请求到底属于对照组还是实验组”。
还有一个工程细节也很重要:确定性哈希只能保证“稳定”和“大体均匀”,但它不是密码学意义上的随机数生成器。对 A/B 分桶来说,这已经足够了,因为我们的目标不是安全,而是低成本地把用户稳定地摊到一组桶里。只要分布没有明显倾斜、样本量足够,实验就可以正常工作。
分桶之后,你就可以开始对比两组的关键指标:
| 指标 | Control | Treatment | 解释 |
|---|---|---|---|
| 记忆命中率 | 68% | 75% | 新版召回更强 |
| 平均检索耗时 | 95ms | 120ms | 新版更慢 |
| 单会话消息数 | 45 | 52 | 用户可能更愿意继续聊 |
这里有一个很重要的经验:不要只看直接指标。
像“用户单会话消息数”“次日留存”“中途退出率”这种间接指标,很多时候比技术指标更接近真实体验。
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. 一条完整的验证链路
把这三阶段串起来,你会得到一条非常清晰的升级路径:
- 影子模式 先确认新旧版本确实有差异,并收集对比数据
- A/B 分桶 再确认这种差异是否真的转化成了更好的用户体验
- 渐进发布 最后逐步放量,并用自动回滚控制风险
这条链路的本质是:先验证“不同”,再验证“更好”,最后验证“可稳定上线”。
7. 总结
这一篇可以看成是可观测性的最后一环。前两篇解决的是“看见发生了什么”和“定位问题在哪”,这一篇解决的是“当你准备改动系统时,如何避免把问题带到线上”。
真正成熟的 AI 系统,不是敢于频繁改节点,而是能用数据化的验证路径安全地改节点。