为什么讲这个案例:换模型、改 Prompt 是智能软件日常最高频的"演化动作",也是最容易"塌方"的地方——代码没动,行为却变了。本案例把 pr02 的组件化思想落到运维侧:让每一次"换个模型"都走一遍可复现 + 回归门禁 + 渐进发布,而不是拍脑袋上线。

阅读路径:§1 讲被管对象与打版本方式,§2 建立可复现(hash 锁 + 兼容矩阵),§3 是本案例核心交付(行为回归门禁),§4 渐进发布,§5 用一次真实换模型跑通门禁判定,§6 给可抄的流水线代码,§7–§10 与三贯穿线索咬合、诚实标注、反面清单与接口。


1. 版本管理三件套:Prompt · 模型 · Skill

AI 组件的"源码"不止 .py。要被纳入版本管理的对象有三类,各自的"diff 语义"完全不同——这是与传统 CI 最根本的分野。

组件被版本化的东西怎么 diff / 打版本存储
Prompt提示词模板、系统指令、负面清单、少样本示例纯文本,直接 git diff;语义版本 prompt@v3.2.0Git 普通对象
模型权重、tokenizer、生成配置、架构 commit二进制无法逐行 diff,按 sha256(权重) + 上游 commit 打版本Git LFS / registry
Skill工具/技能定义、输入输出契约、workflow_api 编排声明式 JSON,可结构化 diff;skill@v1.4Git 普通对象

Prompt 与 Skill 是文本,Git 天然胜任——一次改写 NEG 负面清单、删掉一条示例,都留下可追溯的提交历史。难点在模型权重:几 GB 的二进制塞进 Git 会撑爆仓库,因此用 Git LFS 存大权重、用一个简易 model registry(一个 models.lock.json 清单文件)记录"逻辑版本名 → 内容哈希 → 上游 commit → 存放位置"的四元组。

JSON · models.lock.json(简易 registry)
{
  "prompt/assistant_v3": {
    "kind": "prompt",
    "sha256": "9f2c...a71e",   // 提示词模板文件内容哈希
    "source": "prompts/assistant_v3.md"
  },
  "model/qwen-q4km": {
    "kind": "weights",
    "sha256": "1b77...c0d3",   // GGUF 权重内容哈希,作为不可变版本标识
    "upstream_commit": "a3f91c2",
    "quant": "Q4_K_M",
    "lfs": "https://lfs.internal/models/qwen-q4km.gguf"
  },
  "skill/tool_router": {
    "kind": "skill",
    "sha256": "55aa...e901",
    "source": "skills/tool_router.json",
    "contract": "workflow_api@v2"
  }
}

要点:逻辑名可以复用,内容哈希不可变。CI 只信哈希,不信标签——这正是 §2 hash 锁的伏笔。被管清单里的"模型版本",就是 pr10 选型、pr13 台账登记的那份清单。


2. 可复现 = hash 锁 + 兼容矩阵

"换个模型不塌方"的前提是:这次跑出来的行为,能被逐字节复现并归因到某个具体版本。做法是把所有会变的东西压成一把锁(lock):对提示词/编排文件、模型 commit、量化配置、投机解码配置分别算哈希,拼成一个可复现指纹。

$$\mathcal{L}=\mathrm{H}\big(\,\text{prompt}\,\|\,\text{workflow\_api.json}\,\|\,\text{model\_commit}\,\|\,\text{quant\_cfg}\,\|\,\text{spec\_cfg}\,\big)$$

其中 quant_cfg 承接 pr11 的量化配置(如 Q4_K_M / IQ3_XXS),spec_cfg 承接 pr12 的投机解码配置(草稿模型 + 接受阈值)。任何一项变动,\mathcal{L} 就变,CI 立刻识别为"这是一次需要回归的行为变更"。

锁覆盖的哈希输入来源案例变它意味着什么
prompt / workflow_api.jsonpr02 / pr04 规格需求侧行为改变
model_commit + sha256(权重)pr10 选型 / pr13 台账"换了个脑子",最需回归
quant_cfgpr11 量化精度↔显存权衡点移动(见 §5)
spec_cfgpr12 投机解码延迟↔接受率配置改变,可能改输出

光有锁还不够。运行时、模型版本、量化格式之间存在非法组合——旧版推理引擎读不懂新的量化格式,或某种量化在当前算子库上没有 kernel。把这些坑固化成一张兼容矩阵,CI 在跑评估之前先查矩阵,非法组合直接拒绝(而不是等到运行时报一堆看不懂的错)。

运行时模型版本量化组合状态
llama.cpp r3000qwen@q4kmQ4_K_M✔ 通过
llama.cpp r3000qwen@iq3xxsIQ3_XXS◐ 需 r3100+(旧版无 IQ kernel)
llama.cpp r3120qwen@iq3xxsIQ3_XXS✔ 通过
vLLM 0.5qwen@awqAWQ-4bit✔ 通过
vLLM 0.5qwen@iq3xxsIQ3_XXS✘ 不支持(GGUF-IQ 非 vLLM 路径)

诚实边界:兼容矩阵是人工维护的约定,不是自动推导的真理。矩阵本身也要版本化、也要在 CI 里被读——漏填一行,就等于放行一次"矩阵没覆盖"的赌博。


3. 行为回归门禁(核心交付)

这是本案例的心脏。传统 CI 的门禁是"编译通过 + 单测通过";AI 组件的门禁必须是"行为没退化"。做法很直接:CI 每次改 Prompt / 换模型,就跑一份固定的 eval 集——用例复用 pr05 的测试集,指标复用 pr11 / pr12 定义的度量——再对三个关键指标设阈值,任一回落就把这次 PR 标成阻断(blocking)。

Rretain
基准保留率

新版本在"能力基准"上相对基线的得分比。阈值示例:≥ 0.95。

Stool
工具调用成功率

Skill / 工具编排能否正确触发、参数合法。阈值示例:≥ 0.90。

Pmr
关键 MR 通过率

从 pr09 失败库挑出的高危回归用例(Must-Not-Regress)。阈值示例:≥ 0.85。

三个指标不是凭空的三个数,它们各自有明确的计算口径与来源案例——门禁之所以可信,正因为口径统一、来源可追:

门禁指标怎么算数据来源阈值依据
Rretain 基准保留率新版本基准分 ÷ 基线版本基准分pr05 固定测试集 + pr08 统一度量表团队约定的"能力地板"
Stool 工具调用成功率正确触发且参数合法的调用数 ÷ 应触发数pr11 / pr12 度量 + Skill 契约用例低于此值线上必现"调不动工具"
Pmr 关键 MR 通过率通过的 Must-Not-Regress 用例 ÷ 该类用例总数pr09 失败库沉淀的高危用例已修过的坑不得重新掉进去

门禁判定可写成一个"回落即阻断"的谓词:对每个指标 i,只要新版本值 m_i^new 低于允许下限(基线值减去容忍带 τ_i,或低于绝对地板 f_i,取更严者),则整条流水线失败:

$$\text{BLOCK} \iff \exists\, i \in \{retain,\, tool,\, mr\}:\quad m_i^{new} < \max\!\big(m_i^{base}-\tau_i,\; f_i\big)$$
PR 改 Prompt/ 换模型 查兼容矩阵§2 非法即拒否则校验 \mathcal{L} 跑固定 eval 集 用例←pr05/pr09指标←pr11/pr12 比对基线三指标阈值 ✘ BLOCKING任一回落→红叉 ✔ PASS进 §4 渐进发布
图 3-1 · 行为回归门禁:改 Prompt / 换模型 → 查矩阵 → 跑固定 eval → 比对基线,任一指标回落即 blocking

为什么门禁必须"红叉"而不是"黄灯警告":只要允许"警告但继续",在赶工期的压力下门禁必然退化成永远绿灯的摆设(见 §9 反面清单)。回归门禁的价值来自它的强制性——它是把 pr08 统一度量表的数字接进发布决策的那道闸门。


4. 渐进发布:shadow → 金丝雀 → 全量

门禁"绿灯"只证明离线 eval 集没退化,不代表线上真实分布安全。因此上线要分级放量,每一步都留一条快速回滚的路。

① Shadow 旁路 新版本影子跑真实流量不影响用户,只记录 ② 金丝雀小流量 1%→5%→20% 真实用户盯线上代理指标 ③ 全量 达标后放量到 100%registry 切默认指针 任一阶段线上代理指标越界 → 秒级回滚到上一个 \mathcal{L}(registry 切回旧哈希)
图 4-1 · 三阶段渐进发布:旁路不伤用户,金丝雀控爆炸半径,全量前每步都可回滚
  • Shadow(旁路):新版本接到真实请求但输出不回给用户,只落日志与线上代理指标(延迟、异常率、拒答率)。零风险地拿到"真实分布"上的第一批数据。
  • 金丝雀(Canary):切 1% → 5% → 20% 的真实用户,逐步观察。指标越界即停。
  • 回滚策略:发布单元是 §2 的那把锁 \mathcal{L};回滚就是把 registry 的默认指针切回上一个哈希,因为组件不可变、旧版本仍在,回滚是 O(1) 的指针操作,不需要"重新部署旧代码"。
Bash · 秒级回滚(切换 registry 默认指针)
# 参数: $1 = 目标锁哈希(上一个 \mathcal{L}); 组件不可变, 只动默认指针
set -euo pipefail
TARGET="$1"                                   # 要回滚到的旧锁哈希
# 校验目标哈希仍在 registry / LFS 中, 避免回滚到已 GC 的版本
python tools/registry.py exists --hash "$TARGET"
# 原子切换默认指针 + 记录一条审计事件(供 pr08 度量表回溯)
python tools/registry.py set-default --hash "$TARGET" --actor ci --reason "canary-regression-breach"
curl -sf -X POST "$GATEWAY/reload"            # 让推理网关热加载新指针, 不重启

对比传统"重新部署旧镜像"的分钟级、易出错的回滚,AI 组件的回滚被压缩成一次带审计的指针切换——这正是 §1"内容哈希不可变、逻辑指针可复用"带来的运维红利。


5. 一次换模型的回归报告(示例)

把 pr11 里那件"看起来只是省显存"的事当成案例:为省一半显存,把 Q4_K_M 换成 IQ3_XXS。代码一行没改,锁 \mathcal{L} 因 quant_cfg 变化而变,CI 触发一次完整回归。报告(示意值)如下:

指标基线 Q4_K_M新 IQ3_XXS容忍下限门禁判定
基准保留率 Rretain1.000.93≥ 0.95✘ 回落 −7%
工具调用成功率 Stool0.940.91≥ 0.90◐ 压线
关键 MR 通过率 Pmr0.900.78≥ 0.85✘ 回落 −12%
峰值显存(收益)6.1 GB3.4 GB—↓ 44%

结论:BLOCKING。显存省了近一半是真收益,但基准保留率与关键 MR 双双跌破地板——按 §3 谓词,流水线红叉,PR 不允许直接合入。合理动作不是"调低阈值让它过",而是:把这次差异喂回 pr09 失败库补两条 MR 用例、考虑 pr12 的投机/混合量化折中,或只在"非关键路径子集"上以 shadow 方式限时试用。这正是门禁把"感觉差不多"变成"数字说了算"的时刻。


6. 流水线(可抄的骨架)

下面给两段骨架:一段 GitHub Actions 工作流(改 Prompt / 模型清单 / Skill 时触发),一个 eval 门禁脚本(跑 5 个 eval case、比对基线、超阈值 exit 1 阻断)。注释只写功能与参数含义,不写具体机器/环境细节。

6.1 GitHub Actions 工作流

YAML · .github/workflows/ai-regression.yml
name: ai-component-regression
on:
  pull_request:
    paths:
      - 'prompts/**'          # Prompt 文本变更
      - 'models.lock.json'    # 模型版本清单(§1 registry)变更
      - 'skills/**'           # Skill / 编排契约变更
      - 'quant/**'            # 量化配置(承 pr11)
      - 'spec/**'             # 投机解码配置(承 pr12)

jobs:
  eval-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      # 1) 先查兼容矩阵(§2),非法的 运行时×模型×量化 组合直接失败
      - name: check-compat-matrix
        run: python tools/check_compat.py --matrix compat/matrix.json --lock models.lock.json

      # 2) 按锁里的哈希拉取组件(LFS 指针 → 权重),复现同一 \mathcal{L}
      - name: materialize-lock
        run: python tools/fetch_by_hash.py --lock models.lock.json

      # 3) 跑固定 eval 集,与基线比对,回落则 exit 1 阻断
      - name: behavioral-regression
        run: python ci/eval_gate.py --baseline ci/baseline.json --cases ci/eval_cases.jsonl

      # 4) 门禁产出转成 pr08 度量表格式,供后续实证(pr14)消费
      - name: emit-metrics
        if: always
        run: python ci/emit_metrics.py --out reports/regression_${{ github.run_id }}.json

6.2 eval 门禁脚本

Python · ci/eval_gate.py
import json, hashlib, argparse, sys

# 固定 eval 集:5 个用例,来自 pr05 测试集 + pr09 失败库(MR 用例)
CASES = [
    {"id": "c1", "kind": "retain",   "input": "...", "expect": "..."},
    {"id": "c2", "kind": "retain",   "input": "...", "expect": "..."},
    {"id": "c3", "kind": "tool",     "input": "...", "expect_tool": "search"},
    {"id": "c4", "kind": "tool",     "input": "...", "expect_tool": "calc"},
    {"id": "c5", "kind": "mr",       "input": "...", "expect": "..."},   # Must-Not-Regress
]

# 三指标阈值(团队约定,非普适;承 §3)
FLOOR = {"retain": 0.95, "tool": 0.90, "mr": 0.85}
TOL   = {"retain": 0.03, "tool": 0.02, "mr": 0.02}   # 相对基线容忍带

def lock_hash(lock):
    # 把清单里各组件哈希拼起来,得到可复现指纹 \mathcal{L}
    parts = [v["sha256"] for v in sorted(lock.items)]
    return hashlib.sha256("|".join(parts).encode).hexdigest

def score(cases):
    # 由推理后端跑用例,产出三指标的通过数(此处只示意聚合)
    agg = {k: {"pass": 0, "n": 0} for k in FLOOR}
    for c in cases:
        k = c["kind"]; agg[k]["n"] += 1
        ok = run_inference_and_judge(c)      # 判定"行为是否达标"
        agg[k]["pass"] += int(ok)
    return {k: agg[k]["pass"] / max(agg[k]["n"], 1) for k in FLOOR}

def main:
    ap = argparse.ArgumentParser
    ap.add_argument("--baseline", required=True)
    ap.add_argument("--cases", required=True)
    a = ap.parse_args

    base = json.load(open(a.baseline))            # {"retain":1.0,"tool":0.94,...}
    cases = [json.loads(l) for l in open(a.cases, encoding="utf-8")]
    now = score(cases)

    breaches = []
    for k, floor in FLOOR.items:
        lower = max(base[k] - TOL[k], floor)      # 取"容忍带"与"地板"更严者
        if now[k] < lower:                        # 任一回落 → 记录阻断项
            breaches.append((k, now[k], lower))

    print(f"lock L    : {lock_hash(json.load(open('models.lock.json')))[:12]}")
    print(f"metrics   : {now}")
    if breaches:
        for k, got, low in breaches:
            print(f"[BLOCK] {k}: got {got:.2f} < floor {low:.2f}")
        print("结论: BLOCKING —— 指标回落,PR 不放行")
        sys.exit(1)                                # 非零退出 → CI 红叉
    print("结论: PASS —— 进入 §4 渐进发布 (shadow)")
    sys.exit(0)

if __name__ == "__main__":
    main

诚实提示:上面 run_inference_and_judge 是占位——真实实现要接你的推理后端与判定器(可用 pr05 的 LLM-as-a-Judge 或规则判分)。\mathcal{L}、阈值、用例都要与仓库一起版本化,才能"换模型时复现同一套判定"。


7. 与三条贯穿线索的咬合

本案例不是孤立的 DevOps 故事,它站在前序案例搭好的三条"贯穿线索"上,是它们的消费端也是产出端。

清单
模型版本清单(pr13 建 · pr10 用)= 被管对象

§1 的 registry 直接接管 pr10 选型、pr13 台账里那份清单。没有清单,就没有可打版本、可回归的东西。

度量
统一度量表(pr08)= 门禁指标源

§3 的三指标口径全部来自 pr08 统一度量表与 pr11 / pr12 度量定义,门禁数字才可比、可累计进实证。

失败
失败库(pr09)= 回归用例来源

§3 的关键 MR 用例、§5 报告里补的两条用例,都从 pr09 失败库生长出来——失败一次,永久进回归。

双向流动:清单 → 被管,度量 → 供门禁,失败 → 供用例;反过来,门禁产出 → 喂 pr08 度量表与 pr14 实证,回归失败 → 回灌 pr09 失败库。运维演化不是一条直线,而是一个把"行为变更"闭环成"可度量证据"的环。


8. 诚实标注

  • 阈值是团队约定,不是普适真理。0.95 / 0.90 / 0.85 是示例量级,真实取值取决于业务对"退化"的容忍度与 eval 集的方差;小样本 eval 上 −3% 可能纯属噪声。把别队的阈值抄来当自己的地板,是常见的假严肃。
  • 流水线语法以官方为准。GitHub Actions 的 on.pull_request.paths、if: always、Actions 版本号(@v4/@v5)均以官方文档为准,不同 CI 平台(GitLab CI / Jenkins)语义与写法有差异,本文只给思想骨架。
  • 兼容矩阵靠人维护,可能过时。运行时升级后矩阵要同步更新,否则会把合法组合误判成非法、或漏放非法组合。

9. 反面清单:门禁是怎么烂掉的

反模式会塌在哪解法
只锁代码,不锁模型 / Prompt"我这跑得好好的"无法复现,锅找不到具体版本§2 把 quant/spec/prompt 全纳入 \mathcal{L}
eval 集过拟合对着固定 5 题调到满分,换真实流量就崩用例轮换 + 保留盲测集,失败库持续补 MR 用例(pr09)
门禁形同虚设(永远绿灯)把 BLOCKING 改成 warning、随手 --no-verify,门禁退化成装饰§3 强制红叉;越界必须走"补用例/调架构"而非"调阈值"

三条反模式的共性:把"行为"偷偷降级回了"代码"。AI 组件 CI/CD 的全部意义,是坚持"行为才是被测对象"这条底线。


10. 与其它案例的接口

本案例是运维演化环节的收口,输入输出如下(可作为一次"把 pr10–pr14 串起来"的复盘):

接口对象方向交接物
pr10 选型 / pr13 台账← 输入模型版本清单(被管对象)
pr11 量化 / pr12 投机← 输入quant_cfg、spec_cfg 与对应度量口径
pr05 测试← 输入eval 用例与判定器(LLM-as-a-Judge)
pr08 度量 / pr14 实证→ 输出门禁产出的回归指标,进统一度量表与实证分析
pr16 / pr17 工作流 / 综合实践→ 输出把这套门禁与渐进发布接入端到端部署

一页纸总结:把 Prompt / 模型 / Skill 当组件 → 用 \mathcal{L}(hash 锁 + 兼容矩阵)保证可复现 → 每次行为变更跑固定 eval 集,三指标(基准保留率 / 工具调用成功率 / 关键 MR 通过率)回落即 blocking → shadow / 金丝雀渐进发布、指针级回滚 → 门禁数字回灌 pr08 度量表与 pr14 实证,回归失败回灌 pr09 失败库。一句话:把"感觉差不多"换成"门禁说了算"。