为什么讲这个案例:换模型、改 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.0 | Git 普通对象 |
| 模型 | 权重、tokenizer、生成配置、架构 commit | 二进制无法逐行 diff,按 sha256(权重) + 上游 commit 打版本 | Git LFS / registry |
| Skill | 工具/技能定义、输入输出契约、workflow_api 编排 | 声明式 JSON,可结构化 diff;skill@v1.4 | Git 普通对象 |
Prompt 与 Skill 是文本,Git 天然胜任——一次改写 NEG 负面清单、删掉一条示例,都留下可追溯的提交历史。难点在模型权重:几 GB 的二进制塞进 Git 会撑爆仓库,因此用 Git LFS 存大权重、用一个简易 model registry(一个 models.lock.json 清单文件)记录"逻辑版本名 → 内容哈希 → 上游 commit → 存放位置"的四元组。
{
"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、量化配置、投机解码配置分别算哈希,拼成一个可复现指纹。
其中 quant_cfg 承接 pr11 的量化配置(如 Q4_K_M / IQ3_XXS),spec_cfg 承接 pr12 的投机解码配置(草稿模型 + 接受阈值)。任何一项变动,\mathcal{L} 就变,CI 立刻识别为"这是一次需要回归的行为变更"。
| 锁覆盖的哈希输入 | 来源案例 | 变它意味着什么 |
|---|---|---|
| prompt / workflow_api.json | pr02 / pr04 规格 | 需求侧行为改变 |
| model_commit + sha256(权重) | pr10 选型 / pr13 台账 | "换了个脑子",最需回归 |
| quant_cfg | pr11 量化 | 精度↔显存权衡点移动(见 §5) |
| spec_cfg | pr12 投机解码 | 延迟↔接受率配置改变,可能改输出 |
光有锁还不够。运行时、模型版本、量化格式之间存在非法组合——旧版推理引擎读不懂新的量化格式,或某种量化在当前算子库上没有 kernel。把这些坑固化成一张兼容矩阵,CI 在跑评估之前先查矩阵,非法组合直接拒绝(而不是等到运行时报一堆看不懂的错)。
| 运行时 | 模型版本 | 量化 | 组合状态 |
|---|---|---|---|
| llama.cpp r3000 | qwen@q4km | Q4_K_M | ✔ 通过 |
| llama.cpp r3000 | qwen@iq3xxs | IQ3_XXS | ◐ 需 r3100+(旧版无 IQ kernel) |
| llama.cpp r3120 | qwen@iq3xxs | IQ3_XXS | ✔ 通过 |
| vLLM 0.5 | qwen@awq | AWQ-4bit | ✔ 通过 |
| vLLM 0.5 | qwen@iq3xxs | IQ3_XXS | ✘ 不支持(GGUF-IQ 非 vLLM 路径) |
诚实边界:兼容矩阵是人工维护的约定,不是自动推导的真理。矩阵本身也要版本化、也要在 CI 里被读——漏填一行,就等于放行一次"矩阵没覆盖"的赌博。
3. 行为回归门禁(核心交付)
这是本案例的心脏。传统 CI 的门禁是"编译通过 + 单测通过";AI 组件的门禁必须是"行为没退化"。做法很直接:CI 每次改 Prompt / 换模型,就跑一份固定的 eval 集——用例复用 pr05 的测试集,指标复用 pr11 / pr12 定义的度量——再对三个关键指标设阈值,任一回落就把这次 PR 标成阻断(blocking)。
新版本在"能力基准"上相对基线的得分比。阈值示例:≥ 0.95。
Skill / 工具编排能否正确触发、参数合法。阈值示例:≥ 0.90。
从 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,取更严者),则整条流水线失败:
为什么门禁必须"红叉"而不是"黄灯警告":只要允许"警告但继续",在赶工期的压力下门禁必然退化成永远绿灯的摆设(见 §9 反面清单)。回归门禁的价值来自它的强制性——它是把 pr08 统一度量表的数字接进发布决策的那道闸门。
4. 渐进发布:shadow → 金丝雀 → 全量
门禁"绿灯"只证明离线 eval 集没退化,不代表线上真实分布安全。因此上线要分级放量,每一步都留一条快速回滚的路。
- Shadow(旁路):新版本接到真实请求但输出不回给用户,只落日志与线上代理指标(延迟、异常率、拒答率)。零风险地拿到"真实分布"上的第一批数据。
- 金丝雀(Canary):切 1% → 5% → 20% 的真实用户,逐步观察。指标越界即停。
- 回滚策略:发布单元是 §2 的那把锁
\mathcal{L};回滚就是把 registry 的默认指针切回上一个哈希,因为组件不可变、旧版本仍在,回滚是 O(1) 的指针操作,不需要"重新部署旧代码"。
# 参数: $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 | 容忍下限 | 门禁判定 |
|---|---|---|---|---|
| 基准保留率 Rretain | 1.00 | 0.93 | ≥ 0.95 | ✘ 回落 −7% |
| 工具调用成功率 Stool | 0.94 | 0.91 | ≥ 0.90 | ◐ 压线 |
| 关键 MR 通过率 Pmr | 0.90 | 0.78 | ≥ 0.85 | ✘ 回落 −12% |
| 峰值显存(收益) | 6.1 GB | 3.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 工作流
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 门禁脚本
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 故事,它站在前序案例搭好的三条"贯穿线索"上,是它们的消费端也是产出端。
§1 的 registry 直接接管 pr10 选型、pr13 台账里那份清单。没有清单,就没有可打版本、可回归的东西。
§3 的三指标口径全部来自 pr08 统一度量表与 pr11 / pr12 度量定义,门禁数字才可比、可累计进实证。
§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 失败库。一句话:把"感觉差不多"换成"门禁说了算"。