1. 节点替换的上线风险
第八篇介绍过一个设计原则:AI 管线中的节点应该保持独立,并且能够被替换。例如:
- 记忆检索可以从纯向量检索升级为混合检索
- 情绪分类可以从规则引擎升级为 LLM 分类
- Prompt 模板可以从 v2 升级为 v3
不过,工程上能够替换,并不代表上线过程足够安全。新节点可能确实改善了效果,也可能只是改变了问题的表现方式,因此不能直接通过全量发布来验证。
单元测试很难覆盖这类变化,主要有三个原因:
- 用户输入空间太大,测不完
- LLM 输出非确定,不能简单
assertEqual - 很多质量变化是渐进的,不会立刻报错
因此,节点替换需要一条逐步验证的发布路径,而不是直接切换全部流量。
2. 影子模式
第一阶段可以使用影子模式。新旧节点同时执行,但只有旧节点的结果参与正式回复;新节点在后台陪跑,只负责生成对比数据。
01async function memoryRetrievalWithShadow(02env: Env,03trace: TraceContext,04query: string,05sessionId: string,06config: { shadowEnabled: boolean }07) {08// 主路径:用旧版本的结果作为正式回复09const mainResult = await trace.span(10'memory_retrieval_v1',11{ query },12() => retrieveMemoriesV1(env, query, sessionId)13)1415if (config.shadowEnabled) {16// 影子调用:故意不 await,让它在后台运行,不阻塞正式回复17// .then() 里做结果对比,.catch() 确保任何错误都不会抛到主流程18trace.span(19'memory_retrieval_v2_shadow',20{ query },21() => retrieveMemoriesV2(env, query, sessionId)22).then(shadowResult => {23// overlap 是两个版本召回结果的重合度,用于判断差异大小24logComparison(env, {25traceId: trace.traceId,26v1Results: mainResult,27v2Results: shadowResult,28overlap: calculateOverlap(mainResult, shadowResult)29})30}).catch(err => {31// 影子模式的错误绝不能影响主流程,静默记录即可32console.error('Shadow comparison failed:', err)33})34}3536return mainResult37}
示例中的影子调用使用 .then(),而不是 await。如果使用 await,程序会等待新版本执行结束后再继续,用户也要额外等待这部分耗时。使用 .then() 后,新版本可以在后台运行,旧版本的结果则直接进入后续流程。
影子模式可以用来比较新旧节点本身的差异,例如:
- 新版本和旧版本结果差异大不大
- 新版本召回条数有没有提升
- 新版本平均得分有没有提升
但是,它不能直接回答下面两个问题:
- 用户最终体验有没有变好
- 实际回复质量有没有提升
原因在于新版本并没有真正参与回复生成,用户接触到的仍然是旧版本结果。
使用影子模式时,需要严格保证它不会影响正式处理流程:
- 不阻塞正式回复
- 不修改状态
- 不写正式业务数据
- 出错也不能抛到主流程(所以影子调用必须有
.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,因为不同实验之间应该保持独立。
举个例子:
- 实验 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
渐进发布通常也会复用这套哈希逻辑。这样扩大流量时,只会增加一批进入新版本的用户,已经确定桶位的用户不会反复切换。
例如:
- 用户 A 的桶位是 7
- 用户 B 的桶位是 18
- 用户 C 的桶位是 67
实验比例为 10% 时,只有 A 进入新版本。比例扩大到 30% 后,A 仍然使用新版本,B 新进入实验范围,C 继续留在旧版本。整个过程单调扩张,不会出现用户在实验组和对照组之间反复切换的情况。
01// 确定性哈希:同一个字符串永远返回同一个数字02// 这样同一用户始终落在同一实验组,不会今天走新版明天走旧版03function simpleHash(str: string): number {04let hash = 005for (let i = 0; i < str.length; i++) {06hash = ((hash << 5) - hash + str.charCodeAt(i)) | 007}08return Math.abs(hash)09}1011// 你不需要理解上面 << 5 这些位运算的细节12// 只需要知道:给同一个字符串,这个函数永远返回同一个数字1314// 根据用户 ID 决定分到哪一组15// control = 对照组(旧版本),treatment = 实验组(新版本)16function selectVariant(userId: string, experiment: string): 'control' | 'treatment' {17const hash = simpleHash(`${userId}:${experiment}`)18return hash % 100 < 10 ? 'treatment' : 'control' // 10% 进入实验组19}2021const variant = selectVariant(userId, 'memory_retrieval_v2')2223// 根据分组决定用哪个版本24const isNewVersion = variant === 'treatment'25const spanName = isNewVersion ? 'memory_retrieval_v2' : 'memory_retrieval_v1'26const retrieveFn = isNewVersion27? () => retrieveMemoriesV2(env, query, sessionId)28: () => retrieveMemoriesV1(env, query, sessionId)2930// 用 trace.span() 包装,实验分组信息自然进入 Trace 的 input 字段31const memories = await trace.span(32spanName,33{ query, experiment: 'memory_retrieval_v2', variant },34retrieveFn35)
这段实现有两个需要注意的地方。
第一,同一个用户必须稳定落在同一组。simpleHash 会为相同的 userId + experiment 组合返回相同数字,hash % 100 再把结果映射到 100 个桶,< 10 表示前 10 个桶进入实验组。使用随机数分桶会让用户在新旧版本之间切换,同时影响体验和数据可信度。
第二,实验分组信息必须写入 Trace。示例把 experiment 和 variant 放进了 trace.span() 的 input 字段,排查时便能确认每条请求使用的是哪个版本。如果缺少这些记录,后续就无法判断异常请求来自对照组还是实验组。
确定性哈希只能保证分组稳定并且大体均匀,它并不是密码学意义上的随机数生成器。不过 A/B 分桶的目标不是提供安全随机性,而是以较低成本把用户稳定分布到一组桶中。只要分布没有明显倾斜,并且样本量足够,就可以满足实验要求。
完成分桶后,就可以比较两组的关键指标:
| 指标 | Control | Treatment | 解释 |
|---|---|---|---|
| 记忆命中率 | 68% | 75% | 新版召回更强 |
| 平均检索耗时 | 95ms | 120ms | 新版更慢 |
| 单会话消息数 | 45 | 52 | 用户可能更愿意继续聊 |
评估结果时不能只看记忆命中率和检索耗时等直接指标。用户单会话消息数、次日留存和中途退出率等间接指标,往往更接近用户的真实体验。
4. 渐进式发布
即使 A/B 实验表明新版本总体更好,也不应该立刻把流量切换到 100%。
渐进式发布不是 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的用户加入
这个过程会保持单调扩张,覆盖范围逐渐增加,已经进入新版本的用户不会被切回旧版本。只有保持这一点,才能稳定观察长期行为。
1interface RolloutConfig {2rolloutPercentage: number // 0-100,新版本的流量占比3}45function getNodeVersion(userId: string, config: RolloutConfig): 'v1' | 'v2' {6// 复用同一个 simpleHash,保证同一用户在放量过程中不会来回切换7const hash = simpleHash(userId) % 1008return hash < config.rolloutPercentage ? 'v2' : 'v1'9}
hash < config.rolloutPercentage 仍然是在固定桶位上设置截断位置:
- 当
rolloutPercentage = 10时,前 10 个桶进入新版本 - 当
rolloutPercentage = 30时,前 30 个桶进入新版本 - 当
rolloutPercentage = 50时,前 50 个桶进入新版本
随着阈值增加,桶位本身不会变化。每次放量只是增加新的用户样本,而不会重新打乱原有样本。
分阶段放量是为了给系统留下观察窗口。很多问题不会在 10 个请求中出现,却可能在 1000 个请求中暴露。渐进发布可以提供两个缓冲:
- 给系统一个缓冲,不要让潜在问题在全量用户面前一次性爆发
- 给团队一个缓冲,让你有时间在风险扩大之前观察、定位、回滚
渐进发布期间,需要持续观察以下变化:
- 错误率有没有上升
- P95 延迟有没有恶化(95% 请求的耗时上限)
- 降级率有没有上升(走了 degraded 次优路径的请求占比)
- 关键业务指标有没有变差(如会话时长、次日留存)
除了总体平均值,还需要分别观察存量和增量用户:
- 存量:已经在新版本里的那批用户,指标是否持续稳定
- 增量:这一轮新加入的新用户,是否出现新的异常模式
如果存量用户一直正常,但每次扩大增量用户后就开始出现问题,通常说明异常与规模有关,不一定完全来自版本逻辑。容量瓶颈、地区差异、缓存热点变化和特定人群分布变化,都可能在这一阶段才暴露出来。
同时还要查看分层指标,而不是只观察全局平均值:
- 新老用户是否都稳定
- 高活跃用户和低活跃用户是否都稳定
- 某些地区、语言、设备类型是否异常
- 某些关键节点(检索、生成、流式返回、降级链路)是否在放量后出现尖刺
全局平均值很容易掩盖局部问题。整体指标正常,并不代表某一类关键用户的体验没有明显下降。
5. 自动回滚
很多线上事故并不是缺少回滚能力,而是发现异常和执行回滚的时间太晚。
这里的回滚,是在确认新版本出现问题后,立即把流量切回旧版本。手动回滚需要等待团队发现异常、讨论并确认操作,整个过程可能持续半小时。更稳妥的做法是在发布前定义自动回滚条件:
01// 这个函数通常由定时任务每隔几分钟调用一次02// Cloudflare Cron Triggers 是 Workers 的定时执行能力,可以配置"每 5 分钟运行一次"这样的规则03// 而不是在每个请求里检查——否则回滚检查本身会拖慢主路径04async function checkRollbackCondition(env: Env, experiment: string): Promise<boolean> {05// 从上一篇的 KV Metrics 数据中计算两组的错误率06const v1ErrorRate = await getErrorRate(env, experiment, 'control')07const v2ErrorRate = await getErrorRate(env, experiment, 'treatment')0809// 实验组错误率超过对照组 2 倍,触发回滚10if (v2ErrorRate > v1ErrorRate * 2) return true1112// 检索节点 P95 延迟超过 500ms(正常应在 100ms 以内),触发回滚13const v2P95 = await getLatencyP95(env, experiment, 'treatment')14if (v2P95 > 500) return true1516return false17}
具体阈值需要根据系统基线确定,更重要的是在上线前明确下面三件事:
- 哪些指标触发回滚
- 回滚比较的是哪一组基线
- 连续观察多久才算真正异常
如果这些规则没有提前确定,线上出现波动时,团队很容易反复讨论它究竟是短暂抖动还是真正退化,以及是否还要继续观察。决策过程中的延迟,往往会让事故影响继续扩大。
6. 完整验证链路
把三个阶段串联起来,可以得到一条完整的节点升级路径:
- 影子模式 先确认新旧版本确实有差异,并收集对比数据
- A/B 分桶 再确认这种差异是否真的转化成了更好的用户体验
- 渐进发布 最后逐步放量,并用自动回滚控制风险
整个过程先确认新旧节点是否不同,再验证新版本是否更好,最后判断它能否在更大规模下稳定运行。
7. 总结
前两篇分别解决了如何看见一次请求发生了什么,以及如何定位问题。这一篇继续补上发布验证,让节点改动在进入线上之前和放量过程中都有数据依据。
成熟的 AI 系统不仅需要支持节点替换,还要通过影子模式、A/B 分桶、渐进发布和自动回滚控制替换风险。