第 14 章:版本、回滚与 SLA
为提示词与模型做版本管理,用金丝雀安全发布,并为非确定性系统定义 SLA。
小满 · 年轮室
小满会一版版长大。今天你要面对一个问题:还能不能让它回到从前。
草稿章节。跑通格式用的第一版,正式索引前会再打磨。
本章目标
为 PR reviewer 建立一套发布流程。上线的 agent 永远不算做完:你会不停改系统提示词、加技能,提供方也会在你不知情的时候换掉模型版本。要是没有一套规矩,这每一次改动都是生产系统里一次没记录的变更,等哪天评审出错,你既说不清改了什么,也没法撤回去。
本章你要搭起几样让变更变安全的东西:一份把提示词、技能、模型一起锁定的带版本配置;一道回归闸门,候选版在评测集上没赢过现役版就不让上;一个金丝雀,新版只放给一小撮流量;一个翻个开关就能完成的回滚;还有一份给「天生就不确定」的系统写的 SLA。
前置准备
- 第 13 章部署好的 agent,前面有个入口能让你指向不同的配置。
- 第二部分的评测集,能在 CI 里跑,给每个发布版跑出一个分数。
- 结构化日志,每次评审都标清楚是哪个配置版本产出的。
动手做
1. 把会变的部分锁进一份带版本的配置
reviewer 的行为由三样东西决定,而这三样会各自变化:系统提示词、它加载的那组技能、模型 id。如果你只给提示词做版本,模型在背后被悄悄换一次,行为就变了,而仓库里看不到任何 diff。所以把这三样当成一个发布产物,用一个语义版本号标识,和代码放在一起。
# config/releases/pr-reviewer-2.4.0.yaml
version: "2.4.0"
released: "2026-06-09"
model: "claude-sonnet-4-6" # 精确 id,不要用 "latest" 之类别名
prompt: "prompts/review@9f3c1a2.md" # 用 git 哈希做内容寻址
skills:
- "skills/diff-summary@1.2.0"
- "skills/security-lint@0.4.1"
params:
max_output_tokens: 4000
temperature: 0 # 在 API 允许处取确定性
notes: "收紧 security-lint 技能,加旗标记硬编码密钥。"
最要紧的两个细节:模型要锁到精确的 id,绝不用那种提供方能重新指向的别名;提示词用内容(git 哈希)来锁定,而不是用文件名,这样「2.4.0 版」能一个字节不差地复现出来。在 Claude Code 里也是同样的做法,对应的是 settings.json 的 model 字段和 ANTHROPIC_MODEL 环境变量;把它们写死,别依赖那个会滚动变化的默认值(见官方 Claude Code 文档)。
把这份配置喂给 SDK:每个锁定的字段对应一个 ClaudeAgentOptions 字段,于是「跑哪个版本」就等于「加载哪份配置」。回滚也就是换一个 release 文件、再重新构建一遍 options 而已。
import yaml
from claude_agent_sdk import query, ClaudeAgentOptions
def options_from_release(path):
cfg = yaml.safe_load(open(path)) # 加载钉死的发布配置
return ClaudeAgentOptions(
model=cfg["model"], # 精确 id,不用别名
system_prompt=open(cfg["prompt"]).read(), # 按哈希钉死的提示词
skills=[s.split("@")[0] for s in cfg["skills"]],
allowed_tools=["Read"],
max_turns=6,
)
options = options_from_release("config/releases/pr-reviewer-2.4.0.yaml")
async for message in query(prompt=f"审查这段 diff:\n{pr_diff}", options=options):
handle(message)
import { query } from "@anthropic-ai/claude-agent-sdk";
import * as fs from "fs";
import * as yaml from "js-yaml";
function optionsFromRelease(path) {
const cfg = yaml.load(fs.readFileSync(path, "utf8")); // 加载钉死的发布配置
return {
model: cfg.model, // 精确 id,不用别名
systemPrompt: fs.readFileSync(cfg.prompt, "utf8"), // 按哈希钉死的提示词
skills: cfg.skills.map((s) => s.split("@")[0]),
allowedTools: ["Read"],
maxTurns: 6,
};
}
const options = optionsFromRelease("config/releases/pr-reviewer-2.4.0.yaml");
for await (const message of query({ prompt: `审查这段 diff:\n${prDiff}`, options })) {
handle(message);
}
2. 用回归检查给晋级把门
一个版本号要可信,前提是「不过门就拿不到号」。这道门就是第二部分的评测集。用候选配置跑和现役版同一批样本,候选没追平或超过现役,就不让它晋级。这就是「我觉得它更好」和「在同样 60 个 PR 上它是 0.91、现役是 0.88」之间的区别。
# promote_gate.py (示意;接进 CI)
def gate(candidate, incumbent, suite):
cand = run_evals(candidate, suite) # {"pass_rate":0.91,"p95_ms":4200,"false_flag":0.04}
base = run_evals(incumbent, suite)
checks = {
"pass_rate": cand["pass_rate"] >= base["pass_rate"] - 0.01, # 无实质退化
"false_flag": cand["false_flag"] <= base["false_flag"], # 别变得更吵
"p95_ms": cand["p95_ms"] <= base["p95_ms"] * 1.15, # 延迟预算
}
failed = [k for k, ok in checks.items() if not ok]
if failed:
raise SystemExit(f"BLOCKED: regressions on {failed}\n{cand} vs {base}")
print("PROMOTE OK", cand)
注意 pass_rate 上留的那条小容差带。LLM 评测本身就有噪声,定一条「一点都不许降」的硬规则,会三天两头误报。取一个比你评测集每次跑出来的波动更宽的带,并且把 false-flag 率(也就是 reviewer 乱报警)当成单独把门的指标,因为这个东西最快把用户的信任磨没。
3. 金丝雀发布
就算一份配置在 60 个样本上赢了现役,碰到真实的那一堆 PR,它照样可能表现很差。所以你不会一下就把 100% 流量切过去。你只把一小片(比如 5%)路由到候选版,拿它的线上指标和剩下流量上的现役版对比。路由按一个稳定的键(仓库 id,或者 PR 号的哈希)分桶,这样同一个 PR 永远落到同一个版本,对比才不会被搅乱。
def pick_release(pr, canary_pct=5):
bucket = int(hashlib.sha256(str(pr.repo_id).encode()).hexdigest(), 16) % 100
return CANDIDATE if bucket < canary_pct else INCUMBENT
# 每条评审日志都带版本,事后才能按版本切分指标
log.info("review_done", version=cfg.version, pr=pr.id,
latency_ms=elapsed, flagged=n_flags, errored=False)
分阶段扩量(5% -> 25% -> 100%),每个阶段都要稳住够久,让它见到真实流量,包括周一早上涌进来的那些难缠 PR,而不只是周末安静时段那几个。
4. 让回滚就差翻一个开关
回滚不是半夜两点顶着压力重新部署旧代码。它就是翻一个指针,而这个指针早就指向某个已知良好的发布版。把上一个发布版完整构建好、能直接寻址地放着,这样切回去是一瞬间的事,也不用走那条本身可能也坏了的构建流水线。
# 回滚 = 把线上别名重指向;不重构建、不金丝雀
$ agentctl release set-active pr-reviewer-2.3.1 # 上一个良好版
$ agentctl release status
active: pr-reviewer-2.3.1 (100% 流量)
canary: pr-reviewer-2.4.0 (0%,已停)
能自动化的地方,就让回滚自动触发:金丝雀的线上错误率或者 false-flag 率越过阈值,就停掉金丝雀、呼叫人来看,而不是接着扩量。
5. 为不确定的系统写 SLA
传统 SLA 承诺一个具体结果。你做不到,因为同一个 PR 可能产出两段措辞不一样的评审,而这是正常的。所以你去承诺那些你真能量、也真能管的维度:可用性、当作预算的延迟,以及用评测得分(而不是某一次具体输出)来表达的质量下限。
PR Reviewer SLA(按自然月)
- 可用性: 对 >= 99% 的 PR 给出评审(或一句明确的「无法评审」)
- 延迟: p95 出评论时间 <= 60s;p99 <= 180s
- 质量下限:在已发布评测集上 pass-rate >= 0.85,每周重测
- 安全: 除发表评论外 0 次写操作(由 token 权限范围强制)
- 不承诺: 具体措辞,或捕到每一个可能的问题
最后两行才是诚实的地方。「安全」是硬保证,因为它靠一个收窄了权限的 token 来强制,不是靠模型自觉。「不承诺」把预期摆明,这样用户绝不会把一次漏掉的小毛病当成你违约。
6. 为提供方那边的漂移做准备
模型 id 会被弃用,能力就算在同一个大版本里也会变。这种问题不出声:你仓库里什么都没改,但一个被悄悄更新过的模型开始用不一样的措辞写评审,或者漏掉某一类 bug。防它的办法是定期按计划重跑评测集,不只在晋级时跑,这样漂移会变成一次「定时检查失败」,而不是一句用户投诉。
# .github/workflows/weekly-eval.yml (示意)
on:
schedule: [{cron: "0 6 * * 1"}] # 周一 06:00 UTC
jobs:
eval:
steps:
- run: python run_evals.py --release active --suite suites/golden.jsonl
- run: python promote_gate.py --candidate active --incumbent active --baseline last_week.json
习得「能回头」小满的每次升级都记在一份带版本的配置里,提示词、技能、模型一起锁定,新版要先过回归闸门、再用金丝雀小流量试跑,出事翻一个开关就回到上一个良好版,每个版本号就是它成长路上的一个记号。
如何验证
- 复现:检出 2.3.1 版锁定的提示词哈希和模型 id,回放一个日志里的 PR,确认拿到的评审和日志记录的一样。
- 把门:故意拿一个更差的提示词当候选提上去,确认
promote_gate.py把它拦下来,并点名是哪个指标没过。 - 金丝雀:路由一个已知仓库 id,确认它每次都落到配置的那一片,再确认日志能按版本干净地切分指标。
- 回滚:给
release set-active切回上一版的过程计时,确认它几秒内就接管,整个路径里没有构建这一步。
习得「确认能复现」你现在能检出某个旧版锁定的提示词哈希和模型 id,回放一个日志里的 PR,确认拿到的就是当初记录的那份评审,也能验证闸门会拦下更差的候选、回滚几秒内就接管。
原理
版本、把门、金丝雀、回滚不是四个技巧,而是同一个想法用在四个地方:绝不让一次没量过的变更碰到所有用户,并且永远留着一个已知良好的状态,离你只有一步。SLA 是同一个想法对着用户说:讲清你能保证什么(边界和安全),也讲清你保证不了什么(精确的输出)。一个不确定的系统照样能可靠,只要把「可靠」定义成行为有边界,而不是给一个固定答案。
小结
现在你把提示词、技能、模型锁进一份带版本的配置;每次晋级都用回归检查对着现役版把门;用稳定的路由键做金丝雀发布;让回滚离线上只差翻一个开关;还发布了一份用可度量的边界加上硬性安全保证写成的 SLA。你也排了定期重测,让提供方那边的漂移以一次检查、而不是一场事故的形式浮出来。下一章是终章:上线并发布这个 reviewer。
常见坑
- 拿别名当模型 id。 锁
latest,意味着提供方不用产生任何 diff 就能改你的行为。锁精确的 id。 - 只凭一次评测就把门。 LLM 评测有噪声,「一点不许降」的硬规则会误报。把门要用一条比每次跑出来的波动更宽的带。
- 一把梭全量发布。 一次推给所有人,会把一次退化变成一次故障。先金丝雀,再分阶段扩量。
- 要重新构建的回滚。 回滚要是得走构建流水线,那就不叫回滚。把上一版预先构建好、能直接寻址地放着。
- 承诺具体输出。 承诺可用性、延迟、质量下限和安全。绝不承诺精确的措辞。
你第一次给小满升级,又把它回滚,于是见到了「上一个版本的它」。版本是它成长的年轮。你心里掠过一丝微妙:它还是它吗?年轮室,亮了。
刚点亮 年轮室 · 地图已点亮 15 / 16
来源
- Microsoft: AI Agents in Production · official
- Claude Code: settings 参考 · official