上一章结尾留了一个没解决的问题:权限这条线收住了,身份有据、读有过滤、写有问询,可功能一上线还是全量——所有部门同时看到新入口。权限管的是"谁能做什么",管不了"什么时候让谁能做"。这两件事得分开做。这一章只处理后者:把新功能的开关切到部门粒度,出问题能在几秒内收回去。
前面几章反复强调代行身份由网关校验、AI_SERVICE_SCOPES 写死在部署配置里,这套东西不动。灰度是在它外面加一层准入判断,判断不通过就走旧链路,权限链路根本不进入。
开关的位置
开关放哪,先定一件事:谁改、改多频繁、要不要留痕。可选的就两类:配置中心和数据库表。
配置中心的 key 适合全局粗粒度,比如"整个 AI 助手是否对外可见",改一次影响所有人,历史版本也只记值、不记业务语义。按部门灰度要的是另一回事:一次只动一行,动完能查,能回滚到某个时间点的组合。所以我把它放到业务库的一张表里,和部门树放一起。
一个必须守住的约定:开关不能写进代码分支。if dept == "finance" 一旦进了主干,第二次灰度就得再发一次版,灰度就从运维动作退化成了发版动作。所有部门判断只能查表,代码里不该出现部门编码。
开关表的结构
表的主键是(开关名,部门编码),部门编码带层级,用点分隔,比如 fin.report。查的时候从最具体的一级往上找,找到就用,找不到继续往上,一直到根都没有就按关闭处理。这样父部门开一次,下面所有子部门跟着生效,不用逐条插。
建表和读取的代码是这样:
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS feature_flag (
flag_key TEXT NOT NULL,
dept_code TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
revision INTEGER NOT NULL DEFAULT 1,
updated_by TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (flag_key, dept_code)
);
"""
class FlagStore:
def __init__(self, db_path):
self.conn = sqlite3.connect(db_path)
self.conn.executescript(SCHEMA)
def is_enabled(self, flag_key, dept_code):
node = dept_code
while node:
row = self.conn.execute(
"SELECT enabled, revision FROM feature_flag "
"WHERE flag_key = ? AND dept_code = ?",
(flag_key, node),
).fetchone()
if row is not None:
return bool(row[0]), row[1]
node = node.rpartition(".")[0]
return False, 0
def set_enabled(self, flag_key, dept_code, enabled, operator):
self.conn.execute(
"INSERT INTO feature_flag "
"(flag_key, dept_code, enabled, revision, updated_by, updated_at) "
"VALUES (?, ?, ?, 1, ?, strftime('%s','now')) "
"ON CONFLICT(flag_key, dept_code) DO UPDATE SET "
"enabled = excluded.enabled, "
"revision = feature_flag.revision + 1, "
"updated_by = excluded.updated_by, "
"updated_at = excluded.updated_at",
(flag_key, dept_code, int(enabled), operator),
)
self.conn.commit()
revision 这一列不是装饰。每次改动自增,日志里带上它,事后能回答"这条回答是在第几版开关下产生的"。没有它,排查时只能说"大概是关之前的"。
while node 那个循环有个副作用要提前说清:给父部门配了 enabled = 0 之后,子部门单独配 1 也没用,因为查到父节点就返回了。这是有意的——收回的优先级要高于放量。如果业务上确实需要"父关子开",就把查找逻辑改成"取最近的一条显式配置",但那样收回时得逐个部门点,我不建议。
部门解析与开关前置
开关判断的位置比开关本身的实现更重要。它必须放在请求进入业务逻辑的第一步,在检索和模型调用之前。放在后面,钱已经花掉了,风险也已经出去了——检索跑了、模型答了、日志留了,再返回一句"你没权限",等于做了一次无效且留痕的调用。
顺序是:解析部门 → 查开关 → 通过才进检索。部门不能从请求体或前端传的参数里读,那是用户可控的;从目录服务按用户 ID 反查,带一个短缓存。上一章的代行身份头由网关校验后注入,同理,凡是请求方可控的字段都不作为判断依据。
# 入口只做三件事:定部门、判开关、通过才往下走
def handle_assist(req, flags, directory, pipeline):
dept = directory.dept_of(req.user_id)
if not dept:
return legacy_entry(req, None, reason="dept_unknown")
enabled, revision = flags.is_enabled("ai.assist", dept.code)
if not enabled:
return legacy_entry(req, dept.code, reason="flag_off")
docs = pipeline.retrieve(req.question, dept)
answer = pipeline.generate(req.question, docs)
pipeline.audit(req.user_id, dept.code, revision, docs)
return answer
directory.dept_of 内部查的是人事系统的组织关系,不是请求头。一个人挂在多个部门时取主部门,这一条要在需求里写死,否则同一个人当天看到不同结果,用户会当成 bug 报上来。
审计多带一个 revision 字段,改动量很小:原来记用户和资源,现在多记一个开关版本。灰度期间的数据分析全靠它区分"这一批请求跑在哪一版策略下"。
关闭开关后的降级路径
"一键收回"最容易被做成"一键报错"。开关关掉之后,用户看到的应该是和上线前完全一样的界面,而不是 403 或者一个空白页。降级路径要在灰度开始前就写好并测过,不能等到出事那天现写。
# 关闭不是拒绝,是回到旧入口,响应形态与未上线时保持一致
def legacy_entry(req, dept_code, reason):
log.info(
"assist fallback user=%s dept=%s reason=%s",
req.user_id, dept_code, reason,
)
resp = make_response(redirect("/assist/classic"))
resp.headers["X-Assist-Mode"] = "legacy"
return resp
def health_of_flags(flags, depts):
# 收回后用来确认没有残留流量还在走新链路
return {
d.code: flags.is_enabled("ai.assist", d.code)[0]
for d in depts
}
重定向到旧页面比提示"功能维护中"要好:用户的肌肉记忆还在旧入口,路径一致就不会来回问。响应头带一个 X-Assist-Mode,排障时一眼看出这次走的是哪条链路。
health_of_flags 是个小工具,收回之后跑一遍,确认目标部门全部为关闭。光看后台页面容易漏掉缓存没刷新的实例。
缓存与收回的生效时间
开关每次请求都查库,量大了库受不了;加缓存又有滞后。这中间的取舍只有一个参数:TTL。
import time
# 收回要秒级生效,TTL 只给几秒,配合改动时的主动失效
class FlagCache:
def __init__(self, store, ttl=5.0):
self._store = store
self._ttl = ttl
self._memo = {}
def is_enabled(self, key, dept):
now = time.monotonic()
hit = self._memo.get((key, dept))
if hit is not None and hit[0] > now:
return hit[1], hit[2]
enabled, revision = self._store.is_enabled(key, dept)
self._memo[(key, dept)] = (now + self._ttl, enabled, revision)
return enabled, revision
def invalidate(self, key=None):
if key is None:
self._memo.clear()
return
for k in [k for k in self._memo if k[0] == key]:
self._memo.pop(k, None)
这里的关键不是 invalidate,是它不管用的时候。服务多实例部署,改开关的那个实例把本地缓存清了,其他实例还留着自己的副本,invalidate 根本传不过去。真正兜底的是 TTL:5 秒的 TTL 意味着最坏情况 5 秒后所有实例都看到新值。所以 TTL 不能按"减少数据库压力"来定,要按"能容忍多长时间的错"来定。给到 5 秒,收回的语义才成立。
我见过把它设成 10 分钟的版本,理由是"开关又不常变"。结果第一次收回,改了开关,十分钟内新链路还在放量,最后是靠重启服务解决的——那时候开关已经不算一键收回了。
放量节奏与观察指标
批次本身没什么可设计的:先 1 个部门,观察 3 天,扩到 3 个,再观察 3 天,然后全量。有意思的是"观察"具体看什么。没有阈值的话,观察期一定会变成"看起来还行,继续"。
我用的四个指标和线是这样的,阈值是自己拍的,不是行业基准,拍的时候参考了旧入口同期的水平:
| 指标 | 口径 | 线 |
|---|---|---|
| 采纳率 | 答案被直接采用或点了有用的比例 | 不低于 40% |
| 转人工率 | 会话内转人工客服的比例 | 不高于 18% |
| P95 延迟 | 端到端,含检索与模型 | 不高于 4 秒 |
| 单次成本 | 模型与检索的合计开销 | 不超过 0.03 元 |
任何一条破线就停在这一批,不往下扩。停的意思是保持当前部门开着、不加新部门——不是立刻全关,因为破线往往是单点问题,全关会把已经积累的样本一起丢掉。
还有个只有灰度期才有的指标:开关关闭次数。如果某个部门开了又关、关了又开,说明那里的问题不是随机的,扩量前得先弄清楚。
灰度期间踩到的问题
开关只在前端做了。 前端拿到开关值把入口隐藏了,后端没拦。有人直接调接口,照样跑通,还能绕开按部门的检索过滤。开关判断必须在服务端,前端那层只负责别让用户点了报错。
A/B 两套语义层不一致。 灰度期间新链路用的是第 3 章补的那层语义,旧入口用的是更早的指标定义,同一个"活跃设备数"两边算出来不一样。同期数据没法对比,观察指标全废。后来的做法是灰度只切入口,不切语义层——两条链路查同一份语义定义,差异只在有没有 AI。
灰度期间数据写入分叉。 新链路把用户反馈写进一张新表,旧入口读不到,运营那边看到的反馈量少了一大截。改成写入同一张表、新链路只加列,读侧两种模式都能读到,问题消失。这条也适用于会话记录:谁写的、谁读的,在开灰度之前先画一遍。
开关表没有变更审计。 一开始 updated_by 存的是"admin",出问题查不到是谁开的。后来接到统一登录,字段存工号,才敢让更多人碰这张表。
还没算的账
到这一步,灰度的机制是通的:开关有位置、判断在入口、收回有路径、放量有阈值。但它只解决了"影响面",没解决"花多少"。给一个部门打开之后,检索层的 QPS 涨了多少、每次调用的 token 花在哪、扩到三个部门时哪些资源需要提前扩容、按部门算账时这笔钱挂在谁头上——这些是另一条线,现在一个数都没有。
下一章处理这套东西的成本边界:先把每次调用的账算清,再谈要不要扩量、扩到哪一层。