出了问题查不到原因,是因为没有 trace;改了一版不敢上线,是因为没有 replay。这一章把「线上 badcase → 评测集 → 回归门禁 → 灰度发布」这条闭环接起来。接上之后,评测才从一份报告变成一套能挡住问题的机制。
编辑部注 · 本章是全站最有「系统感」的一章。面试里讲清这条闭环,等于证明你做过完整的质量工程,而不只是写过评测脚本。
前面四章分别解决了「评什么维度」「用哪些样本」「指标怎么算」「机器打分怎么可信」。但还有一个更根本的问题:这套东西怎么才能真的挡住问题,而不是变成每季度产出一份没人看的报告。
一、闭环的五个环节
这五个环节各自解决一个具体问题:全链路 Trace 让失败可复现,归因(Attribution) 把失败落到具体环节,入集把一次性问题变成永久约束,回归门禁(Regression Gate) 让评测拥有否决权,灰度发布(Canary Release) 用小流量在真实分布上做最后一次验证。断掉任何一个,后面的环节都会退化——这正是「明明做了评测,线上却还在出同样的问题」的最常见原因。
二、Trace 的设计:为「重放」而记录
Trace 的价值不是「记录发生了什么」,而是「能在本地重放(Replay)当时发生了什么」。这个区别决定了你要记录什么。
| # | 字段 | 为什么必须 |
|---|---|---|
| 1 | 完整的请求体(脱敏后) | 没有它就无法重放。这是 trace 的核心,其余都是辅助 |
| 2 | 模型与版本标识 | 换模型/换版本后的行为差异只有对比才能发现 |
| 3 | 每次工具调用的名称、参数、返回、耗时 | 归因的最主要依据——多数失败发生在工具环节 |
| 4 | 上下文各部分的大小(指令/知识/状态各占多少) | 定位「上下文膨胀」类问题的唯一手段 |
| 5 | 重试与失败的完整序列(含错误分类) | 区分「一次就对」与「重试三次才对」——后者是隐患 |
| 6 | 关键时间戳(TTFB、每次 chunk、总时长) | 定位卡死与性能问题的唯一依据 |
| 7 | 用户后续行为(是否重试、是否中断、是否手动修正) | 这是最强的质量信号——用户用脚投票比任何评分都真实 |
「用户重试率」和「用户中断率」是最接近真相的质量指标。原因很简单:模型评分再准也只是代理指标,用户行为是真实结果。一个完成率 90% 但用户重试率 30% 的系统,实际体验远差于完成率 85%、重试率 8% 的系统。
建议把这两个信号当作一等指标,与完成率并列观察。它们还能自动帮你挖掘 badcase——所有重试请求的原始 trace,就是一份高质量的问题样本来源。
这两个信号值得单独说清楚。用户重试率(User Retry Rate) 是「同一件事被问第二次」的比例,用户中断率(Abort Rate) 是中途放弃的比例;它们比任何离线评分都更接近真相,而且天然自带 badcase 挖掘功能——每一次重试都意味着上一次输出没被接受。
三、动手:Trace 与重放
import json, time, hashlib
from dataclasses import dataclass, field, asdict
@dataclass
class ToolCall:
turn: int
name: str
args: dict
status: str # ok | empty | bad_args | timeout | ...
duration_ms: int
result_digest: str # 结果指纹,而不是全文(全文另存)
@dataclass
class Trace:
trace_id: str
started_at: float
model: str
request_body: dict # 脱敏后的完整请求体 —— 重放的依据
tool_calls: list[ToolCall] = field(default_factory=list)
context_sizes: dict = field(default_factory=dict) # {"instructions":1200,...}
ttfb_ms: int | None = None
total_ms: int = 0
retries: list[dict] = field(default_factory=list)
outcome: str = "unknown" # done | budget_exhausted | failed | need_human
user_signal: str = "none" # none | retried | aborted | corrected
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False)
def trace_id_for(request_body: dict, model: str) -> str:
"""
trace id 用「请求内容 + 模型」派生。
好处:同一个输入重复出现时能自动聚类,便于统计高频问题。
"""
key = json.dumps(request_body, sort_keys=True, ensure_ascii=False) + model
return hashlib.sha256(key.encode()).hexdigest()[:16]
def replay(trace: Trace, run_once) -> dict:
"""
重放:用当时的请求体重新跑一遍,对比结果差异。
这是「改了代码到底有没有改善」的最直接验证方式。
"""
result = run_once(trace.request_body) # 用同一份请求体
diffs = {
"outcome_changed": result.get("outcome") != trace.outcome,
"tool_call_count": (len(result.get("tool_calls", [])), len(trace.tool_calls)),
"retry_count": (len(result.get("retries", [])), len(trace.retries)),
"latency_ms": (result.get("total_ms"), trace.total_ms),
}
return {"trace_id": trace.trace_id, "improved": not diffs["outcome_changed"]
or result.get("outcome") == "done", "diffs": diffs}
def classify_failure(trace: Trace) -> str:
"""
归因:把一次失败定位到具体环节。
这一段逻辑是「评测报告」与「评测工具」的分界线 ——
有归因,报告才能直接变成改动清单。
"""
if trace.outcome == "budget_exhausted":
if trace.context_sizes.get("state", 0) > 0.6 * sum(trace.context_sizes.values()):
return "上下文膨胀:状态占比过高,需要加强压缩或状态外置"
return "轮数/预算上限不足,或陷入无进展循环(检查调用签名重复度)"
bad = [c for c in trace.tool_calls if c.status == "bad_args"]
if bad:
return f"参数错误({len(bad)} 次):检查工具描述与参数约束"
to = [c for c in trace.tool_calls if c.status == "timeout"]
if to:
return f"工具超时({len(to)} 次):检查超时设置与退避策略"
if trace.retries:
return f"重试 {len(trace.retries)} 次后成功:属隐患,应查清根因"
if trace.ttfb_ms and trace.ttfb_ms > 15000:
return "首字节延迟过高:可能需要换 provider 或降级流式"
return "未归因:需要人工查看 trace"
def attribution_report(traces: list[Trace]) -> dict:
"""归因分布:这份报告比「失败率 12%」有用得多"""
from collections import Counter
failed = [t for t in traces if t.outcome != "done"]
reasons = Counter(classify_failure(t) for t in failed)
return {
"total": len(traces),
"failed": len(failed),
"failure_rate": len(failed) / len(traces) if traces else 0,
"by_reason": reasons.most_common(),
"user_retry_rate": sum(1 for t in traces if t.user_signal == "retried") / len(traces)
if traces else 0,
}重放的产出不是「这次跑通了」,而是一份按环节切分的 归因分布(Attribution Report)——参数错占几成、工具超时占几成、上下文膨胀占几成。这份分布才是后续改动的优先级清单:它把「失败率 12%」这个没有信息量的数字,换成了「本周该先修哪一层」。
四、回归门禁:让评测有约束力
这是闭环里最容易被省略、也最关键的一环。没有门禁的评测只是报告。
| 阶段 | 跑什么 | 耗时预算 | 不通过的后果 |
|---|---|---|---|
| 本地提交前 | 解析器与错误分类的单元测试 | < 10 秒 | 开发者自己发现问题,不浪费 CI 资源 |
| PR 创建后 | 冒烟集(10-20 条)+ 静态检查 | < 2 分钟 | 阻塞合并——没有例外 |
| 合并到主分支后 | 回归集 + 边界集 | < 15 分钟 | 产生告警,并自动创建 issue |
| 发版候选 | 回归 + 边界 + 对抗 + 保留池 | 可到 1 小时 | 阻塞发布;主指标回归必须人工确认才能放行 |
| 上线后 | 线上指标监控(完成率、重试率、成本) | 持续 | 触发回滚条件则自动回滚 |
坑一:把门槛设得太高,导致人人都想办法绕过它。如果 PR 阶段要跑 40 分钟,团队很快就会加 skip-eval 标签。正确做法是分层:PR 只跑最关键的冒烟集(2 分钟内),重的评测放到合并后与发版前。
坑二:只卡「总体指标」。这样很容易被「总体提升、局部退化」的改动骗过去(见第 2 章)。正确做法是把哪些分层指标不允许下降写进配置——尤其是对抗集,安全类的退步不能因为总体分数上升而被容忍。
门禁的另一半在线上:回滚条件(Rollback Condition) 必须在发版前写死,而不是出事时临时决定。常用的三条是完成率跌幅超过 3 个百分点、单次成本涨幅超过 20%、用户重试率翻倍——命中任意一条即自动回滚,不等人开会。写出可自动判定的数值条件,才算写完了发版流程;需要人来解释才知道该不该触发的条件,等于没有。
五、常见误区与追问
5.1 误区:Trace 记录得越全越好(其实标准是「能不能重放」)
错在哪:把「留痕」当成目标,结果日志里堆满了好看但用不上的字段。为什么自然:出问题时第一反应总是「当时记的东西不够」,于是下一次什么都记。正确做法:判据只有一个——把这条 trace 拿回来,能不能不改一行代码就在本地跑出同样的失败?不能,说明字段缺了;能,但你还想加字段,那属于过度记录。做法:优先保证完整请求体、工具调用参数与返回、上下文各部分大小这三项,其余字段按「它能让哪一类问题可归因」来决定去留。
5.2 误区:门禁越严格越好(其实门槛过高会诱导团队绕过它)
错在哪:把门禁的严格程度当成质量的唯一变量。为什么自然:「宁可多拦一点」听起来永远政治正确。正确做法:门禁的可用性和严格性必须一起设计。数字:PR 阶段如果要求跑 40 分钟全量评测,团队很快会加 skip-eval 标签;一旦出现第一个例外,门禁的约束力就开始归零。做法:让耗时与频率成反比——每次提交跑的层控制在 2 分钟内,重的评测放到合并后与发版前。
5.3 误区:灰度就是「小流量上线」,回滚条件到时候再定
错在哪:把回滚当成应急响应,而不是发布流程的一部分。为什么自然:谁都不想在发版前花时间写一份「可能用不上」的条款。正确做法:回滚条件必须在发版前写死,并且是可自动判定的数值条件。数字:完成率跌幅超过 3 个百分点、单次成本涨幅超过 20%、用户重试率翻倍——命中任意一条即自动回滚,不等人开会。判据:如果一条条件需要人来解释才知道该不该触发,那它写得还太模糊,等于没有。
5.4 误区:归因到「失败率 12%」就算做完归因了
错在哪:把总量指标当成归因结论。为什么自然:失败率是最容易算、也最容易向上汇报的数字。正确做法:失败率只回答「有多少」,不回答「为什么」;归因分布才回答「该改哪一层」。判据:如果一份失败报告读完,你说不出「这周该优先修哪一个环节」,那它就是一份没做完的归因。数字:把失败按参数错、工具超时、上下文膨胀、无进展循环四类切开,通常会看到某一类占到一半以上——那一类就是本周的改动清单。
5.5 误区:用户重试率是体验问题,不该混进质量指标
错在哪:把用户行为信号归到「产品侧」,让它和数据指标分家。为什么自然:质量指标通常由工程团队定义,用户行为由产品团队看,两者天然被切在不同的报表里。正确做法:用户重试率与中断率是最接近真相的质量指标,因为模型评分只是代理指标,用户行为才是真实结果。数字:完成率 90%、重试率 30% 的系统,实际体验远差于完成率 85%、重试率 8% 的系统。判据:把它和完成率并列观察;只提升完成率而重试率上升的改动,应当视为失败。
5.6 误区:所有 badcase 都该进回归门禁
错在哪:把「收藏问题」和「设成门槛」当成同一件事。为什么自然:每一条 badcase 看起来都值得防住。正确做法:只有能通过 Harness 改动解决的工程问题才进回归集;需要模型侧改进的研究问题单独归档,作为改进输入而不是门槛。判据:如果一条样本注定长期失败,团队就会为它开白名单、加例外;而一旦开始加例外,门禁就废了。
六、自测
七、小结
| 环节 | 关键要求 |
|---|---|
| Trace | 为「可重放」而记录:完整请求体、工具调用、上下文大小、重试、时间戳、用户行为 |
| 归因 | 定位到具体环节(参数错 / 超时 / 上下文膨胀 / 无进展),产出归因分布而非单一失败率 |
| 入集 | 只纳入工程问题;标注期望行为;写清来源与原因 |
| 门禁 | 分层设计,PR 只跑冒烟(2 分钟内),发版前跑全量;不允许下降的分层指标要写进配置 |
| 灰度 | 回滚条件事先写死;关注完成率、重试率、成本三项 |
| 最强信号 | 用户重试率 / 中断率——真实结果,且自带 badcase 挖掘 |
评测没有接进 CI,就只是文档。文档不会挡住任何一个坏改动。本刊编辑部
评测工程部分结束。最后一部分回到「回答必须有据可查」这个问题:知识怎么建模、怎么检索、怎么让人信。
八、参考与延伸
闭环这件事没有单一权威文档,下面几份材料各自补一块:规范、基准、编排原则,以及线上监控的实践。
先看图(建立直觉)
- Anthropic · Building effective agents —— 讲 Agent 循环该在什么地方停下来交给人,对应本章「归因之后该改哪一层」的判断。如果团队正在争「这个失败该改提示词还是改流程」,先读它关于工作流与 Agent 分界的部分。
再看代码(动手实现)
- tau-bench —— 多轮 Agent 基准,把用户建模成模拟器、把工具建模成真实接口,它的评测协议本身就是一套 replay。想理解「重放为什么必须固定外部依赖」,看它怎么把用户与工具都做成可复现的受控对象。
最后读论文(对齐一手定义)
- OpenTelemetry · Generative AI semantic conventions —— 目前唯一在推进「LLM 与 Agent 调用该记哪些字段」标准化的官方规范。本章第二节那张 trace 字段表,可以直接拿去和它的字段命名对照,省掉自己造一套私有 schema。
- SWE-bench —— 用「真实仓库的测试能不能跑通」当门禁的极端形态。想知道把门禁彻底程序化会变成什么样,看它的任务构造与判定方式。
延伸阅读
- Hamel Husain 的博客 —— 线上监控与离线评测怎么接成闭环,以及「评测不接进流程就等于没做」的多次讨论。本章的门禁分层与告警阈值,可以拿他的实践清单逐条核对。
◇ 面试官会怎么问
共 6 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
Trace 你会记录哪些字段? 高频
任务级——任务 ID、用户、入口、目标与约束、开始/结束时间、最终状态、总 token 与成本。
轮次级——轮次编号、模型与参数、完整的 prompt 组成(含系统提示版本号、注入的上下文片段及其来源、裁剪了什么)、模型原始输出、解析后的工具调用、耗时、输入/输出 token、缓存命中情况。
工具级——工具名与版本、参数、执行耗时、返回结果摘要与原始结果指针、错误分类、是否重试。
变更级——本次运行使用的 prompt / 工具 / 模型配置版本号。
两个容易被漏掉但很关键的字段:prompt 的各段来源与版本(否则改了提示词就再也复现不了旧结果)、以及裁剪/压缩的日志(否则你不知道模型当时"没看到"什么)。
追问链
- 存原始 prompt 全文会不会太大?
- 怎么按任务链路把多 Agent 的 trace 串起来?
Replay 有什么用?
① 复现——线上失败很难在开发环境重现(输入随机、上下文巨大、依赖外部状态),replay 让你用当时的完整输入重跑,把不可复现变成可调试。
② 对比——在同一个测试用例上跑旧版本与新版本(A/B replay),直接看出行为差异,这是判断改动是否有效的核心手段。
③ 回归——把有代表性的 trace 固化成回归用例,每次改动自动重放这批用例,形成门禁。
实现要点是把"可重放"作为架构约束而不是事后补的功能:所有外部依赖(模型、工具、检索)都要能在 replay 模式下用录制结果替换(mock/replay 层),否则外部状态一变,重放就不成立。
从线上 badcase 到回归门禁,完整闭环怎么建?
① 发现——通过用户反馈信号(重试、纠错、放弃、点踩)与系统指标异常(成功率下降、超时率上升)自动捞取候选 badcase。
② 归因——按失败原因打标分类(意图理解 / 参数生成 / 工具选择 / 上下文缺失 / 表达 / 系统故障),并区分"模型问题"与"系统问题"。
③ 固化——把可复现的案例整理成评测用例入库,保留原始 trace。
④ 修复——针对原因改对应的层(提示 / 工具描述 / 上下文策略 / 代码逻辑),而不是一律改提示词。
⑤ 回归——在固定评测集上跑,既有全量指标也要看新用例;不能有回归才允许进入下一步。
⑥ 灰度与回滚——按用户或租户灰度,监控线上指标与失败率,劣化自动回滚,正常后放量。
这个闭环能持续运转的前提是第 ③ 步要自动化一部分,否则人工整理会成为瓶颈,闭环很快就断了。
PR 阶段的门禁要求跑 40 分钟全量评测,团队开始加 skip-eval 标签。你怎么改? 高频
① 本地提交前:只跑解析器与错误分类的单元测试,10 秒级;② PR 创建后:只跑冒烟集(10 到 20 条)加静态检查,控制在 2 分钟内,不通过就阻塞合并;③ 合并到主分支后:跑回归集与边界集,15 分钟级,失败产生告警并自动建 issue;④ 发版候选:跑全量(回归、边界、对抗、保留池),可以到 1 小时,不通过阻塞发布。
关键判断是:门禁的价值不是「跑得多」,而是「跑得久」——一个总被绕过的门禁,价值等于零。宁可让 PR 阶段只拦最粗的低级错误,也不要让它变成必须跳过的流程。
追问链
- 冒烟集只跑 10 到 20 条,会不会漏掉真正的回归?
- skip-eval 标签要不要允许存在?如果允许,怎么加约束?
系统 A:完成率 90%、用户重试率 30%;系统 B:完成率 85%、重试率 8%。你选哪个上线?
完成率是代理指标,重试率是真实结果。用户重试一次,等于对上一次输出投了一张反对票;30% 的重试率意味着三成请求没有被一次接受——这通常就是「答得像但不对」的那类失败,而它恰好是完成率最难捕捉的:系统给出一个看似合理的答案,评测算通过,用户不满意。
把两个数一起读:A 的「一次就满意」大约是 63%(90% 乘 70%),B 大约是 78%(85% 乘 92%),B 明显更好。
还有一个附带收益:所有重试请求的原始 trace 天然就是一批高质量 badcase,B 的改进闭环会转得更快。如果必须在两者间取舍,我会把重试率写进发版门禁,而不是只盯完成率。
追问链
- 完成率用的是哪个口径,会影响你的判断吗?
- 如果 A 的重试集中在少数几个场景,你会改变结论吗?
Trace 里最容易被漏掉、但重放时最致命的是哪一类字段?
大多数团队会记请求体、工具调用和耗时——这些能让你知道发生了什么,但不够让你复原为什么。最常见的三种无法重放是:① 提示词改过了,只存了最终渲染出来的字符串,没存版本号,旧 trace 无法判断当时用的哪一版;② 上下文是动态拼的,检索片段、记忆、用户画像每轮都不同,不存来源就只能看到一个巨大的字符串;③ 裁剪没有留痕,你只知道模型没答对,不知道它当时没看到哪一段。
判据很简单:拿一条三个月前的 trace 出来,不改一行代码跑一遍,能不能复现同样的失败?不能,缺的就是这一类字段。
追问链
- 存全量提示词体积很大,有什么工程上的折中?
- 合规要求不能存原文时,怎么保证可重放?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。