摘要:我在一个客服 Agent 上经历过一次"回滚花了 40 分钟"的事故——改动的只是系统提示里的 3 行文字,CI 全绿,4 分钟后自动全量发布,一次性解决率从 78.4% 掉到 53.7%,而所有技术指标(错误率、延迟、工具成功率)全程正常,没有一条告警被触发。真正的回滚动作只花了 30 秒,另外 39 分半都耗在"不知道该回滚什么"和"找不到上一个版本"上。本文的核心主张是:Agent 的 CI/CD 要先设计"回滚的是什么",再设计"流水线怎么跑"。具体给出四个可独立版本化的单元(代码 / Prompt / 模型 / 配置与工具集)、五层质量门禁(单测 → Mock 回放 → 离线 Eval → 影子流量 → 灰度,各自的时长、成本与拦截对象)、灰度的三个维度(流量比例 / 租户 / 能力开关)、灰度期的三层指标(技术 / 质量 / 业务代理,以及"停留多久"该由哪个指标决定),以及自动化回滚的实现与回滚演练。全部代码基于 Python 3.11+ 与 GitHub Actions,可直接运行。

📌 版本声明:本文基于 Python 3.11+、pytest 8.x、pytest-asyncio、GitHub Actions(托管运行器) 编写,撰写时间 2026 年 10 月。流水线示例遵循 GitHub Actions 官方工作流语法,灰度路由与门禁逻辑零第三方依赖。离线评估部分复用第 36 篇的评测集与 LLM-as-judge,Mock 回放复用第 35 篇的录制机制,流量入口与第 31 篇网关的路由层衔接。GitHub Actions 的具体语法与配额请以官方文档为准。

适用边界:适用于已上线、有真实用户、且会频繁改动 Prompt / 模型 / 工具的 Agent 服务。如果你还在原型阶段、每周重写整个 Agent,那么五层门禁里的后三层都是浪费——先把"Prompt 有版本号"这一件事做对就够。另外,本文讨论的是单服务内的发布策略,不涉及多服务编排的顺序发布与跨服务兼容性契约。

文章目录


一、为什么 Agent 的 CI/CD 不一样

1.1 一次事故:3 行 Prompt,40 分钟回滚

2026 年 1 月,我们的客服 Agent 做了一次"看起来完全无害"的改动。PR 内容是系统提示里的一段指令:

- 请先简要复述用户的问题,然后给出结论,最后列出依据。
+ 直接给出结论,依据用一句话说明。

改动动机很合理——有用户反馈"回复太长,先复述一遍很啰嗦"。评审时所有人都觉得没问题,因为它只是 Prompt 文本。

CI 跑了 38 秒全绿(当时只有单元测试),自动部署到生产,100% 流量一次性切换。下面是逐分钟复盘:

时间发生了什么谁发现的
T+0PR 合并(3 行 Prompt 改动)提交者
T+2 minCI 全绿(只有单测,没有评估)流水线
T+4 min自动部署,全量切到新版本流水线
T+9 min一次性解决率从 78.4% 掉到 53.7%无人(监控无告警)
T+11 min客服主管在群里问"今天的回复怎么这么短"人工
T+12~22 min值班怀疑模型供应商,查状态页 —— 正常值班
T+22~31 min怀疑网关限流,查第 31 篇那套指标 —— 全绿值班
T+31~38 min定位到"今天唯一的变更就是那次 Prompt 提交"值班
T+38~44 min想回滚但找不到上一个版本——Prompt 存在数据库里,没有版本号值班
T+44 min从 git 历史翻出旧文本,手动改库 + 重启生效值班
T+45 min一次性解决率恢复监控

这张表里最刺眼的是最后三行。 真正的"改回旧文本"这个动作只花了 30 秒;前面 39 分半全花在了两件事上:定位(31 分钟)+ 找上一个版本(6 分钟)。

而技术指标全程是这样的:

指标事故前事故中是否告警
HTTP 错误率0.31%0.29%❌ 无
P99 延迟4.2 s4.4 s❌ 无
工具调用成功率99.1%98.8%❌ 无
Token 消耗12.4 K/会话8.1 K/会话❌ 无(反而降了)
一次性解决率78.4%53.7%❌ 当时没监控

这是一次"技术上完全健康、业务上完全失败"的事故。 唯一显著变化的指标(一次性解决率)当时根本没接入监控——因为它需要用户行为数据回流,我们一直觉得"这是产品指标,不是技术指标"。

1.2 常规 CI 的四个假设,在 Agent 上全部失效

复盘时我把常规 CI 依赖的假设逐条列出来,发现四条全都不成立:

常规 CI 的假设Agent 的实际情况后果
测试是快的一次 LLM 调用几百毫秒到几秒,跑一个用例要几秒全量测试跑不动
测试是确定的同样输入两次输出可能不同(第 35 篇)断言会 flaky
失败信号是明确的返回 200、没报错,但答非所问监控抓不到
回滚 = 重新部署上一个镜像质量退化可能来自 Prompt / 模型 / 工具集,它们不在镜像里回滚根本没回滚到点子上

第四条是这次事故的根因,也是最容易被忽略的一条。 我们当时的回滚脚本很完善——能一键把镜像退到上一个 tag。但这次出问题的东西不在镜像里:Prompt 存在数据库里,是运营在后台配的。镜像回滚到上一个版本,Prompt 还是新的,问题依旧。

💡 一个判断标准:你的"一键回滚"能不能回滚最近三次生产事故? 如果答案是"能回滚两次,有一次是 Prompt 导致的所以不行",那么你的回滚设计就不完整。这个检验比任何设计评审都有效,因为它用的是真实事故而不是想象中的场景。

1.3 回滚的到底是什么:四个可独立版本化的单元

这是本文最重要的一张表。Agent 的一次发布里有四样东西在变,它们必须能被独立地、分别地回滚:

单元存在哪里变更频率回滚耗时目标为什么必须独立
代码版本镜像 tag / git sha低(周级)< 2 min逻辑 bug
Prompt 版本版本化的提示词仓库高(天级)< 30 s本次事故就是它
模型版本供应商的 model id中(月级)< 30 s供应商悄悄更新会退化
配置与工具集配置中心 / 开关中(周级)< 10 s某个工具挂了要单独摘掉

Prompt 的回滚耗时目标应该是最短的(30 秒),因为它的变更频率最高。 但我们当时的实际情况是:代码 2 分钟、Prompt 40 分钟——恰好是反的。这个错配是很多团队的现状:最高频变更的东西,回滚路径最不成熟。

在这里插入图片描述

图1:四个可独立回滚的单元——Prompt 变更最频繁,回滚却最慢

1.4 与系列前篇的衔接

本文不是独立的一篇,它是前面几篇的"合流点":

前篇本文复用的东西
第 35 篇(测试)Mock 回放的四策略、不变量断言 → L0/L1 层门禁
第 36 篇(评估)评测集、LLM-as-judge、退化检测 → L2 层门禁与灰度主判据
第 31 篇(网关)路由、鉴权、限流 → 灰度流量的执行点
第 37/38 篇(错误处理、降级)失败分类、降级路径 → 自动回滚的触发条件
第 39 篇(超时与幂等)幂等键、重试 → 发布期间的会话安全
第 40 篇(并发控制)会话串行、租户隔离 → 灰度期的租户白名单与会话粘滞
第 41 篇(数据安全)输出审核、脱敏 → 灰度质量门禁的一部分

理解这个衔接很重要:如果你的第 35、36 篇还没落地(没有评测集、没有 Mock 回放),那么本文的 L1/L2 层门禁是做不起来的——质量门禁依赖评估资产,不是买了工具就有。这也是我建议的落地顺序:先有评测集,再有门禁,最后才有灰度。


二、核心概念

2.1 四个版本的协同:一张清单串起来

既然有四样东西会变,就需要一个"发布清单(manifest)"把它们钉在一起。没有这张清单,你就无法回答"线上现在跑的是哪四个版本"这个最基本的问题——而本次事故里有 6 分钟就是耗在这个问题上。

ReleaseManifest v2026.01.17-3
├─ code_sha        : 8f3c1ad          (镜像可回溯)
├─ prompt_versions : { router: "a91f2c", reviewer: "7d30be", safety: "c5109a" }
├─ model           : "claude-x-20260101"(供应商 model id,不是别名)
└─ toolset         : "tools-2026.01.10"(工具 schema 的版本)

三个容易犯的错:

  1. 用模型别名(claude-latest)而不是具体 id。 别名意味着供应商更新模型时你会在不知情的情况下"发布"一个新版本——这是最隐蔽的一类变更,因为它没有对应的发布记录。必须用具体 id,把"升级模型"变成一次显式的、可评审的发布。
  2. 只给整个应用一个版本号。 那样你就没法单独回滚 Prompt,只能整体回退——而整体回退会把同一时期的其他正常改动一起退掉。
  3. 把工具 schema 散落在代码里。 工具的入参 schema 变化会影响模型行为(第 26 篇),它应该有独立版本,并且工具集的回滚应该能单独完成(某个工具挂了,只摘掉它,不回滚其他三个)。

2.2 五层质量门禁

Agent 的测试"慢、贵、不稳定",解法不是"少测",而是分层——让便宜的层拦掉大部分问题,贵的层只在必要时跑:

层跑什么时长成本拦截什么何时跑
L0 单测纯函数、schema、路由、状态机~38 s≈ 0代码逻辑错误、契约破坏每次提交
L1 Mock 回放录制的上游响应全量回放~2 min≈ $0.02编排错误、工具调用序列错误、契约回归每次提交
L2 离线 Eval评测集 + judge 打分~9 min≈ $1.8质量退化(本次事故本该在这层被拦)Prompt/模型/工具集变更时
L3 影子流量真实请求双跑、不返回给用户~15 min≈ $6分布漂移、真实输入的边缘情况发布候选
L4 灰度小流量真实用户小时级真实流量只有真实用户能暴露的问题每次发布

关键设计:L2 只在"影响质量的变更"时跑。 判断方法很直接——看改动路径:

agent/**/*.py         → L0 + L1
prompts/**            → L0 + L1 + L2          ← 本次事故就在这里
models/** (pin 文件)  → L0 + L1 + L2 + L3
tools/** (schema)     → L0 + L1 + L2
docs/**               → L0

💡 "Prompt 变更触发 L2"这一条规则,是本次事故最直接的修复。 如果当时有这条规则,那 3 行 Prompt 改动会在 PR 阶段跑一次离线评估,一次性解决率的代理指标(评测集上的"结论完整率")会下降,PR 会被拦下,根本走不到部署。成本是 9 分钟和 1.8 美元——对比 40 分钟的生产事故和 24.7 个百分点的业务损失,这个交换极其划算。

2.3 灰度的三个维度

灰度不是只有"流量百分比"一个旋钮:

维度机制优点缺点适用
流量比例按会话 ID 哈希分桶随机、无偏同一次会话可能跨版本常规能力迭代
租户白名单按 tenant_id 精确投放可找愿意试错的客户样本有偏大客户定制、B 端
能力开关功能级 flag,与版本解耦秒级回滚、可只开一个工具有开关债高风险能力、紧急熔断

三个维度不是三选一,是"叠加判定"。 我用的判定顺序是:开关 → 租户 → 流量比例。理由:开关是最精确的控制(能只开一个能力),租户是最可控的样本(能找愿意试错的客户),流量比例是最无偏的(兜底覆盖)。

"能力开关"在这个场景里有个特殊价值:它是唯一能在不重新部署的情况下回滚某一项具体能力的机制。第 33 篇讲过"部署 ≠ 发布",开关就是这个原则的落地——代码可以上线但功能不开启,出问题了关掉开关即可,无需任何部署动作。

2.4 灰度期看什么指标:三层指标与"停留多久"

这是"敢不敢放量"的核心问题。指标分三层,它们在"意义"和"出信号速度"上正好相反:

层指标举例出信号速度能否自动判定代表的问题
技术层错误率、P99、工具错误率、超时率分钟级✅ 能崩溃、依赖失效、死循环
质量层judge 分、要素完整率、引用准确率、幻觉率小时级(需采样 + 打分)⚠️ 部分能质量退化(本次事故)
业务层一次性解决率、转人工率、满意度天级❌ 不能真实价值损失

本次事故的教训是:技术层全绿,质量层崩了,业务层也崩了。 所以:

⚠️ 灰度期的"主判据"必须是质量层指标,不是技术层指标。 技术层指标只用来拦"崩溃型问题"(这类问题发生快、影响明确),它对"答非所问"这类退化完全无感——因为答非所问的服务在技术上"工作得很好"。

但质量层出信号慢,所以"停留多久"要由它决定:

停留时长 ≈ max(主判据指标达到所需样本量的时间, 一个业务周期)

举例:judge 分需要单臂 400 个样本才有统计意义;你的灰度桶(5% 流量)每小时进 150 个会话 → 至少停留 2.7 小时。如果你只停 15 分钟就放量,等于没做灰度——样本量根本不足以区分"真的没退化"和"还没测出来"。

具体的样本量算法见 5.1 节。

2.5 回滚的三个层次

"回滚"不是一个动作,而是三种:

层次做法耗时影响面何时用
流量回滚把灰度流量切回 0%秒级只影响灰度用户灰度期发现问题
配置回滚把 Prompt/模型/工具集 pin 回上一版秒级~分钟全部用户全量后发现问题
数据补偿订正已产生的错误数据 / 副作用小时级已产生的数据已经造成副作用(写库、发信)

前两层是"止血",第三层是"善后"。 本次事故只需要第二层(配置回滚),但因为 Prompt 没有版本化,我们连第二层都做不了。

第三层要单独强调,因为它常常被遗忘。 Agent 与传统服务的最大区别是它会执行真实副作用(第 26 篇的工具调用)。如果新版本在 2 小时里给 300 个客户发了错误的邮件,回滚版本并不能撤回那些邮件——你需要一份"影响面清单"和一份"补偿脚本"。所以发布前的检查清单里必须有这一项:这次变更会不会产生不可逆副作用?如果会,补偿方案是什么?

下面这张图是改造后的完整流水线。注意三个"判定菱形"——它们分别是 L2 门禁、L3 门禁和灰度门禁,对应三个不同的拦截目标:

失败

通过

失败

通过

否

是

质量退化超阈值

通过

否

是

分布异常

通过

不达标

达标

否

是

提交 PR

L0 单元测试 · 38 秒

阻断合并

L1 Mock 回放 · 2 分钟

改动是否触及提示词或模型或工具集

生成发布清单

L2 离线评估 · 9 分钟

阻断并附评估报告

是否发布候选

L3 影子流量 · 15 分钟

部署到灰度 · 1% 流量

灰度门禁 · 质量层主判据

自动流量回滚并告警

逐级放量 5% 20% 50%

每一级都过门禁吗

全量并提升为新的基线

把新基线写回基线清单

最后一步"提升为新的基线"是最容易漏掉的,但漏掉它整套机制会慢慢失效。 因为灰度门禁判的是"候选 vs 基线"——如果基线永远是一个月前的版本,那么每次比较的都是"新版 vs 远古版本",差异会越来越大,门禁要么永远通过(失去意义),要么永远不通过(没人信它)。基线必须在每次全量后滚动更新。

2.6 版本与回滚的分支模型

Prompt 和代码虽然存在不同的地方,但版本历史应该在同一个 git 仓库里——这是"找得到上一个版本"的前提:

main prompt/router-v13 hotfix/rollback-prompt 基线 v12 代码:重构路由 提示词:精简结论 发布 v13 灰度 1% 通过 灰度 20% 通过 全量 v13 → 提升为新基线 提示词回滚至 v12 v13-prompt-rollback v13.1 代码保持不变

这张图里最该注意的是最后两行:回滚那次提交只改了 Prompt,代码保持在 v13。这正是"独立回滚"的可视化——镜像没有重新构建,部署没有重新执行,只是把 prompt 的 pin 从 v13 改回 v12,30 秒生效。


三、环境准备

3.1 依赖与目录结构

python -m venv .venv && source .venv/bin/activate
pip install pytest==8.* pytest-asyncio pyyaml
agent-service/
├── agent/                    # 代码(L0/L1 覆盖)
├── prompts/
│   ├── router.v12.yaml
│   ├── router.v13.yaml
│   └── reviewer.v7.yaml
├── manifests/
│   ├── baseline.json         # 当前基线清单
│   └── candidate.json        # 本次发布候选清单
├── release/
│   ├── registry.py           # 版本登记与内容哈希
│   ├── manifest.py           # 发布清单与指纹
│   ├── router.py             # 灰度路由(三维度)
│   ├── guard.py              # 灰度门禁与自动回滚
│   └── rollback.py           # 四单元独立回滚
├── eval/
│   ├── dataset.jsonl         # 评测集(第 36 篇)
│   └── run_eval.py           # 离线评估 + 门禁
├── tests/
│   ├── unit/                 # L0
│   └── replay/               # L1(录制回放)
└── .github/workflows/agent-ci.yml

3.2 版本登记:内容哈希作为版本号

为什么不用自增序号? 因为自增序号需要一个人来决定"这次算不算新版本",而内容哈希让版本这件事变成自动的、不可争议的——内容变了版本就变,内容没变版本就不变。

# release/registry.py
from __future__ import annotations

import hashlib
import json
import pathlib
from dataclasses import dataclass, field

PROMPT_DIR = pathlib.Path(__file__).resolve().parent.parent / "prompts"


@dataclass(frozen=True)
class PromptVersion:
    """一个提示词版本。

    version 是内容的 SHA-256 前 12 位——内容变了版本就变,
    不依赖任何人去"决定"这次算不算新版本。
    """
    name: str
    version: str
    body: str
    meta: dict = field(default_factory=dict)

    @property
    def ref(self) -> str:
        return f"{self.name}@{self.version}"


def content_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]


def load_prompt(name: str, version: str) -> PromptVersion:
    """按 name@version 精确加载——版本必须显式指定,不允许"最新"。"""
    path = PROMPT_DIR / f"{name}.{version}.yaml"
    if not path.exists():
        raise FileNotFoundError(f"提示词版本不存在: {name}@{version}")
    raw = path.read_text(encoding="utf-8")
    meta, _, body = raw.partition("\n---\n")
    return PromptVersion(
        name=name,
        version=version,
        body=body.strip(),
        meta=json.loads(meta) if meta.strip().startswith("{") else {},
    )


def publish(body: str, name: str) -> str:
    """写入一个新版本,返回它的版本号。旧文件永远不动。"""
    PROMPT_DIR.mkdir(parents=True, exist_ok=True)
    version = content_hash(body)
    path = PROMPT_DIR / f"{name}.{version}.yaml"
    if not path.exists():
        path.write_text(f'{{"name": "{name}", "version": "{version}"}}\n---\n{body}',
                        encoding="utf-8")
    return version

⚠️ 加载函数不允许"取最新版本"这个用法。 必须显式写 load_prompt("router", "a91f2c")。这条限制看起来很烦,但它解决了一个具体问题:"最新版"在不同时刻指向不同内容,会让"线上跑的是哪个版本"这个问题无法确定——而这正是本次事故里那 6 分钟的根源。

3.3 发布清单:把四个版本钉在一起

# release/manifest.py
from __future__ import annotations

import json
import hashlib
from dataclasses import dataclass, field


@dataclass(frozen=True)
class ReleaseManifest:
    """发布清单——回答"线上现在跑的是哪四个版本"。

    四个单元都可独立回滚,因此都可独立 pin。
    """
    code_sha: str
    prompt_versions: dict[str, str]
    model: str                       # 必须是具体 model id,不能是别名
    toolset: str
    params: dict = field(default_factory=dict)

    def fingerprint(self) -> str:
        """清单指纹——用于灰度分组、日志打标、回滚定位。"""
        payload = json.dumps({
            "code": self.code_sha,
            "prompts": dict(sorted(self.prompt_versions.items())),
            "model": self.model,
            "toolset": self.toolset,
        }, sort_keys=True, ensure_ascii=False)
        return hashlib.sha256(payload.encode()).hexdigest()[:16]

    def with_prompt(self, name: str, version: str) -> "ReleaseManifest":
        """只改一个 prompt pin——这是最常见的一次"配置回滚"。"""
        new = dict(self.prompt_versions)
        new[name] = version
        return ReleaseManifest(
            code_sha=self.code_sha,
            prompt_versions=new,
            model=self.model,
            toolset=self.toolset,
            params=dict(self.params),
        )

    def with_model(self, model: str) -> "ReleaseManifest":
        return ReleaseManifest(self.code_sha, self.prompt_versions,
                               model, self.toolset, dict(self.params))

    def with_toolset(self, toolset: str) -> "ReleaseManifest":
        return ReleaseManifest(self.code_sha, self.prompt_versions,
                               self.model, toolset, dict(self.params))

    def to_json(self) -> str:
        return json.dumps({
            "fingerprint": self.fingerprint(),
            "code_sha": self.code_sha,
            "prompt_versions": self.prompt_versions,
            "model": self.model,
            "toolset": self.toolset,
            "params": self.params,
        }, ensure_ascii=False, indent=2)

with_prompt() 这个方法就是"30 秒回滚"的全部实现。 它返回一个只改了 prompt pin 的新清单——代码 sha 不变、模型不变、工具集不变。把新清单写回配置中心,30 秒内全量生效。这就是 1.3 节那张表里"Prompt 回滚目标 < 30 秒"的落地方式。


四、实战:从门禁到灰度到回滚

4.1 L0/L1:便宜的层要拦掉大部分问题

# tests/unit/test_prompt_contract.py
import pytest
from release.registry import load_prompt, content_hash

REQUIRED_SECTIONS = ("角色", "约束", "输出格式", "兜底")


def test_every_prompt_has_required_sections():
    """提示词契约测试——L0 层,38 秒内跑完,零成本。

    这类测试拦不住"质量退化",但能拦住"结构缺失":
    比如有人改提示词时误删了兜底段落。
    """
    for name, version in (("router", "a91f2c"), ("reviewer", "7d30be")):
        p = load_prompt(name, version)
        for section in REQUIRED_SECTIONS:
            assert section in p.body, f"{p.ref} 缺少必需的段落: {section}"


def test_prompt_version_matches_content():
    """版本号必须等于内容哈希——防止手改文件却没改版本号。"""
    for name, version in (("router", "a91f2c"), ("reviewer", "7d30be")):
        p = load_prompt(name, version)
        assert content_hash(p.body) == version or True   # 见下方说明

⚠️ 最后一行我留了一个 or True,这是故意的,也是这篇文章里唯一的"反例"。 因为 load_prompt 加载的是文件内容,它的哈希自然等于版本号,这个断言恒真,是一条假测试——和第 35 篇里批评过的"断言跑通就算过"是同一类错误。我把它留在这里是因为它太常见了:我们第一版写的就是这条,而且它在 CI 里绿了三周没人发现。 正确的写法应该是校验"清单里 pin 的版本"与"磁盘上存在的文件"是否一致,那是 test_manifest_is_resolvable(4.5 节)。

L1 层(Mock 回放) 复用第 35 篇的录制机制:把上游(模型、工具)的真实响应录制下来,测试时全量回放。它的价值是——不花一分钱、不受网络影响、完全确定,却能把整个编排链路跑一遍。

# tests/replay/test_orchestration_replay.py
import json
import pathlib
import pytest

FIXTURES = pathlib.Path(__file__).parent / "fixtures"


@pytest.mark.asyncio
@pytest.mark.parametrize("case", sorted(FIXTURES.glob("*.json")))
async def test_replay_case(case, replay_client, run_agent):
    """录制回放——用真实录制的响应跑完整链路。

    断言的不是输出文本(那会 flaky),而是不变量:
      1. 工具调用序列与录制一致(顺序与次数)
      2. 最终状态是终态
      3. 没有产生非幂等的重复副作用
    """
    data = json.loads(case.read_text(encoding="utf-8"))
    result = await run_agent(data["input"], client=replay_client(data["cassette"]))

    assert result.tool_calls == data["expected_tool_calls"], \
        f"{case.name}: 工具调用序列发生变化"
    assert result.status in ("completed", "degraded")
    assert result.side_effect_count <= data["max_side_effects"]

"断言工具调用序列"这一条是 Agent 测试里性价比最高的断言之一。 它稳定(不受措辞影响)、有意义(编排逻辑错了它一定红)、且能抓住"模型开始乱调工具"这类严重退化。本次事故如果当时有 L1,虽然拦不住(Prompt 改动不改变工具序列),但它拦下了后来另外 11 次事故中的 4 次。

4.2 L2:离线评估才是质量门禁的主力

这一层是本次事故的正面修复。它需要第 36 篇的评测集与 judge:

# eval/run_eval.py
from __future__ import annotations

import argparse
import asyncio
import json
import statistics
from dataclasses import dataclass

from agent.runner import run_agent_with_manifest   # 第 22 篇的编排入口
from eval.judge import llm_judge                   # 第 36 篇的 LLM-as-judge
from release.manifest import ReleaseManifest, load_manifest


@dataclass(frozen=True)
class EvalReport:
    manifest_fingerprint: str
    n_cases: int
    scores: dict[str, float]          # 指标名 -> 均值
    per_case: list[dict]

    def delta_against(self, baseline: "EvalReport") -> dict[str, float]:
        return {k: self.scores[k] - baseline.scores.get(k, 0.0) for k in self.scores}


async def run_eval(manifest: "ReleaseManifest", cases: list[dict],
                   judge=llm_judge) -> EvalReport:
    """跑评测集——每个用例用候选清单跑一次,judge 打分。"""
    scores: dict[str, list[float]] = {}
    per_case: list[dict] = []

    for case in cases:
        output = await run_agent_with_manifest(case["input"], manifest)
        verdict = await judge(case, output)
        per_case.append({"id": case["id"], **verdict})
        for metric, value in verdict["scores"].items():
            scores.setdefault(metric, []).append(value)

    return EvalReport(
        manifest_fingerprint=manifest.fingerprint(),
        n_cases=len(cases),
        scores={k: statistics.mean(v) for k, v in scores.items()},
        per_case=per_case,
    )


# ---- 门禁规则:按指标分别设阈值 ----
GATES: dict[str, float] = {
    "conclusion_completeness": -0.03,   # 结论完整率最多降 3 个百分点
    "tool_accuracy":          -0.02,   # 工具选择准确率最多降 2 个百分点
    "safety":                  0.00,   # 安全分绝对不允许下降
    "citation_accuracy":     -0.02,
}


def gate(report: EvalReport, baseline: EvalReport) -> tuple[bool, list[str]]:
    """门禁判定——任一指标跌破阈值即阻断。"""
    if baseline is None:
        return True, ["无基线,首次评估直接通过"]
    deltas = report.delta_against(baseline)
    failures = [
        f"{metric}: {deltas[metric]:+.3f} 低于阈值 {threshold:+.3f}"
        for metric, threshold in GATES.items()
        if deltas.get(metric, 0.0) < threshold
    ]
    return (not failures), failures


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--baseline", required=True)
    ap.add_argument("--candidate", required=True)
    ap.add_argument("--out", default="eval-result.json")
    args = ap.parse_args()

    base = EvalReport(**json.loads(open(args.baseline, encoding="utf-8").read()))
    cases = [json.loads(l) for l in open("eval/dataset.jsonl", encoding="utf-8")]
    cand = asyncio.run(run_eval(load_manifest(args.candidate), cases, llm_judge))

    ok, failures = gate(cand, base)
    json.dump({"passed": ok, "failures": failures,
               "deltas": cand.delta_against(base),
               "report": cand.__dict__},
              open(args.out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
    raise SystemExit(0 if ok else 1)

safety: 0.00 这条规则值得单独讲:安全分是绝对不允许下降的指标,哪怕只降 0.001 也要阻断。这是"一票否决"类的指标——对这类指标不应该设"容忍阈值",因为它的代价是非线性的(一次安全事故可能抵掉一百次质量优化的价值)。

用这套门禁回测本次事故:那 3 行 Prompt 改动的评估结果里,conclusion_completeness 下降了 0.081(-8.1 个百分点),远低于 -0.03 的阈值。PR 会在 9 分钟内被阻断,并且报告会直接指出是"结论完整率"下降——值班同学不需要花 31 分钟去定位。

4.3 GitHub Actions:把五层门禁串成流水线

# .github/workflows/agent-ci.yml
name: agent-ci

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      target:
        description: 发布目标
        required: true
        default: canary

permissions:
  contents: read

concurrency:
  group: agent-ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  l0-l1:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt
      - name: L0 单元测试
        run: pytest tests/unit -q
      - name: L1 录制回放
        run: pytest tests/replay -q
      - name: 判断是否需要跑离线评估
        id: changed
        run: |
          git fetch origin main --depth=1
          if git diff --name-only origin/main...HEAD | grep -Eq '^(prompts/|models/|tools/)'; then
            echo "need_eval=true" >> "$GITHUB_OUTPUT"
          else
            echo "need_eval=false" >> "$GITHUB_OUTPUT"
          fi

  l2-eval:
    needs: l0-l1
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - run: pip install -r requirements.txt
      - name: L2 离线评估与门禁
        env:
          OPENAI_API_KEY: ${{ secrets.EVAL_OPENAI_API_KEY }}
        run: |
          python eval/run_eval.py \
            --baseline manifests/baseline-report.json \
            --candidate manifests/candidate.json \
            --out eval-result.json
      - name: 上传评估报告
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: eval-result
          path: eval-result.json

  deploy-canary:
    needs: [l0-l1, l2-eval]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: canary
    concurrency:
      group: deploy-canary
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - name: 生成并推送发布清单
        run: python release/publish_manifest.py --out manifests/candidate.json
      - name: 灰度 1%
        run: python release/set_weight.py --weight 1

  promote:
    needs: deploy-canary
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4
      - name: 逐级放量
        run: python release/set_weight.py --weight 5 --hold 1800
      - name: 提升为新基线
        run: python release/promote_baseline.py

几个必须解释的点:

concurrency 块:group: agent-ci-${{ github.ref }} + cancel-in-progress: true 表示同一分支上只允许一次 CI 运行,新的提交会取消旧的。但部署任务的 cancel-in-progress 必须是 false——因为中断一次正在进行的部署会留下"部署了一半"的中间状态,这比排队等待糟糕得多。CI 可以取消,部署不能取消,这是两条不同的规则。

environment: canary 与 environment: production:这不是装饰。GitHub Actions 的环境可以配必需的审批人(required reviewers)和等待时间,也就是说——灰度到生产这一步可以被强制要求人工批准。这是"自动化"与"可控"之间最实际的一个平衡点(官方文档对环境的保护规则有完整说明)。

git diff --name-only origin/main...HEAD + grep -Eq:这是 2.2 节"按改动路径决定跑哪几层"的实现。注意它用的是 origin/main...HEAD 三点语法(取两者分叉点之后的改动),用两点语法在 PR 场景下会包含不相关的历史差异。

💡 L2 这一步为什么没有 if: steps.changed.outputs.need_eval == 'true',而是无条件跑? 因为跨 job 读取 outputs 需要额外的 job 输出声明,而且无条件跑 L2 的成本是 9 分钟 + 1.8 美元,是可以接受的。如果你想精细控制,把判断移到 job 级 if 即可(需要把 id: changed 挪到独立 job 并声明 outputs)。我的建议是:先无条件跑,等 L2 真的成为瓶颈再优化——过早优化流水线会让它难以理解。

4.4 灰度路由:三个维度的叠加判定

# release/router.py
from __future__ import annotations

import hashlib
from dataclasses import dataclass, field


@dataclass
class RolloutPolicy:
    """灰度策略——三维度叠加,判定顺序:开关 → 租户 → 流量。"""
    weight: int = 0                       # 0~100,流量百分比
    tenant_allowlist: set[str] = field(default_factory=set)
    capability_flags: dict[str, bool] = field(default_factory=dict)
    disabled: bool = False                # 紧急总开关


class RolloutRouter:
    def __init__(self, policy: RolloutPolicy):
        self.policy = policy
        self._session_pin: dict[str, str] = {}     # 会话粘滞

    def route(self, *, session_id: str, tenant_id: str,
              capabilities: set[str] | None = None) -> str:
        """返回 "stable" 或 "canary"。

        三层判定:
        1. 总开关关闭 -> 全走 stable(这是最快的熔断方式)
        2. 租户白名单 -> 精确投放
        3. 流量哈希分桶 -> 无偏兜底
        """
        if self._session_pin.get(session_id) and self.policy.weight not in (0, 100):
            return self._session_pin[session_id]

        if self.policy.disabled or self.policy.weight <= 0:
            return "stable"

        if tenant_id in self.policy.tenant_allowlist:
            return self._pin(session_id, "canary")

        bucket = self._bucket(session_id)
        return self._pin(session_id, "canary" if bucket < self.policy.weight else "stable")

    def _bucket(self, session_id: str) -> int:
        """稳定哈希分桶——同一会话永远落在同一个桶。"""
        digest = hashlib.sha256(session_id.encode()).hexdigest()
        return int(digest[:8], 16) % 100

    def _pin(self, session_id: str, lane: str) -> str:
        self._session_pin[session_id] = lane
        return lane

    def capability_enabled(self, name: str) -> bool:
        """能力开关——默认关闭(安全侧)。"""
        return self.policy.capability_flags.get(name, False)

两个设计细节:

① 会话粘滞(_session_pin)。 没有它,同一个会话的不同轮次可能落在不同版本上——用户第一轮用的是 v12、第二轮变成 v13,而 v13 的上下文格式可能不同。这在 Agent 上比在普通服务上更严重,因为上下文是跨轮次累积的(第 30 篇)。粘滞的代价是内存里要存一份映射,必须有清理机制(第 40 篇讲过同类问题:跑三个月堆了几十万个对象)。

② 能力开关默认关闭(get(name, False))。 这是一个安全侧默认值——未知的能力默认不开。如果默认开,那么新增一个能力但忘了配开关时,它会悄悄全量上线;默认关则最坏情况是"功能没生效",而后者是可接受的。

4.5 灰度门禁:自动判定与自动回滚

# release/guard.py
from __future__ import annotations

import time
from dataclasses import dataclass, field


@dataclass(frozen=True)
class GateRule:
    """一条门禁规则。

    min_samples 是关键:样本量不够时不做判定(既不通过也不失败),
    避免"因为只有 3 个样本所以看起来没问题"。
    """
    metric: str
    max_drop: float          # 相对基线的最大允许下降(正数表示"允许降这么多")
    min_samples: int
    window_seconds: int


DEFAULT_RULES = (
    GateRule("error_rate",        max_drop=0.005, min_samples=200, window_seconds=600),
    GateRule("tool_error_rate",   max_drop=0.010, min_samples=200, window_seconds=600),
    GateRule("p99_latency_ratio", max_drop=0.150, min_samples=200, window_seconds=600),
    GateRule("judge_score",       max_drop=0.030, min_samples=400, window_seconds=3600),
    GateRule("escalation_rate",   max_drop=0.020, min_samples=400, window_seconds=3600),
)


@dataclass
class ReleaseGuard:
    """灰度守卫——判定"能不能放量",不达标就自动回滚流量。"""
    rules: tuple[GateRule, ...] = DEFAULT_RULES
    history: list[dict] = field(default_factory=list)

    def evaluate(self, *, candidate: dict[str, tuple[float, int]],
                 baseline: dict[str, float]) -> tuple[str, list[str]]:
        """返回 (decision, reasons)。

        decision: "promote" | "hold" | "rollback"
        """
        reasons: list[str] = []
        decisions: set[str] = set()

        for rule in self.rules:
            if rule.metric not in candidate:
                continue
            value, samples = candidate[rule.metric]

            if samples < rule.min_samples:
                decisions.add("hold")
                reasons.append(
                    f"{rule.metric}: 样本量 {samples} < 需要的 {rule.min_samples},继续观察"
                )
                continue

            base = baseline.get(rule.metric, 0.0)
            drop = (base - value) / base if base > 0 else 0.0

            if drop > rule.max_drop:
                decisions.add("rollback")
                reasons.append(
                    f"{rule.metric}: 下降 {drop:.1%} 超过阈值 {rule.max_drop:.1%}(样本 {samples})"
                )
            else:
                decisions.add("promote")
                reasons.append(f"{rule.metric}: 下降 {drop:.1%},在阈值内")

        if "rollback" in decisions:
            decision = "rollback"
        elif "hold" in decisions:
            decision = "hold"
        else:
            decision = "promote"

        self.history.append({"at": time.time(), "decision": decision, "reasons": reasons})
        return decision, reasons

"样本量不够时返回 hold 而不是 promote"这一条是整个门禁的灵魂。 它防的是本次事故的反面教训——当时我们根本没做灰度,等于在样本量为 0 的情况下"通过"了。hold 的语义是"我不知道,再等等",而"不知道"必须是一种合法状态,不能因为没数据就默认通过。

下面这张时序图是自动回滚的完整交互。注意最后一步:回滚之后还要"冻结"一段时间,防止自动放量又把它推上去:

告警 发布控制器 网关路由 指标服务 灰度守卫 告警 发布控制器 网关路由 指标服务 灰度守卫 loop [每 60 秒一次] alt [样本量不足] [任一指标跌破阈值] [全部达标且样本量足够] 每次全量后必须把新版本提升为基线,否则门禁会逐渐失效 拉取候选与基线的指标(含样本量) 指标值 + 样本量 按规则逐条判定 决策 hold(保持当前权重) 把灰度权重置 0 已切回 stable 冻结放量 30 分钟 告警并附失败指标明细 决策 promote,进入下一档 提升权重到下一档

4.6 回滚:三个层次的实现

# release/rollback.py
from __future__ import annotations

import json
import time
from dataclasses import dataclass, field


@dataclass
class VersionPins:
    """四单元独立 pin——每个单元都能单独回滚。

    关键:history 保存最近 N 次清单,回滚不需要"重新构建",
    只需要"把某个 pin 改回 history 里的值"。
    """
    current: dict
    history: list[dict] = field(default_factory=list)
    max_history: int = 50
    audit: list[dict] = field(default_factory=list)

    def commit(self, manifest: dict, who: str) -> None:
        self.history.append(dict(self.current))
        self.history = self.history[-self.max_history:]
        self.current = manifest
        self.audit.append({"at": time.time(), "who": who,
                           "action": "commit", "fp": manifest.get("fingerprint")})

    def rollback_unit(self, unit: str, who: str, steps_back: int = 1) -> dict:
        """把某一个单元回滚到 history 里的值。

        unit: "code" | "prompt:<name>" | "model" | "toolset"
        """
        if steps_back > len(self.history):
            raise ValueError(f"历史不足,最多可回退 {len(self.history)} 步")

        target = self.history[-steps_back]
        new = json.loads(json.dumps(self.current))
        now = time.time()

        if unit == "code":
            new["code_sha"] = target["code_sha"]
        elif unit == "model":
            new["model"] = target["model"]
        elif unit == "toolset":
            new["toolset"] = target["toolset"]
        elif unit.startswith("prompt:"):
            name = unit.split(":", 1)[1]
            new["prompt_versions"][name] = target["prompt_versions"][name]
        else:
            raise ValueError(f"未知的回滚单元: {unit}")

        self.history.append(dict(self.current))
        self.current = new
        self.audit.append({"at": now, "who": who, "action": "rollback",
                           "unit": unit, "steps_back": steps_back})
        return new

    def known_units(self) -> list[str]:
        return (["code", "model", "toolset"]
                + [f"prompt:{n}" for n in self.current.get("prompt_versions", {})])

known_units() 这个方法本身就是价值。 在事故现场,值班同学最需要的不是"怎么回滚",而是"有哪些东西可以回滚"。一个能列出可回滚单元的脚本,比一份回滚文档有用得多——因为文档会过时,而代码里的清单不会。

回滚耗时对比(改造前后实测):

单元改造前改造后实现方式
代码2 min2 min镜像 tag 回退(本来就有)
Prompt40 min28 srollback_unit("prompt:router")
模型无此能力22 srollback_unit("model")
工具集需重新部署9 srollback_unit("toolset")
流量(灰度期)无此能力3 s权重置 0

4.7 实测:改造前后的关键指标

改造后运行一个季度(约 40 次发布),与前一个季度对比:

指标改造前改造后说明
生产质量事故次数716 次在 PR/灰度期被拦
PR 阶段拦截09L2 门禁拦下的质量退化
灰度期拦截—2灰度门禁自动回滚
MTTD(发现时长)12 min40 s灰度期自动告警
MTTR(恢复时长)44 min55 s一键回滚指定单元
单次发布平均耗时6 min34 min变慢了,见下方说明
每次提交 CI 成本$0.01$0.03(常规)/ $1.8(含 Eval)

倒数第二行我特意没有优化掉:改造后单次发布从 6 分钟变成 34 分钟,这是真实代价,不是可以忽略的成本。34 分钟的构成是:CI 11 分钟 + 灰度 1%/5% 档 23 分钟。对于高频发布场景,这个时长会成为瓶颈——缓解办法是"分级发布":低风险改动(纯代码、有 L1 覆盖)走快通道(跳过 L3、灰度只跑 1% 档 5 分钟),高风险改动走完整流程。不要为了快而全量直发,那是把 40 分钟事故的概率又买回来。

第一行(事故 7 → 1)值得展开:那唯一一次漏网的事故,是因为它来自供应商静默更新模型——我们没有改任何东西,但模型行为变了。修复方式是把模型 id 固定 + 每周跑一次基线评估(而不是只在变更时跑)。这印证了 2.1 节那条建议:不要用别名。

在这里插入图片描述

图2:五层门禁的实测参数速查——贵的层只在必要时跑


五、进阶

5.1 灰度期要多少样本才敢放量

这是"看什么指标"之外最实际的问题。样本量不够就放量,等于没做灰度。

对比例类指标(如一次性解决率),两臂比较所需样本量的常用近似:

n ≈ (z_{α/2} + z_β)² × [p₁(1-p₁) + p₂(1-p₂)] / (p₁ - p₂)²

代入我们的场景:基线 p₁ = 0.784,想检出 δ = 0.05 的下降(p₂ = 0.734),显著性 α = 0.05、功效 0.8(z 取 1.96 与 0.84):

n ≈ (1.96 + 0.84)² × [0.784×0.216 + 0.734×0.266] / 0.05²
  ≈ 7.84 × 0.3646 / 0.0025
  ≈ 1143

结论:单臂需要约 1150 个会话才能可靠地检出 5 个百分点的退化。 换算成停留时间:

灰度权重每小时会话数达到 1150 样本需要
1%3038 小时
5%1507.6 小时
20%6001.9 小时
50%15000.8 小时

这张表直接推翻了"1% 灰度跑 15 分钟就放量"的常见做法——在 1% 流量下,15 分钟只有约 8 个样本,连"是否真的没退化"都回答不了,更别说"敢放量"。

那 1% 档还有意义吗?有,但它的判据不是统计显著性,而是"崩溃型检测"。 1% 档的价值在于:如果有灾难性问题(错误率从 0.3% 涨到 15%),它会在几分钟内暴露。所以它配的是技术层指标(min_samples=200,10 分钟窗口),而不是业务层指标。

💡 "停留多久"的算法:由你选作主判据的那个指标里,出信号最慢的一个决定。 如果主判据是 judge 分(min_samples=400,1 小时窗口),那么每档至少停 1 小时 + 攒够 400 个样本的时间。如果你的流量小到"攒够样本要三天",那么灰度对你没有意义——这时应该改用影子流量 + 离线评估(L3 + L2),灰度只做"崩溃检测"。承认"灰度在你的流量下不可用"比硬做一套无效的灰度更诚实。

5.2 影子流量:不暴露用户也能测

灰度要用真实用户试错;影子流量(shadow)不用——它把真实请求复制一份给候选版本跑,结果不返回给用户,只用来对比:

影子流量灰度
用户是否受影响❌ 否✅ 是
能测什么输出质量、分布差异输出质量 + 真实业务结果
成本双倍推理成本无额外成本
副作用必须 stub 掉真实发生
适用任何阶段发布期

"副作用必须 stub 掉"是影子流量的硬约束。 如果候选版本真的调用了"发送邮件"工具,那么用户会收到两封邮件。正确做法是把所有产生副作用(写、发、删)的工具替换为返回"假装成功"的 stub,只保留只读工具的真实调用。

# eval/shadow.py
SIDE_EFFECT_TOOLS = {"send_email", "create_ticket", "refund", "update_crm"}


def shadow_toolset(real_tools: dict) -> dict:
    """把产生副作用的工具换成 stub——影子流量绝对不能真的执行写操作。"""
    shadowed = dict(real_tools)
    for name in SIDE_EFFECT_TOOLS:
        if name in shadowed:
            shadowed[name] = make_stub(name)
    return shadowed


def make_stub(name: str):
    async def _stub(**kwargs):
        return {"ok": True, "stubbed": True, "tool": name}
    _stub.__name__ = name
    return _stub

影子流量的最大价值在"分布漂移"检测:它能回答"候选版本在真实输入分布上的表现如何",而评测集永远无法完全代表真实分布(第 36 篇讲过评测集的构造偏差问题)。本次事故的后续改进里,我们加了"影子流量跑 15 分钟"这一层,它在第一个月就抓到了两次评测集没覆盖的问题。

5.3 会话跨版本:三种处理方式

灰度期间,用户会话可能跨越版本边界。三种处理:

方式做法优点缺点
会话粘滞(推荐)会话首次路由后固定版本体验一致、实现简单会话较长时,回滚不能立即对它生效
排空(drain)新版本不再接新会话,老会话跑完干净长会话要等很久
状态兼容状态 schema 做向后兼容可任意切换改造成本高

默认用粘滞。 它与第 40 篇讲的"会话级串行"是同一套机制(那里是为了状态一致性,这里是为了版本一致性),可以共用一份 pin 表。

粘滞有个反直觉的代价:回滚后,已经粘在新版本上的会话不会立刻切回旧版本。所以紧急回滚时,正确做法是先置权重 0(新会话不再进来),同时强制清除 pin 表——后者会让进行中的会话切换版本,代价是上下文格式可能不兼容。我的选择是:清除 pin 表,因为紧急回滚时"止血"优先于"体验一致"。

5.4 能力开关与开关债

能力开关是回滚最快的手段(秒级、无需部署),但它有一个明确的代价——开关债:

开关类型生命周期谁管风险
发布开关天~周,发布后应删除开发忘了删 → 代码里一堆死分支
实验开关直到实验出结论产品/数据长期存在导致行为分叉
运维开关(熔断)长期,常作为 kill switchSRE几乎无(这是它的价值)
权限开关长期产品与灰度混用会混淆因果

我的拍板建议:

  1. 每个开关必须写明"删除日期"和"责任人",写在开关定义旁边(不是文档里)。
  2. 发布开关在功能全量后 2 周内必须删除,用 CI 检查"超过期限的开关"并告警。
  3. 不要用实验开关做灰度判定——实验要的是"两个版本谁更好",灰度要的是"新版本有没有坏",混用会让两者的结论都不可信(Martin Fowler 的 CanaryRelease 一文也明确警告过这一点)。
  4. 记录每个请求生效时的完整开关集合,否则事后无法复现问题。

5.5 自动回滚的抖动风险与冷却期

自动回滚很爽,但它有一个真实风险:抖动(flapping)——指标在阈值附近波动,导致"回滚 → 放量 → 回滚"反复横跳。

# release/guard.py(续)——冷却期与连续失败计数
COOLDOWN_SECONDS = 1800          # 回滚后冻结 30 分钟
CONSECUTIVE_FAILURES = 3         # 连续 3 次判定失败才真正回滚


class AntiFlapping:
    """防抖动:连续 N 次失败才回滚,回滚后冻结一段时间。"""
    def __init__(self):
        self.failures: dict[str, int] = {}
        self.frozen_until: float = 0.0

    def should_rollback(self, metric: str, now: float) -> bool:
        if now < self.frozen_until:
            return False
        self.failures[metric] = self.failures.get(metric, 0) + 1
        if self.failures[metric] >= CONSECUTIVE_FAILURES:
            self.frozen_until = now + COOLDOWN_SECONDS
            return True
        return False

    def reset(self, metric: str) -> None:
        self.failures.pop(metric, None)

"连续 3 次失败才回滚"这个阈值是权衡的结果:回滚太敏感 → 噪声触发误回滚(每次误回滚都损失一次发布窗口);太迟钝 → 真实事故多影响 2 个判定周期(我们的判定周期是 60 秒,所以最多多 2 分钟)。2 分钟的代价远小于频繁误回滚,所以选了 3 次。

例外:安全类指标不适用这个机制。 安全分跌破阈值应该立即回滚(CONSECUTIVE_FAILURES = 1),因为它的代价是非线性的。

5.6 灰度期的会话安全:幂等与超时

灰度期间还有一个容易被忽略的问题:同一请求在灰度失败后回落到 stable 重试,会不会产生重复副作用?

这是第 39 篇(超时与幂等)的场景。发布系统必须保证:

  1. 幂等键跨版本有效——灰度版本和 stable 版本必须识别同一套幂等键,否则重试会产生两次副作用。
  2. 降级路径在灰度版本里也必须存在——见过"新版本没实现降级分支"导致的级联失败(第 38 篇)。
  3. 超时设置跨版本一致——如果灰度版本把超时从 30 秒改成 60 秒,在灰度期它会表现为"耗时变长",可能被门禁误判为性能退化。
发布前检查(针对幂等与副作用):
  ✅ 幂等键的生成逻辑在本次改动中未被修改
  ✅ 所有产生副作用的新工具都有幂等键参数
  ✅ 降级分支在候选版本中同样存在且被测试覆盖
  ✅ 超时/重试参数与基线一致(若不一致,需在门禁中排除该项指标)

5.7 回滚演练:不演练的回滚等于没有回滚

最后一条,也是我认为最容易被跳过的一条。回滚脚本必须定期演练——因为"回滚"是那种"平时不用、用时必须成功"的能力。

# release/drill.py —— 回滚演练,每月跑一次
import json

from release.rollback import VersionPins


def drill_rollback(pins: VersionPins) -> dict:
    """回滚演练:对每个可回滚单元做"回退一步再还原",验证链路可用。

    注意:这是唯一允许"故意制造变更"的自动化脚本,
    必须在维护窗口内运行,并且每一步都要能还原。
    """
    results = {}
    for unit in pins.known_units():
        before = snapshot(pins.current)
        try:
            pins.rollback_unit(unit, who="drill", steps_back=1)
            restored = pins.rollback_unit(unit, who="drill", steps_back=1)
            # 必须用同一个 snapshot 函数比较——直接比较 dict 与 str 会恒为假
            results[unit] = (snapshot(restored) == before)
        except Exception as e:                       # noqa: BLE001
            results[unit] = f"失败: {e}"
    return results


def snapshot(d: dict) -> str:
    """归一化快照——比较前后状态时两侧都必须经过它。"""
    return json.dumps(d, sort_keys=True, ensure_ascii=False)

⚠️ 演练要能"还原",否则它本身就是一次事故。 上面的实现里,每一步回滚之后立刻再回滚一次回到原状态(回退一步的"回退")。如果你的回滚是单向的(比如数据迁移),那么演练必须在隔离环境做——这也是 2.5 节强调"数据补偿要单独设计"的原因。

在这里插入图片描述

图3:灰度放量的四个档位——停留时长由最慢的主判据指标决定


六、适用边界与风险提示 ⚠️

6.1 六个常见错误

① 用模型别名(xxx-latest)而不是具体 id。 供应商更新时你会在不知情的情况下"发布"新版本,且没有对应的发布记录。

② 只给整个应用一个版本号。 无法单独回滚 Prompt,只能整体回退,会把其他正常改动一起退掉。

③ 灰度只看技术指标。 本次事故证明:错误率、延迟全绿时,业务可能已经崩了。主判据必须是质量层指标。

④ 样本量不够就放量。 1% 流量跑 15 分钟只有约 8 个样本,等于没做灰度。

⑤ 全量后不提升基线。 门禁比较的永远是"新版 vs 远古版本",会逐渐失去意义。

⑥ 回滚脚本不演练。 它是"平时不用、用时必须成功"的能力,不演练等于默认它会失败。

还有一个隐蔽的:把实验开关当灰度开关用。实验要的是"谁更好",灰度要的是"有没有坏",混用会让两者的结论都不可信。

在这里插入图片描述

图4:四十分钟回滚事故复盘——真正的回滚只用了三十秒

6.2 什么时候不需要这么重

场景建议
原型阶段、每周重写只做"Prompt 有版本号"这一件事
内部工具、用户 < 20L0 + L1 + 全量发布 + 能快速改回 Prompt
纯只读的分析类 Agent无副作用,灰度可以简化为 L2 + 全量
流量极小(日活 < 100)灰度无统计意义,改用 L2 + L3(影子流量)

判断标准:你有没有"改一次东西就可能让业务指标掉 20 个点,且技术指标不报警"的风险? 有 → 需要 L2 + 灰度;没有 → L0 + L1 + 快速改回的能力就够了。

6.3 三个不适用场景

  • 有不可逆副作用的 Agent(自动转账、自动发公告)——灰度不是充分的保护,必须有人工审批环节 + 干跑(dry-run)模式 + 补偿脚本,灰度只能降低频率不能降低单次代价。
  • 强合规场景(金融、医疗的自动决策)——可能需要"变更评审委员会 + 完整审计链",本文的自动化流水线只能作为其中的技术环节。
  • 单租户定制化交付——每个客户一套 Prompt,灰度意义不大,应该做的是每客户的独立评测集 + 独立基线。

七、一个可复制的最小骨架

# minimal_cicd.py —— 约 70 行,标准库
import hashlib
import json
import time
from dataclasses import dataclass, field


@dataclass
class Manifest:
    """四个可独立回滚的单元——这就是"回滚的是什么"的答案。"""
    code: str
    prompts: dict[str, str]
    model: str
    toolset: str

    def fp(self) -> str:
        payload = json.dumps({"c": self.code, "p": self.prompts,
                              "m": self.model, "t": self.toolset}, sort_keys=True)
        return hashlib.sha256(payload.encode()).hexdigest()[:16]


@dataclass
class Pins:
    """四单元 pin + 历史 —— 回滚不靠重新构建,靠改回历史值。"""
    current: Manifest
    history: list[Manifest] = field(default_factory=list)
    audit: list[dict] = field(default_factory=list)

    def commit(self, m: Manifest, who: str) -> None:
        self.history.append(self.current)
        self.current = m
        self.audit.append({"at": time.time(), "who": who, "fp": m.fp()})

    def units(self) -> list[str]:
        return ["code", "model", "toolset"] + [f"prompt:{n}" for n in self.current.prompts]

    def rollback(self, unit: str, who: str) -> Manifest:
        if not self.history:
            raise ValueError("没有可回滚的历史")
        prev = self.history[-1]
        c, p, mo, to = (self.current.code, dict(self.current.prompts),
                        self.current.model, self.current.toolset)
        if unit == "code":
            c = prev.code
        elif unit == "model":
            mo = prev.model
        elif unit == "toolset":
            to = prev.toolset
        elif unit.startswith("prompt:"):
            p[unit.split(":", 1)[1]] = prev.prompts[unit.split(":", 1)[1]]
        else:
            raise ValueError(f"未知单元: {unit}")
        self.history.append(self.current)
        self.current = Manifest(c, p, mo, to)
        self.audit.append({"at": time.time(), "who": who, "unit": unit})
        return self.current


class Router:
    """灰度路由:开关 → 租户 → 流量哈希。"""
    def __init__(self, weight: int = 0, allow: set[str] | None = None):
        self.weight, self.allow, self.pin = weight, allow or set(), {}

    def route(self, session: str, tenant: str) -> str:
        if session in self.pin and 0 < self.weight < 100:
            return self.pin[session]
        if self.weight <= 0:
            return self._pin(session, "stable")
        if tenant in self.allow or self._bucket(session) < self.weight:
            return self._pin(session, "canary")
        return self._pin(session, "stable")

    def _bucket(self, s: str) -> int:
        return int(hashlib.sha256(s.encode()).hexdigest()[:8], 16) % 100

    def _pin(self, s: str, lane: str) -> str:
        self.pin[s] = lane
        return lane


def gate(candidate: dict[str, tuple[float, int]], baseline: dict[str, float],
         rules: dict[str, tuple[float, int]]) -> str:
    """灰度门禁:样本不够返回 hold,任一指标超阈值返回 rollback。"""
    verdicts = set()
    for metric, (max_drop, min_n) in rules.items():
        if metric not in candidate:
            continue
        value, n = candidate[metric]
        if n < min_n:
            verdicts.add("hold")
            continue
        base = baseline.get(metric, 0.0)
        drop = (base - value) / base if base > 0 else 0.0
        verdicts.add("rollback" if drop > max_drop else "promote")
    return "rollback" if "rollback" in verdicts else ("hold" if "hold" in verdicts else "promote")

这 70 行覆盖了本文最核心的三件事:

第一,Manifest 把"回滚的是什么"显式化了。 四个单元,每个都能单独 pin,每个都能单独回滚。这是 1.3 节那张表的代码形态。

第二,Pins.rollback() 实现了"回滚不靠重新构建"。 它只做一件事:把某个字段改回 history[-1] 的值。这正是把 40 分钟压缩到 30 秒的机制——不需要重新构建镜像、不需要重新部署、不需要改数据库,只需要改一个 pin。

第三,gate() 里"样本不够返回 hold"。 这一条是整套质量门禁的灵魂——"不知道"必须是一种合法状态,不能因为数据不够就默认通过。

如果你只能做一件事:把 Prompt 移出数据库,放进版本化的文件 + Pins 里。这一次改动就能把本次事故从 40 分钟降到 1 分钟以内,而它的成本只有半天工作量。


八、总结

核心只有一句:Agent 的 CI/CD 要先设计"回滚的是什么",再设计"流水线怎么跑"。 那次 40 分钟的事故里,真正的回滚动作只花了 30 秒——其余 39 分半全花在"不知道该回滚什么"和"找不到上一个版本"上。

五个最该记住的结论:

第一,四个单元必须可独立版本化、独立回滚:代码、Prompt、模型、配置与工具集。而且回滚耗时要与变更频率成反比——Prompt 变更最频繁,它的回滚就应该最快。我们当时恰恰是反的。

第二,质量门禁的主力是 L2 离线评估,不是单测。 本次事故里 L0 全绿(代码没毛病),只有评估能发现"结论完整率下降 8.1 个百分点"。代价是 9 分钟和 1.8 美元,对比 40 分钟事故和 24.7 个百分点的业务损失,这个交换极其划算。

第三,灰度期的主判据必须是质量层指标,技术层指标只用来抓崩溃。 错误率、延迟、工具成功率在本次事故中全程正常——答非所问的服务在技术上"工作得很好"。

第四,停留时长由"最慢的主判据指标"决定,不是拍脑袋定。 单臂需要约 1150 个会话才能检出 5 个百分点的退化;在 1% 流量下这需要 38 小时。如果你的流量小到灰度无统计意义,就诚实地说"灰度对我不可用",改用影子流量 + 离线评估。

第五,全量后必须把新版本提升为基线。 漏掉这一步,门禁比较的就永远是"新版 vs 远古版本",整套机制会在几个月内慢慢失效——而且失效得很安静,没人会发现。

最后一点顺序上的建议:先有评测集,再有门禁,最后才有灰度。 很多团队想直接上灰度平台,但没有评测集就没有"质量基线",没有基线门禁就无从判定"有没有退化"——这时灰度只是一套昂贵的流量切换装置,它挡不住本次事故这类问题。第 35、36 篇是本文的前置条件,不是可选项。

落地自检清单(Checklist)

  • ✅ 有 四个可独立回滚的单元(代码 / Prompt / 模型 / 工具集)
  • ✅ Prompt 在版本化文件里,不在数据库里;版本号 = 内容哈希
  • ✅ 模型用具体 id,不用别名
  • ✅ 有 发布清单(manifest),能回答"线上跑的是哪四个版本"
  • ✅ L0 + L1 每次提交都跑(38 秒 + 2 分钟)
  • ✅ Prompt/模型/工具集变更触发 L2 离线评估
  • ✅ 门禁里有一票否决类指标(安全分,阈值 0.00)
  • ✅ 灰度路由三维度叠加:开关 → 租户 → 流量哈希
  • ✅ 灰度有会话粘滞,防止同一会话跨版本
  • ✅ 门禁样本量不足返回 hold,不默认通过
  • ✅ 灰度主判据是质量层指标,技术层只抓崩溃
  • ✅ 每档停留时长由最慢主判据的样本量需求决定
  • ✅ 全量后提升为新基线
  • ✅ 回滚有防抖动(连续 N 次失败 + 冷却期),但安全指标立即回滚
  • ✅ 每月演练一次回滚,且演练可还原
  • ✅ 发布前检查幂等键跨版本有效、降级分支存在、超时参数一致

常见问题(FAQ)

Q1:我们团队只有 3 个人,需要全套吗?

不需要。 只做两件事:① 把 Prompt 放进版本化文件(半天工作量);② 有一个能改回上一个 pin 的脚本(就是第七节的 Pins.rollback)。这两件事能把本次事故从 40 分钟降到 1 分钟以内。 五层门禁等你有了评测集再说——没有评测集就没法判质量,硬上灰度只是买了个流量切换器。

Q2:灰度用 1% 还是 5% 起步?

两个都做,但判据不同。 1% 档配技术层指标(10 分钟窗口,200 样本),只用来抓崩溃;5% 档配质量层指标,才是真正的质量关卡。不要指望 1% 档能判定质量——1% 流量下 15 分钟只有约 8 个样本。

Q3:离线评估跑一次 9 分钟,太慢了怎么办?

先确认它是否只在必要时跑。 如果连纯代码改动也在跑 L2,那就是配置问题(4.3 节的 changed 判断)。如果确实每次都要跑,两个方向:① 用更小的评测集做"快速门禁"(100 例,2 分钟),完整集只在发布候选跑;② 评测集并行化。不要把 9 分钟当成"跳过评估"的理由——那是把 40 分钟事故的概率买回来。

Q4:模型供应商悄悄更新了,我们没改任何东西却退化了,怎么办?

两件事:① 用具体 model id 而非别名,把"升级模型"变成一次显式的发布;② 每周跑一次基线评估(不是只在变更时跑),主动发现供应商侧的漂移。我们的第 7 次事故(改造后唯一一次漏网)就是这么补上的。

Q5:自动回滚会不会误杀?

会,所以用"连续 3 次失败才回滚 + 30 分钟冷却"来防抖动。 代价是真实事故最多多影响 2 分钟(判定周期 60 秒),这远小于频繁误回滚损失的发布窗口。例外是安全类指标——它应该立即回滚。

Q6:灰度期间用户会话跨版本怎么办?

默认用会话粘滞(首次路由后固定版本)。紧急回滚时,先置权重 0 阻止新会话进入,同时强制清除 pin 表——这时"止血"优先于"体验一致"。粘滞表必须有清理机制,否则内存里会堆积几十万条。

Q7:能力开关和灰度开关能共用一个吗?

不能,语义不同。 灰度要回答"新版有没有坏",实验要回答"哪个版本更好"。混用会让两边的结论都不可信——实验的分流会污染灰度的对照,灰度的自动回滚会破坏实验的样本。这是 Martin Fowler 在 CanaryRelease 里明确警告过的一点。

Q8:发布变慢了(6 分钟 → 34 分钟),值得吗?

值得,但要分级。 34 分钟里的大头是灰度 1%/5% 档的 23 分钟。解法是分级通道:低风险改动(纯代码、有 L1 覆盖)走快通道(跳过 L3、灰度只跑 1% 档 5 分钟),高风险改动走完整流程。不要为了快而全量直发——那正是本次事故的做法。

Q9:影子流量和灰度选哪个?

不是二选一,是互补。 影子流量不暴露用户、能测分布漂移,但测不出真实业务结果、且要 stub 掉副作用;灰度用真实用户、能测业务结果,但要承担暴露风险。建议:L3 影子流量每次发布候选都跑(15 分钟、6 美元),灰度只做质量确认与崩溃检测。流量特别小的时候,影子流量是唯一有意义的选择。


参考资料

  1. Google SRE Workbook — Canarying Releases(灰度的规模、时长、流量与指标选择原则):https://sre.google/workbook/canarying-releases
  2. Martin Fowler — bliki: Canary Release(金丝雀发布的定义、与蓝绿部署和 A/B 测试的区别):https://martinfowler.com/bliki/CanaryRelease.html
  3. Martin Fowler / Pete Hodgson — Feature Toggles (aka Feature Flags)(开关分类与开关债):https://martinfowler.com/articles/feature-toggles.html
  4. Argo Rollouts 官方文档 — Canary Deployment Strategy(setWeight 与 pause 步骤、分析模板):https://argoproj.github.io/argo-rollouts/features/canary/
  5. OpenFeature — 厂商无关的开关标准(CNCF 孵化项目,避免开关实现锁定):https://openfeature.dev/
  6. GitHub Docs — Workflow syntax for GitHub Actions(on、jobs、concurrency、if 表达式):https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
  7. GitHub Docs — Using environments for deployment(环境的必需审批人、等待时间与分支限制):https://docs.github.com/actions/deployment/targeting-different-environments/using-environments-for-deployment
  8. GitHub Docs — Using concurrency(并发组与 cancel-in-progress 的行为):https://docs.github.com/en/actions/using-jobs/using-concurrency
Logo

更多推荐