一、AI 系统的变更为什么特别难归因

普通服务的灰度发布建立在一个被工程实践反复验证的假设之上:一次发布只改变少量确定的东西,出问题的时候把流量切回去,状态就复原了。这个假设在传统服务里基本成立,因为一次发布触及的变量是离散且可枚举的——一个新的镜像、一个新增的环境变量、一张改过字段的表。变更边界清晰,行为差异往往在几秒内暴露,回滚是一个纯粹的路由动作。

到了大模型应用上,这个假设几乎同时从三个方向失效。模型在变、提示词在变、检索索引在变,而且这三者不是并列的开关,它们是互相咬合的齿轮。你把模型从 A 换成 B,为了适配 B 的输出风格改了提示词模板,又因为 B 对长上下文的利用方式变了而把 top_k 从 5 调到 12——线上指标掉了,你怎么知道是哪一刀造成的?

更麻烦的是,这三层的失效模式会互相伪装。提示词改动导致的输出格式退化,在监控上看起来和模型能力下降几乎一样;而检索层换了切分策略导致的召回下降,往往要到两三轮对话之后才以"答非所问"的形式出现,它的所有即时信号都显得很正常。

1.1 传统变更的归因是"找到一个坏的构件"

在传统系统里定位问题的方式是二分查找:先怀疑依赖,再怀疑自己的代码,必要时回滚一半流量看指标是否恢复。这套方法成立的前提是指标和变更之间存在相对稳定的因果映射,而且坏的行为是确定性的——同样的输入,在新版本上一定以同样的方式失败。

生成式系统的输出本身带有采样随机性。同一个 prompt、同一个模型,temperature 不为零的时候两次输出不完全相同。这意味着你无法用"再看一次"来确认某条失败样本是变更引起的还是本来就存在的偶发。归因的对象从"哪次调用失败"变成了"这一类请求的失败率相对于基线提高了多少",这直接要求你有足够大的样本量和足够稳定的评测口径。

当输出是随机的,传统的二分定位法就失效了。你不能靠"再跑一遍"来复现问题,只能靠对照实验把噪声从信号里分离出来。

1.2 三层变更的耦合与"半生效"状态

把一次 AI 变更拆开看,通常是这样的结构:

  • 模型层:基座模型版本、微调版本、推理参数(temperature、top_p、max_tokens)、量化与推理框架版本;
  • 编排层:系统提示词、few-shot 示例、工具定义、结构化输出约束、重试与降级策略;
  • 检索层:embedding 模型、切分策略与重叠窗口、索引快照、混合检索的权重与重排模型、top_k 与阈值。

这三层里任何一层的变更都不是孤立地作用于输出。模型变了,提示词里的指令措辞可能不再最优;切分策略变了,同一个问题的召回结果集合就完全不同,而模型看到不同上下文的输出自然也不同。于是一次"只改了检索"的发布,实际上同时改变了输入分布;而一次"只改了模型"的发布,如果线上存在针对特定模型调优的提示词模板,实际上也同时改变了提示词。

这里有一个特别容易被忽略的陷阱:半生效状态。灰度期间,你可能只把模型流量切了 5%,但提示词的加载是全局的,或者检索索引的重建是分批完成的。这时候线上同时存在多种配置组合,而你的监控面板把它们汇总成了一个数字。汇总之后你看到的下降,可能是某个组合的问题被其他组合稀释了,也可能是某个组合的问题被放大了——无论哪种情况,你都失去了归因能力。

这不是理论推演。我在多个团队的实际排障记录里看到的模式是:一次包含模型升级的发布导致满意度下降,团队花了三天定位,最终发现真正原因是新模型的 tokenizer 对某个特定语言片段的处理变化,使某类短查询的检索分数整体偏移,而提示词的改动只是让这个偏移更容易被触发。三个变量,一个可观测的下游指标,三天。

二、先把版本标识做对:让一次请求可以被完整还原

在讨论任何灰度手段之前,有一个前置条件必须先满足:任意一条线上请求,都能在事后被还原成「哪个模型 + 哪个提示词 + 哪批检索结果」这个三元组。做不到这一点,影子流量、A/B 分流、回归门禁全都无从谈起,因为它们产出的都是无法定位的对比数据。

大多数团队在这一步的做法是把版本号散落在各个配置项里:模型版本存在模型配置文件里,提示词版本存在提示词库的表里,索引版本存放在检索服务的元数据表里。每张表单独看都是对的,但没有任何一张表能回答"这条请求当时用的是哪一版"。等到需要归因的时候,往往发现某个字段在三个月前被某次紧急操作覆盖过,而那次覆盖没有留下任何日志。

2.1 版本三元组与不可变快照

正确的做法是给每次变更生成一个不可变的变更标识,它由四个部分拼成,而不是三个:

  1. 模型标识:基座模型 + 微调权重 + 推理参数的完整指纹。参数也要算进去,因为 temperature 从 0.3 调到 0.7 就是一次真实的行为变更;
  2. 提示词标识:系统提示词正文 + few-shot 示例集合 + 工具定义的哈希;
  3. 检索标识:embedding 模型 + 切分参数 + 索引快照版本 + 检索参数与重排模型的哈希;
  4. 路由标识:这个请求实际落到了哪个变体上,包含灰度规则版本和分桶结果。

第四项经常被漏掉,但它在灰度期至关重要。同一个变更标识可能同时对应 10%、50%、100% 三种流量占比,如果你不知道某条请求当时落在哪一档,就没法把它归入正确的对照组。

关键约束是不可变。提示词的更新必须是追加式的:新版本写入一条新记录,而不是原地修改老记录。老记录要能在任意历史时刻被取回,因为你三个月后复查一次用户投诉的时候,需要取回的是三个月前的那一版提示词,而不是今天的。

2.2 请求指纹的构造与落库

有了变更标识,接下来要解决的问题是"怎么把它绑到具体的请求上"。做法是在请求入口处把各组件版本解析成具体值并计算指纹,写入 trace 上下文,然后随请求一起透传到所有下游子调用。

下面是一个最小实现。关键点是 build_bundle 在请求开始时就把解析结果固化下来,而不是留下一个引用或函数指针——这样即使配置在运行期被热更新,已经开始的请求仍然使用它开始时的那一组版本,不会出现同一次请求的前后两段逻辑看到不同版本的裂缝。

import hashlib
import json
from dataclasses import dataclass
from typing import Any, Dict, Optional


def digest(payload: Any) -> str:
    """对任意可 JSON 序列化结构做稳定哈希,生成不可变版本指纹。"""
    blob = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]


@dataclass
class Bundle:
    """一次请求所使用的完整配置快照,构造后不可变。"""
    model_ref: str                  # 基座模型与微调权重的标识
    infer_params: Dict[str, Any]    # 推理参数,参数本身也是变更的一部分
    prompt_hash: str                # 系统提示词与 few-shot 的哈希
    tools_hash: str                 # 工具定义的哈希
    retrieval_hash: str             # 索引快照与切分、检索参数的哈希
    serving_stack: str              # 推理框架与量化方式,排查性能问题时要靠它
    route: str = "control"          # 实际命中的灰度变体名
    bucket: Optional[int] = None    # 分桶结果,未分流时为空

    @property
    def bundle_id(self) -> str:
        """把整组配置折叠成一个短标识,日志里只需要带这一个字段。"""
        return digest({
            "m": self.model_ref, "i": self.infer_params,
            "p": self.prompt_hash, "t": self.tools_hash,
            "r": self.retrieval_hash, "s": self.serving_stack,
        })


def build_bundle(resolver, variant: str = "control") -> Bundle:
    """
    在请求入口处解析出完整配置快照。

    resolver 是只读接口,负责把逻辑名称解析为当前生效的具体版本:
      resolve_model(variant)     -> (模型标识, 推理参数字典)
      resolve_prompt(variant)    -> 提示词正文与示例集合
      resolve_tools()            -> 工具定义列表
      resolve_retrieval(variant) -> 索引快照与检索参数
    """
    model_ref, infer_params = resolver.resolve_model(variant)
    prompt_doc = resolver.resolve_prompt(variant)
    ret_cfg = resolver.resolve_retrieval(variant)

    # 检索要区分"配置"与"内容":配置进哈希;
    # 内容不进哈希,而是单独记录命中的 doc id,否则索引重建后无法区分两次采样。
    return Bundle(
        model_ref=model_ref,
        infer_params=dict(infer_params),
        prompt_hash=digest({"sys": prompt_doc.system, "shots": prompt_doc.shots}),
        tools_hash=digest(resolver.resolve_tools()),
        retrieval_hash=digest({
            "cfg": ret_cfg.params,            # top_k、阈值、混合权重、重排模型
            "embed": ret_cfg.embedding_model,
            "chunk": ret_cfg.chunk_params,
            "index": ret_cfg.index_snapshot,  # 索引快照版本号
        }),
        serving_stack=resolver.resolve_serving_stack(),
        route=variant,
    )

检索的实际采样结果——命中的文档 id 列表、最终得分、是否经过重排、是否因上下文预算被截断——要作为独立的记录落库,而不是事后用同样的参数重跑。因为向量库实现在细节上不完全确定,同一索引快照下两次调用的召回顺序可能存在微小差异,事后重跑得到的证据在事故复盘时是没有说服力的。这份数据的存储成本不低,但它是在事故复盘时唯一可信的证据。

2.3 三个容易踩的实现细节

哈希要覆盖嵌套结构。很多人只对提示词正文做哈希,结果 few-shot 示例换了但哈希没变,归因时完全看不出来。用规范化序列化再做哈希,才能保证对顺序和空白不敏感,同时对内容真正敏感。

配置解析要"早固化"。如果在模型调用时才去解析提示词版本,而配置中心恰好在这一刻热更新了,那么同一次请求可能看到两个版本的提示词。把解析提前到入口处是为了消除这个时间窗口,代价是长生命周期请求(比如流式输出)中途不能切换配置——但这本来就是我们想要的行为。

索引重建要有独立版本号。不要用"索引的最后更新时间"充当版本。向量库的增量更新和后台合并会让同一次重建产生多个内部状态,时间戳完全无法区分。正确做法是每次重建生成一个单调的快照号,检索配置里显式写明"这一组配置用的是哪个快照"。

三、影子流量:它到底能回答什么问题

影子流量(也叫镜像流量、双写)的做法是:真实用户请求照常路由到线上对照组,同时把同一份请求复制一份,投递到实验组的完整链路上跑一遍,实验组的结果不返回给用户,只用来做对比。

这个机制看起来平淡无奇,但它是唯一能在零风险前提下拿到"新链路在真实分布上到底会输出什么"的手段。而真实分布这件事本身是拿不到的——你手上的评测集再精心构造,也只是线上流量的一个有偏子集。所以影子流量在 AI 变更里的第一个用途很朴素:补齐评测集的分布盲区。

3.1 影子能回答的三个问题

问题一:格式与结构有没有被破坏。这是影子流量性价比最高的一项。把线上请求灌给新版本,然后对新输出跑一遍结构校验:JSON 能不能解析、必填字段在不在、枚举值合不合法、代码块有没有闭合。这项检查几乎零成本,而且能拦住相当大比例的"看着没问题其实全是乱码"型事故。

因为模型升级和提示词改动都极易引发这类退化:新模型的指令遵循度可能不如旧模型严格,few-shot 示例改成某种格式之后模型可能不再稳定遵守,在 JSON 输出约束上稍微改一个字符的描述就可能让解析成功率掉一大截。这些都是可以在影子期一次性统计出来、且完全可以在正式放量前修掉的问题。

问题二:语义漂移有多大。把实验组和对照组的输出并排放在一起,计算语义相似度,或者用一个固定的判分模型给出相对评分。这个指标不适合用来做"过不过"的判定,但非常适合回答"这次改动到底动了多少"这个问题。如果平均相似度还在 0.95 以上,说明这次变更基本没改变行为,那你就应该问自己:为什么要发这个版本?如果相似度掉到 0.6,那说明这是一次实质性的行为变更,必须走完整的回归门禁和 A/B 流程。

一个很实用的经验判断是:把语义相似度当作发布的"变化量度量",而不是"质量度量"。它告诉你这次变更的影响半径有多大,从而决定你要投入多少验证资源。

问题三:延迟与失败率是否在可接受范围。影子流量同样能暴露超时、限流、上下游连接失败这类工程层面的问题。它测出来的延迟数据在分布上是真实的(因为输入分布是真实的),虽然因为不占用真实用户的配额而略显乐观。

3.2 影子回答不了的三件事

影子的局限同样清晰,而且这些局限决定了它绝对不能单独承担灰度放量的判定职责。

它测不出交互层面的退化。用户拿到的是对照组的结果,实验组的输出被丢弃了。用户体验到的是完整的旧链路。那么新链路的输出会不会导致用户追问、会不会让用户困惑、会不会被当成噪声而放弃,完全不在观测范围内。一个典型的例子是回答变得更啰嗦:单轮看质量没明显下降,但用户读完之后仍然需要追问,那么在多轮场景里这个版本的价值是负的,而影子流量对此一无所知。

它测不出成本与配额的真实压力。影子请求要真实调用模型,成本是真的,只是没让用户承担。所以影子期看到的延迟曲线比真实放量后更平滑,等到真正切流量时,实验组占用的是共享的推理配额池,高峰期的排队时间会急剧上升,影子期完全没有这个现象。更糟的是,影子请求和真实用户请求同时抢配额,可能反过来拖慢对照组,让你在一个被自己污染的基线上做判断。

它无法回答"是否更好"。影子只能做新旧对比,不能直接判定新版本的质量。要判定"更好",必须有用户反馈作为外部标尺。而用户只跟对照组交互,产生不了针对实验组的反馈。这就是影子流量必须和 A/B 分流配合使用的原因——一个负责"会不会坏",一个负责"是不是更好"。

影子的价值在于"用零风险换一个更宽的观测面"。它不是放量的依据,它是放量的前置输入。

3.3 影子流量的三个真实坑

副作用污染。如果新链路上包含任何写操作——写数据库、调用下游接口创建工单、发送通知、扣减余额、触发外部动作——影子流量会造成真实的副作用。这是最危险的一个坑,因为一次影子流量回放可能在上游系统里造出成百上千条脏数据。处理方式是明确的:影子请求必须在上下文里携带一个标记,所有写操作路径都要检查这个标记并短路。关键是这个标记必须由框架统一注入,而不是靠每个开发者记得手写。靠自觉的方案在第一次赶进度上线时就会失效。

还有一个更隐蔽的变体:检索索引的访问本身可能改变状态——比如触发缓存预热、拉取远程分片、甚至触发自动扩容。这些变化通常无害,但如果你的索引存储是按查询计费的,或者你把命中率当成重要指标,那么影子流量会持续地、系统性地污染这些数字。

缓存与连接池污染。影子请求和真实请求共用同一个推理服务、同一套连接池和同一个本地缓存。你的实验组如果用不同的提示词但共用了语义缓存的 key 空间,就会出现实验组结果污染对照组缓存的经典问题。解决办法是缓存 key 里必须包含完整的 bundle_id,绝不能只用 query 文本。听起来是很显然的设计,但在很多系统里缓存 key 就是单纯的 query 字符串,因为在线上单版本时代这样做没有问题。

数据量与成本的放大。影子流量意味着实验组的调用量等于全量流量,成本直接翻倍。一个中等规模的应用,一天下来多出的推理费用可能是可观的数字。缓解手段有三个:按时间窗采样(比如只镜像全天流量的 20%)、按请求类型分层采样(高价值请求全量、低价值请求采样)、以及在进入模型调用之前做前置过滤(长度超限的、超时的、命中缓存的都可以跳过)。

采样必须是无偏的。用"只镜像前 20% 请求"的写法是典型的有偏采样,因为请求的时间顺序往往与业务负载相关,早高峰和低峰期的分布完全不同。应该按请求 id 哈希取模来采样,这样每个时间段的分布都被保留。

3.4 影子流量的落地形态

把上面这些约束落到配置上,大致是这样一份分流规则。这份配置里最关键的两行是 shadow.sample_rate 和 read_only_enforced:前者决定镜像比例,后者保证副作用被拦在发生之前。

# 影子流量配置片段:control 为线上对照组,candidate 为待验证的新组合
variants:
  control:
    weight: 1.0
    model: prod-model-a
    prompt: prompt-v7
    retrieval: { index: idx-2026-09-18, top_k: 5 }

  candidate:
    shadow: true                    # 结果不返回给用户
    mirror_of: control              # 影子请求以对照组的入参为准
    model: prod-model-b
    prompt: prompt-v8
    retrieval: { index: idx-2026-10-02, top_k: 8 }

routing:
  # 按请求 id 哈希取模,保证每个时间段的分布都与全量一致,避免按时序截取造成偏差
  shard_key: request_id
  shadow:
    sample_rate: 0.2                # 只镜像 20% 的真实流量
    skip_if_cached: true            # 命中语义缓存的请求不再重复计算
    max_input_chars: 12000          # 超长请求不进影子,避免成本与延迟失真
    strip_when:
      - internal_probe             # 内部探针与压测流量不参与镜像

safety:
  read_only_enforced: true          # 框架级拦截:带 shadow 标记的请求调用写接口一律拒绝
  cache_key_includes: [bundle_id]   # 防止实验组结果污染对照组缓存
  suppress_side_effects: [email, webhook, ticket_create, billing]

这里还有一个决策点:skip_if_cached 看起来省了钱,但它会让影子样本产生选择性偏差——被跳过的恰好是最容易命中的那类重复请求,而这类请求往往是最容易暴露问题的。是否跳过应该按你的目标来定:如果你想验证语义质量和格式稳定性,就不该跳过缓存;如果你只想监控延迟和失败率,跳过是合理的。

四、A/B 分流:它到底能回答什么问题

如果说影子流量回答的是"新链路会不会坏",那么 A/B 分流回答的就是唯一无法离线替代的那个问题:把结果返回给真实用户之后,它到底是不是更好。这个判断需要用户的真实反应作为标尺,而在线下环境里你永远拿不到这个标尺。

4.1 分流的正确单元是"变更组合"而不是单个变量

AI 场景下的一个常见错误,是把传统的单变量 A/B 习惯直接搬过来:这一次改模型,那就只切模型,其他全部保持不变。这个做法的问题是它把三件本该一起评估的事强行解耦了。

举个具体的例子。假设你要评估新模型,它在离线评测上比旧模型好 2 个百分点。但新模型的输出更长、更啰嗦。如果提示词还是针对旧模型调的那一版,其中有一段专门压制模型输出额外解释的指令,那么新模型很可能表面上更长是因为它更"听话",但实际上这些额外内容在产品场景里是噪声。如果你只切模型不调提示词,你测出来的结论是"新模型没有明显提升,甚至有轻微下降"——这个结论是对的,但它没有告诉你该怎么做。你接下来需要知道的是:如果同时调整提示词,新模型能不能超过旧模型?

所以正确的实验单元是用户可感知的完整组合。用户不知道也不关心你背后换了几个组件,他们只感知到"这次回答好不好"。实验单元应该对齐到用户的感知边界,而不是对齐到你的架构边界。

这个结论有一个重要推论:当一次变更同时动多层时,不要试图在一次实验里分离出每一层的贡献。如果你想要这个信息,正确做法是在离线阶段用受控实验去拆,而不是在在线分流上拆。在线分流要拆的话,样本量需求会指数级上升,因为你是用正交实验去测多个主效应,通常每个格子都要跑到足够的量——而 AI 应用的用户量未必支撑得起。

4.2 互斥分层:为什么必须做正交分流

假设你正在同时进行两个独立的实验:一个是检索层的切分策略调整,一个是模型版本升级。它们在时间上重叠,各自都想拿流量。如果没有分层,两个实验的实验组会重叠,同一部分用户同时处在两个实验里,结果就互相污染——如果都变好了你不知道是谁的功劳,如果一个变好一个变差你甚至无法区分是交互作用还是噪声。

标准解法是正交分流:把用户分成两套互斥的桶,比如按用户 id 哈希的末位分成四桶,实验 A 用桶 0、1,实验 B 用桶 0、2。这样两个实验共用桶 0 作为共同基线,各自独占一个桶,就能在合并分析时做正确的分解。

但在 AI 场景里还有一层额外的复杂性:会话内的粘性。如果一个用户的第 3 轮对话落到了实验组,而前两轮在对照组,会产生非常糟糕的体验,并且污染数据——因为对话历史里已经包含了不同配置产生的输出。分流必须以会话为单位,一个会话从头到尾绑定在同一个变体上。这在多轮场景里是硬约束,不是优化项。

还有一个容易被忽略的点:实验组之间也要互斥。如果你同时上线实验 A 和实验 B,两者的实验组用户不能重叠——因为 A 的实验组要评估的是"相对当前生产版本的变化",如果同时还叠加了 B 的变化,那这个差值里就混了 B 的贡献。真正允许多个实验组重叠的场景,只有在你采用"累积效应"假设并且分析时明确控制了所有其他因素的时候。

4.3 A/B 回答不了的三件事

把 A/B 的能力边界讲清楚,比讲它能做什么更有价值。

长尾质量问题看不见。A/B 只能观测到你能定义指标的事件。如果你在度量"用户是否点了有帮助"这个按钮的转化率,那么所有没有点这个按钮的用户就是未观测的。但真实的伤害经常发生在沉默的大多数身上——用户读完了,没点任何按钮,也不投诉,然后不再来了。这个损失在 A/B 面板上是完全看不见的,因为你的指标定义把它归到了"无信号"。

缓解办法是把"沉默"拆成几个可观测的代理指标:会话轮数变化、是否有后续追问、会话在多少秒后被关闭、用户是否在短时间窗口内重复发起相同主题的会话。这些代理指标都不完美,但它们能把一部分沉默的伤害显性化。

样本量不够时,结论不可靠。这是 AI 应用 A/B 最常见的翻车点。传统产品的转化率基线可能是百分之几,提升几个百分点就有统计显著性;而"答案有帮助"这类指标可能基线就在 60% 上下,你要检测 3 个百分点的差异,需要的样本量比前者大一个数量级。如果你的日流量只有几千,那么实验跑一周可能仍然没有结论。

更麻烦的是,很多团队在样本量不足时仍然会读出一个"显著"的结果——因为他们在跑实验的过程中反复查看数据,一旦某天看到 p 值小于 0.05 就立刻停止实验。这是一个真实存在的方法论错误,它把偶然的随机波动当成了结论,而且因为每天都在看,你几乎必然会遇到一次假阳性。

延迟和成本的真实压力测不出来。实验组只占一部分流量,它对共享推理资源的占用是按比例的。当实验组从 5% 涨到 50%,推理集群的负载特征会非线性变化——排队、批处理调度、KV 缓存命中率都会走向完全不同的区间。A/B 实验在低占比时观察到的延迟不能外推到高占比。

4.4 实验设计上的三个务实的建议

先定停机规则,再开始实验。在实验开始之前就写清楚什么情况下必须停止:样本量达标、观察窗口到期、或者任何一项护栏指标跌破阈值。这条规则应该在启动时就固定下来,而不是由看数据的人临场判断。护栏指标和优化指标要分开看:优化指标是"有帮助率",护栏指标是"响应时间"、"投诉率"、"单次会话成本"。护栏指标一旦触发,优化指标再好也要停。

护栏指标要设成硬阈值而不是统计检验。护栏的逻辑是"不能更差",而不是"可能有差异"。所以它应该是"实验组延迟超过对照组 20% 持续 10 分钟则停止"这种确定性规则,而不是对延迟做 t 检验看 p 值。

给每个实验设置唯一责任人。听起来像管理话题,但在工程上极其关键。多个团队同时上线实验,而实验面板是共享的,那么当指标波动时,没有人能确定这是某个实验引起的还是多个实验叠加的结果。责任人制度保证每个实验的启动和停止都有明确的人在盯。

手段能回答的问题回答不了的问题
影子流量格式是否破坏、语义漂移幅度、延迟失败率是否更好、交互退化、真实成本压力
A/B 分流用户是否更偏好、成本效率对比沉默伤害、长尾质量、小样本结论
回归门禁是否跌破可接受的质量下界质量是否提升、真实用户体验
线上监控退化是否正在发生、影响面多大退化的根因、具体是哪次变更

这张表是我认为整篇文章里最值得记住的部分:四种手段覆盖的是四个不同的象限,它们不是替代关系,缺任何一个都会在灰度期出现盲区。现实中的事故复盘里,"我们做了 A/B 就放心放量了"几乎总是后面跟着"但影子流量显示格式合规率掉了 15%"这种补充说明。

五、联合版本追踪:内容寻址与可重放

第二节讲了怎么给配置打标识。但标识只是第一步,真正困难的是让这套标识在时间上保持稳定,让三个月前的一条 trace 还能被完整复现。这一节讲三个具体的技术选择,它们决定了归因能力的天花板。

5.1 提示词必须内容寻址,而不是名称寻址

最常见的错误设计是用名称和版本号引用提示词,比如 system_prompt@v7。问题在于 @v7 是一个会漂移的引用:如果你在 v7 基础上修了一个错别字,版本号会变成 v8,但线上仍然引用 v7;如果你出于紧急需要直接在生产环境改了 v7 的内容,那么所有历史 trace 里的 @v7 就全部指向了新内容,历史记录被静默篡改。

正确做法是内容寻址:提示词的标识就是它正文的哈希,而不是一个可以递增的计数器。名称只作为人类可读的前缀存在,用来帮助人检索;真正起区分作用的是哈希。这样就自然获得了两个性质:内容不变则标识不变,可以放心地在多个变体里复用同一份提示词;内容一变标识必变,不可能出现"改了内容但标识没变"的情况。

代价是你失去了"v7 变 v8"这种直观的历史感。缓解办法是同时记录一个单调递增的展示序号,它只用于人和审计看,不参与任何身份判定。这样你既有可读的历史,又有内容寻址的正确性。

还有一个容易忽略的坑:模板变量。如果提示词里用了模板语法,比如包含 {{用户等级}} 这样的占位符,那么对模板原文做哈希是不够的——同一个模板在不同用户等级下渲染出的实际提示词是不同的。正确做法是对渲染后的完整提示词做哈希,同时额外记录模板哈希。这样你既能按实际提示词做精确分组,又能在发现某个渲染分支有问题时定位到模板。

5.2 检索层的版本化比想象中难

检索层是最容易被漏掉、也最容易出问题的部分。它有三类需要独立版本化的东西,而且这三类的时间常数完全不同。

  • 配置类:切分尺寸、重叠比例、top_k、相似度阈值、混合检索权重、重排模型。这类变更立刻生效,容易追踪。
  • 内容类:索引快照。这个变更以小时或天为单位发生,而且是自发的——没人发版,文档库更新了,索引就变了。
  • 模型类:embedding 模型和重排模型。它们的变更会让旧索引失效,必须全量重建。

内容类的自发性是最大的麻烦。你的实验昨天开始,模型和提示词都没变,但今天索引因为文档库的自然更新而变了,那么实验前后的对照就不是同一个系统了。这不是理论问题——对于文档更新频繁的知识库,几乎是默认发生的。

解决办法有三种,成本递增:接受它并在分析时控制(把索引版本作为分层变量纳入分析),把实验锚定到固定的索引快照(实验期间强制使用实验开始时的快照,新文档不进入索引),或者双索引并行(对照组用生产索引,实验组用固定快照,代价是两套索引的维护成本和查询时延)。

对大多数团队,第二种是性价比最高的:灰度期间把索引快照显式固定并写进 bundle_id。这意味着灰度期内新入库的文档暂时不参与实验,等放量完成后再统一追平。代价是灰度期内新文档对实验组不可见,这个代价在大部分场景下是可以接受的,而且它比"实验结果不可解释"要好得多。

更根本的问题是文档库本身的状态也要被记录。索引快照号只能告诉你"这是第几次重建",不能告诉你"重建的时候库里有 12000 篇文档,其中 340 篇是上个季度新增的"。如果某篇关键文档恰好在实验中途被删除或修改了,你的实验结果里就混入了一个你没有控制的变量。在证据链要求高的场景下,需要把文档库的状态也做成不可变的版本——也就是文档内容寻址的那一套逻辑,只是作用在文档上。

5.3 可重放:让归因从推断变成证明

有了完整的版本标识,下一步是让归因从"根据日志推测"升级为"重放验证"。重放的价值在于它能把推理的不确定性降下来——你可以用同一个 bundle_id 跑一百次,观察结果的分布,而不是靠一次采样来判断。

下面是重放工具的骨架。三个要点:重放请求必须带上原始的 bundle_id 来解析配置,而不是用当前配置;必须显式指定采样参数和随机种子策略;要能导出同一 bundle_id 下的多次采样结果,用来区分"确定性失败"和"概率性偶发"。

"""
离线重放工具:给定 trace_id,还原当次的完整输入与配置,重复执行以观察结果分布。
"""
from collections import Counter
from typing import Any, Dict, List


class Replayer:
    def __init__(self, trace_store, bundle_store, executor):
        self._traces = trace_store      # 保存原始请求体、bundle_id、检索采样结果
        self._bundles = bundle_store    # 按 bundle_id 取回不可变配置快照
        self._exec = executor           # 执行一次完整链路的函数

    def replay(self, trace_id: str, runs: int = 20) -> Dict[str, Any]:
        trace = self._traces.load(trace_id)
        # 关键:用原始 bundle_id 解析配置,而不是当前生效的版本
        bundle = self._bundles.get(trace["bundle_id"])

        outcomes: List[Dict[str, Any]] = []
        for i in range(runs):
            # 检索层必须复用原始采样结果,否则重放的输入就不是当次的输入了
            ctx = {
                "messages": trace["request"].messages,
                "retrieved_docs": trace["retrieval"].doc_ids,   # 复用原始召回,不重新检索
                "retrieval_scores": trace["retrieval"].doc_scores,
                # 推理参数取自快照而非当前配置,否则重放的就不是同一件事
                "infer_params": bundle.infer_params,
                "force_deterministic": i == 0,   # 第 0 次固定种子,其余按真实分布采样
            }
            result = self._exec(bundle=bundle, ctx=ctx)
            outcomes.append({
                "ok": result.is_valid,
                "format_ok": result.structured_parse_ok,
                "cited_ok": result.citation_supported,
                "latency_ms": result.latency_ms,
                "cost_tokens": result.total_tokens,
            })

        return self._summarize(trace, bundle, outcomes)

    def _summarize(self, trace, bundle, outcomes) -> Dict[str, Any]:
        """区分确定性失败与概率性偶发,这决定了问题该怎么修。"""
        n = len(outcomes)
        fmt_fail_rate = sum(1 for o in outcomes if not o["format_ok"]) / n
        cite_fail_rate = sum(1 for o in outcomes if not o["cited_ok"]) / n
        return {
            "trace_id": trace["id"],
            "bundle_id": bundle.bundle_id,
            "model_ref": bundle.model_ref,
            "prompt_hash": bundle.prompt_hash[:12],
            "retrieval_hash": bundle.retrieval_hash[:12],
            "index_snapshot": trace["retrieval"].index_snapshot,
            # 关键判据:失败率是 0 还是有比例,决定了这是配置问题还是波动
            "format_fail_rate": round(fmt_fail_rate, 3),
            "citation_fail_rate": round(cite_fail_rate, 3),
            "verdict": "确定性失败" if fmt_fail_rate == 1.0 else
                       "概率性不稳定" if fmt_fail_rate > 0 else "稳定通过",
            "p50_latency_ms": sorted(o["latency_ms"] for o in outcomes)[n // 2],
            "mean_tokens": round(sum(o["cost_tokens"] for o in outcomes) / n, 1),
        }

重放结果里的 verdict 字段是实操中最有价值的输出。确定性失败意味着无论采样多少次都会失败,指向配置或结构约束的问题;概率性不稳定意味着这是一个需要在门禁里用成功率而不是布尔值来衡量的指标;稳定通过则说明这条 trace 只是偶然撞上了失败。这类区分能大幅缩短排障路径。

5.4 归因的三种粒度

有了数据之后,还要想清楚归因要在哪个粒度上回答问题。实践中至少有三种粒度,它们的成本和用途都不同。

单条 trace 粒度:回答"这一次调用到底发生了什么"。成本最低,数据量最大,适合回答"这一类失败样本的共同特征是什么"。做法是按 bundle_id 分组,统计各组内的失败率差异。

会话粒度:回答"这一整段交互的体验是否退化"。这是更贴近用户感知的粒度,因为很多退化只在多轮里才暴露。需要记录会话的 bundle_id 序列,如果一个会话跨了多个 bundle,那它本身就是一个分析对象。

人群粒度:回答"这次变更对哪类用户影响最大"。做法是按用户属性或者问题类型分桶看指标差异。这个粒度往往能发现最严重的问题——某次变更对一个垂直场景伤害很大,但对大盘指标的贡献小到看不见。如果只看全局指标,这类问题会被完全掩盖。

三种粒度都要有。全局指标是最好的健康信号,但它对局部退化的敏感度太低;单条 trace 最敏感,但容易陷入噪声;分桶是唯一能同时兼顾两者的方法。实践中的经验是:灰度期每天至少看一次分桶结果,而不是只看大盘。大盘告诉你"没事",分桶告诉你"某类用户正在被伤害"。

六、回归门禁:到底该卡哪些指标

回归门禁是在发布流水线上设卡:指标不达标就不允许进入灰度。它和影子流量的区别在于,门禁的判定是二值的、有强制力的,而影子流量的结论是给人参考的。门禁设计的核心问题不是"测什么",而是哪些指标能真的挡住该挡的东西,哪些指标只会制造噪声让人习惯性忽略。

一个被广泛接受的划分是三类:阻断型门禁一旦不达标就必须挡住发布;告警型门禁不达标时通知但不强制阻断;观察型门禁只记录、只在趋势明显恶化时才提示。把所有指标都设成阻断型是最常见的错误——结果是门禁经常误报,团队开始习惯性地忽略红灯,最后门禁形式上存在、实际上失效。

6.1 结构化门禁:最该做成硬阻断的一类

结构类指标的特点是判定确定、没有主观空间、误报率低,所以它们是唯一的天然阻断型门禁候选。

  • 可解析率:输出能不能被 JSON 或约定的结构成功解析。这一项应该是阻断型的硬门槛,因为它一旦跌破某个水平,下游所有基于结构的逻辑都会失效。
  • 字段完整率:必填字段的缺失率,按字段分别统计。整体完整率是一个会掩盖问题的指标——某个字段 100% 失败、另一个字段 100% 成功,平均下来可能还行。必须按字段拆开卡。
  • 枚举合规率:分类字段的取值是否在约定的枚举集合内。模型偶尔会产出训练数据里没有的类别名。
  • 引用可追溯率:如果产品要求给出处,那么每个引用标记是否都能在提供的上下文里找到对应片段。这一项应该是阻断型的,因为它直接对应"编造"这个最严重的失败模式。

这四项的共同特点是:它们衡量的失败是用户无法接受的失败,而不是"质量不够好"。用户宁愿看到一个粗糙但格式正确的答案,也不要一个漂亮但解析不了的答案。

6.2 语义质量门禁:必须承认它不精确

语义质量类指标(相关性、完整性、帮助程度)的根本困难是:它们本身是有噪声的测量。无论是用判分模型、规则匹配还是人工标注,你测到的都是"某个不完美的评判器对这个输出的看法"。所以这类指标不应该用"绝对值是否达标"来判定,而应该用相对基线的变化量来判定。

具体来说,门禁的正确形式是"相对基线的退化幅度不超过 X 个百分点",而不是"得分高于 Y 分"。后者要求你有一个跨版本、跨时间都稳定可靠的绝对标准,而判分模型的绝对分值会随着被评对象分布的变化而漂移——新的模型输出风格不同,同样的语义质量会拿到不同的分数。用相对量能自动吸收掉这种漂移。

另一个必须处理的点是判分器自身的版本漂移。如果你在用大模型做判分,那么判分模型本身也是一次变更。某次基线重建时悄悄换了判分模型版本,历史上所有基线数值就都失效了。判分模型的版本必须和被评对象的版本一样被记录进 bundle_id,否则一次门禁失败你连该怀疑谁都不知道。

实操上我建议对语义类门禁做两件事:一是用多次采样取成功率而不是单次通过率,前面讲过输出是随机的,单次样本的通过率估计噪声极大;给定真实的通过概率 p 和采样次数 n,成功率估计的标准差是 sqrt(p(1-p)/n),如果 p 接近 0.9 而 n 只有 20,标准差还有 6.7 个百分点,这个噪声足以让你在正确和错误之间来回摇摆。二是用配对比较而不是分组比较:对同一个输入同时评估新旧两个版本,然后统计"新版更好 / 持平 / 更差"的比例。这消除了输入难度差异带来的方差,是小样本条件下显著提高灵敏度的标准做法。

6.3 检索质量门禁:它常被忽略但影响最大

检索层变更是最常见的变更类型之一,也是最容易绕过门禁的类型。原因很直接:检索质量的变化往往不会立刻体现在最终输出上,因为模型有一定的容错能力——召回错了几篇文档,模型可能凭借自身知识或其余正确的文档给出看似合理的答案。于是检索退化被掩盖了,直到某次需要召回的信息比较冷门时才暴露。

要卡住检索层,必须在检索这一层单独设门禁,而不是只看最终输出。核心指标是黄金集上的召回指标:当你的业务里有明确的"这个问题应该由哪几篇文档回答"的标注时,Recall@k 是个直接可用的指标。没有标注时可以退而求其次,用"最终答案中的关键实体是否出现在召回文档里"作为代理指标。

还有一个容易被忽略的检索门禁维度:上下文利用率。检索返回的文档有多少真正被模型用上了?如果 top_k 从 5 调到 20,而实际被引用的文档数量基本没变,那多出来的 15 篇只是推高了成本和延迟,还引入了噪声。这可以通过统计"进入上下文的文档中被引用或被提及的比例"来衡量。

6.4 成本与延迟门禁:被最普遍地漏掉的一类

很多团队的回归门禁只测质量,不测成本和延迟。这在早期阶段问题不大,因为那时候样本量小、影响有限。但一旦上了规模,成本门禁就是必须项,原因有两个。

第一,质量和成本高度相关,且方向可能相反。一个很自然的"优化"是让模型输出更详细、把 top_k 调大、加一轮自检重写——每一个都能提升质量指标,而每一个都在推高成本。这类改动如果不受门禁约束,会在你追求指标的驱动下一次又一次累积,直到成本曲线失控。

第二,延迟退化会在放量后才显形。同样输入下,新版本的输出 token 数增加 40%,在门禁的固定样本上你可能只看到 P50 延迟涨了 5%,因为 P50 反映的是分布中位数,而输出长度的分布右偏得很厉害,中位数会系统性低估影响。必须看 P95 和 P99。

成本门禁至少要卡两个量:单次请求的平均 token 消耗(输入和输出分别卡,因为它们的优化方向经常相反)和单位成功请求的成本。后者比前者更有意义:一次重试三次才成功的请求,真实成本是三次的量,只看单次成本会低估。

6.5 门禁设计的四条原则

门禁的基线必须来自同一份评测集。如果控制组和新版本的评测集不同,比较就毫无意义。评测集一旦确定就要冻结,任何一方都不能在自己那侧增删样本。

门禁要卡相对变化,不卡绝对值。绝对值会随时间漂移,也会让团队陷入"为了过门禁而调评测集"的坏习惯。

门禁的阈值要定期重估。上线初期基线很差,阈值定得宽一点是合理的;随着系统成熟,阈值应该逐步收紧。一个从不收紧的回归门禁,作用只会越来越弱——因为标准在提高,而门槛没动。

门禁失败时要能直接指出是哪个 bundle 字段的差异导致的。如果失败信息只有"质量分 71.3 低于阈值 72",那开发者还是要从头做一遍第二节讲的归因工作。门禁报告里应该直接带上控制组和实验组的 bundle_id diff:模型不同、提示词不同、检索不同,让归因一步到位。

门禁类别典型指标建议级别常见误设
结构可解析率、必填字段缺失率、引用可追溯率阻断设成告警,结果解析失败照样上线
语义配对比较的退化幅度、关键实体覆盖阻断或告警用绝对分值判定,被判分器漂移误导
检索黄金集 Recall@k、上下文利用率阻断只看最终输出,检索退化被模型容错掩盖
成本输入 token、输出 token、单次成功请求成本阻断只卡均值,不卡高分位,长尾请求拖垮整体
延迟P95、P99、首 token 延迟告警只看 P50,输出长度分布右偏导致低估

七、快速回滚的真实成本

"一键回滚"是发布系统里最让人安心的一句话,但它在 AI 应用里的成立程度,取决于你这次变更到底动了什么。模型、提示词、检索三层的回滚成本完全不在一个量级上,混在一起讲"快速回滚"是没有意义的。

7.1 回滚不是切流量,是重建一致性状态

传统系统的回滚之所以便宜,是因为状态是干净的:回滚之后,代码是旧的,数据结构和旧代码是兼容的,连接池里没有残留的旧格式对象,回滚完立刻就正确了。

AI 应用里这个前提经常不成立。任何在变更期间产生、且被系统记住了的产物,都会让回滚变复杂。这类产物至少有五类:

  1. 落库的输出:已经写进数据库、会话历史、工单系统的模型输出。回滚代码不会删除这些数据,用户下次继续对话时看到的还是新版本的历史。
  2. 缓存:语义缓存、摘要缓存、向量缓存。如果缓存 key 不含 bundle_id,回滚之后缓存里还留着新版本的结果,会持续产生不一致。
  3. 检索索引:如果新版本的 embedding 模型重建了索引,回滚到旧模型意味着旧索引可能已经被覆盖或删除,需要一次紧急全量重建——这是以小时计的操作,不是以秒计的。
  4. 微调产物与在线学习数据:如果有基于用户反馈的在线更新,回滚之后线上会继续学习,历史反馈可能已经被用来更新过权重。
  5. 外部副作用:已发出的通知、已创建的工单、已扣减的额度。这些在任何系统里都不可回滚,AI 应用只是让它们更容易被批量触发。

所以真正的回滚成本等于:切流量恢复的时间 + 上述五类产物重新达成一致的时间。前者通常是秒级,后者可能从分钟到小时不等,而事故复盘的结论应该看后者——用户感受到的是后者。

7.2 提示词回滚便宜,但不是免费的

提示词回滚之所以便宜,是因为提示词是纯粹的数据:改回去立刻生效,没有外部依赖,不影响已经产生的数据。

但它有两个容易被低估的成本。第一是认知成本:提示词通常很长,回滚意味着要完整恢复一整段文本,而不是改一个参数。如果提示词是分模块拼接的,回滚某个模块时如果它和其他模块有隐式耦合(比如一处去掉了某个约束,另一处的措辞就依赖了这个约束的存在),单独回滚一个模块可能产生一个从未被测试过的组合。

这是提示词版本管理最隐蔽的风险:模块化的提示词会产生组合爆炸,而灰度系统只测试了被验证过的那些组合。如果你的实验里动了三个模块,灰度期验证的是这三个模块的新组合,回滚时你回到的是一个新组合而不是老组合。

解决思路是给回滚定义粒度:要么整体回到最后一个已知良好状态(此时灰度期的所有实验都得停),要么只回滚一个模块(此时必须验证这个新组合)。第二种做法更灵活但要求你有能力快速验证任意组合,这又回到了回归门禁的能力问题。

第二是权限与流程成本。如果提示词是业务方直接编辑的,那么回滚意味着业务方需要重新编辑,而他们通常不具备判断"这个版本哪里坏了"的能力。所以提示词回滚的技术路径应该由工程侧准备好并预置——把上一个已知良好版本的完整提示词作为候选版本提前准备好,回滚时只需要一次切换动作,而不是临时去找内容和拼接。

7.3 检索层回滚:可能是最贵的一层

检索层回滚的成本取决于变更类型。改 top_k 或者改混合权重,回滚就是改一个参数,秒级。但下面这几类是真正昂贵的:

换了 embedding 模型。旧模型和新模型的向量空间完全不兼容,意味着旧索引不能被新模型使用。回滚需要保留旧索引并且不能让它被垃圾回收,同时新旧两套索引要并存运维。对于数据量大的一方,紧急重建一次索引的耗时可能是数小时。

改了切分策略。切分策略的变更和 embedding 模型一样会导致索引不兼容,但它更隐蔽——因为很多向量库的元数据里记录了切分参数,混用时不会报错,只会静默地降低召回质量。所以切分策略的版本必须和索引快照强绑定,不允许在同一个快照号下混用两种切分策略。

改了文档内容本身。这是最麻烦的一种。如果这次变更是"修正了一篇错误的文档"或者"删除了一批过期文档",那么回滚在语义上是不可接受的——你不会为了让指标好看而把错误的内容放回去。这种情况下"回滚"这个动作在业务上根本不成立,你只有向前修复这条路。

因此正确的做法是在设计变更方案时就给每层标注回滚能力等级,把它作为变更评审的一部分。见下面的分级表。这个标注的作用是让"我们这次可以快速回滚"这句话变成一个可以被验证的判断,而不是一个乐观的承诺。

层级回滚动作典型耗时前置条件
提示词模块切回上一个已知良好版本秒级版本内容寻址,旧版本可取回
模型推理参数改回旧参数组合秒级参数随 bundle 一起记录
模型权重版本切回旧部署的推理实例分钟级旧版本未被回收,具备并行容量
索引与检索配置切回旧索引快照与参数分钟到小时旧索引保留,切分策略与快照强绑定
embedding 模型切回旧模型并紧急重建索引小时级旧模型可部署,需保留全量重建能力
文档内容变更语义上不可回滚不适用必须在评审阶段就标记出来

7.4 把回滚做成常态,而不是事故时的例外

如果回滚是一条只在事故时才走、平时从不测试的路径,那么它在你真正需要的时候大概率是不工作的。至少要做到三件事。

定期演练。每隔一段时间在低峰期真的执行一次回滚,测量真实耗时并记录。这个数字才是你事故时能给出的承诺,而不是"应该很快"。很多团队在演练时才发现,回滚脚本依赖某个已经下线了两个版本的内部接口。

保持双版本并行容量。回滚的前提是被回滚的版本还在运行。如果模型版本一上线就把旧实例缩容到零,那么"回滚"实际上是"重新部署",耗时从秒级变成小时级。折中做法是旧版本保留最小容量(比如 10%),这样既不浪费太多成本,又保证了回滚的即时性。

把可回滚性作为设计约束。这条最容易被忽略,但它反过来约束了很多设计决策:缓存 key 必须含 bundle_id;提示词必须内容寻址;索引快照必须显式声明;副作用必须能被框架级拦截。这些约束看起来像洁癖,但它们唯一的作用就是让回滚这个动作保持简单。当有人想简化其中任何一条时,应该被问的问题是"这会让回滚变难吗"。

八、灰度期到底该盯什么信号

前七节讲的是怎么设计,归因是能力,而监控是感官。灰度期的问题往往不在于你没能力查,而在于你没在还来得及的时候去查。这一节按"信号出现的速度"给出一份监控清单——从秒级到天级分层,因为不同的时间尺度对应完全不同的处置动作。

8.1 分钟级信号:立刻可观测,用于快速止损

这类信号的共同特征是零延迟:请求一发生就能观测到。它们不可能告诉你"为什么",但能告诉你"必须马上停"。

  • 错误率与异常类型分布:超时率、限流率、上游连接失败率,以及最关键的——异常类型的分布变化。总错误率持平但错误类型构成变了,往往是新版本引入了新的失败模式,这是灰度期最灵敏的信号之一。
  • 输出长度分布:token 数的 P50 和 P95。输出突然变短往往意味着模型开始偷懒或拒答,变长往往意味着在兜圈子或者重复。这两个都是早期预警。
  • 结构合规率:前面说过的解析成功率,它在灰度期应该被当成实时指标监控,而不是只在门禁阶段测一次。
  • 延迟分位数:P50、P95、P99 以及首 token 延迟。灰度期占比低,资源压力小,所以延迟的异常往往要到占比上去才明显。但异常出现得越早越好。
  • 空输出与截断率:因为 max_tokens 触顶而被截断的比例上升,意味着用户拿到的答案全都是不完整的。

分钟级信号必须做成按 bundle_id 拆分的面板。如果只有全局面板,实验组占 5% 流量,它的异常在全局曲线上会被稀释 20 倍,几乎必然被噪声淹没。这是灰度监控最常见的失效方式。

8.2 小时级信号:需要聚合才有意义

这类信号需要一定的样本量才能稳定,适合用来做放量决策。

拒答与降级率。包括模型显式拒答、触发了安全策略、走了降级路径、走了兜底模板。这类信号的变化通常反映的是"模型对某些输入的处理方式变了",是提示词和模型变更最典型的中间层表现。

引用支撑率。输出中声称来自某篇文档的内容,是否真的能在那篇文档里找到对应。这是抓"幻觉型"退化的最佳实时指标,而且它不需要人工标注——纯靠规则就能算。它的价值在于比任何主观判分都要快,且几乎无误报。

检索层的直接指标。空召回率、召回数量分布、相似度分数分布。空召回率上升说明查询和索引的匹配出了问题,这往往比最终答案的质量下降更早暴露。

缓存命中率和单请求 token 成本。命中率是灰度期最容易悄悄恶化的指标,因为它的变化不体现在任何质量指标上。如果新版本的提示词变长了,语义缓存的 key 空间或者相似度阈值可能不再适配,命中率会下降,而单次请求成本会上升——用户和业务感知到的就是"变慢了、变贵了"。

8.3 天级信号:真正的伤害大多在这里显现

前两类信号能抓住"坏了",但抓不住"变差了"——而后者是 AI 发布中更常见也更昂贵的问题。

会话轮数与重问率。如果新版本让用户更频繁地需要追问,说明单轮质量下降了。这是多轮场景下最有效的质量信号之一,因为它完全基于行为,不需要任何人工标注或者判分模型。

主动放弃率与超时退出率。用户在读到一半时离开,或者点开生成后立刻关掉。这两个行为在旧版本上如果在正常区间,新版本上出现上升就是明确的质量退化信号。

显式负反馈的绝对量与分布。点踩、投诉、人工客服升级。注意看分布而不只是总量:如果总量持平但投诉的原因分类变了,说明问题的性质变了——从"没解决"变成了"说错了",这是完全不同的排查方向。

按场景分桶后的指标差异。这是天级里最重要的一项。全局指标告诉你"没问题",分桶指标告诉你"某类场景正在被伤害"。实践中大量被漏掉的严重问题都是长尾场景的退化被大盘掩盖了。分桶的维度可以有:业务线、用户等级、问题类型分布、请求复杂度(输入长度、是否多轮)、地域或语言。

8.4 放量节奏:比指标更重要的是节奏本身

有了监控清单之后,剩下的问题是怎么决定什么时候放量。这里有几个实践上很有效的原则。

阶梯放量,且每级都有最小观测窗口。常见的阶梯是 1% → 5% → 20% → 50% → 100%。关键不在阶梯本身,而在于每一级必须待够时间,让前面讲的三类信号各自都有机会出现。1% 适合抓分钟级信号,5% 到 20% 适合抓小时级,20% 以上才可能看到天级的分布差异。很多事故的机制都是"放量过快导致观测窗口不足"——不是没有坏信号,是坏信号还没来得及从噪声里浮出来。

低峰启动,但不要在低峰启动后高峰放量。一个反直觉但正确的建议:放量决策的时间点要考虑流量结构。高峰期的请求分布和低峰期不同,尤其是长尾场景。如果你在低峰期放量看到"一切正常"然后直接推高峰,你相当于用低峰期的样本去推断高峰期的表现。

节假日前后不要放量。这不是技术判断,但影响真实。节假期的流量结构、数据分布、用户行为都显著异于常态,此时的实验结论向平日的迁移性很差。

留足回滚决策的窗口。放量到 50% 之后,至少要观察一个完整的业务周期(如果业务有明显的日周期就是 24 小时),再决定是否全量。这一条的作用是给天级信号留出时间——很多团队在 50% 停留了不到两小时就全量了,因为从数据上看"没问题",但那个"没问题"其实只是观测时间不够。

8.5 一个常见的反模式:只用单一北极星指标盯灰度

最后说一个我见到的频率很高的错误做法:灰度期间只看一个综合评分。这个评分通常是把若干个子指标加权平均得到的,看起来信息密度高,实际效果是彻底丧失了对局部退化的敏感度。

原因在于加权平均的性质。当某个子指标下降而另一个上升时,综合分可能纹丝不动,而实际上你已经在伤害某一类用户了。更糟的是,这种做法会主动引导团队去优化那个综合分,从而在无意中做出"拆东墙补西墙"的改动。

更合理的结构是分三层:北极星(唯一一个,反映这次变更的主要目标)、护栏指标(一组,任何一个越界就回滚,它们不参与加权)、诊断指标(按 bundle_id 和分桶拆开,只在出问题时用来定位)。护栏指标必须独立于北极星独立判定,这是整套监控结构里最重要的一条纪律。

九、把这一切固化到发布流程

前面八节讲的都是原理。原理不落到流程里,就会退化成"某个团队的最佳实践",而不是系统的能力。这一节讨论怎么把上面这些约束变成不依赖个人自觉的流程。

9.1 变更评审清单

一次同时动多层的变更,在提交评审时应当附带下面这份清单。它的价值不在于填表,而在于强制评审者在动手之前把影响面想清楚——大部分事故的根因,是在动手之前就没有人意识到自己动了三层。

  1. 变更范围:本次发布动了哪几层(模型 / 提示词 / 检索 / 编排),每一层的具体差异是什么。
  2. 回滚能力标注:每一层的回滚动作、预计耗时、以及是否语义上不可回滚。不可回滚的项必须在评审阶段显式指出并被接受。
  3. 评测集状态:本次用的是哪份评测集,版本号是什么,控制组是否在完全相同的评测集上跑过。
  4. 门禁结果:各分类门禁的相对变化,而不是绝对分数。同时说明哪些门禁缺失以及为什么。
  5. 实验设计:分流单元(组合而非单变量)、分流比例、观测窗口、停止规则、护栏指标与阈值。
  6. 索引影响:是否涉及索引重建,重建期间的查询策略是什么,新旧索引能否并存。
  7. 灰度节奏:阶梯方案、每一级的最小停留时间、谁在盯每一级的判断。

9.2 四个反模式

把实践中最常见也最昂贵的几个错误单独列出来,因为它们都有同一个特征:短期看起来是在提高效率。

反模式一:把三层变更拆成三次独立发布。直觉上这是"降低风险"的做法,但每次单独发布时你在评估的是一个部分优化的中间状态,这个状态往往比优化前更差。比如模型先切、提示词后调,那么在两次发布之间,线上跑的是一个你实际上并不想长期运行的新旧搭配。为它单独做一次完整的门禁和实验,成本是三倍,而中间状态本身没有价值。

更好的做法是:离线可以把三层拆开分别验证,在线必须作为一个组合一起放量。离线的受控实验帮你理解每一层的影响,在线的组合实验负责验证"这个组合是不是更好"。两者回答的是不同的问题,不该混用。

反模式二:只测最终输出,不测中间环节。这是前面说过的检索门禁问题的更general 版本。只测最终输出,会遇到两个麻烦:一是模型的容错能力会掩盖中间层的退化,二是最终输出的评估噪声大、信号弱。中间环节(检索结果、重排结果、结构化约束的满足情况)的评估是确定性的、廉价的、灵敏的,应该被优先测量。

反模式三:门禁设得很严,但阈值是拍的。阈值的来源只有三种,都比拍脑袋好:从历史分布的分位数取(比如"不能低于过去 30 天 P10")、从业务目标反推(比如"单请求成本必须低于 X 以保证单位经济模型")、或者从用户可接受的体验下界取(比如"P95 延迟不能超过 3 秒")。拍出来的阈值要么紧到天天误报让人习惯性忽略,要么松到什么都拦不住。

反模式四:把灰度当成上线前的最后一步。如果灰度是整个流程的最后一环,说明前面的验证能力不足,只能靠最后这一道防线。理想的形态是把灰度的思想前移到开发阶段:本地起一个影子模式跑真实流量、每次 PR 自动在固定的黄金集上对比 bundle 差异、把这些结果作为 PR 的一部分。这样到真正灰度的时候,你面对的是一个已经被反复验证过的候选版本,而不是一个第一次上线的未知组合。

9.3 基础设施的优先级

如果资源有限,只能先建一部分,合理的顺序是这样的。

第一优先:bundle_id 和 trace 透传。这是所有能力的地基。没有它,后面所有手段产出的都是无法定位的数据。这一步的成本最低、收益最高,通常只需要改请求入口和几处埋点。

第二优先:影子流量 + 结构化门禁。这两项加起来能覆盖"会不会坏"这个最高优先级的风险,而且都是确定性判定,误报率低。

第三优先:按 bundle_id 拆分的监控面板。这是止损能力的保障,成本主要在看板建设上。

第四优先:回归门禁的完整体系。包括语义门禁、检索门禁、成本门禁。这一步的投入最大,而且需要评测集和判分体系先就位。

第五优先:正交分流与会话级粘性。当你开始并行跑多个实验时才有必要,但一旦需要就会很痛。

值得注意的是这个顺序里没有"实时大盘看板",而很多团队的第一个投入就是它。这不是巧合——在归因能力建成之前,大盘看板只能告诉你"出事了",不能告诉你"为什么"和"怎么办"。

十、把这些规则套到一个具体变更上

前面讲的都是原则。原则最容易在脑子里成立、在实践中被忘掉,所以这一节把它们套到一个具体形态的变更上走一遍,看看每一条约束在什么地方起作用。

假设的场景是:要把一个面向客服场景的问答应用,从模型 A 升级到模型 B,同时调整系统提示词、增加两轮自检重写、并把检索的 top_k 从 5 提到 8。这是一次典型的三层同时变更,正好是本文开头说的那种难归因的情况。

10.1 先判断这次变更能不能拆分

按前面说的反模式一,这个变更不应该拆成三次上线发布。但它可以拆成离线的四次实验:先用模型 B 配原提示词跑一次,量化模型升级单独的贡献;再用模型 B 配新提示词跑一次,看提示词适配后是否更好;加上自检重写看它的边际贡献;最后调 top_k 看检索侧的边际贡献。

这四次离线实验的目的不是决定"上线哪个组合",而是理解每一层的行为。它会让你在评审时能说出"提示词适配带来了主要的提升,自检重写带来了 3 个百分点的额外提升但成本上升 40%"这种具体的话,而不是"感觉新组合更好"。

然后在线上,只发布这一个组合。线上实验回答的是完全不同的问题:这个组合在真实用户那里是不是更好。

10.2 这次变更的回滚能力是什么级别

按第七节的分级表来标注。模型权重从 A 到 B:回滚是切回旧部署的推理实例,分钟级,前置条件是旧版本保留最小容量。提示词改动:回滚是切回上一个已知良好版本,秒级,但要确认新提示词里是否新增了对自检重写的依赖——如果有,回滚提示词而不回滚编排逻辑会留下悬空引用。检索的 top_k:改一个参数,秒级,不涉及索引重建。

这三层都是可回滚的,组合整体也是可回滚的。评审结论是:可以走灰度,回滚预案的最大耗时是分钟级的模型切回。写清楚这个数字,比写"可快速回滚"有用得多。

还要注意一个组合层面的问题:这次变更里没有换 embedding 模型,也没有改切分策略,所以索引不需要重建,索引快照号不变。这一点必须显式写进评审记录——因为"索引是否变化"是最容易被忽略、但影响最大的一个字段。

10.3 门禁怎么设

结构类全部阻断:可解析率、必填字段、枚举合规、引用可追溯。这几项对模型升级尤其重要,因为新模型的指令遵循严格程度很可能和旧模型不同。

语义类按相对退化幅度阻断,阈值取 3 个百分点,判定方式用配对比较加多次采样。检索类阻断,黄金集 Recall@5 相对基线不许下降,Recall@8 的提升要单独看是不是值得这个成本。成本类阻断,输入 token 和输出 token 分别设阈值——自检重写必然会让输出 token 大幅上升,这个必须事先定好能接受的上限,而不是等上线后才发现成本翻倍。

延迟类只做告警,因为灰度期占比低,延迟数据的可信度本身有限。

10.4 灰度期盯什么,以及预期会看到什么

这次变更的合理预期是:质量指标小幅上升,输出 token 数明显上升,引用支撑率因为自检重写的存在而上升(重写阶段倾向于核对和补充依据),P95 延迟上升。如果实际观察到的形态和这个预期差异很大,本身就是一个值得警惕的信号。

具体到信号:分钟级盯结构合规率和输出长度分布,因为自检重写最可能引发的问题是输出被截断;小时级盯引用支撑率和缓存命中率,因为提示词变长很可能影响语义缓存的适配;天级盯会话轮数,因为如果多轮里上下文被自检重写的内容挤占,追问率会上升。

最后按 1% → 5% → 20% → 50% → 100% 放量,每级至少观察一个完整的流量周期,在 50% 停留至少 24 小时再决定是否全量。责任人固定,每级的判断看按 bundle_id 拆分的面板而不是全局面板。

这个走查花了大约二十分钟,但它覆盖了前面八节的所有约束。而如果不走这个流程,最可能发生的结果是:上线之后发现输出 token 涨了 60%、成本超预期、会话轮数上升,等定位到是自检重写加上提示词变长共同作用的时候,已经切了 50% 流量,而回滚旧版本会让成本回落但无法解释质量问题为什么只是缓慢改善。

结语

回到开头那个问题:一次变更同时动了模型、提示词和检索,怎么保证出了问题能归因?

我的答案分三层。最底层是标识能力:任意一条请求都必须能被还原成"哪个模型、哪个提示词、哪批检索结果",而且这个还原要靠内容寻址的不可变快照,不是靠会漂移的版本号引用。这一层做得不扎实,上面所有的灰度手段都会产出无法定位的数据。

中间层是手段的分工:影子流量回答"会不会坏",A/B 分流回答"是不是更好",回归门禁回答"是否跌破可接受的下界"。三者覆盖不同的象限,它们不是替代关系。真正的工程判断力体现在知道每一种手段回答不了什么——影子测不出交互退化,A/B 看不见沉默伤害,门禁判不了质量有没有提升。

最上层是纪律:护栏指标独立于北极星判定、每一级放量留够最小观测窗口、不可回滚的变更在评审阶段就标出来、以及每一条让回滚变简单的设计约束都要守。这几条不需要新技术,但比任何技术都更难执行,因为它们要求人在最忙的时候还按流程走。

AI 应用的发布本质上是一个永续的科学实验过程,而不是一次性的交付事件。这个转变带来的要求是:你必须有能力在任何时刻回答"当前线上跑的到底是什么配置",以及"这个配置和上次比变了什么"。这两个问题的答案,决定了你在事故中是拥有选择权,还是只能看着指标掉下去祈祷它自己恢复。