对话类功能不能随机分配模型,否则同一用户会交替遇到两个性格和格式不同的版本;正确的做法是基于 stable key(如 conversation.id)做确定性分配,并给出了 FNV-1a 哈希实现。
模型灰度发布:模型迁移的灰度策略
模型迁移是一种生产变更,其影响范围异常广泛,失败表现却异常模糊。灰度发布与任何风险发布使用的手段相同——区别在于观察什么,以及每个阶段需要运行多久才能使观察结果有意义。
粘性分配,而非每次请求随机掷币
最直接的做法——每次请求掷一个随机数——对于对话场景是错误的。当用户的对话在不同模型之间交替时,对话会呈现两种人格、两种格式风格和两种谨慎程度。更糟糕的是,这使得对比毫无意义:你的两个分组并不是两个人群,而是同一个人群的两次抽样。
// 分配是一个稳定 key 的纯函数。无状态、无需协调,
// 请求链路上的每个服务都计算出相同的结果。
export function canaryModel(cfg: CanaryConfig, key: string): string {
const bucket = fnv1a32(cfg.experimentId + ":" + key) % 10000;
return bucket < cfg.rolloutBps ? cfg.candidate : cfg.baseline;
}
// 选择 key 的依据是必须保持一致的范围:
// conversation.id — 单个对话在线程中永不切换
// tenant.id — 整个工作空间使用同一模型(B2B 场景最佳)
// user.id — 个人在所有对话中保持一致
用 experimentId 给哈希加盐,其重要性超出表面。没有它,相同的租户永远落在每个实验的灰度桶中——所以你的早期采纳者永远是你的实验对象,他们遇到的任何相关问题都会污染你运行的每一次发布。
选择哪个 key 更多是产品决策而非技术决策。对于 B2B 产品,按租户哈希:一个工作空间中两个同事对同一问题得到明显不同的回答,会产生与质量无关的支持工单。对于独立会话的消费级产品,对话 id 能提供更细粒度的流量,从而更快得到结果。绝对不能在发布中途切换 key,因为这会打乱所有人的分组,丢弃你一直在积累的对比数据。
粘性分配有一个后果值得规划:你的两个分组在统计上不是完全相同的群体。落在灰度桶中的租户有自己的请求分布,在小百分比下这个差异可能比你正在寻找的效果更大。防护措施是比较每个分组与其自身近期历史,同时也要与另一个分组对比——一个候选模型比基线差,但不比同一批租户上周的表现差,这说明问题在于租户本身,而非模型。
阶梯,以及每一级需要多久
阶段大小通常选取整数。但更好是根据你需要观察什么来选取,使用与回归检测相同的样本量计算:要检测一个率 p 的绝对变化 δ,每分组大约需要 7.84 · 2p(1−p) / δ² 次请求。
这个计算的诚实结论:对于低流量功能,灰度发布无法在任意阶段大小下检测到细微的质量变化,装作能检测只会浪费一周时间。对于这类场景,依赖影子流量和离线评估,只把灰度发布用作冒烟测试。
触发器,规范化定义
"如果质量下降就回滚"这样的触发器不是触发器。每个触发器需要指定:指标、对比方式、阈值、最小样本数和动作。
triggers:
- name: hard_errors
metric: error_rate # 5xx, timeouts, refused connections
compare: candidate_vs_baseline
threshold: absolute +0.5pp
min_samples: 200
window: 10m
action: rollback # automatic, no human
- name: schema_validity
metric: schema_failure_rate
compare: candidate_vs_baseline
threshold: relative +25%
min_samples: 1000
window: 1h
action: rollback
- name: latency_p95
metric: ttft_p95_ms # TTFT, not total: streaming
compare: candidate_vs_baseline
threshold: relative +30%
min_samples: 500
window: 30m
action: hold # stop advancing, page nobody
- name: unit_cost
metric: cost_per_request
compare: candidate_vs_baseline
threshold: relative +20%
min_samples: 1000
window: 1h
action: hold
- name: refusal_rate
metric: refusal_rate
compare: candidate_vs_baseline
threshold: absolute +2pp
min_samples: 2000
window: 2h
action: rollback
其中两个设计选择值得明确说明。对比始终是候选模型与基线在同一窗口下的对比,而不是候选模型与固定数值的对比——否则提供商范围的减速会导致一个完全正常的灰度被回滚。而且并非每个触发器都执行回滚;hold 对于成本和延迟是正确的动作,因为这些是决策而非紧急情况。
质量触发器的缺席是故意的。基于评分规则判断的质量是采样的、滞后的且有噪声的——它无法在上述时间尺度上支持自动动作,而将其接入自动动作会产生由重新采样引起的回滚。质量属于阶段之间的晋升决策,由人查看分级样本后做出,而非属于自动化在阶段之间运行的依据。
还要注意 latency_p95 关注的是首个 token 到达时间而非总时长。候选模型如果生成更长的回答,其总时长必然更差,因此为此回滚等于以延迟回归的名义拒绝更冗长的模型。如果冗长是问题,它会体现在 unit_cost 上,而这才是你真正想要展开讨论的地方。
不对噪声回滚
min_samples 字段是防止灰度震荡的关键。没有它,1% 流量下的前 10 个请求偶尔会包含 2 个错误,触发器以 20% 错误率启动,然后灰度发布因一个从未存在的原因被放弃。
还有两个防护措施值得加入。要求条件在两个连续的评估窗口内持续成立才执行自动回滚,这样单次提供商的抖动不会推翻一周的工作。还要对自动化本身限速:如果一个灰度已经回滚两次,停下来并升级,而不是尝试第三次,因为一个自动重试失败迁移的系统最终会在凌晨 3 点无人值守时执行它。
回滚必须是配置变更
如果回滚意味着撤销提交并等待 CI,你的回滚时间就是你的发布时间,整个过程就成了演戏。候选模型和灰度百分比应该在一个运行时解析的开关中,可从本地缓存读取,嵌入默认值以使配置故障不会变成推理故障。
在需要它之前测试它:在 staging 环境中将 rolloutBps 设为 0,然后计时多久后最后一个请求被路由到候选模型。如果这个数字超过一分钟,现在就修复,而不是在迁移过程中才发现。
回滚还必须不仅仅是路由。候选模型可能需要不同的 max_tokens、措辞不同的工具描述或稍有不同的 prompt 才能表现良好——如果这些作为代码随模型作为配置一起发布,回滚模型后你会用候选模型的脚手架运行基线模型。将它们打包:开关应该选择一个命名配置,而不是一个模型字符串。
最后,在开始之前写下这次迁移的目的是什么。灰度发布告诉你候选模型是否更差;它不会告诉你它在你在乎的方面是否更好。如果目标是成本,晋升标准是保持质量不变的成本数字;如果是质量,则相反。在事后、查看结果时才做决定,迁移就会因为恰好移动的那个指标而被晋升。
如果路由已经发生在你的应用之外,百分比分割和即时回滚可以放在那里而不是你的代码中——在选中之前通过价格和上下文比较两个候选模型,也比把两者都接入你自己的客户端来发现要少做很多工作。
影子流量:在真实请求上测试新模型
模型和 Prompt 的特性开关
静默模型更新:检测行为变化