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 分组。这里,我们使用确定性哈希来保证同一个用户始终落在同一组。
确定性哈希可以分成两个步骤理解:
- 先把一个稳定标识(比如
userId + experiment)映射成一个稳定整数 - 再把这个整数投影到固定桶位里,例如
0-99
只要输入不变,得到的桶位就不会变化。例如 user_123 + memory_retrieval_v2 永远落在同一个桶里,这个桶如果属于实验组,那这个用户在整个实验期都走实验组;另一个用户 user_456 会因为哈希结果不同,大概率