大模型工程化实战(三):LLM 灰度发布 + 自动熔断,1% 流量试错、指标劣化自动回滚

目录

  • 前言
  • 一、问题定义:三连错,病在把线上验证当运维玄学
  • [二、核心方案:影子验证 → 金丝雀放量 → 熔断兜底](#二、核心方案:影子验证 → 金丝雀放量 → 熔断兜底)
    • [2.1 影子验证:判偏差,但不轻信"全绿"](#2.1 影子验证:判偏差,但不轻信“全绿”)
    • [2.2 金丝雀分层放量:每档双门禁,不是"试一下挺顺就全量"](#2.2 金丝雀分层放量:每档双门禁,不是“试一下挺顺就全量”)
    • [2.3 熔断阈值:先抄表,再懂为什么](#2.3 熔断阈值:先抄表,再懂为什么)
    • [2.4 熔断状态机:三态 + 自动回滚 + 复盘清单](#2.4 熔断状态机:三态 + 自动回滚 + 复盘清单)
  • [三、代码实战:熔断状态机 + 影子判定 + 放量配置](#三、代码实战:熔断状态机 + 影子判定 + 放量配置)
    • [3.1 主代码:三态熔断状态机(纯标准库,可离线跑)](#3.1 主代码:三态熔断状态机(纯标准库,可离线跑))
    • [3.2 副代码:影子三级偏差判定](#3.2 副代码:影子三级偏差判定)
    • [3.3 金丝雀放量配置 + 20 行哈希取模前缀](#3.3 金丝雀放量配置 + 20 行哈希取模前缀)
    • [3.4 测试:4 个断言锁死状态机](#3.4 测试:4 个断言锁死状态机)
  • 四、踩坑记录:三个真坑,每个都付过费
    • [4.1 影子全绿,全量翻车](#4.1 影子全绿,全量翻车)
    • [4.2 阈值拍脑袋,熔断器被注释](#4.2 阈值拍脑袋,熔断器被注释)
    • [4.3 回滚只切流量,不切状态](#4.3 回滚只切流量,不切状态)
  • 五、选型对比:灰度熔断五条路线
  • [六、总结 + 下一篇预告](#六、总结 + 下一篇预告)
  • [附注(可跳过):Poisson / Wilson / 3σ 完整推导](#附注(可跳过):Poisson / Wilson / 3σ 完整推导)

前言

下午 2:17,灰度发布放到 10%。离线评测全绿,监控面板一片祥和。新版这轮优化了 prompt,让回复更短更快------谁也没想到,"更短"的代价是"不再追问"。2:18,转人工率就破了基线 3 倍------可惜当时没装熔断器,这个信号没人接。2:56 客服甩了张截图:用户问"颜色跟图片差太多,我要退,运费谁出?",新版回"亲,退货需您自理运费哦"------可色差是描述不符,该商家出运费。3:00 人肉回滚完成,曝光整整 43 分钟、约 2600 次请求。不是没人监看,是监控在"看"、人没在"盯"------等红点飘满屏幕,船已经沉了一半。

上篇结尾我们说过:离线评测是静态快照,Golden Set 三个月就过期,你测的是上个月的业务------新版这轮只是让回复更短、少问一句,歧义长尾就翻了车。离线全绿不是终点 。这篇兑现后半段:离线评测回答"新版本好不好";灰度+熔断要回答两个问题------不好的时候谁先知道、谁先兜住。自动熔断把"谁先知道"从 43 分钟压到 1 分钟(2:17 放量,2:18 转人工率破阈值,就替我们跳闸),把"谁先兜住"从人肉切流变成状态机自动回滚。下面按影子验证、分层放量、阈值推导、状态机实现的顺序展开,代码都贴在对应小节,能直接跑。

一、问题定义:三连错,病在把线上验证当运维玄学

先看最常见的三种翻车姿势,你多半踩过至少一个:

常见错误做法 为什么无效
离线全绿直接全量上线 离线评测是静态快照,测的是上个月的业务;线上 query 分布一直在漂,Golden Set 三个月就过期
只灰度不熔断 放 1%"看起来不错"就一路放到 100%,或者放完就不管了,出问题靠人盯群------2:17 那场事故就是这么发生的
比例照抄 + 阈值拍脑袋 灰度比例抄大厂 10%,熔断阈值抄文档 5pp,不管你业务基线的 σ;阈值落在噪声带里,一周误伤 5 次,团队第 6 次把熔断器注释掉

三个错法病在同一个地方:把线上验证当运维玄学------"放多少流量凭经验,出没出问题看运气"。而第2篇已经证明,评测是一个统计问题:样本量决定检验力,噪声地板决定哪个 delta 可信。线上验证是同源问题:放量比例决定你多快攒够可判的样本,熔断阈值决定你分不分得清"信号"和"业务基线的正常抖动"。灰度不是玄学,是和离线评测同源的统计控制------本篇就是把它写进状态机。

二、核心方案:影子验证 → 金丝雀放量 → 熔断兜底

流水线 序篇已定:离线全量评测 → 影子并行验证 → 小流量金丝雀 → 分层放量 → 全量上线,配套一键回滚。这篇把实现细节补齐。四个部件,分工一句话说清:前两个管"怎么安全地放量",后两个管"不好时谁先兜住"。

2.1 影子验证:判偏差,但不轻信"全绿"

影子模式 = 新旧版本并行,接全量真实请求,新版只计算、不对外输出。判偏差分两级:

结构一致性 :新版输出能不能过第1篇的 Order 契约。过不了 = 结构不合法,直接进合规致命。这一步 $0、全量跑,坐落在第1篇的 model_validate 上------第1篇的契约在这里获得第二个正式身份(第一个是第2篇评测级联的 L1)。没有契约,影子连"结构一致"都没定义。

语义相似度 :新版输出和旧版(或 gold)做字段级加权比对。业务高危字段(status/total_cents/items)权重 3 倍------抽错了会写库、发货错;次要字段权重 1 倍。然后按分数进三级处置:

判定 条件 处置
无害 结构合法 且 语义相似度 ≥ 0.95 记录放行,影子继续
业务风险 结构合法 且 0.70 ≤ 相似度 < 0.95 阻断放量,告警人工复核
合规致命 结构不合法 / 相似度 < 0.70 / PII 泄露 / 与 gold 硬冲突 立即熔断,一票否决

这里必须泼盆冷水:影子全绿 ≠ 可全量。影子的暗区在于------它只镜像"旧版被请求过的流量"。用户提问后旧版答错了,用户就不会追问第二次,这段"本该有后续"的暗区流量根本没发生,新版再好也测不到。影子通过只意味着"新版在旧版答对的路上没跑偏",不意味着"新版能覆盖旧版没答对的路"(踩坑 4.1)。所以影子只是第一道闸,后面还得走金丝雀。

2.2 金丝雀分层放量:每档双门禁,不是"试一下挺顺就全量"

分档 1% → 5% → 10% → 30% → 100%。每档两个门禁,都过了才进下一档

  • 时间门禁 :该档至少待够 min_hold_min(1% 档 120 分钟、5% 档 240 分钟......)。为什么?让指标在真实流量下攒够统计样本,也防"晚上 11 点顺手放量"的静默推进。
  • 指标门禁:该档窗口内快/慢层指标不熔断(阈值同熔断器)。只要熔断一次,放量打回 1% 重新走,而不是从断点续传。

放量维度按用户 ID 哈希取模前缀 (一致性哈希的简化形态):每个用户被 sha256 散列到固定槽位,放量 = 单调扩大"放行前缀"。为什么不能每档换模数?如果你按 user_id % n 且每档换 n(1% 档用 %100、5% 档换 %20),升档 = 换模数 = 全量重映射,已放量的用户被切回旧版,同一次会话新老版本交替------上半句旧版答、下半句新版答,语义断裂。固定槽位 + 单调阈值只补边界槽位,用户全程走同一版本;sha256 的额外价值是摊平非均匀的 user_id 分布。金融/政务等高价值强监管客户、指定白名单,exclude 名单一票否决,永不参与灰度。

2.3 熔断阈值:先抄表,再懂为什么

序篇给过推荐初始值(解决率 10pp / 违规 3 次 / 成本比 2.0x / P99 3x),本篇补"为什么是这个数"和校准方法,并新增一个快层信号------转人工率。先别急着懂为什么,把结论抄走,落地就靠这张表:

信号 阈值(先抄) 一句话为什么 校准旋钮
违规 5 分钟 ≥ 3 次 3 次基本不可能是正常抖动 按档位流量缩放
Token 成本比 ≥ 2.0x 翻倍是回退/重试风暴 写死
P99 ≥ 3x 基线 3 倍是降档/饱和,不是抖动 写死
转人工率 ≥ 3x 基线 且 ≥ 3 次 解决率的实时代理 写死
解决率 下滑 ≥ 10pp 业务波动 ±6pp,5pp 落在噪声带 3σ 校准
样本门槛 窗口 < 200 不判 样本不够,率全是噪声 min_samples

先对齐口径,后面不再重复解释:解决率 = 这条会话用户问题有没有被解决的比例(线上抽样 + LLM 判卷 + 用户反馈三路合成,不是现成数字);转人工率 = 用户点"转人工"的比例;违规 = 护栏漏出的违规输出次数;Token 成本比 = 新版单请求成本 ÷ 旧版;P99 = 响应延迟的 99 分位,99% 的请求比它快、剩下 1% 是最慢的长尾;样本 = 窗口内请求数。

分两层,取向正好相反:

快层(合规/成本/延迟/转人工率):宁可误伤。漏判的代价(合规处罚、账单爆炸、体验崩坏)远大于误判的代价(一次自动回滚,10 分钟就能重新放量),所以零样本门槛:成本/P99 一次命中立即熔断,转人工率和违规一样是"基线本身就有残余"的计数型,要累计到阈值才判(下详)。

  • 违规 5 分钟 ≥ 3 次(按档位流量缩放) :护栏漏掉的违规,基线本来就极少(万分之五上下)。所以 5 分钟里撞见 3 次,几乎能断定不是正常抖动------正常 5 分钟你连 1 次都凑不齐。但"3 次"不能写死:流量越大,正常漏掉的也按比例变多,10% 档流量是 1% 档的十倍,还死卡 3 次,就会把正常当异常、熔断器天天误伤,放量卡死在 1% 档。所以阈值跟着档位流量走:流量放大几倍,阈值就放大几倍(代码里 violation_threshold_for 一行算好)。流量特别小的业务(总 QPS < 50),5 分钟凑不够 3 次,得按流量折算窗口------这就是"阈值写死、统计是活的"的活法。想看严谨推导的去文末「推导附注」。

  • Token 成本比 ≥ 2.0x:单请求成本翻倍是 prompt 回退/重试风暴的量级,同一小时账单看得见。1.5x 可能是批次波动,2.0x 是确定的事故。

  • P99 ≥ 3x 基线:正常 P99 抖动约 ±20%,3 倍是换模型降档/上游饱和,不是抖动。

  • 转人工率 ≥ 3x 基线(且窗口内 ≥ 3 次):转人工是解决率的实时代理。用户点"转人工"是实时点击事件,一发生就进计数,不像解决率要等会话结束、等 LLM 判卷、等 200 个样本攒够。基线转人工率 r₀ ≈ 2%(正常客服里每 50 个会话 1 个转人工),3 倍即 6%,外加窗口内 ≥ 3 次转人工,卡掉"单个用户连点"的小样本噪声。开头那场 2:17 的事故,2:18 转人工率就破了 3 倍------快层抢在慢层前喊停,靠的就是这个实时信号。(实时的是"信号",分钟级的是"跳闸"。)

慢层(业务解决率):宁可不误。解决率天然波动(我见过业务基线 ±6pp),误伤导致团队不信熔断------和 #02 门禁被注释是同一个病。所以双门槛:样本门槛 + 3σ 阈值。

  • min_samples(样本门槛):窗口内请求 < 200 一概不判。样本不够时算出来的率全是噪声,看着"掉 12pp"可能只是样本还没攒够(#02 算过,样本少的时候上下能晃出好几个点)。
  • 3σ 校准 :上线前观察旧版一周,按 10 分钟窗口切解决率,先算出它平时自己会晃多少(记作 σ)。阈值 = max(业务能忍的下滑, 3 倍这个晃动幅度)。例:基线 0.90、平时晃动约 1.8pp,3 倍约 5.4pp;业务觉得掉 10pp 不能忍 → 阈值就取 10pp。照抄 5pp?你的业务波动 ±6pp,一周误伤 5 次(踩坑 4.2)。

沿用第2篇的纪律:阈值是写死的,统计是活的 。上表那行"阈值(先抄)"是写死的起点,min_samples、3σ 校准和违规阈值按档位缩放就是"统计是活的"三个旋钮------在代码里它们都是 TripRules 的字段,改一行重发即可。离线评测用配对检验,线上熔断用窗口统计 + 3σ,同一个心智模型:分不清信号和噪声,就不配下结论。(嫌上面不够严谨、想看 Poisson / Wilson / 3σ 完整推导的,见文末「推导附注」。)

解决率这个数别想当然,它不是裸遥测,是线上回流抽样 + LLM-as-judge 判"这条会话解决没解决" + 用户显式反馈(转人工/差评/追问次数) 三路合成。判卷复用 #02 的跨厂商纪律------不能拿生成模型给自己判卷。注意:转人工在这里是解决率的合成成分之一(慢层),但它同时是独立的快层信号------解决率要等三路合成都到位才出炉,转人工事件本身实时可见,所以 2.3 单独给它设了快层阈值。

2.4 熔断状态机:三态 + 自动回滚 + 复盘清单

复制代码
CLOSED ──指标劣化(_trip)──▶ OPEN ──冷却期到点──▶ HALF_OPEN
  ▲                          │                        │
  └────试探达标(_recover)────┘                         │
                 OPEN ◀──试探失败(_trip, escalate)────┘

三态语义:CLOSED(正常,全量 new)→ OPEN(熔断,全量 old)→ HALF_OPEN(冷却后小流量试探)。为什么不是通用熔断器那套多态(DISABLED / FORCED_OPEN 等扩展态)?熔断对象是"版本"不是"下游依赖",决策只有"路由 new 还是 old"的二选一;Hystrix 那套按调用方隔离、并发试探、失败计数桶,在这里全是多余状态------状态越多,"谁先知道"越慢。

自动回滚_trip 时同时做两件事------① should_route() 返回 "old",流量侧兜住;② 调部署平台的 rollback API 把 canary 占比压到 0,配置侧兜住。每次跳闸写一条 RollbackRecord,既是复盘证据,也是审计证据。

HALF_OPEN 试探必须是"有限"的 :配额(如 5 个试探)+ 通过率(如 80%)双条件。试探本身就是风险------试探失败说明事故没过去,立刻回 OPEN 并标记 escalate(升级人工),绝不让试探流自动放大成下一次事故。一个容易忽略的约束:冷却期必须 ≥ 违规统计窗口(示例 6 > 5 分钟)。否则 HALF_OPEN 第一个试探会被窗口里还没过期的违规信号当场打回------快层红线是"粘滞"的,冷却不足等于试探永远开不了闸。

回滚复盘清单 (每次熔断自动推给责任人,五问):① 谁触发的------快层还是慢层?哪个指标?命中值多少?② 发现到兜住耗时------TRIP 时间戳到全量切回 old,应 < 60s;③ 暴露面------熔断瞬间放量比例、已触达用户数;④ 是否误伤------旧版同窗口跑同数值会不会也触发?会就回去重标 3σ;⑤ 下次怎么更快------沉淀成 RollbackRecord 的 review 结论。

三、代码实战:熔断状态机 + 影子判定 + 放量配置

工程结构(放在 release/,和 第2篇的 evals/ 平级):

复制代码
你的仓库/
├── release/
│   ├── circuit_breaker.py       # 主代码:熔断状态机(可离线运行)
│   ├── shadow_compare.py        # 副代码:影子三级偏差判定
│   ├── canary.yaml              # 金丝雀分层放量配置
│   └── test_circuit_breaker.py  # 4 个断言,验证状态机

3.1 主代码:三态熔断状态机(纯标准库,可离线跑)

熔断决策只依赖指标,不依赖任何 API------所以它可以是纯标准库,离线就能单测、回放、审计。生产里指标来自线上 trace 聚合,demo 里来自模拟 feed。

python 复制代码
"""
熔断状态机:CLOSED / OPEN / HALF_OPEN 三态 + 自动回滚。
纯标准库实现,无第三方依赖、无任何 API 密钥------熔断决策只依赖喂给它的指标。

依赖安装:无(Python >= 3.10 即可)
    python circuit_breaker.py          # 跑模拟,看状态机完整生命期
    python test_circuit_breaker.py     # 跑 4 个断言

为什么是纯标准库:熔断是发布侧的"最后一公里"决策,必须能在离线环境里
单测、回放、审计。决策逻辑和指标采集解耦,决策层越轻越不容易被拖下水。
"""
from collections import deque
from dataclasses import dataclass
from typing import Literal


# ---------- 指标样本:熔断器眼里只有这个 ----------

@dataclass
class MetricSample:
    """一个聚合窗口的线上指标(生产来自 trace 聚合,分钟级一批)。
    五个字段对应五类 LLM 特有劣化:
      solved/total = 业务解决率(慢层)------数据来源:线上回流抽样 + LLM-as-judge + 用户反馈,
                    判卷复用 #02 跨厂商纪律,不能拿生成模型给自己判卷
      violations   = 违规输出数(快层,合规红线)
      transfers    = 转人工次数(快层,解决率的实时代理:用户点击即上报)
      cost         = 与基线版本的单请求 Token 成本比(快层,钱是即时的)
      p99_ms       = 响应延迟(快层,P99 比均值更能暴露长尾)"""
    t: float = 0.0
    total: int = 1
    solved: int = 1
    violations: int = 0
    transfers: int = 0
    cost: float = 1.0
    p99_ms: float = 300.0

    @property
    def solve_rate(self) -> float:
        return self.solved / self.total if self.total else 1.0


@dataclass
class RollbackRecord:
    """一次熔断/回滚的审计证据。复盘清单五问全都能在这条记录里答出来。"""
    ts: float
    reason: str
    escalate: bool          # True = HALF_OPEN 试探失败,升级人工介入
    canary_pct: float = 0.0  # 熔断瞬间的放量比例


# ---------- 环形缓冲:只看最近,信号会过期 ----------

class RollingWindow:
    """定长环形缓冲:只保留最近 N 个样本,老的自动滚出。
    为什么用定长而不是无限累积:熔断只看"最近"的指标,
    三天前的正常不该挡今天的放量,三天前的劣化也不该替今天受过------信号会过期。"""
    def __init__(self, capacity: int):
        self._buf: deque[MetricSample] = deque(maxlen=capacity)

    def push(self, sample: MetricSample) -> None:
        self._buf.append(sample)

    def samples(self) -> list[MetricSample]:
        # 返回只读快照而非内部引用:外面改 deque 会让统计口径漂移
        return list(self._buf)

    def __len__(self) -> int:
        return len(self._buf)


# ---------- 熔断规则:阈值 + 校准参数 ----------

@dataclass(frozen=True)
class TripRules:
    """熔断阈值。注意:这里是"写死的起点",不是"写死的真理"------
    沿用 #02 的纪律:阈值是写死的,统计是活的。
    solve_rate_drop_pp / min_samples_slow / violation_ratio_multiplier 就是
    "统计是活的"的三个旋钮:分别按 3σ 校准、保证样本够、按档位流量缩放。"""
    # ---- 快层(合规/成本/延迟/转人工率):宁可误伤,成本/P99 一次命中即熔断、违规与转人工累计到阈值 ----
    # 触发即止损;误伤代价 = 一次自动回滚(可立刻重新放量),
    # 漏判代价 = 合规处罚/账单爆炸/体验崩坏,两者完全不对等
    violation_limit: int = 3               # 违规阈值下限(1% 档的起点值,Poisson 推导见文末附注)
    baseline_violation_rate: float = 0.0005  # 护栏后基线违规率(1/2000)
    violation_ratio_multiplier: float = 10.0 # 违规率放大倍数 k:涨 10 倍才判熔断(按档位流量缩放)
    violation_window_min: float = 5.0
    cost_ratio_limit: float = 2.0          # Token 成本比 ≥ 2x(翻倍,账单同一小时看得见)
    p99_baseline_ms: float = 300.0
    p99_ratio_limit: float = 3.0           # P99 ≥ 3x 基线(正常抖动约 ±20%,3 倍是降档/饱和量级)
    baseline_transfer_rate: float = 0.02   # 基线转人工率 r₀(正常客服约每 50 会话 1 次转人工)
    transfer_rate_ratio_limit: float = 3.0 # 转人工率 ≥ 3x 基线才判(解决率的实时代理,见正文 2.3)
    min_transfer_count: int = 3            # 窗口内 ≥ 3 次转人工,卡"单个用户连点"的小样本噪声

    # ---- 慢层(业务解决率):宁可不误,样本不足不下结论 ----
    solve_baseline: float = 0.90           # 基线解决率(上线前按旧版 10 分钟窗口统计,3σ 校准)
    solve_rate_drop_pp: float = 10.0       # 下滑 ≥ 10pp 才判------max(业务容忍 10pp, 3σ≈5.4pp) 推导见文末附注
    min_samples_slow: int = 200            # 慢层样本门槛:低于它算出来的率全是噪声(#02 的噪声地板)

    # ---- HALF_OPEN 有限试探 ----
    cooldown_min: float = 6.0              # 熔断后冷却期,必须 ≥ 违规窗口(5min):否则 HALF_OPEN 一开闸就被旧信号打回
    half_open_trials: int = 5              # 试探配额(有限,试探本身就是风险)
    half_open_success_rate: float = 0.8    # 试探 4/5 通过才回 CLOSED

    def violation_threshold_for(self, reqs_5min: float) -> int:
        """违规阈值按档位流量缩放。固定 3 次只对 1% 档成立(推导见文末附注):
        高流量档基线期望违规 λ₀ = reqs_5min × baseline_violation_rate 本身就接近 3,
        固定 3 次会被基线噪声误触发,放量卡死在低档。threshold = max(下限, λ₀ × k)。"""
        lam = reqs_5min * self.baseline_violation_rate
        return max(self.violation_limit, int(lam * self.violation_ratio_multiplier))


# ---------- 熔断状态机 ----------

State = Literal["CLOSED", "OPEN", "HALF_OPEN"]


class CircuitBreaker:
    """三态熔断器。为什么是三态而不是通用熔断器那套多态(DISABLED / FORCED_OPEN 等扩展态):
    熔断对象是"版本"不是"下游依赖"------决策只有二选一(路由 new 还是 old),
    不需要 Hystrix 那套按调用方隔离/并发试探/失败计数桶。状态越少,越快回答
    "谁先知道、谁先兜住"。

    __init__ 的窗口容量是写死的默认值(慢窗 25 分钟 / 快窗 10 分钟),
    生产按你的流量特征调:窗口越短反应越快、越容易被瞬时抖动骗;越长越稳、越慢。"""
    def __init__(self, rules: TripRules):
        self.rules = rules
        self.state: State = "CLOSED"
        self._now = 0.0
        self.tripped_at: float | None = None
        self._slow_win = RollingWindow(capacity=25)   # 25 分钟窗口:解决率稀释分析见 2.3
        self._fast_win = RollingWindow(capacity=10)   # 10 分钟窗口,筛最近 5 分钟违规用
        self._last: MetricSample | None = None
        self.probe_remaining = 0
        self.probe_done = 0
        self.probe_ok = 0
        self.canary_pct: float = 0.0       # 当前放量档位,_trip 时写进 RollbackRecord(复盘③用)
        self.rollbacks: list[RollbackRecord] = []
        self.recoveries: list[float] = []

    # ---------- 对外两个入口 ----------

    def should_route(self, t: float) -> str:
        """当前请求该路由到 new 还是 old。三态语义:
        CLOSED → 全部 new;OPEN → 全部 old;HALF_OPEN → 配额内放试探给 new。
        返回 "new"/"old",调用方(网关)只认这两个枚举------和 #02 的 exit-code 契约同构:
        决策方只输出枚举,不输出理由给调用方解释。"""
        self._now = t
        if self.state == "CLOSED":
            return "new"
        if self.state == "OPEN":
            # 冷却期到点就转入 HALF_OPEN。必须在这里转:OPEN 期没有 observe 会被调用
            # (流量全在 old),如果不在决策入口转,这个状态机就永远停在 OPEN 了
            if t - self.tripped_at >= self.rules.cooldown_min:
                self._enter_half_open()
                return "new" if self.probe_remaining > 0 else "old"
            return "old"
        return "new" if self.probe_remaining > 0 else "old"

    def observe(self, sample: MetricSample) -> None:
        """把路由到 new 的请求的真实指标喂回来。只有 new 的指标参与跳闸决策------
        old 的指标属于上一版,是拿来当基线的,不是拿来决定这次跳不跳闸的。"""
        self._now = sample.t
        if self.state == "OPEN":
            return  # 已熔断不喂指标:OPEN 期 canary 流量为 0,喂进来的都是假象
        self._slow_win.push(sample)
        self._fast_win.push(sample)
        self._last = sample
        if self.state == "HALF_OPEN":
            self._consume_probe(sample)
        else:
            self._check_trip(sample)

    # ---------- 内部:跳闸 / 试探 / 恢复 ----------

    def _check_trip(self, sample: MetricSample) -> None:
        """先查快层再查慢层:合规/成本/延迟是 fail-safe,永远优先。"""
        # 快层①:违规------阈值按档位流量缩放(violation_threshold_for),
        # 合规红线不跟统计讲道理,但高流量档不能让基线噪声误触发
        viol = self._violations_last(self.rules.violation_window_min, sample.t)
        reqs = self._reqs_last(self.rules.violation_window_min, sample.t)
        v_threshold = self.rules.violation_threshold_for(reqs)
        if viol >= v_threshold:
            self._trip(f"快层违规:最近{self.rules.violation_window_min:.0f}分钟内 {viol} 次 ≥ 阈值 {v_threshold}(窗口 {reqs:.0f} 请求)")
            return
        # 快层②:Token 成本比------翻倍就是 prompt 或重试回退,账单同一小时看得见
        if sample.cost >= self.rules.cost_ratio_limit:
            self._trip(f"快层成本:单请求成本比 {sample.cost:.1f}x ≥ {self.rules.cost_ratio_limit:.1f}x")
            return
        # 快层③:P99------长尾恶化,3 倍远超正常抖动
        if sample.p99_ms >= self.rules.p99_baseline_ms * self.rules.p99_ratio_limit:
            self._trip(f"快层延迟:P99 {sample.p99_ms:.0f}ms ≥ {self.rules.p99_ratio_limit:.0f}x 基线 {self.rules.p99_baseline_ms:.0f}ms")
            return
        # 快层④:转人工率------解决率的实时代理。转人工是用户点击的实时事件,即点即报;
        # 比例 ≥ 3x 基线 且 窗口内 ≥ 3 次才判(卡掉"单个用户连点"的小样本噪声)。
        transfers = self._transfers_last(self.rules.violation_window_min, sample.t)
        transfer_floor = self.rules.baseline_transfer_rate * self.rules.transfer_rate_ratio_limit
        if transfers >= self.rules.min_transfer_count and reqs > 0 and transfers / reqs >= transfer_floor:
            self._trip(f"快层转人工率:近{self.rules.violation_window_min:.0f}分钟 {transfers} 次转人工 / {reqs:.0f} 请求 = {transfers / reqs:.1%} ≥ {transfer_floor:.1%}")
            return
        # 慢层:解决率------样本不足时一概不判(#02 噪声地板),样本够了再比阈值
        rate, total = self._slow_solve_rate()
        floor = self.rules.solve_baseline - self.rules.solve_rate_drop_pp / 100
        if total >= self.rules.min_samples_slow and rate <= floor:
            self._trip(
                f"慢层解决率:{rate:.2%} ≤ 基线{self.rules.solve_baseline:.2%}-{self.rules.solve_rate_drop_pp:.0f}pp,"
                f"窗口 {total} 样本(≥ {self.rules.min_samples_slow})"
            )

    def _trip(self, reason: str, escalate: bool = False) -> None:
        """CLOSED/HALF_OPEN → OPEN:立即切断 new 流量并写 RollbackRecord。
        生产里这里要调部署平台的 rollback API,把 canary 占比压到 0 并锁定旧版;
        本 demo 用记录代替调用------should_route 返回 old 本身就完成了流量侧切回。"""
        self.state = "OPEN"
        self.tripped_at = self._now
        self.rollbacks.append(RollbackRecord(ts=self._now, reason=reason, escalate=escalate, canary_pct=self.canary_pct))
        print(f">>> TRIP → OPEN   {reason}" + ("  [升级人工]" if escalate else ""))

    def _enter_half_open(self) -> None:
        """OPEN → HALF_OPEN:冷却期到点,放少量试探。配额是有限的------试探本身就是风险,
        配额用尽都没达标说明事故没过,直接回 OPEN 等下一轮冷却。"""
        self.state = "HALF_OPEN"
        self.probe_remaining = self.rules.half_open_trials
        self.probe_ok = 0
        self.probe_done = 0
        print(f">>> 冷却到点 → HALF_OPEN(试探配额 {self.rules.half_open_trials} 次,通过率 ≥{self.rules.half_open_success_rate:.0%} 才恢复)")

    def _consume_probe(self, sample: MetricSample) -> None:
        """HALF_OPEN 试探:先查快层,再验解决率。
        为什么先查快层:试探样本同样是真实流量,合规/成本/延迟/转人工率劣化不会因为状态是
        HALF_OPEN 就豁免------事故若是快层类,试探样本 5 分钟内违规超阈值就该立刻回 OPEN,
        而不是因为单样本 solve_rate≥0.8 就误判"事故过去了"(PASS 回 CLOSED 会放大下一次事故)。"""
        if self.probe_remaining <= 0:
            return
        self.probe_remaining -= 1
        self.probe_done += 1
        # 快层优先:试探流量也是流量,红线指标一票否决
        viol = self._violations_last(self.rules.violation_window_min, sample.t)
        reqs = self._reqs_last(self.rules.violation_window_min, sample.t)
        if viol >= self.rules.violation_threshold_for(reqs):
            self._trip(f"HALF_OPEN 试探期违规 {viol} 次 ≥ 阈值,快层红线,回 OPEN", escalate=True)
            return
        if sample.cost >= self.rules.cost_ratio_limit:
            self._trip(f"HALF_OPEN 试探期成本比 {sample.cost:.1f}x ≥ {self.rules.cost_ratio_limit:.1f}x,回 OPEN", escalate=True)
            return
        if sample.p99_ms >= self.rules.p99_baseline_ms * self.rules.p99_ratio_limit:
            self._trip(f"HALF_OPEN 试探期 P99 {sample.p99_ms:.0f}ms 超阈值,回 OPEN", escalate=True)
            return
        transfers = self._transfers_last(self.rules.violation_window_min, sample.t)
        if transfers >= self.rules.min_transfer_count and reqs > 0 and transfers / reqs >= self.rules.baseline_transfer_rate * self.rules.transfer_rate_ratio_limit:
            self._trip(f"HALF_OPEN 试探期转人工率 {transfers / reqs:.1%} 超阈值,回 OPEN", escalate=True)
            return
        # 快层干净才看解决率
        if sample.solve_rate >= self.rules.half_open_success_rate:
            self.probe_ok += 1
        if self.probe_done >= self.rules.half_open_trials:
            ok_rate = self.probe_ok / self.probe_done
            if ok_rate >= self.rules.half_open_success_rate:
                self._recover()
            else:
                self._trip(f"HALF_OPEN 试探 {self.probe_ok}/{self.probe_done} 通过 < {self.rules.half_open_success_rate:.0%},回 OPEN", escalate=True)

    def _recover(self) -> None:
        """HALF_OPEN → CLOSED:试探达标,放量继续。记录恢复时间点,复盘用。"""
        self.state = "CLOSED"
        self.recoveries.append(self._now)
        print(f">>> 试探达标 → CLOSED(恢复,放量从档位起点重新走)")

    # ---------- 统计工具 ----------

    def _violations_last(self, minutes: float, now: float) -> int:
        cutoff = now - minutes
        return sum(s.violations for s in self._fast_win.samples() if s.t >= cutoff)

    def _reqs_last(self, minutes: float, now: float) -> float:
        cutoff = now - minutes
        return sum(s.total for s in self._fast_win.samples() if s.t >= cutoff)

    def _transfers_last(self, minutes: float, now: float) -> int:
        cutoff = now - minutes
        return sum(s.transfers for s in self._fast_win.samples() if s.t >= cutoff)

    def _slow_solve_rate(self) -> tuple[float, int]:
        samples = self._slow_win.samples()
        solved = sum(s.solved for s in samples)
        total = sum(s.total for s in samples)
        return (solved / total if total else 1.0, total)

    def snapshot(self) -> dict:
        """给模拟器打表用的当前指标快照。"""
        rate, n = self._slow_solve_rate()
        return {
            "state": self.state,
            "solve_rate": rate,
            "solve_samples": n,
            "violations_5min": self._violations_last(self.rules.violation_window_min, self._now),
            "cost": self._last.cost if self._last else 1.0,
            "p99": self._last.p99_ms if self._last else self.rules.p99_baseline_ms,
        }


# ---------- 模拟 feed:20 分钟正常 → 劣化脉冲 → 恢复 ----------

def make_sample(minute: int, scenario: str) -> MetricSample:
    """模拟一个聚合窗口。
    正常期:解决率 0.875-0.917、P99 300ms、成本 1.0x、零违规。
    fast 场景(60 请求/分):第 21 分钟开始解决率掉到 0.75,并在 21-22 分钟注入违规脉冲
      (2 次 → 3 次,累计 5 次)------快层抢在慢层前跳闸(fail-safe 优先),成本/延迟只轻微劣化不触发。
    slow 场景(8 请求/分,低流量 canary):只降解决率(0.875 → 0.75),不注入快层指标------
      让慢层的 min_samples 门禁 + 窗口稀释自己演戏。8 请求/分是为了让样本门槛在第 24 分钟
      才达标(分钟 23 时窗口 192 < 200),门槛的拦截能被看见。"""
    if minute < 21:
        return MetricSample(t=minute, total=8 if scenario == "slow" else 60,
                            solved=7 if scenario == "slow" else 55, p99_ms=300, cost=1.0)
    if scenario == "slow":
        return MetricSample(t=minute, total=8, solved=6, p99_ms=300, cost=1.0)
    if minute <= 26:
        viol = 2 if minute == 21 else (3 if minute == 22 else 0)
        return MetricSample(t=minute, total=60, solved=45, p99_ms=500, cost=1.3, violations=viol)
    return MetricSample(t=minute, total=60, solved=55, p99_ms=300, cost=1.0)


def run_scenario(name: str, scenario: str, minutes: int, canary_pct: float = 0.01) -> None:
    """驱动一次完整的模拟:每个分钟 tick = should_route 决策 + observe 喂指标。
    注意:只有路由到 new 的分钟才把指标喂回熔断器------和线上一样,canary 没有流量就没有指标,
    熔断器只能基于"真去过 new 的请求"下结论。
    canary_pct 是本次场景的放量档位,写进 RollbackRecord,复盘③"熔断瞬间放量比例"答得出来。"""
    rules = TripRules()
    cb = CircuitBreaker(rules)
    cb.canary_pct = canary_pct
    print(f"\n===== 场景 {name} =====")
    print(f"{'分钟':>4} {'决策状态':>8} {'路由':>5} {'跳闸':>4} {'解决率':>8} {'样本':>5} {'违规/5m':>7} {'成本':>5} {'P99':>6}")
    print("  注:决策状态 = 本轮 should_route 决策时的状态;跳闸 = 本 tick 喂指标后是否触发熔断")

    prev_state = cb.state
    gate_announced = False
    for minute in range(minutes):
        sample = make_sample(minute, scenario)
        decision_state = cb.state
        rolls_before = len(cb.rollbacks)
        route = cb.should_route(float(minute))
        if route == "new":
            cb.observe(sample)
        tripped = len(cb.rollbacks) > rolls_before
        if cb.state != prev_state:
            print(f"  {'-- ' + prev_state + ' -> ' + cb.state:.<52}")
            prev_state = cb.state
        snap = cb.snapshot()
        if scenario == "slow" and not gate_announced and snap["solve_samples"] >= rules.min_samples_slow:
            gate_announced = True
            print(f"  -- min_samples 达标:窗口样本 {snap['solve_samples']} ≥ {rules.min_samples_slow},慢层开始有资格判罚 --")
        print(f"{minute:>4} {decision_state:>8} {route:>5} {'*' if tripped else '':>4} "
              f"{snap['solve_rate']:.3f} {snap['solve_samples']:>5} "
              f"{snap['violations_5min']:>7} {snap['cost']:.2f}x {snap['p99']:>5.0f}")

    print(f"\n结果:熔断 {len(cb.rollbacks)} 次,恢复 {len(cb.recoveries)} 次")
    for i, r in enumerate(cb.rollbacks, 1):
        print(f"  RollbackRecord#{i}  t={r.ts:.0f}  canary={r.canary_pct:.0%}  escalate={r.escalate}  {r.reason}")


def main() -> None:
    # 场景 A:违规脉冲 + 解决率骤降,10% 档。快层第 22 分钟跳闸
    # (5 分钟内违规 5 次 ≥ 该档阈值 3)------慢层窗口太宽根本来不及反应,快层做的是慢层做不了的事。
    # 冷却期 6 分钟 > 违规窗口 5 分钟,第 28 分钟窗口干净后才进 HALF_OPEN,试探 5/5 达标 → 32 分钟回 CLOSED。
    # 完整生命期 CLOSED→OPEN→HALF_OPEN→CLOSED。试探失败回 OPEN 的路径由 test③ 锁死;
    # 把 cooldown_min 改成 3 跑一遍,你会看到第一个试探被 5 分钟窗口里还没过期的违规信号打回------
    # 冷却期必须 ≥ 违规窗口,这就是正文 2.4 那条约束。
    run_scenario("A|违规脉冲 + 解决率骤降(10% 档,快层熔断,冷却达标后恢复)", "fast", 40, canary_pct=0.10)
    # 场景 B:只降解决率,1% 档。21-23 分钟窗口样本 192 < 200,12.5pp 下滑也不跳闸(样本门槛);
    # 第 35 分钟窗口被劣化样本填满才真正触发(窗口稀释)------慢层的"慢"是设计,不是 bug。
    # 熔断后 HALF_OPEN 试探全失败 → 保持 OPEN 等人工介入(escalate 标记的意义所在)。
    run_scenario("B|只降解决率(1% 档,慢层熔断,min_samples 门禁 + 窗口稀释)", "slow", 46, canary_pct=0.01)


if __name__ == "__main__":
    main()

跑一遍 python circuit_breaker.py,你会看到两段完整轨迹。场景 A 的看点:第 22 分钟快层因为违规跳闸------慢层窗口太宽根本来不及反应 。快层存在的意义不是"更快地做慢层的事",而是"做慢层做不了的事":合规/成本/延迟不等人攒样本。冷却期 6 分钟 > 违规窗口 5 分钟,所以第 28 分钟进 HALF_OPEN 时窗口已干净,试探 5/5 达标、32 分钟回 CLOSED------完整生命期 CLOSED→OPEN→HALF_OPEN→CLOSED。cooldown_min 改成 3 试试 :第一个试探会被 5 分钟窗口里还没过期的违规信号当场打回,这就是"冷却期必须 ≥ 违规窗口"的原因;试探失败回 OPEN 的路径由 test③ 锁死。场景 B 的看点:21-23 分钟解决率已经掉了 12.5pp,但样本 192 < 200,不跳闸------样本门槛把"还没统计意义的劣化"挡在外面;第 24 分钟样本达标、第 35 分钟窗口稀释到 0.80 才真正触发。

还有两个设计点,值得拎出来单独说:should_route() 返回 "new"/"old" 两个枚举,和 第2篇的 exit-code 契约同构------决策方只输出枚举,调用方只认枚举 ,不解析任何中间态;observe() 只在路由 new 时被调用------canary 没有流量就没有指标,熔断器绝不会基于"没去过的请求"下结论。这两条合起来,保证了熔断决策是"真去过 new 的流量"投的票。

3.2 副代码:影子三级偏差判定

影子判定是发布前的第一道闸,结构一致性坐落在 第1篇的 Order 契约上。demo 为可独立运行,内联了一份字段与 第1篇完全一致的契约------生产环境必须从共享 schema 包 import 同一份契约,绝不各写各的(各写一份,影子判定和契约迟早漂移,影子就失效了)。

python 复制代码
"""
影子模式三级偏差判定:新旧版本并行跑同一批真实请求,判"新版偏离旧版多少"。
结构一致性坐落在 #01 的 Order 契约上(model_validate),语义偏差按字段加权。
复用 #02 的纪律:judge 的职责不是打分,是报警/不报警。

依赖安装(Python >= 3.10):
    pip install "pydantic>=2.5"

用法:
    python shadow_compare.py

注意:本 demo 为可独立运行,内联了一份字段与 #01 完全一致的契约;
生产环境必须从共享 schema 包 import 同一份 Order(#01 的契约),不要各写一份------
各写一份,影子判定和契约迟早漂移,影子就失效了。
"""
import json
import re
from dataclasses import dataclass
from enum import Enum

from pydantic import BaseModel, Field, ValidationError


# ---------- 契约(demo 内联版,字段与 #01 完全一致)----------

class OrderItem(BaseModel):
    product_id: str = Field(pattern=r"^SKU-\d{6}$")
    quantity: int = Field(ge=1, le=999)
    unit_price_cents: int = Field(ge=0)


class Order(BaseModel):
    order_id: str = Field(min_length=6)
    customer_name: str = Field(min_length=1)
    status: str                       # #01 契约里是 OrderStatus 枚举;demo 用 str 保持可运行
    items: list[OrderItem] = Field(min_length=1)
    total_cents: int = Field(ge=0)


# ---------- 三级判定 ----------

class ShadowVerdict(Enum):
    BENIGN = "benign"            # 无害:结构一致 + 语义相似,记录放行
    RISK = "business_risk"       # 业务风险:结构合法但语义偏差,阻断放量 + 告警人工
    FATAL = "compliance_fatal"   # 合规致命:结构不合法 / 与 gold 冲突 / PII 泄露,立即熔断


@dataclass
class ShadowResult:
    verdict: ShadowVerdict
    structural_ok: bool
    semantic_sim: float
    diff_fields: list[str]
    note: str


def _plain(v) -> str:
    # 兼容枚举值与普通字符串:生产契约里 status 是 OrderStatus 枚举,demo 里是 str
    return v.value if isinstance(v, Enum) else v


def semantic_similarity(a: Order, b: Order) -> tuple[float, list[str]]:
    """字段级加权语义相似度。权重说明为什么这么设计:
      status / total_cents / items 是业务高危字段,抽错了会写库/发货错------权重 3 倍;
      order_id / customer_name 抽错最多是用户找客服------权重 1 倍。
    我们不需要精确相似度,要的是"这个偏差要不要阻断放量"的二分类
    (#02:judge 的职责不是打分,是报警)。"""
    a_items = json.dumps([i.model_dump() for i in a.items], sort_keys=True, ensure_ascii=False)
    b_items = json.dumps([i.model_dump() for i in b.items], sort_keys=True, ensure_ascii=False)
    fields = [
        ("order_id", _plain(a.order_id), _plain(b.order_id), 1.0),
        ("customer_name", _plain(a.customer_name), _plain(b.customer_name), 1.0),
        ("status", _plain(a.status), _plain(b.status), 3.0),
        ("total_cents", a.total_cents, b.total_cents, 3.0),
        ("items", a_items, b_items, 3.0),
    ]
    score = 0.0
    total = 0.0
    diffs: list[str] = []
    for name, va, vb, w in fields:
        total += w
        if va == vb:
            score += w
        else:
            diffs.append(name)
    return (score / total if total else 1.0), diffs


_PII_PATTERN = re.compile(r"(?<!\d)(\d{17}[\dXx]|\d{15}|\d{11})(?!\d)")


def find_pii(text: str) -> str | None:
    """身份证(18/15位)/手机号(11位)粗检。合规红线:影子阶段发现 PII 泄露,
    说明新版护栏退化,直接按合规致命处理------不等到用户被打骚扰电话才报警。"""
    m = _PII_PATTERN.search(text)
    return m.group(1) if m else None


def judge_shadow(old: Order, new: Order | dict, gold: Order | None = None) -> ShadowResult:
    """三级判定。判定顺序就是处置优先级:结构 → 合规 → 语义。"""
    # 一级:结构一致性。new 可能是裸 dict,先过 #01 契约;过不了 = 结构不合法
    try:
        new_valid = Order.model_validate(new)
    except ValidationError as exc:
        return ShadowResult(ShadowVerdict.FATAL, False, 0.0, [], f"结构不合法:{exc}")
    sim, diffs = semantic_similarity(old, new_valid)
    # 二级:合规致命(PII / 与 gold 硬冲突)------一票否决,不进入语义处置
    leaked = find_pii(json.dumps(new_valid.model_dump(), ensure_ascii=False))
    if leaked:
        return ShadowResult(ShadowVerdict.FATAL, True, sim, diffs, f"PII 泄露:{leaked}")
    if gold is not None and new_valid.total_cents != gold.total_cents:
        return ShadowResult(ShadowVerdict.FATAL, True, sim, ["total_cents"], "与 gold 硬冲突(按合规级处理)")
    # 三级:语义偏差分级处置
    if sim >= 0.95:
        return ShadowResult(ShadowVerdict.BENIGN, True, sim, diffs, "语义一致,影子通过")
    if sim >= 0.70:
        return ShadowResult(ShadowVerdict.RISK, True, sim, diffs, "语义偏差进入业务风险区,阻断放量")
    return ShadowResult(ShadowVerdict.FATAL, True, sim, diffs, "语义严重偏离,按合规致命处理")


def main() -> None:
    """四个 demo 样本:无害 / 业务风险 / 合规致命(PII)/ 结构致命,跑通全部处置路径。"""
    old_dict = {
        "order_id": "O-20260807-001", "customer_name": "王芳", "status": "paid",
        "items": [{"product_id": "SKU-000123", "quantity": 2, "unit_price_cents": 9900}],
        "total_cents": 19800,
    }
    old = Order.model_validate(old_dict)
    cases = [
        ("无害:新版输出与旧版完全一致", old_dict),
        ("业务风险:status 从 paid 被抽成 shipped", {**old_dict, "status": "shipped"}),
        ("合规致命:金额错 + 输出混入手机号", {**old_dict, "customer_name": "王芳 13800138000", "total_cents": 19000}),
        ("结构致命:缺失 total_cents 字段", {**old_dict, "total_cents": None}),
    ]
    for title, new in cases:
        r = judge_shadow(old, new)
        print(f"{r.verdict.value:>16} | 结构={r.structural_ok} 相似度={r.semantic_sim:.2f} 差异字段={r.diff_fields} | {title}")
        print(f"{'':>16}   → {r.note}")


if __name__ == "__main__":
    main()

四个样本把处置路径全跑一遍:第 1 个无害放行;第 2 个 status 抽错,相似度 0.727 进业务风险区,阻断放量;第 3 个手机号混进姓名 + 金额错,PII 一票否决;第 4 个缺字段,model_validate 直接判结构不合法。影子判定的输出可以直接接到熔断器的快层违规计数上------合规致命 = 一次违规。

3.3 金丝雀放量配置 + 20 行哈希取模前缀

yaml 复制代码
# release/canary.yaml ------ 金丝雀分层放量配置
# 每档双门禁:时间门禁(min_hold_min 该档至少待多久)+ 指标门禁(该档窗口内快/慢层
# 指标不熔断才放行下一档)。熔断一次,放量打回 1% 重新走,绝不从断点续传。
canary:
  target_service: order-service
  traffic_dimension: user_id   # 放量维度:user_id 哈希取模前缀 / business_line / customer_value
  hash_ring_slots: 100         # 哈希环槽位数

  steps:                       # 1% → 5% → 10% → 30% → 100%
    - { pct: 0.01, min_hold_min: 120,  gate: [time, metric] }
    - { pct: 0.05, min_hold_min: 240,  gate: [time, metric] }
    - { pct: 0.10, min_hold_min: 240,  gate: [time, metric] }
    - { pct: 0.30, min_hold_min: 720,  gate: [time, metric] }
    - { pct: 1.00, min_hold_min: 1440, gate: [time, metric] }

  exclude:                     # 金融/政务等高价值强监管客户,永不参与灰度(一票否决)
    segments: [finance, government]
    user_ids: [C-88001, C-88002]

  metrics:                     # 字段名与 TripRules 一一对应,避免配置与代码漂移
    fast_layer:
      violation_limit: 3
      violation_ratio_multiplier: 10.0
      cost_ratio_limit: 2.0
      p99_ratio_limit: 3.0
    slow_layer:
      solve_baseline: 0.90
      solve_rate_drop_pp: 10
      min_samples_slow: 200

放量控制器里做用户分配的核心,就这 20 行:

python 复制代码
import hashlib


def canary_slot(user_id: str, slots: int = 100) -> int:
    """用户 ID → 固定槽位。为什么用"固定槽位 + 单调前缀"而不是每档换模数:
    如果 1% 档用 %100、5% 档换 %20,升档 = 换模数 = 全量重映射,
    已放量的用户被切回旧版,同一次会话新老版本交替;固定槽位 + 单调阈值
    只补边界槽位,用户全程走同一版本。sha256 的额外价值是摊平非均匀的 user_id 分布。"""
    digest = hashlib.sha256(user_id.encode()).digest()
    return int.from_bytes(digest[:8], "big") % slots


def in_canary(user_id: str, pct: float, slots: int = 100) -> bool:
    # 前 round(pct*slots) 个槽位放行;升档时这些槽位是单调扩容的,没有用户被"踢"出
    return canary_slot(user_id, slots) < round(pct * slots)

3.4 测试:4 个断言锁死状态机

python 复制代码
"""
4 个断言验证熔断状态机,纯标准库 + assert,直接跑:
    python test_circuit_breaker.py
    # 或 pytest test_circuit_breaker.py
"""
from circuit_breaker import CircuitBreaker, MetricSample, TripRules


def test_slow_layer_requires_min_samples():
    """断言①:慢层样本未达 min_samples 时,即使下滑 15pp 也不熔断。
    喂 100 个样本(慢窗容量 25,窗口 total=25 < 200),解决率 0.75(比基线 0.90 掉 15pp)。
    快层四阈值全不触发,慢层因样本不足不判 → 必须保持 CLOSED。
    这是"慢层宁可不误"的门禁在代码里的落地:样本不够,算出来的率全是噪声。"""
    cb = CircuitBreaker(TripRules())
    for i in range(100):
        cb.should_route(float(i))
        cb.observe(MetricSample(t=float(i), total=1, solved=1 if i % 4 else 0,  # 解决率 0.75
                                violations=0, cost=1.0, p99_ms=300))
    assert cb.state == "CLOSED", f"样本不足不应熔断,当前状态 {cb.state}"
    assert len(cb.rollbacks) == 0, "不应产生任何 RollbackRecord"


def test_fast_layer_violation_trips_immediately():
    """断言②:快层违规 3 次立即熔断,零样本门槛。
    前 2 个样本各 1 次违规不跳闸;第 3 个到达时 5 分钟内累计 3 次 → 当场 OPEN。
    合规是 fail-safe:不等统计,一次命中立即止损。"""
    cb = CircuitBreaker(TripRules())
    for i in range(3):
        cb.should_route(float(i))
        cb.observe(MetricSample(t=float(i), total=1, solved=1, violations=1, cost=1.0, p99_ms=300))
    assert cb.state == "OPEN", f"违规 3 次应立即熔断,当前状态 {cb.state}"
    assert "违规" in cb.rollbacks[0].reason
    assert cb.rollbacks[0].escalate is False


def test_transfer_rate_trips_fast():
    """断言③:转人工率是解决率的实时代理,比例翻倍立即熔断。
    每样本 total=10、transfers=1(转人工率 10% ≥ 3x 基线 6%),累计到第 3 个样本
    (窗口内 3 次转人工)快层转人工率跳闸------不等慢层解决率攒满 200 个样本。"""
    cb = CircuitBreaker(TripRules())
    for i in range(3):
        cb.should_route(float(i))
        cb.observe(MetricSample(t=float(i), total=10, solved=9, transfers=1, cost=1.0, p99_ms=300))
    assert cb.state == "OPEN", f"转人工率破阈值应立即熔断,当前状态 {cb.state}"
    assert "转人工率" in cb.rollbacks[0].reason
    assert cb.rollbacks[0].escalate is False


def test_half_open_failure_returns_to_open():
    """断言④:HALF_OPEN 试探失败必须回到 OPEN,而不是 CLOSED。
    试探的意义是"验证事故过去了"------没过就回 OPEN 等下一轮冷却,绝不放行。"""
    rules = TripRules()
    cb = CircuitBreaker(rules)
    # 先制造一次熔断(违规 3 次)
    for i in range(3):
        cb.should_route(float(i))
        cb.observe(MetricSample(t=float(i), total=1, solved=1, violations=1, cost=1.0, p99_ms=300))
    assert cb.state == "OPEN"
    # 冷却期到点,should_route 触发转入 HALF_OPEN
    cb.should_route(rules.cooldown_min + 5)  # tripped_at=2,+5 远超冷却 6 分钟,且让违规样本(0-2)走出 5 分钟窗口
    assert cb.state == "HALF_OPEN", "冷却到点应进入 HALF_OPEN"
    # 试探全部失败(解决率全 0)
    base = rules.cooldown_min + 5
    for k in range(rules.half_open_trials):
        cb.should_route(base + k)
        cb.observe(MetricSample(t=base + k, total=1, solved=0, violations=0, cost=1.0, p99_ms=300))
    assert cb.state == "OPEN", f"试探失败必须回 OPEN,当前状态 {cb.state}"
    assert len(cb.rollbacks) == 2
    assert cb.rollbacks[-1].escalate is True, "试探失败是升级事件,必须标记人工介入"


if __name__ == "__main__":
    test_slow_layer_requires_min_samples()
    test_fast_layer_violation_trips_immediately()
    test_transfer_rate_trips_fast()
    test_half_open_failure_returns_to_open()
    print("全部 4 个断言通过:CLOSED/OPEN/HALF_OPEN 三态行为正确")

四个断言,一条压一种翻车:样本不够慢层不判、违规三次快层动手、转人工翻三倍下一分钟喊停、HALF_OPEN 试探失败绝不放行。跑 python test_circuit_breaker.py,全绿。

四、踩坑记录:三个真坑,每个都付过费

4.1 影子全绿,全量翻车

症状:影子跑 3 天全绿,全量上线当天用户投诉暴涨,新版答不出"怎么退款"。排查:回放 shadow trace 发现,新版只在"旧版答对了"的流量上被验证过------旧版答错 → 用户放弃追问 → 这段暗区流量根本没进影子,而新版恰恰在暗区(旧版答错的疑难问题)上全面退化。

根因:影子镜像的是"旧版被请求过的流量",测不出"旧版答错所以用户根本没问"的暗区

修复:影子通过 ≠ 可全量。全量前必须走金丝雀分层放量;暗区用线上"疑难问题池"(旧版转人工的会话)补测------这正是 第2篇评测集 edge/adversarial 分层要抓的东西,影子和评测集是互相咬合的两道闸,不是二选一。

4.2 阈值拍脑袋,熔断器被注释

症状:照抄文档 5pp,上线第一周熔断 5 次,每次回滚后查旧版同窗口指标------旧版也长这样。团队第 6 次把熔断器注释掉,回到人肉盯群。排查:业务基线解决率本身波动 ±6pp(节假日、促销、晚间高峰),5pp 阈值落在噪声带里,等于让业务周期波动决定熔断。根因:和第2篇门禁被注释同一个病------把统计问题写成了固定阈值问题

修复:3σ 校准(上线前观察旧版一周,阈值 = max(业务容忍, 3σ))+ min_samples 样本门槛。阈值写死是起点,统计是活的;熔断器被注释一次,团队就再也不信了,重建信任比写阈值贵得多。

4.3 回滚只切流量,不切状态

症状:熔断后流量切回旧版,旧版却表现异常,新旧版本反复抖动。排查:新版期间写入的语义缓存/会话亲和性 还在------用户会话被路由到旧版,但上下文缓存里存的是新版的回答和状态,旧版读到"被污染"的状态;加上 HALF_OPEN 没做,OPEN 后冷却一到直接 CLOSED(全量 new),事故没过去又放满量,来回抖。

根因:回滚的一半是流量,另一半是状态 ------回滚是"切流量"+"切状态"两件事。

修复:回滚时同时清/隔离 canary 期间的语义缓存与亲和性路由;回滚后必须走 HALF_OPEN 试探验证旧版健康再全量。三态里最容易被砍掉的就是 HALF_OPEN,砍掉它,抖动就来了。

五、选型对比:灰度熔断五条路线

方案 类型 LLM 特有指标(解决率/违规/转人工率/成本) 三态 + 自动回滚 适用场景 推荐指数
Argo Rollouts K8s 原生 ✗ 通用 HTTP/metrics,需自己接 judge 有 canary + analysis,支持自动回滚 已在 K8s 的通用服务 ⭐⭐⭐
自研状态机(本篇) 自建 ✓ 解决率/违规/转人工率/成本/P99 全有 ✓ 三态 + HALF_OPEN 试探 理解原理、强定制、政企私有化 ⭐⭐⭐⭐
Portkey AI Gateway 商业/开源网关 ✓ 内置成本/延迟/护栏违规 + canary + CB ✓ 开箱即用 已有网关的 SaaS 团队 ⭐⭐⭐⭐
LiteLLM 路由 开源代理 △ 有成本/延迟,无解决率/违规语义 ✗ 只有 fallback,无熔断状态机 中小团队多模型路由 ⭐⭐⭐
Dify 灰度 低代码平台 ✗ 只有版本发布/回滚 △ 手动回滚 非代码团队快速发布 ⭐⭐

2026 主流组合拳:已在 K8s 的团队,Argo Rollouts 管"怎么放"(流量分层),把它的 analysis 换成本篇状态机做"什么时候收"的 LLM 指标决策 ;网关层团队,Portkey 开箱即用(canary + CB 都内置,成本/延迟指标原生)。自研状态机存在的理由是核心差异化------"回滚依据是 LLM 业务指标(解决率/违规/转人工率/成本)"这件事,通用发布工具给不了;而你是选 Argo 还是自研,判断标准只有一条:你的回滚决策需不需要"解决率/违规率"这些非通用指标?需要,就得上个能算这些的状态机。

六、总结 + 下一篇预告

这套东西拆开看,每层管一件事:影子验证确认"新版在旧版答对的路上有没有跑偏",金丝雀决定敢不敢再放一点,熔断决定什么时候收手、收手后怎么确认。合起来,"上线"从赌一次运气,变成有护栏、能自动回滚的过程------离线评测是快照,灰度+熔断是接住变化的那道闸(第2篇说离线全绿不是终点,这里补上后半句)。回到开头 2:17:如果转人工率在 2:18 就替我们跳闸,2600 次请求、一行 RollbackRecord 都不会发生。

但还有一个前提问题:灰度/熔断能回滚,是因为你"精确知道该回滚到哪个版本" 。目前我们的回滚粒度是"整个服务"------而 LLM 应用的真正逻辑载体是 Prompt。Prompt 改了,diff 在哪?回滚 Prompt 靠 Ctrl+Z?离线评测、灰度、熔断全链跑通之后你会发现:Prompt 还不是可版本化的资产,它是被复制粘贴的字符串。下一篇《Prompt 即代码》,把提示词变成可版本化、可评审、可回滚的资产------否则上面这套体系,回滚时只能切整个服务,拉不回你真正的逻辑。

评论区聊聊:你上次灰度翻车,从发现到切回旧版用了多久?自动还是人肉?

最后补一句:把告警配成"红点自己跳出来",别等客服甩截图------这功夫比调任何阈值都值。

附注(可跳过):Poisson / Wilson / 3σ 完整推导

给较真读者的数学底稿,正文结论全从这三处来,跳过不影响落地。

违规为什么是"3 次",以及为什么要按档位缩放

基线违规率 p₀ ≈ 0.05%(护栏后残余,即 1/2000)。拿一个大流量业务(总 QPS 200,开头那家才 10 QPS)举例:

  • 1% 档 canary 2 QPS → 5 分钟 600 请求,期望违规 600 × 0.0005 = 0.3 次。Poisson(0.3) 下 P(X≥3) ≈ 0.004------3 次基本不可能来自噪声,说明违规率至少涨了一个数量级。
  • 10% 档 canary 20 QPS → 5 分钟 6000 请求,期望违规 λ₀ = 6000 × 0.0005 = 3 次。Poisson(3) 下 P(X≥3) ≈ 0.577------固定 3 次,基线正常也有一半以上概率误熔断,放量会卡死在 1% 档。

所以阈值按档位流量缩放:threshold = max(3, λ₀ × k),λ₀ = 该档 5 分钟请求量 × 基线违规率,k = 违规率放大倍数(默认 10,即"违规率涨 10 倍才判")。代码里就是 violation_threshold_for() 那一行。

样本门槛为什么是 200

慢层解决率是比例型指标,样本越少晃得越厉害。用 Wilson 区间估:n=100 时 95% 置信区间半宽约 ±6pp------你看到的"下滑 12pp"可能只是噪声。n=200 时半宽收窄到约 ±4pp,才开始有资格谈"下滑 10pp"。所以 min_samples = 200 不是拍脑袋,是"让率的误差小于你要抓的下滑"。

3σ 校准为什么能把阈值设到"波动之外"

解决率序列近似正态,旧版一周正常波动 σ≈1.8pp 时,正常值落在 ±3σ(约 ±5.4pp)以外的概率不到 0.3%------把阈值设在 3σ 外,误伤概率就压到千分之几。阈值 = max(业务能忍的下滑, 3σ):业务能忍的下滑若比 3σ 还宽,就以业务为准(宁可慢点报);反之以 3σ 为准(别让正常抖动天天误触发)。


🎯 更多专栏系列文章可以查看博客主页📑 👍 若文章对你有所触动,恳请点赞 ⭐ 关注 ⭐ 收藏

相关推荐
李昊哲小课1 小时前
大模型应用开发课程——项目 13~16 完整教程
人工智能·大模型·智能体
程序猿编码1 天前
不用PyTorch,我用C++手写了一个能认手写数字的神经网络
c++·pytorch·神经网络·大模型
安逸sgr1 天前
视觉 Token 是什么?图片是怎么送进大模型的?
人工智能·ai·大模型·agent·智能体
前沿在线1 天前
视频模型卷向第二个维度,生数科技 Vidu S1 打响第一枪
人工智能·ai·大模型
赵大仁1 天前
Prompt 缓存与上下文压缩:把 Token 账单砍一刀的实操清单
ai·大模型·prompt·token·成本优化
漂流瓶jz2 天前
一文读懂大模型生态:分类/参数/结构/训练/GPU/评测/排行/社区
人工智能·大模型·llm·openai·nvidia·deepseek·anthropic
Raas1002 天前
MAI Gateway(魔芋企业级AI网关):私有化部署的身份同步与数据内控设计
网关·网络安全·大模型·gateway·mai gateway·企业级网关
虎鲸不是鱼2 天前
【RTX Pro 4500】Qwen3.8 27B NVFP4在LM Studio挂载多模态模型
大模型·多模态·qwen·lm studio·千问