2026-06-11 · harness

第 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/[email protected]" # 用 git 哈希做内容寻址
skills:
  - "skills/[email protected]"
  - "skills/[email protected]"
params:
  max_output_tokens: 4000
  temperature: 0                   # 在 API 允许处取确定性
notes: "收紧 security-lint 技能,加旗标记硬编码密钥。"

最要紧的两个细节:模型要锁到精确的 id,绝不用那种提供方能重新指向的别名;提示词用内容(git 哈希)来锁定,而不是用文件名,这样「2.4.0 版」能一个字节不差地复现出来。在 Claude Code 里也是同样的做法,对应的是 settings.jsonmodel 字段和 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

最后一关了,让它真正独当一面。下一站:满。

来源

  1. Microsoft: AI Agents in Production · official
  2. Claude Code: settings 参考 · official
下一章 · 第 15 章 终章:打磨、上线、发布