目录
- 前言
- 一、问题定义:三连错,病在把线上验证当运维玄学
- [二、核心方案:影子验证 → 金丝雀放量 → 熔断兜底](#二、核心方案:影子验证 → 金丝雀放量 → 熔断兜底)
-
- [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σ 为准(别让正常抖动天天误触发)。

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