Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
III 版 · 评测工程 第 05 章 Closing the Loop
III · 评测工程 05 / 22 进阶

Trace、Replay 与线上闭环

Closing the Loop
预计阅读 28 分钟
难度 进阶
关键词 Trace · Replay
本机状态 未读

出了问题查不到原因,是因为没有 trace;改了一版不敢上线,是因为没有 replay。这一章把「线上 badcase → 评测集 → 回归门禁 → 灰度发布」这条闭环接起来。接上之后,评测才从一份报告变成一套能挡住问题的机制。

编辑部注 · 本章是全站最有「系统感」的一章。面试里讲清这条闭环,等于证明你做过完整的质量工程,而不只是写过评测脚本。

前面四章分别解决了「评什么维度」「用哪些样本」「指标怎么算」「机器打分怎么可信」。但还有一个更根本的问题:这套东西怎么才能真的挡住问题,而不是变成每季度产出一份没人看的报告。

一、闭环的五个环节

质量闭环:五个环节,一个都不能断 ① Trace 全链路记录 每次请求记录: · 完整上下文(脱敏后) · 每次工具调用的参数与返回 · token 数、延迟、重试次数 要求:能完整重放(replay) ② 归因 定位到具体环节 按环节切分失败原因: · 理解错(意图识别) · 选错工具 / 填错参数 · 工具本身故障 / 数据问题 产出:归因分布,而非「失败率」 ③ 入集 沉淀为回归样本 只纳入「工程问题」类: · 标注期望行为 · 写明加入原因与来源 · 研究类问题单独归档 否则回归集永远修不完 ④ 门禁 把评测接进 CI 提交前(每次 PR,2 分钟内必须跑完): · 冒烟集 100% 通过(硬性) · 关键单测(解析器、错误分类) 发版前: · 回归集不得低于基线;保留池结果作为决策依据 ⑤ 灰度 用小流量验证真实分布 1% → 5% → 20% → 全量,每个档位观察: · 任务完成率、平均轮数、成本 / 次 · 用户主动重试率、中断率(最有价值的负向信号) 回滚条件要事先写死,不能临时决定 · 例:完成率跌幅 > 3pct 或成本涨幅 > 20% → 立即回滚 灰度中发现的新 badcase 回到 ① Trace —— 闭环成立 闭环断在哪,问题就出在哪 断在 ① → 出了问题只能靠猜(最常见,也最致命) 断在 ② → 知道有多少失败,但不知道该改哪一层,改进靠碰运气 断在 ③ → 修过的问题反复出现(回归集没长起来) 断在 ④ → 评测只是一份报告,不构成约束力
图 1 评测闭环的五个环节。最容易断的是 ① 和 ④:没有可重放的 trace,一切归因都是猜;评测没接进 CI,它就没有约束力,改动照样能合进去。

二、Trace 的设计:为「重放」而记录

Trace 的价值不是「记录发生了什么」,而是「能在本地重现当时发生了什么」。这个区别决定了你要记录什么。

Trace 必须记录的七类字段(按重要性排序)
#字段为什么必须
1完整的请求体(脱敏后)没有它就无法重放。这是 trace 的核心,其余都是辅助
2模型与版本标识换模型/换版本后的行为差异只有对比才能发现
3每次工具调用的名称、参数、返回、耗时归因的最主要依据——多数失败发生在工具环节
4上下文各部分的大小(指令/知识/状态各占多少)定位「上下文膨胀」类问题的唯一手段
5重试与失败的完整序列(含错误分类)区分「一次就对」与「重试三次才对」——后者是隐患
6关键时间戳(TTFB、每次 chunk、总时长)定位卡死与性能问题的唯一依据
7用户后续行为(是否重试、是否中断、是否手动修正)这是最强的质量信号——用户用脚投票比任何评分都真实
第 7 项被严重低估

「用户重试率」和「用户中断率」是最接近真相的质量指标。原因很简单:模型评分再准也只是代理指标,用户行为是真实结果。一个完成率 90% 但用户重试率 30% 的系统,实际体验远差于完成率 85%、重试率 8% 的系统。

建议把这两个信号当作一等指标,与完成率并列观察。它们还能自动帮你挖掘 badcase——所有重试请求的原始 trace,就是一份高质量的问题样本来源。

三、动手:Trace 与重放

trace_replay.pypython
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 章)。正确做法是把哪些分层指标不允许下降写进配置——尤其是对抗集,安全类的退步不能因为总体分数上升而被容忍。

五、自测

本章自测第 2 题为高区分度题
Q1为什么「用户重试率」和「中断率」是很有价值的质量信号?
B。一个完成率 90% 但重试率 30% 的系统,体验远差于完成率 85%、重试率 8% 的系统——这个例子能很好说明代理指标与真实结果的差距。而且这两个信号自带 badcase 挖掘功能:所有重试请求都值得看一眼。
Q2PR 阶段的门禁应该跑多少评测?
D。这是很实用的工程经验:门禁的可用性和严格性需要平衡。让开发者每次提交等 40 分钟,结果一定不是「质量更好」,而是「大家想办法跳过」。分层设计让每一层的耗时与其约束力匹配,门禁才能长期存活。
Q3为什么要把 badcase 分成「工程问题」与「研究问题」分别处理?
C。关键在「门禁的可维护性」:如果回归集里混着一批「模型能力不足导致」的样本,这些样本会长期失败,团队就不得不为它们开白名单、加例外——一旦开始加例外,门禁就废了。研究类问题应该单独归档,作为模型侧改进的输入,而不是回归门槛。

六、小结

环节关键要求
Trace为「可重放」而记录:完整请求体、工具调用、上下文大小、重试、时间戳、用户行为
归因定位到具体环节(参数错 / 超时 / 上下文膨胀 / 无进展),产出归因分布而非单一失败率
入集只纳入工程问题;标注期望行为;写清来源与原因
门禁分层设计,PR 只跑冒烟(2 分钟内),发版前跑全量;不允许下降的分层指标要写进配置
灰度回滚条件事先写死;关注完成率、重试率、成本三项
最强信号用户重试率 / 中断率——真实结果,且自带 badcase 挖掘

评测没有接进 CI,就只是文档。文档不会挡住任何一个坏改动。本刊编辑部

评测工程部分结束。最后一部分回到「回答必须有据可查」这个问题:知识怎么建模、怎么检索、怎么让人信。

面试官会怎么问

共 3 条 · 其中 1 条高频 · 先自己答一遍,再展开对照

Trace 你会记录哪些字段? 高频
目标是让任何一次失败都能被完整复现,所以按"能 replay"的标准来设计:
任务级——任务 ID、用户、入口、目标与约束、开始/结束时间、最终状态、总 token 与成本。
轮次级——轮次编号、模型与参数、完整的 prompt 组成(含系统提示版本号、注入的上下文片段及其来源、裁剪了什么)、模型原始输出、解析后的工具调用、耗时、输入/输出 token、缓存命中情况。
工具级——工具名与版本、参数、执行耗时、返回结果摘要与原始结果指针、错误分类、是否重试。
变更级——本次运行使用的 prompt / 工具 / 模型配置版本号。
两个容易被漏掉但很关键的字段:prompt 的各段来源与版本(否则改了提示词就再也复现不了旧结果)、以及裁剪/压缩的日志(否则你不知道模型当时"没看到"什么)。

追问链

  1. 存原始 prompt 全文会不会太大?
  2. 怎么按任务链路把多 Agent 的 trace 串起来?
Replay 有什么用?
Replay 是"把一次线上运行在本地原样重放"的能力,它解决三个具体问题:
① 复现——线上失败很难在开发环境重现(输入随机、上下文巨大、依赖外部状态),replay 让你用当时的完整输入重跑,把不可复现变成可调试。
② 对比——在同一个测试用例上跑旧版本与新版本(A/B replay),直接看出行为差异,这是判断改动是否有效的核心手段。
③ 回归——把有代表性的 trace 固化成回归用例,每次改动自动重放这批用例,形成门禁。
实现要点是把"可重放"作为架构约束而不是事后补的功能:所有外部依赖(模型、工具、检索)都要能在 replay 模式下用录制结果替换(mock/replay 层),否则外部状态一变,重放就不成立。
加分点:"把可重放做成架构约束而非事后补功能",是有系统设计经验的人才会说的。
从线上 badcase 到回归门禁,完整闭环怎么建?
六步闭环:
① 发现——通过用户反馈信号(重试、纠错、放弃、点踩)与系统指标异常(成功率下降、超时率上升)自动捞取候选 badcase。
② 归因——按失败原因打标分类(意图理解 / 参数生成 / 工具选择 / 上下文缺失 / 表达 / 系统故障),并区分"模型问题"与"系统问题"。
③ 固化——把可复现的案例整理成评测用例入库,保留原始 trace。
④ 修复——针对原因改对应的层(提示 / 工具描述 / 上下文策略 / 代码逻辑),而不是一律改提示词。
⑤ 回归——在固定评测集上跑,既有全量指标也要看新用例;不能有回归才允许进入下一步。
⑥ 灰度与回滚——按用户或租户灰度,监控线上指标与失败率,劣化自动回滚,正常后放量。
这个闭环能持续运转的前提是第 ③ 步要自动化一部分,否则人工整理会成为瓶颈,闭环很快就断了。
加分点:指出"闭环的瓶颈通常在人工整理用例这一步,所以必须部分自动化",非常贴近真实工程。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes