AI 电子伴侣
创建时间: 2026-03-24最后更新: 2026-08-04

1. 节点替换的上线风险

第八篇介绍过一个设计原则:AI 管线中的节点应该保持独立,并且能够被替换。例如:

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

不过,工程上能够替换,并不代表上线过程足够安全。新节点可能确实改善了效果,也可能只是改变了问题的表现方式,因此不能直接通过全量发布来验证。

单元测试很难覆盖这类变化,主要有三个原因:

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

因此,节点替换需要一条逐步验证的发布路径,而不是直接切换全部流量。

2. 影子模式

第一阶段可以使用影子模式。新旧节点同时执行,但只有旧节点的结果参与正式回复;新节点在后台陪跑,只负责生成对比数据。

shadow.ts
01
async function memoryRetrievalWithShadow(
02
env: Env,
03
trace: TraceContext,
04
query: string,
05
sessionId: string,
06
config: { shadowEnabled: boolean }
07
) {
08
// 主路径:用旧版本的结果作为正式回复
09
const mainResult = await trace.span(
10
'memory_retrieval_v1',
11
{ query },
12
() => retrieveMemoriesV1(env, query, sessionId)
13
)
14
15
if (config.shadowEnabled) {
16
// 影子调用:故意不 await,让它在后台运行,不阻塞正式回复
17
// .then() 里做结果对比,.catch() 确保任何错误都不会抛到主流程
18
trace.span(
19
'memory_retrieval_v2_shadow',
20
{ query },
21
() => retrieveMemoriesV2(env, query, sessionId)
22
).then(shadowResult => {
23
// overlap 是两个版本召回结果的重合度,用于判断差异大小
24
logComparison(env, {
25
traceId: trace.traceId,
26
v1Results: mainResult,
27
v2Results: shadowResult,
28
overlap: calculateOverlap(mainResult, shadowResult)
29
})
30
}).catch(err => {
31
// 影子模式的错误绝不能影响主流程,静默记录即可
32
console.error('Shadow comparison failed:', err)
33
})
34
}
35
36
return mainResult
37
}

示例中的影子调用使用 .then(),而不是 await。如果使用 await,程序会等待新版本执行结束后再继续,用户也要额外等待这部分耗时。使用 .then() 后,新版本可以在后台运行,旧版本的结果则直接进入后续流程。

正在加载图示...

影子模式可以用来比较新旧节点本身的差异,例如:

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

但是,它不能直接回答下面两个问题:

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

原因在于新版本并没有真正参与回复生成,用户接触到的仍然是旧版本结果。

使用影子模式时,需要严格保证它不会影响正式处理流程:

  • 不阻塞正式回复
  • 不修改状态
  • 不写正式业务数据
  • 出错也不能抛到主流程(所以影子调用必须有 .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,因为不同实验之间应该保持独立。

举个例子:

  • 实验 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 继续留在旧版本。整个过程单调扩张,不会出现用户在实验组和对照组之间反复切换的情况。

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

这段实现有两个需要注意的地方。

第一,同一个用户必须稳定落在同一组。simpleHash 会为相同的 userId + experiment 组合返回相同数字,hash % 100 再把结果映射到 100 个桶,< 10 表示前 10 个桶进入实验组。使用随机数分桶会让用户在新旧版本之间切换,同时影响体验和数据可信度。

第二,实验分组信息必须写入 Trace。示例把 experimentvariant 放进了 trace.span() 的 input 字段,排查时便能确认每条请求使用的是哪个版本。如果缺少这些记录,后续就无法判断异常请求来自对照组还是实验组。

确定性哈希只能保证分组稳定并且大体均匀,它并不是密码学意义上的随机数生成器。不过 A/B 分桶的目标不是提供安全随机性,而是以较低成本把用户稳定分布到一组桶中。只要分布没有明显倾斜,并且样本量足够,就可以满足实验要求。

完成分桶后,就可以比较两组的关键指标:

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

评估结果时不能只看记忆命中率和检索耗时等直接指标。用户单会话消息数、次日留存和中途退出率等间接指标,往往更接近用户的真实体验。

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 的用户加入

这个过程会保持单调扩张,覆盖范围逐渐增加,已经进入新版本的用户不会被切回旧版本。只有保持这一点,才能稳定观察长期行为。

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

hash < config.rolloutPercentage 仍然是在固定桶位上设置截断位置:

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

随着阈值增加,桶位本身不会变化。每次放量只是增加新的用户样本,而不会重新打乱原有样本。

分阶段放量是为了给系统留下观察窗口。很多问题不会在 10 个请求中出现,却可能在 1000 个请求中暴露。渐进发布可以提供两个缓冲:

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

渐进发布期间,需要持续观察以下变化:

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

除了总体平均值,还需要分别观察存量和增量用户:

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

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

同时还要查看分层指标,而不是只观察全局平均值:

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

全局平均值很容易掩盖局部问题。整体指标正常,并不代表某一类关键用户的体验没有明显下降。

5. 自动回滚

很多线上事故并不是缺少回滚能力,而是发现异常和执行回滚的时间太晚。

这里的回滚,是在确认新版本出现问题后,立即把流量切回旧版本。手动回滚需要等待团队发现异常、讨论并确认操作,整个过程可能持续半小时。更稳妥的做法是在发布前定义自动回滚条件:

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

具体阈值需要根据系统基线确定,更重要的是在上线前明确下面三件事:

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

如果这些规则没有提前确定,线上出现波动时,团队很容易反复讨论它究竟是短暂抖动还是真正退化,以及是否还要继续观察。决策过程中的延迟,往往会让事故影响继续扩大。

6. 完整验证链路

把三个阶段串联起来,可以得到一条完整的节点升级路径:

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

整个过程先确认新旧节点是否不同,再验证新版本是否更好,最后判断它能否在更大规模下稳定运行。

7. 总结

前两篇分别解决了如何看见一次请求发生了什么,以及如何定位问题。这一篇继续补上发布验证,让节点改动在进入线上之前和放量过程中都有数据依据。

成熟的 AI 系统不仅需要支持节点替换,还要通过影子模式、A/B 分桶、渐进发布和自动回滚控制替换风险。