出了问题查不到原因,是因为没有 trace;改了一版不敢上线,是因为没有 replay。这一章把「线上 badcase → 评测集 → 回归门禁 → 灰度发布」这条闭环接起来。接上之后,评测才从一份报告变成一套能挡住问题的机制。
编辑部注 · 本章是全站最有「系统感」的一章。面试里讲清这条闭环,等于证明你做过完整的质量工程,而不只是写过评测脚本。
前面四章分别解决了「评什么维度」「用哪些样本」「指标怎么算」「机器打分怎么可信」。但还有一个更根本的问题:这套东西怎么才能真的挡住问题,而不是变成每季度产出一份没人看的报告。
一、闭环的五个环节
二、Trace 的设计:为「重放」而记录
Trace 的价值不是「记录发生了什么」,而是「能在本地重现当时发生了什么」。这个区别决定了你要记录什么。
| # | 字段 | 为什么必须 |
|---|---|---|
| 1 | 完整的请求体(脱敏后) | 没有它就无法重放。这是 trace 的核心,其余都是辅助 |
| 2 | 模型与版本标识 | 换模型/换版本后的行为差异只有对比才能发现 |
| 3 | 每次工具调用的名称、参数、返回、耗时 | 归因的最主要依据——多数失败发生在工具环节 |
| 4 | 上下文各部分的大小(指令/知识/状态各占多少) | 定位「上下文膨胀」类问题的唯一手段 |
| 5 | 重试与失败的完整序列(含错误分类) | 区分「一次就对」与「重试三次才对」——后者是隐患 |
| 6 | 关键时间戳(TTFB、每次 chunk、总时长) | 定位卡死与性能问题的唯一依据 |
| 7 | 用户后续行为(是否重试、是否中断、是否手动修正) | 这是最强的质量信号——用户用脚投票比任何评分都真实 |
「用户重试率」和「用户中断率」是最接近真相的质量指标。原因很简单:模型评分再准也只是代理指标,用户行为是真实结果。一个完成率 90% 但用户重试率 30% 的系统,实际体验远差于完成率 85%、重试率 8% 的系统。
建议把这两个信号当作一等指标,与完成率并列观察。它们还能自动帮你挖掘 badcase——所有重试请求的原始 trace,就是一份高质量的问题样本来源。
三、动手: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,
}四、回归门禁:让评测有约束力
这是闭环里最容易被省略、也最关键的一环。没有门禁的评测只是报告。
| 阶段 | 跑什么 | 耗时预算 | 不通过的后果 |
|---|---|---|---|
| 本地提交前 | 解析器与错误分类的单元测试 | < 10 秒 | 开发者自己发现问题,不浪费 CI 资源 |
| PR 创建后 | 冒烟集(10-20 条)+ 静态检查 | < 2 分钟 | 阻塞合并——没有例外 |
| 合并到主分支后 | 回归集 + 边界集 | < 15 分钟 | 产生告警,并自动创建 issue |
| 发版候选 | 回归 + 边界 + 对抗 + 保留池 | 可到 1 小时 | 阻塞发布;主指标回归必须人工确认才能放行 |
| 上线后 | 线上指标监控(完成率、重试率、成本) | 持续 | 触发回滚条件则自动回滚 |
坑一:把门槛设得太高,导致人人都想办法绕过它。如果 PR 阶段要跑 40 分钟,团队很快就会加 skip-eval 标签。正确做法是分层:PR 只跑最关键的冒烟集(2 分钟内),重的评测放到合并后与发版前。
坑二:只卡「总体指标」。这样很容易被「总体提升、局部退化」的改动骗过去(见第 2 章)。正确做法是把哪些分层指标不允许下降写进配置——尤其是对抗集,安全类的退步不能因为总体分数上升而被容忍。
五、自测
六、小结
| 环节 | 关键要求 |
|---|---|
| Trace | 为「可重放」而记录:完整请求体、工具调用、上下文大小、重试、时间戳、用户行为 |
| 归因 | 定位到具体环节(参数错 / 超时 / 上下文膨胀 / 无进展),产出归因分布而非单一失败率 |
| 入集 | 只纳入工程问题;标注期望行为;写清来源与原因 |
| 门禁 | 分层设计,PR 只跑冒烟(2 分钟内),发版前跑全量;不允许下降的分层指标要写进配置 |
| 灰度 | 回滚条件事先写死;关注完成率、重试率、成本三项 |
| 最强信号 | 用户重试率 / 中断率——真实结果,且自带 badcase 挖掘 |
评测没有接进 CI,就只是文档。文档不会挡住任何一个坏改动。本刊编辑部
评测工程部分结束。最后一部分回到「回答必须有据可查」这个问题:知识怎么建模、怎么检索、怎么让人信。
◇ 面试官会怎么问
共 3 条 · 其中 1 条高频 · 先自己答一遍,再展开对照
Trace 你会记录哪些字段? 高频
任务级——任务 ID、用户、入口、目标与约束、开始/结束时间、最终状态、总 token 与成本。
轮次级——轮次编号、模型与参数、完整的 prompt 组成(含系统提示版本号、注入的上下文片段及其来源、裁剪了什么)、模型原始输出、解析后的工具调用、耗时、输入/输出 token、缓存命中情况。
工具级——工具名与版本、参数、执行耗时、返回结果摘要与原始结果指针、错误分类、是否重试。
变更级——本次运行使用的 prompt / 工具 / 模型配置版本号。
两个容易被漏掉但很关键的字段:prompt 的各段来源与版本(否则改了提示词就再也复现不了旧结果)、以及裁剪/压缩的日志(否则你不知道模型当时"没看到"什么)。
追问链
- 存原始 prompt 全文会不会太大?
- 怎么按任务链路把多 Agent 的 trace 串起来?
Replay 有什么用?
① 复现——线上失败很难在开发环境重现(输入随机、上下文巨大、依赖外部状态),replay 让你用当时的完整输入重跑,把不可复现变成可调试。
② 对比——在同一个测试用例上跑旧版本与新版本(A/B replay),直接看出行为差异,这是判断改动是否有效的核心手段。
③ 回归——把有代表性的 trace 固化成回归用例,每次改动自动重放这批用例,形成门禁。
实现要点是把"可重放"作为架构约束而不是事后补的功能:所有外部依赖(模型、工具、检索)都要能在 replay 模式下用录制结果替换(mock/replay 层),否则外部状态一变,重放就不成立。
从线上 badcase 到回归门禁,完整闭环怎么建?
① 发现——通过用户反馈信号(重试、纠错、放弃、点踩)与系统指标异常(成功率下降、超时率上升)自动捞取候选 badcase。
② 归因——按失败原因打标分类(意图理解 / 参数生成 / 工具选择 / 上下文缺失 / 表达 / 系统故障),并区分"模型问题"与"系统问题"。
③ 固化——把可复现的案例整理成评测用例入库,保留原始 trace。
④ 修复——针对原因改对应的层(提示 / 工具描述 / 上下文策略 / 代码逻辑),而不是一律改提示词。
⑤ 回归——在固定评测集上跑,既有全量指标也要看新用例;不能有回归才允许进入下一步。
⑥ 灰度与回滚——按用户或租户灰度,监控线上指标与失败率,劣化自动回滚,正常后放量。
这个闭环能持续运转的前提是第 ③ 步要自动化一部分,否则人工整理会成为瓶颈,闭环很快就断了。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。