智能体面试准备(二十三):灰度发布与回滚------版本治理、影子流量、回归门禁与自动熔断
上一篇《高并发编排与多租户隔离》解决的是"系统能不能扛住",这一篇解决的是它的下一个问题:"改动能不能安全地上线"。这是 B 系列第二十三篇。Agent 系统的发布有一个传统软件没有的特性------它的行为变更是概率性的、不可静态审查的 。改一行 prompt,代码 diff 只有一行,但影响面可能覆盖全部流量,且没有任何编译器或类型系统能提前告诉你哪里会坏。更麻烦的是,上游模型供应商可能在你毫不知情的情况下悄悄更新了模型权重,你的系统在没有任何代码变更的情况下行为漂移。"你们的 Agent 怎么发版"是有生产经验和没有生产经验最直接的分水岭之一,也是本系列 B11(Agent 评估)、B20(可观测性)之后自然的延伸------评估告诉你好不好,可观测性告诉你现在怎么样,而发布治理决定了改动能不能安全落地。本文按"Agent 发布的特殊性 → 可变更资产的版本化 → 发布策略谱系 → 影子流量 → 回归门禁 → 灰度指标体系 → 自动回滚 → 数据与状态的兼容 → 上游模型漂移 → 事故复盘"展开,结尾给面试速答和高频追问清单。
一、Agent 发布为什么特殊
1.1 与传统服务的五个差异
| 维度 | 传统微服务 | Agent 系统 |
|---|---|---|
| 变更载体 | 代码 | 代码 + prompt + 模型 + 工具 + 知识库 + 配置 |
| 变更可审查性 | 静态分析、类型检查、单测 | prompt 改动无法静态验证 |
| 行为确定性 | 相同输入必得相同输出 | 温度采样导致同输入不同输出 |
| 正确性判定 | 断言相等 | 需要语义判定,本身就有误差 |
| 故障表现 | 500 错误、超时,立即可见 | 静默降级:能返回、格式对、内容错 |
第五行是最致命的。传统服务坏了会报错,监控立刻告警。Agent 坏了往往还是 200 OK,JSON 格式合法,只是答案质量下降了 20%。这种故障可以在生产环境潜伏几天甚至几周,等到用户投诉积累到一定量才被发现,而那时候已经无法定位是哪次发布引入的。
1.2 一次典型的 prompt 事故
一行 prompt 改动引发的连锁反应
改动:在系统提示词里加一句
"回答要简洁,不超过三句话"
目的:降低 token 成本
│
▼
直接效果:输出 token 数下降 40%,成本达标 ✓
│
├──> 副作用1:思维链被压缩
│ → 多步推理任务准确率下降 18%
│
├──> 副作用2:工具调用的参数说明变短
│ → 下游工具解析失败率上升 3 倍
│
├──> 副作用3:模型学会了省略免责声明
│ → 合规风险
│
└──> 副作用4:多轮对话时上下文摘要变短
→ 长会话的信息丢失加剧
没有任何一条会触发 5xx 告警。
成本看板显示"优化成功",
质量问题两周后才通过用户流失率发现。
这个例子在面试里非常好用,因为它一次性说明了:为什么 Agent 必须有独立的质量门禁,为什么单看成本和延迟指标会误判,为什么需要影子流量做全面对比。
二、可变更资产的版本化
2.1 六类资产都要版本化
Agent 系统的可变更资产清单
┌─────────────────────────────────────────────────┐
│ ① 代码 Git commit │
│ ② Prompt 模板 独立版本号,不要硬编码在代码里 │
│ ③ 模型 provider + model + snapshot 日期 │
│ ④ 采样参数 temperature / top_p / max_tokens │
│ ⑤ 工具定义 schema 版本 + 后端服务版本 │
│ ⑥ 知识库 索引快照 ID + embedding 模型版本 │
└─────────────────────────────────────────────────┘
│
▼
组合成一个 AgentRelease
(不可变、可寻址、可回滚的原子单元)
关键原则:任何一项变了,就是一个新版本。
最常见的错误是只把代码纳入版本控制,
prompt 和知识库靠"直接改线上配置",
出事后完全无法复现当时的行为。
2.2 Release 定义
python
from dataclasses import dataclass, field, asdict
from datetime import datetime
import hashlib, json
@dataclass(frozen=True)
class AgentRelease:
"""不可变的 Agent 版本定义。所有影响行为的东西都在这里。"""
release_id: str
code_commit: str
prompt_bundle: dict # {"system": "sha256:ab12...", "planner": "sha256:cd34..."}
model: dict # {"provider":"x","name":"y","snapshot":"2026-06-01"}
sampling: dict # {"temperature":0.2,"top_p":0.9,"max_tokens":2048}
tools: dict # {"search":"v3.1","db_query":"v2.0"}
knowledge: dict # {"index_id":"kb-20260805","embed_model":"bge-m3-v1"}
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
def fingerprint(self) -> str:
"""行为指纹:只要影响行为的字段变了,指纹就变。
用于判断两个 release 是否行为等价、缓存是否可复用。"""
payload = {k: v for k, v in asdict(self).items()
if k not in ("release_id", "created_at")}
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(blob.encode()).hexdigest()[:16]
def diff(self, other: "AgentRelease") -> dict:
"""变更面分析:决定该用哪种发布策略"""
a, b = asdict(self), asdict(other)
return {k: {"from": b[k], "to": a[k]} for k in a
if k not in ("release_id", "created_at") and a[k] != b[k]}
fingerprint 的实际用途:线上每条 trace 都要记录当时的 release fingerprint。当某天质量指标下跌,可以直接按 fingerprint 分组对比,秒级定位是哪个版本的问题。没有这个字段,排查基本靠翻发布记录猜。
2.3 按变更面选择发布策略
变更面 → 风险等级 → 发布策略 映射表
仅采样参数微调 (temperature 0.2→0.3)
→ 低风险 → 5% 灰度 1 小时 → 全量
Prompt 措辞优化(不改结构)
→ 中风险 → 影子流量 24h + 10% 灰度 24h → 阶梯放量
Prompt 结构性改动(加/减环节)
→ 高风险 → 完整回归 + 影子 3 天 + 1%/5%/25%/50% 阶梯
换模型(含供应商快照更新)
→ 极高风险 → 全量离线回归 + 影子 1 周 + 阶梯 + 人工审核采样
知识库重建
→ 高风险 → 检索层单独评测 + 影子对比 + 保留旧索引可秒切
工具 schema 变更
→ 极高风险 → 必须双写兼容期,新旧 schema 并存
三、发布策略谱系
3.1 五种策略
Agent 发布策略对比
① 蓝绿部署 (Blue-Green)
两套完整环境,流量一次性切换
优点:回滚极快(切回去就行)
缺点:资源翻倍;无法渐进观察;长时任务会被切断
适用:基础设施升级,不适合 Agent 行为变更
② 金丝雀 (Canary)
少量流量到新版本,逐步放大
1% → 5% → 25% → 50% → 100%
优点:风险可控,指标可对比
缺点:小流量下质量指标统计功效不足
适用:Agent 的主力策略
③ 影子流量 (Shadow / Dark Launch)
100% 流量复制到新版本,但结果不返回用户
优点:全量数据、零用户风险
缺点:成本翻倍;有副作用的工具不能真跑
适用:模型/prompt 大改的前置验证
④ A/B 实验
按用户分桶,长期运行,统计显著性检验
优点:能测长期指标(留存、满意度)
缺点:周期长
适用:产品级决策,不是安全发布手段
⑤ 特性开关 (Feature Flag)
代码已上线但功能关闭,按配置动态开启
优点:发布与放量解耦,秒级关闭
缺点:代码里堆积分支
适用:所有策略的底层机制
面试里要能说清"影子流量和金丝雀不是二选一,而是前后串联":影子流量在零风险下验证"新版本不会崩、指标不会大幅偏离",金丝雀在真实用户反馈下验证"新版本确实更好"。跳过影子直接金丝雀,等于让 1% 的真实用户当小白鼠。
3.2 流量路由实现
python
import hashlib
from dataclasses import dataclass
@dataclass
class RolloutRule:
release_id: str
percentage: float # 0~100
allowlist_tenants: set = None # 强制走新版本(内部测试)
denylist_tenants: set = None # 强制走旧版本(大客户/高风险)
sticky_key: str = "user_id" # 粘性维度
class TrafficRouter:
def __init__(self, baseline: str, rules: list[RolloutRule]):
self.baseline = baseline
self.rules = rules
def route(self, ctx: dict) -> tuple[str, str]:
"""返回 (release_id, 命中原因)。原因要写进 trace 便于归因。"""
for r in self.rules:
tid = ctx.get("tenant_id")
if r.denylist_tenants and tid in r.denylist_tenants:
continue
if r.allowlist_tenants and tid in r.allowlist_tenants:
return r.release_id, "allowlist"
# 粘性哈希:同一用户始终落在同一版本,避免体验跳变
key = f"{r.release_id}:{ctx.get(r.sticky_key, '')}"
bucket = int(hashlib.md5(key.encode()).hexdigest()[:8], 16) % 10000
if bucket < r.percentage * 100:
return r.release_id, f"canary_{r.percentage}%"
return self.baseline, "baseline"
三个必须讲出来的设计细节:
- 粘性哈希里必须拼上
release_id。如果只用user_id哈希,那么从 5% 放量到 10% 时,原来 5% 的用户集合是新集合的子集,看起来没问题;但如果同时有多个实验在跑,不加 release_id 会导致用户在不同实验间的分桶完全相关,实验结果互相污染。 - denylist 优先于 allowlist 检查。大客户、金融/医疗类高风险租户应该能被无条件排除在灰度之外。
- 路由原因要落进 trace。事后分析必须能区分"这条请求走新版本是因为随机分桶还是因为白名单",否则统计会有偏。
3.3 长时任务的发布难题
这是 Agent 特有的坑,也是很好的追问点:
问题:一个 Agent 任务要跑 40 分钟,中途发布了新版本,
后续步骤该用旧版还是新版?
┌─ 方案A:任务级版本锁定(推荐)
│ 任务开始时确定 release,写进任务状态,
│ 整个生命周期不变,即使中间发布了新版本。
│ 优点:行为一致,可复现
│ 缺点:旧版本要保留到最后一个任务结束(可能几小时)
│
├─ 方案B:步骤级动态路由
│ 每步都重新路由
│ 缺点:同一任务前后行为不一致,
│ prompt 结构变了会导致状态不兼容,直接崩
│ 基本不可用
│
└─ 方案C:排空 (drain) 后切换
停止接新任务,等存量跑完再发布
缺点:长尾任务会让排空时间不可控
适用:紧急回滚场景可强制排空
方案 A 是标准答案,配套要求是:旧版本的运行时(prompt、工具 schema)必须保留一个宽限期,通常是"最长任务时长 × 2"。这也是为什么 B22 讲的 checkpoint 状态里必须记录 release_id。
四、影子流量
4.1 架构
影子流量的完整链路
用户请求
│
├──────────────> [Baseline 版本] ──> 返回给用户 ✓
│ │
│ └──> 记录 trace_base
│
└── 异步复制 ────> [Candidate 版本] ──> 结果丢弃 ✗
│ (绝不返回用户)
└──> 记录 trace_cand
│
▼
┌──────────────────┐
│ 离线对比分析器 │
│ · 结果差异率 │
│ · 质量分对比 │
│ · 成本/延迟对比 │
│ · 工具调用差异 │
│ · 失败模式聚类 │
└──────────────────┘
4.2 副作用隔离:影子流量最大的坑
Agent 会调用工具,工具会产生副作用(发邮件、写数据库、下订单)。影子跑一遍等于副作用执行两遍。
python
class ShadowToolProxy:
"""
影子模式下的工具代理:
读操作放行,写操作拦截并返回模拟结果。
"""
WRITE_PATTERNS = ("create", "update", "delete", "send", "post",
"pay", "order", "insert", "drop", "write", "submit")
def __init__(self, real_tools, shadow_mode: bool):
self.real = real_tools
self.shadow = shadow_mode
self.blocked = [] # 记录被拦截的写操作,本身就是重要的对比信号
def is_write(self, name: str) -> bool:
return any(p in name.lower() for p in self.WRITE_PATTERNS)
async def call(self, name: str, args: dict):
if not self.shadow:
return await self.real.call(name, args)
if self.is_write(name):
self.blocked.append({"tool": name, "args": args})
# 返回结构合法的模拟结果,让 Agent 能继续往下走
return {"ok": True, "shadow_simulated": True,
"id": f"shadow-{len(self.blocked)}"}
# 读操作可以真跑,但要标记来源便于成本归因
return await self.real.call(name, args, tag="shadow")
这里有个更深的问题值得主动提 :靠名字前缀判断读写是脆弱的。工业级做法是在工具 schema 里显式声明 side_effect: none | idempotent | destructive ,由工具注册时强制填写,代理按声明决定放行策略。靠猜名字迟早会出事------比如一个叫 get_and_lock_resource 的工具,名字像读操作,实际上会加锁。
另外,被拦截的写操作列表本身就是高价值的对比信号:如果新版本比旧版本多调了 30% 的写操作,即使最终答案质量看起来一样,也说明行为发生了显著漂移,需要人工审查。
4.3 差异分析
python
def compare_shadow_batch(pairs, judge=None):
"""
pairs: [(trace_base, trace_cand), ...]
输出结构化的差异报告,用于决定是否进入金丝雀阶段。
"""
import numpy as np
from collections import Counter
rep = {
"n": len(pairs),
"exact_match": 0, # 完全一致(低温场景才有意义)
"semantic_match": 0, # 语义等价
"quality_delta": [], # 质量分差
"cost_ratio": [],
"latency_ratio": [],
"tool_seq_diff": 0, # 工具调用序列不同的比例
"cand_only_failure": 0, # 仅新版失败(最危险)
"base_only_failure": 0, # 仅旧版失败(新版修复了)
"failure_modes": Counter(),
}
for b, c in pairs:
if b["output"] == c["output"]:
rep["exact_match"] += 1
elif judge and judge.equivalent(b["output"], c["output"]):
rep["semantic_match"] += 1
if judge:
rep["quality_delta"].append(
judge.score(c["output"]) - judge.score(b["output"]))
rep["cost_ratio"].append(c["cost"] / max(b["cost"], 1e-9))
rep["latency_ratio"].append(c["latency"] / max(b["latency"], 1e-9))
if [t["name"] for t in b["tools"]] != [t["name"] for t in c["tools"]]:
rep["tool_seq_diff"] += 1
bf, cf = b.get("failed"), c.get("failed")
if cf and not bf:
rep["cand_only_failure"] += 1
rep["failure_modes"][c.get("error_type", "unknown")] += 1
elif bf and not cf:
rep["base_only_failure"] += 1
for k in ("quality_delta", "cost_ratio", "latency_ratio"):
v = rep[k]
rep[k] = {"mean": float(np.mean(v)), "p50": float(np.percentile(v, 50)),
"p95": float(np.percentile(v, 95))} if v else None
return rep
cand_only_failure 是影子阶段的一票否决指标。只要出现新版本失败而旧版本成功的案例,无论质量分平均值多好看,都必须逐条人工分析。平均值会掩盖尾部灾难。
五、回归门禁
5.1 三层门禁
发布前的三层质量门禁
┌── L1 冒烟测试(分钟级,每次提交都跑)────────┐
│ · 50 条核心用例 │
│ · 断言:不崩、格式合法、必调工具被调用 │
│ · 阻塞条件:任一失败 │
└────────────────────────────────────────────┘
│ 通过
▼
┌── L2 回归评测(小时级,合入主干前)──────────┐
│ · 500~2000 条覆盖各能力维度 │
│ · 指标:任务成功率、工具准确率、质量分 │
│ · 阻塞条件:任一维度下降超阈值 │
│ · 必须包含历史 bug 用例集(防回归) │
└────────────────────────────────────────────┘
│ 通过
▼
┌── L3 对抗与安全(每次发布前)────────────────┐
│ · 提示注入、越权、数据泄露、有害内容 │
│ · 阻塞条件:安全类零容忍 │
└────────────────────────────────────────────┘
│ 通过
▼
进入影子 / 灰度
5.2 门禁判定的统计学问题
这是本篇最容易拉开差距的地方。Agent 输出有随机性,直接比较两次跑分是不严谨的。
python
from scipy import stats
import numpy as np
def gate_decision(base_scores, cand_scores, min_effect=-0.02, alpha=0.05):
"""
回归门禁判定:用非劣性检验,而不是简单比大小。
问题:Agent 有采样随机性,单次跑分对比会误判。
正确姿势:检验"新版本不显著劣于旧版本超过 min_effect"。
"""
b, c = np.asarray(base_scores), np.asarray(cand_scores)
diff = c.mean() - b.mean()
# 配对样本(同一批用例)用配对 t 检验,统计功效更高
if len(b) == len(c):
t_stat, p_two = stats.ttest_rel(c, b)
else:
t_stat, p_two = stats.ttest_ind(c, b, equal_var=False)
# 效应量:均值差多大在实际上才有意义
pooled_sd = np.sqrt((b.var(ddof=1) + c.var(ddof=1)) / 2)
cohens_d = diff / pooled_sd if pooled_sd > 0 else 0.0
# 差值的 95% 置信区间下界
se = np.sqrt(b.var(ddof=1)/len(b) + c.var(ddof=1)/len(c))
ci_low = diff - 1.96 * se
# 非劣性判定:置信区间下界高于可接受的最差退化
non_inferior = ci_low > min_effect
return {
"mean_diff": round(float(diff), 4),
"ci95_low": round(float(ci_low), 4),
"cohens_d": round(float(cohens_d), 3),
"p_value": round(float(p_two), 4),
"non_inferior": bool(non_inferior),
"decision": "PASS" if non_inferior else "BLOCK",
"note": "效应量 <0.2 视为无实质差异" if abs(cohens_d) < 0.2 else "",
}
三个关键点:
- 用非劣性检验而不是优越性检验。发布门禁关心的是"有没有变差",不是"有没有变好"。很多改动(比如降成本)本来就不追求质量提升,只要不显著变差就该放行。
- 用配对检验。同一批测试用例在两个版本上各跑一次,是配对数据,配对 t 检验能消除用例难度差异带来的方差,统计功效显著高于独立样本检验。
- 同时看效应量。样本量大时,0.001 的微小差异也能统计显著,但没有实际意义。必须结合 Cohen's d 判断是否值得关注。
5.3 用例集的维护
回归集会腐化,必须持续维护:
| 来源 | 占比 | 说明 |
|---|---|---|
| 核心场景 | 30% | 人工设计,覆盖主要用户旅程 |
| 生产采样 | 40% | 从真实流量分层采样,脱敏后固化 |
| 历史 bug | 20% | 每个线上事故都要沉淀成用例,永不删除 |
| 对抗样本 | 10% | 红队产出 |
"每个线上事故沉淀成用例"是硬规则。没有这条,同一个问题会反复出现。实践中建议在事故复盘的 action item 里强制包含"补充回归用例"这一项,并且这个用例要能在修复前复现失败。
六、灰度期指标体系
6.1 四层指标
灰度观察的四层指标(按信号延迟排序)
┌─ 第0层:系统健康(秒级)────────────────────┐
│ 错误率、超时率、P95 延迟、饱和度 │
│ 用途:立即熔断。这层出问题就是彻底坏了 │
└────────────────────────────────────────────┘
┌─ 第1层:行为指标(分钟级)──────────────────┐
│ 工具调用次数分布、平均轮数、输出长度分布 │
│ 停止原因分布、token 消耗 │
│ 用途:早期漂移检测。质量没法秒级测, │
│ 但行为漂移是质量问题的先行指标 │
└────────────────────────────────────────────┘
┌─ 第2层:质量指标(小时级)──────────────────┐
│ 自动 judge 打分、任务完成率、格式合规率 │
│ 用途:核心决策依据 │
└────────────────────────────────────────────┘
┌─ 第3层:业务指标(天级)────────────────────┐
│ 重问率、会话放弃率、人工接管率、留存 │
│ 用途:最终裁决,但太慢,不能作为熔断依据 │
└────────────────────────────────────────────┘
第 1 层是这套体系里最实用的一层,也是最容易被忽略的。质量评估需要跑 judge,有成本和延迟;但"平均工具调用次数从 2.3 涨到 4.1"这种行为漂移是秒级可算的,而且几乎必然对应着某种质量问题(模型开始瞎试工具了)。用行为指标做早期预警,用质量指标做最终判定,是成熟的做法。
6.2 漂移检测
python
import numpy as np
from scipy import stats
def behavior_drift(base_samples: dict, cand_samples: dict, psi_thresh=0.2):
"""
行为漂移检测:对比新旧版本的行为指标分布。
连续量用 KS 检验 + PSI,离散量用卡方检验。
"""
def psi(exp, act, bins=10):
"""Population Stability Index:<0.1 稳定,0.1~0.25 轻微,>0.25 显著漂移"""
edges = np.percentile(exp, np.linspace(0, 100, bins + 1))
edges[0], edges[-1] = -np.inf, np.inf
e = np.histogram(exp, edges)[0] / len(exp) + 1e-6
a = np.histogram(act, edges)[0] / len(act) + 1e-6
return float(np.sum((a - e) * np.log(a / e)))
out = {}
for metric in ("n_tool_calls", "n_turns", "output_tokens", "latency_ms"):
b, c = base_samples.get(metric), cand_samples.get(metric)
if not b or not c:
continue
ks_stat, ks_p = stats.ks_2samp(b, c)
p = psi(np.asarray(b), np.asarray(c))
out[metric] = {
"base_mean": round(float(np.mean(b)), 3),
"cand_mean": round(float(np.mean(c)), 3),
"ks_p": round(float(ks_p), 4),
"psi": round(p, 4),
"drift": bool(p > psi_thresh or ks_p < 0.01),
}
out["_alert"] = any(v.get("drift") for v in out.values() if isinstance(v, dict))
return out
6.3 小流量的统计功效陷阱
1% 灰度、日活 10 万 → 每天新版本样本 1000 条
假设基线任务成功率 85%,想检测 3 个百分点的下降(85% → 82%)
在 alpha=0.05、power=0.8 下所需样本量约 2700 条/组
→ 1% 灰度需要跑近 3 天才有足够统计功效
常见错误:灰度 1 小时看指标"差不多"就放量
实际上此时置信区间宽到 ±8 个百分点,
什么结论都得不出来。
对策:
① 灰度时长按所需样本量倒推,而不是拍脑袋定
② 小流量阶段主要看第0层和第1层(这两层信号密度高)
③ 质量判定主要依赖影子流量(100% 样本量)
这段是很好的加分内容------它解释了为什么影子流量不可替代:影子能拿到 100% 的样本量,统计功效远高于 1% 的金丝雀。
七、自动回滚
7.1 熔断规则
python
from dataclasses import dataclass
from typing import Callable
@dataclass
class CircuitRule:
name: str
check: Callable[[dict], bool]
severity: str # "critical" 立即回滚 / "warning" 暂停放量
min_samples: int = 50 # 样本不足不判定,避免早期噪声误触发
RULES = [
CircuitRule("error_rate_spike",
lambda m: m["error_rate"] > max(3 * m["base_error_rate"], 0.05),
"critical", 30),
CircuitRule("latency_regression",
lambda m: m["p95_latency"] > 2.0 * m["base_p95_latency"],
"critical", 50),
CircuitRule("cost_explosion",
lambda m: m["cost_per_req"] > 1.5 * m["base_cost_per_req"],
"critical", 100),
CircuitRule("quality_drop",
lambda m: m["quality_ci_low"] < m["base_quality"] - 0.05,
"critical", 300),
CircuitRule("tool_call_drift",
lambda m: m["psi_tool_calls"] > 0.25,
"warning", 200),
CircuitRule("safety_violation",
lambda m: m["safety_incidents"] > 0,
"critical", 1), # 安全类零容忍,一条就回滚
]
class AutoRollback:
def __init__(self, router, notifier, cooldown_sec=300):
self.router, self.notifier = router, notifier
self.cooldown, self.last_action = cooldown_sec, 0
def evaluate(self, metrics: dict, release_id: str, now: float):
if now - self.last_action < self.cooldown:
return "cooldown"
fired = [r for r in RULES
if metrics.get("n_samples", 0) >= r.min_samples and r.check(metrics)]
if not fired:
return "healthy"
crit = [r for r in fired if r.severity == "critical"]
if crit:
self.router.set_percentage(release_id, 0) # 秒级切零
self.last_action = now
self.notifier.page(
f"AUTO-ROLLBACK {release_id}: " + ", ".join(r.name for r in crit))
return "rolled_back"
self.router.freeze(release_id) # 冻结当前比例,不再放量
self.notifier.warn(
f"FREEZE {release_id}: " + ", ".join(r.name for r in fired))
return "frozen"
设计要点:
min_samples分级。安全事件 1 条就触发,质量指标要 300 条才判定。样本量要求应该与指标的信号强度成反比。- critical 直接切零,warning 只冻结。不是所有异常都值得回滚,行为漂移可能是预期内的改进,冻结后交人工判断更合理。
- 冷却期防抖动。没有冷却期会出现"回滚→指标恢复→自动放量→再回滚"的震荡。
- 回滚是切流量比例,不是重新部署。这要求旧版本必须一直在线待命,这是"发布与放量解耦"的价值所在。
7.2 回滚不是终点
回滚后必须做的四件事:
① 冻结版本:把 candidate release 标记为 quarantined,
防止别人不知情又发一遍
② 保留证据:把触发回滚时间窗内的全部 trace 单独归档,
这些是最有价值的失败样本(默认采样率会漏掉)
③ 补充用例:从归档 trace 里挑典型失败,加进 L2 回归集
④ 检查状态残留:回滚代码容易,回滚数据难(见第八节)
八、状态与数据的兼容性
8.1 回滚的真正难点
代码可以秒回,但新版本已经写进数据库/记忆库的数据回不去:
版本回滚时的状态兼容矩阵
│ 旧版能读新版写的数据?│ 处理方式
──────────────────┼─────────────────────┼──────────────
新增可选字段 │ 能(忽略未知字段) │ 安全
新增必填字段 │ 不能 │ 必须给默认值
修改字段语义 │ 能读但会误解 │ 最危险,必须改名
删除字段 │ 不能 │ 分两次发布
新的记忆条目格式 │ 取决于解析器容错 │ 版本号 + 兼容读
新的 checkpoint │ 通常不能 │ 版本号 + 迁移器
黄金规则:
任何 schema 变更都必须能被前一个版本安全读取(向后兼容),
这样才敢回滚。破坏性变更必须拆成"扩展-迁移-收缩"三次发布。
8.2 扩展-迁移-收缩
以"把 memory 的 tags 字段从 string 改成 list[string]"为例:
发布 N (扩展 Expand)
· 新增字段 tags_v2: list[string]
· 写:同时写 tags 和 tags_v2(双写)
· 读:优先读 tags_v2,缺失则从 tags 转换
· 此时回滚安全:旧版本只读 tags,还在写
────────────────────────────────────
发布 N+1 (迁移 Migrate)
· 后台任务把存量 tags 全部回填到 tags_v2
· 校验:抽样比对两个字段一致性
· 仍保持双写
────────────────────────────────────
发布 N+2 (收缩 Contract)
· 确认 N+1 稳定运行超过回滚窗口(如 7 天)
· 停止写 tags,代码里删除旧字段引用
· 此时已无法回滚到 N 之前,但可回滚到 N+1
这套流程适用于所有有状态的 Agent 组件:长期记忆、任务 checkpoint、工具调用日志、向量库 metadata。面试时能主动提到"回滚代码容易,回滚数据难",说明确实踩过坑。
8.3 知识库版本切换
python
class VersionedRetriever:
"""
知识库双索引:新索引构建期间旧索引继续服务,
切换是原子的指针变更,回滚只需切回去。
"""
def __init__(self, store):
self.store = store
self.active = None # 当前生效的 index_id
self.standby = None # 上一个版本,保留用于快速回滚
def build(self, index_id, docs, embed_model):
# 新索引独立构建,不影响线上
self.store.create_index(index_id, docs, embed_model)
return index_id
def validate(self, index_id, probe_set) -> dict:
"""切换前必须跑检索层专项评测,不能等端到端才发现问题"""
hits = [self.store.search(index_id, p["q"], k=10) for p in probe_set]
recall = np.mean([
any(d["id"] in p["gold_ids"] for d in h)
for h, p in zip(hits, probe_set)])
return {"recall@10": float(recall), "n": len(probe_set)}
def promote(self, index_id, min_recall=0.85):
m = self.validate(index_id, self.probe_set)
if m["recall@10"] < min_recall:
raise RuntimeError(f"索引质量不达标,拒绝切换: {m}")
self.standby, self.active = self.active, index_id # 原子切换
return m
def rollback(self):
if not self.standby:
raise RuntimeError("无可回滚的索引版本")
self.active, self.standby = self.standby, self.active
关键设计是"检索层单独评测"。如果只做端到端评测,知识库变更引起的检索退化会被生成层部分掩盖(模型有参数化知识兜底),等到端到端指标下降时问题已经很严重了。检索层的 recall 是更灵敏的先行指标(这一点和 B19 讲的 RAG 评估归因决策树是一脉相承的)。
九、上游模型漂移
9.1 你没发版,但模型变了
三类你不控制的变更:
① 别名指向变更
model="gpt-xx-latest" → 供应商静默切换底层快照
对策:永远锁定具体快照版本,禁止用 latest 别名
② 快照下线
供应商宣布某快照 3 个月后停止服务
对策:订阅变更公告;维护"下一个可用快照"的预案
并定期跑迁移评测
③ 无声调整
即使锁定快照,供应商仍可能调整推理栈
(量化精度、批处理策略、安全过滤层)
对策:这是最难防的,只能靠持续基线监控
9.2 金丝雀基线集
python
class ModelCanary:
"""
每小时用固定的探针集打一次上游模型,
检测"我方无变更但模型行为变了"的情况。
这是防御上游漂移的唯一有效手段。
"""
def __init__(self, probes, model_cfg, history_store):
self.probes = probes # 30~100 条固定探针,含确定性任务
self.model_cfg = model_cfg
self.history = history_store
async def run(self):
results = []
for p in self.probes:
# 温度设 0,最大限度排除采样噪声
out = await call_model(self.model_cfg, p["prompt"], temperature=0)
results.append({
"id": p["id"],
"output": out.text,
"hash": hashlib.md5(out.text.encode()).hexdigest()[:12],
"n_tokens": out.usage.completion_tokens,
"correct": p["check"](out.text) if "check" in p else None,
})
prev = self.history.latest()
alert = None
if prev:
changed = sum(1 for a, b in zip(results, prev["results"])
if a["hash"] != b["hash"])
ratio = changed / len(results)
# 温度 0 下输出应该高度稳定,变化超过 20% 说明上游动了
if ratio > 0.2:
alert = f"上游模型疑似变更:{changed}/{len(results)} 条探针输出不同"
self.history.append({"ts": time.time(), "results": results})
return {"alert": alert, "results": results}
探针集的设计要点:要包含确定性任务(数学计算、格式转换、固定抽取),这些在温度 0 下应该逐字稳定。开放式生成任务即使模型没变也可能有细微差异,不适合做指纹探针。
十、面试速答
Q:Agent 系统的发布和传统微服务发布最大的区别是什么?
A:最大区别是故障形态。传统服务坏了会抛异常、返回 5xx、超时,监控立刻告警,是显式失败。Agent 坏了往往还是 200 OK,JSON 格式合法,只是答案质量下降了一两成,这叫静默降级。这种故障能在生产潜伏几天甚至几周,等到用户投诉积累起来才被发现,那时已经很难定位是哪次发布引入的。另外三个差异也很关键,一是变更载体不只是代码,prompt、模型快照、工具 schema、知识库索引任何一个变了都是行为变更;二是 prompt 改动无法静态审查,没有编译器和类型系统能提前告诉你哪里会坏;三是输出有采样随机性,同样输入两次结果不同,不能用断言相等来验证。这三点决定了 Agent 必须有独立的质量门禁和影子验证机制。
Q:一个 Agent 版本应该包含哪些内容?
A:六类资产必须全部纳入版本,缺一不可。代码的 git commit、prompt 模板的内容哈希、模型的供应商加名称加快照日期、采样参数如温度和 top_p、每个工具的 schema 版本和后端服务版本、知识库的索引快照 ID 和 embedding 模型版本。这六项组合成一个不可变的 release 对象,并计算一个行为指纹。行为指纹的实际用途是,线上每条 trace 都记录当时的指纹,一旦质量指标下跌就能按指纹分组对比,秒级定位问题版本。最常见的错误是只把代码纳入版本控制,prompt 和知识库靠直接改线上配置,这样出事后完全无法复现当时的行为,排查只能靠猜。
Q:影子流量和金丝雀发布的区别?该怎么配合?
A:影子流量是把百分之百的生产流量复制一份给新版本跑,但结果丢弃不返回给用户,用户零风险,能拿到全量样本做统计对比。金丝雀是把小比例真实流量切给新版本,结果真的返回用户,能拿到真实的用户反馈但有风险。两者不是二选一,而是前后串联的两个阶段。先跑影子,在零风险下验证新版本不会崩、成本和延迟不失控、质量指标不显著劣化;影子通过后再进金丝雀,用真实用户反馈验证确实更好。跳过影子直接金丝雀,等于让百分之一的真实用户当小白鼠。另外影子有个金丝雀没有的优势是样本量,一个百分之一的金丝雀要跑三天才能积累足够样本检测三个百分点的质量下降,而影子第一天就有全量数据,统计功效高得多。
Q:影子流量最大的坑是什么?
A:副作用重复执行。Agent 会调工具,工具会发邮件、写数据库、下订单,影子跑一遍等于这些副作用执行两遍。解法是给工具做代理层,读操作放行,写操作拦截并返回结构合法的模拟结果,让 Agent 能继续往下走完整个流程。这里的关键细节是判断读写不能靠工具名字前缀猜,那样太脆弱,比如一个叫 get_and_lock_resource 的工具名字像读实际会加锁。工业级做法是在工具 schema 里强制声明 side_effect 字段,取值为无副作用、幂等、破坏性三种,代理层按声明决定策略。还有个容易被忽略的点,被拦截的写操作列表本身就是高价值信号,如果新版本比旧版本多调了三成写操作,即使最终答案看起来一样,也说明行为发生了显著漂移,需要人工审查。
Q:回归门禁怎么判定"新版本没有变差"?
A:不能简单比较两次跑分的均值大小,因为 Agent 有采样随机性。正确做法是做非劣性检验。第一,用配对检验而不是独立样本检验,同一批用例在两个版本上各跑一次是配对数据,配对 t 检验能消除用例难度差异带来的方差,统计功效显著更高。第二,检验的假设应该是非劣性而不是优越性,也就是检验新版本的质量下降幅度是否小于可接受阈值,因为很多改动比如降成本本来就不追求质量提升,只要不显著变差就该放行。具体做法是算质量差值的百分之九十五置信区间下界,下界高于负的可接受退化幅度就判通过。第三,必须同时看效应量,样本量大时千分之一的差异也能统计显著但没有实际意义,Cohen's d 小于 0.2 应该视为无实质差异。
Q:灰度期应该监控哪些指标?
A:按信号延迟分四层。第零层系统健康,错误率、超时率、P95 延迟,秒级可得,用于立即熔断。第一层行为指标,工具调用次数分布、平均对话轮数、输出长度分布、停止原因分布,分钟级可得。第二层质量指标,自动 judge 打分、任务完成率,小时级。第三层业务指标,重问率、会话放弃率、人工接管率,天级。这里最实用也最容易被忽略的是第一层。质量评估要跑 judge,有成本有延迟;但平均工具调用次数从 2.3 涨到 4.1 这种行为漂移是秒级可算的,而且几乎必然对应着某种质量问题,说明模型开始瞎试工具了。所以成熟的做法是用行为指标做早期预警,质量指标做最终判定,业务指标做事后验证但不能作为熔断依据因为太慢。
Q:自动回滚的规则怎么设计?
A:几个原则。第一,不同指标的最小样本量要求应该与信号强度成反比,安全类事件一条就触发回滚,错误率飙升三十条就够,质量分下降要三百条才判定,否则早期噪声会频繁误触发。第二,严重程度要分级,critical 类直接把流量切到零,warning 类只冻结当前比例不再放量并通知人工判断,因为行为漂移可能是预期内的改进,不该一律回滚。第三,必须有冷却期,否则会出现回滚后指标恢复、自动放量、再次触发的震荡。第四也是最重要的,回滚动作应该是调整流量比例而不是重新部署,这要求旧版本一直在线待命,这就是发布与放量解耦的价值,回滚能做到秒级。回滚之后还有四件事必须做,把问题版本标记为隔离防止别人不知情又发一遍,把触发窗口内的全部 trace 单独归档因为默认采样率会漏掉这些最有价值的失败样本,从归档里挑典型案例补进回归集,最后检查数据层有没有残留。
Q:为什么说"回滚代码容易,回滚数据难"?怎么办?
A:代码切流量比例就能秒回,但新版本已经写进数据库、记忆库、checkpoint 的数据回不去,旧版本可能根本读不懂这些数据。最危险的不是新增字段或删除字段,而是修改字段语义,旧版本能读但会误解,产生完全错误的行为且不报错。解法是黄金规则加三段式发布。黄金规则是任何 schema 变更都必须能被前一个版本安全读取,也就是向后兼容,这样才敢回滚。破坏性变更必须拆成扩展、迁移、收缩三次发布:第一次只新增字段并双写,此时回滚绝对安全;第二次跑后台任务回填存量数据并抽样校验一致性,仍保持双写;第三次确认稳定超过回滚窗口后才停止写旧字段、删除旧代码。这套流程适用于所有有状态的组件,包括长期记忆、任务 checkpoint、向量库 metadata。
Q:长时任务在发布时怎么处理版本一致性?
A:标准做法是任务级版本锁定。任务开始时确定使用哪个 release,把 release_id 写进任务状态和每次 checkpoint,整个生命周期内不再变化,即使中途发布了新版本,存量任务仍然用旧版本跑完。这样保证同一任务前后行为一致、可复现。代价是旧版本的运行时必须保留一个宽限期,一般设为最长任务时长的两倍。反例是步骤级动态路由,每步重新决定版本,这基本不可用,因为 prompt 结构变了会导致前面存下的状态无法被新版本正确解析,任务直接崩掉。还有一种排空方案,停止接新任务等存量跑完再发布,问题是长尾任务会让排空时间不可控,只适合紧急回滚时强制使用。
Q:模型供应商悄悄更新了模型,你怎么发现?
A:靠固定探针集做持续基线监控。维护三十到一百条探针,每小时用温度零打一次上游模型,记录每条输出的哈希、token 数、正确性。探针要选确定性任务,比如数学计算、格式转换、固定信息抽取,这些在温度零下应该逐字稳定,开放式生成不适合做指纹。如果某次运行有超过两成的探针输出哈希发生变化,就说明上游大概率动了。除了监控,还有三个预防措施:第一,永远锁定具体的模型快照版本,绝不使用 latest 这类会静默切换的别名;第二,订阅供应商的变更公告,为快照下线准备迁移预案并定期跑迁移评测;第三,把上游漂移当成一种必然会发生的事件纳入应急预案,而不是当成异常。
十一、高频追问清单
- 粘性哈希为什么必须把 release_id 拼进哈希 key?不拼会导致什么统计问题?
- 影子流量的成本是双倍,怎么在不牺牲统计功效的前提下降本?
- 工具 schema 变更时,双写兼容期应该维持多久?依据是什么?
- 非劣性检验的 min_effect 阈值怎么定?不同业务场景应该一样吗?
- 回归用例集会过拟合吗?怎么发现"模型在回归集上刷分但生产变差"?
- PSI 阈值 0.2 是怎么来的?对 Agent 的行为指标合适吗?
- 自动回滚误触发的代价怎么衡量?宁可多回滚还是宁可少回滚?
- 多个变更同时灰度时,怎么做归因?要不要强制串行发布?
- 知识库全量重建和增量更新,在发布策略上应该有什么区别?
- Agent 的 A/B 实验里,同一个用户在多轮会话中的观测是独立样本吗?
- 探针集本身会不会被上游供应商识别并特殊处理?怎么防御?
- 灰度期间发现问题但不严重,是回滚还是带病放量后热修?决策依据是什么?
- 多租户场景下,能不能允许不同租户运行不同版本?会带来什么运维负担?
- 如何设计发布流程,让"紧急修复"也能走完必要的安全门禁而不至于太慢?
发布治理这件事的本质,是在不可静态验证的系统里,用统计手段和渐进暴露来替代确定性保证。传统软件靠类型系统和单测在上线前就把大部分问题挡住,Agent 做不到这一点,只能把验证的重心后移到影子和灰度阶段,用数据说话。理解了这个转变,就能理解为什么 Agent 的发布流程看起来比传统服务重得多------它不是流程冗余,而是必要的风险补偿。明天的 B24 会转向一个完全不同的方向:GUI 智能体与 Computer Use,看看当 Agent 的动作空间从 API 调用变成鼠标键盘时,整套工程范式会发生什么变化。今天 A 系列的两篇(A23 数据工程与合成数据、A24 位置编码与长度外推)则回到了模型本身。