上一章结尾留了一个没解决的问题:权限这条线收住了,身份有据、读有过滤、写有问询,可功能一上线还是全量——所有部门同时看到新入口。权限管的是"谁能做什么",管不了"什么时候让谁能做"。这两件事得分开做。这一章只处理后者:把新功能的开关切到部门粒度,出问题能在几秒内收回去。

前面几章反复强调代行身份由网关校验、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 花在哪、扩到三个部门时哪些资源需要提前扩容、按部门算账时这笔钱挂在谁头上——这些是另一条线,现在一个数都没有。

下一章处理这套东西的成本边界:先把每次调用的账算清,再谈要不要扩量、扩到哪一层。