评测跑完,99 条全 PASS,我本来准备合代码了,随手点开一条 trace,发现有个任务的内部路径和预期差了十万八千里。再一查,同样一个输入,两条 PASS 记录对应的执行轨迹根本不是同一个剧本。这件事让我意识到:PASS 只是结果层的绿灯,Agent 的稳定性还得看路径层的表现。
这篇文章记录的是我用 Jev 对 Agent trace 做逐 Span 评估的方法,核心要解决一个问题:为什么两个 Agent 任务都判了 PASS,走的路径却不一样。适合正在做 Agent 评测、稳定性治理和可观测建设的开发者参考。如果你手里的评测模型不是 Jev,用同类推理能力强的模型也能跑通这套逻辑,差别只在提示词和输出格式,整体方法可以直接复刻。
1. 同一个 PASS,内部却是两种剧本
1.1 PASS 只看结果,不看你绕了多少路
Agent 评测框架的通行做法是:给定任务,让 agent 跑一遍,把最终输出和预期答案做对比,满足条件就给 PASS。流程上这套东西没问题,但它有一个隐藏前提——我们默认 agent 能稳定地以合理路径完成任务。
实际真不是这样。在引入可观测之前,一个任务在 agent 内部走了哪些步骤,对我来说完全是黑盒。PASS 记录里没有中间步骤,没有工具调用的顺序,没有失败的尝试,也没有重试的次数。所以当 agent 第一次绕远路、又自己绕回来的时候,评分系统毫无反应。结果是对的,过程已经悄悄失控了。
单轮任务里,多绕路可能只是慢一点。但在带副作用的场景里,路径错误会造成不可逆影响:写错了数据库、给客户发了重复消息、调了不该调的审批流程。所以在 Agent 场景里,路径层面的审查不是洁癖,是必要的工程防线。
1.2 路径分叉的三个主要来源
分析了不少 PASS trace 之后,我把路径分叉的原因归纳成三类,几乎每个异常案例都能套进去:
| 来源 | 典型表现 | 识别特征 |
|---|---|---|
| LLM 采样随机性 | 温度大于 0 时,plan 步骤的表现不稳定 | 同一任务两次 trace,规划部分的结构不同 |
| 工具响应时序漂移 | 下游接口抖动、缓存命中率变化 | 某次查询超时或返回空,agent 被迫换方案 |
| 上下文污染 | 前面失败的工具结果留在上下文里,带偏后续决策 | 后续步骤引用了前面错误尝试的输出 |
最后一个最容易忽略。Agent 把失败的 tool_call 结果当作"已知信息"继续推理,结果就是绕了一大圈又回到正确路径。最终答案还是对,但中间已经多付了好几倍 token。
1.3 为什么用逐 Span 而不是直接看日志
日志是给人读的,trace 是给系统读的。日志散落在不同服务里,格式不统一,级别也乱;trace 有父子关系、有时间线、有统一字段,且天然适合转成程序可处理的序列。
逐 Span 评估的本质,是把"路径"从模糊的印象变成可以量化的序列。有了序列,就能做编辑距离对比、聚类、打分、告警。如果不逐 Span 看,你只能复述"好像绕路了",但说不清在哪一步开始偏离的。
2. 先有骨架再有证据:用 OpenTelemetry 把 Agent 拆成 Span
2.1 Agent 的每个阶段都要有锚点
要让逐 Span 评估成立,前提是 trace 里真的存在足够细的 Span。我建议给 Agent 运行时固定埋五类 Span:agent.plan、tool.call、memory.read、llm.response、agent.final。
Span 的核心价值是继承关系。父 Span 是任务本身,子 Span 是具体步骤,一条 trace 就是一棵树。后端的 trace 系统会把这些串成一个完整的时间线,测评时按时间展平就行。
属性方面有几样东西必须打上:task_id、tool_name、input_summary、output_summary、latency_ms、token_count、aborted。注意是 summary 而不是全量内容,后面喂给模型做评估时,过长的 attribute 会稀释判断力。
2.2 埋点模板(以 Python 运行时为例)
用 OpenTelemetry Python SDK 做局部埋点,大概长这样:
from opentelemetry import trace from opentelemetry.trace import Status, StatusCode tracer = trace.get_tracer("agent-runtime") def run_agent(task): # 规划阶段 with tracer.start_as_current_span("agent.plan") as plan_span: plan_span.set_attribute("task_id", task.id) plan_span.set_attribute("input_summary", summarize(task.prompt)[:200]) # 一次工具调用 with tracer.start_as_current_span("tool.call") as tool_span: tool_span.set_attribute("tool_name", "search_index") result = tool.search_index(task.query) tool_span.set_attribute("output_summary", result.summary[:200]) tool_span.set_attribute("latency_ms", result.latency_ms) tool_span.set_attribute("token_count", result.token_count) if not result.ok: tool_span.set_status(Status(StatusCode.ERROR, result.error_code)) # 最后一次模型生成 with tracer.start_as_current_span("llm.response") as llm_span: llm_span.set_attribute("token_count", response.usage.total_tokens) answer = response.text return answer要点:set_attribute 不要放完整日志,放摘要;长度统一截到 200 字符。这样评估 prompt 才能保持节奏一致,不会因为某一步输入太长抢占注意力。
2.3 把 Trace 转成能直接喂给模型的文本
OpenTelemetry Collector 导出的原生数据结构是 JSON,但 LLM 读文本比读嵌套 JSON 的稳定度高。所以我的流程里有一个固定转换层,把树形 trace 展平成阶梯文本:
Step 1 | span=agent.plan | tool=plan | input="查找合同编号并生成摘要" | output="先定位索引,再取摘要" | latency=420ms Step 2 | span=tool.call | tool=search_index | input="合同编号 GZ-2025-031" | output="命中合同记录 1 条" | latency=183ms Step 3 | span=llm.response | tool=llm | input="根据合同记录生成摘要" | output="已生成摘要" | latency=950ms Step 4 | span=agent.final | tool=final | output="摘要文本" | latency=1ms转换后不只有 span 类型,还有每一步的输入输出摘要、耗时代替。后面 Jev 就是拿这份文本做逐 Span 评估的。
3. 让 Jev 当 Trace 评审:逐 Span 评估的 Prompt 与评分逻辑
3.1 为什么路径评估必须用模型
规则引擎可以做 span 数量统计,但"这个工具调用是否必要"是语义层面的判断,规则写不出来。同样是查了两遍数据库,在数据一致性校验任务里算合理,在纯查询任务里就是明显的冗余步骤。
Jev 在这个流程里的角色是评审裁判:给它一条文本化的 trace,它输出结构化结论。如果你们团队没有 Jev,用具备强工具调用理解和推理能力的模型也可以,评估逻辑不变。
3.2 我用的两段式 Prompt
评估 prompt 我坚持两段式写法:系统段定角色和输出格式,用户段给任务和 trace 文本。
系统: 你是一名资深的 Agent 系统工程师,负责审查一条 Agent 执行轨迹。你的任务是逐 Span 检查每一步是否必要、是否高效、是否存在风险。 输出要求是 JSON,字段如下: steps: [{"index": 1, "span_kind": "...", "necessity": 1-5, "reason": "...", "risk": "..."}] path_score: 0-100 conclusion: "..." 用户: 任务描述:{task_description} 执行轨迹: {trace_text} 请逐 Span 评估。注意:necessity 是 1 到 5 的整数,5 表示完全必要;如有冗余或风险步骤,请在 reason 中说明证据链。为什么要求 JSON 输出?因为后续要做指标聚合。模型自由发挥的文本看着爽,但没法自动算平均分、没法持续追踪趋势。结构化的 output 才是工程上可用的。
一份典型的 Jev 输出长这样:
{ "steps": [ {"index": 1, "span_kind": "agent.plan", "necessity": 5, "reason": "规划简洁,直接指向索引检索", "risk": "none"}, {"index": 2, "span_kind": "tool.call", "necessity": 5, "reason": "按编号精确检索,命中正确记录", "risk": "none"}, {"index": 3, "span_kind": "tool.call", "necessity": 2, "reason": "重复检查同一记录,无新增信息", "risk": "redundant_query"}, {"index": 4, "span_kind": "llm.response", "necessity": 5, "reason": "基于检索结果正常生成摘要", "risk": "none"} ], "path_score": 78, "conclusion": "整体可完成任务,但存在一次冗余重复查询,建议检查 agent 是否缺少缓存去重逻辑。" }3.3 从单条评估到指标聚合
拿到 JSON 后,我会提取四个指标:平均 necessity、冗余 span 数量、风险 span 数量、path_score。
路径偏离度我单独算,不直接依赖 Jev 的结论。做法是:对每个任务,取历史 PASS trace 里的 span kind 序列众数作为基准序列,再把当前 trace 的 span kind 序列(忽略工具参数,只保留步骤类型)与基准做编辑距离,除以较长序列的长度做归一化。偏离度超过 0.3 我就认为这条 PASS 值得人工看一眼。
这样 PASS 不再是最终答案的附庸,而成了路径质量的一个切面。
4. 复盘一次"绕路 PASS":Span 级对比让我找到根因
4.1 现象:全部 PASS,但路径明显分成两簇
有一次跑回归,200 次"查找合同编号并生成摘要"任务,全部 PASS。我顺手把 span kind 序列做了聚类,发现明显分成两簇。
簇 A 占比 82%,序列很短,都是"规划 + 精确检索 + 生成摘要 + 结束"。簇 B 占比 15%,序列明显变长,里面多出了"全表扫描失败 + 重试 + 二次检索"这几步。剩下几个零星样本属于抖动。
如果只盯 PASS 率,这里不会有任何动作。但路径占比的变化已经在提示:某些决策点开始不稳定了。
4.2 两份 Trace 的逐 Span 对照
我挑了两条典型 trace 并排对比,差异非常直观:
| Step | 簇 A(正常路径) | 簇 B(绕路路径) | 耗时对比 | 评审关注点 |
|---|---|---|---|---|
| 1 | 规划:直接按合同编号检索 | 规划:考虑扫描数据总表 | 相近 | 目标选择是否准确 |
| 2 | 精确检索索引表,命中 | 调用全表扫描工具 | B 多 3.8s | 工具选择合理性 |
| 3 | 生成摘要 | 全表扫描超时失败 | B 多 7.2s | 失败后的恢复策略 |
| 4 | 输出结果 | 重试,改用索引检索 | B 多 1.4s | 是否及时纠偏 |
| 5 | - | 命中合同记录 | - | - |
| 6 | - | 生成摘要 | - | - |
| 7 | - | 输出结果 | - | - |
两份 trace 最终都 PASS,但簇 B 平均多了 12.4 秒,多花费近 60% 的工具调用 token。这是结果评分完全感知不到的代价。
4.3 Jev 的评估结果怎么定位根因
簇 B 的 path_score 平均只有 61,簇 A 是 92。Jev 在评估报告里给 Step 2 的 necessity 打了 2 分,理由很直白:"该任务明显适用于按合同编号精确检索,全表扫描并不能直接返回摘要所需字段,属于工具选择错误。"
拿到这个结论后,我去查了工具描述和 embedding 配置,发现"合同编号索引"和"数据总表"两个工具在向量空间里距离过近,模型经常在两者之间摇摆。修复方案是改写工具描述,突出精确检索工具的适用边界,并给全表扫描工具加了显式前置条件:"仅适用于无法确定主键时"。
修复后重新回归,簇 B 占比降到 3% 以下,path_score 均值也上来了。这就是逐 Span 评估真正的价值:它不看最终答案,而是直接指出你在第几个 Span、哪个决策点上出了问题。
5. 把逐 Span 评估落成常态:路径健康分与告警
5.1 批量评估的工程姿势
逐 Span 评估不能只做临时复盘,要固化成持续运行的评估任务。
我的做法是独立异步 job,不阻塞 agent 主流程。每天定时从 trace 系统拉取当天所有 trace,按抽样策略取子集,批量交给 Jev,结果落到指标表。
指标表至少包含这些字段:
| 字段 | 含义 | 用途 |
|---|---|---|
| task_id | 任务标识 | 关联原始 trace |
| path_score | 路径质量分 | 人工复查排序 |
| step_count | span 数量 | 辅助判断复杂度 |
| necessity_avg | 平均必要性评分 | 冗余程度趋势 |
| risk_spans | 带风险标记的 span 数 | 告警依据 |
| path_deviation | 与基准路径的偏离度 | 新行为监测 |
5.2 路径健康分怎么用
我设了两条线:path_score 低于 70 自动进入"需要人工复查"队列;低于 50 直接告警。告警不直接发给全部人,只在"结果 PASS 但路径健康分下降"时触发。
为什么只告警 PASS 场景?因为 FAIL 本来就会被测试系统盯住,不需要路径评估再插一脚。真正有价值的信息是"结果全绿,但仍然有新行为出现"——这说明行为漂移已经开始,但结果评测还没来得及捕捉。
5.3 和现有评测体系的分工
路径健康分不是替代结果正确性评测,是叠加层。结果评测回答"答没答对",路径评估回答"走得稳不稳"。
我建议把评估任务挂在 CI 的 post-test 阶段,每天跑一次。这样既不会拖慢每次提交的反馈速度,又能保证行为漂移在一天内被发现。出现新路径簇时再临时触发全量逐 Span 评估,精确打击,不浪费 token。
6. 实践经验:逐 Span 评估的成本、陷阱和最佳姿势
6.1 先算账再开工
逐 Span 评估是真花钱的,要提前算一下。
假设一条 trace 有 15 个 span,每个 span 的摘要约 300 token,转换后的 trace 文本大约 5k token。加上系统 prompt、任务描述和模型输出,一次评估大概 8k token。200 条 trace 全量评估就是 1.6M token,成本不小。
我的采样策略是:每天晚上拉当天生产环境的 trace,失败和超时的全部评估,其余随机抽 10%;出现路径健康分骤降或新路径簇时,临时把采样率提到 100%。这样既能看到全局趋势,又不会把预算烧在重复的正常路径上。
6.2 三个容易踩的坑
坑一:把完整输入输出塞进 trace 文本。span 超过 30 个时上下文会过长,评估质量明显下降。解法是把每个 span 的输入输出摘要统一截到 200 字符以内。
坑二:把 agent 内部日志的 error 字段直接丢给评审模型。agent 正常的失败重试会被当成风险步骤,误报率飙升。解法是在喂给 Jev 之前,先做错误归一化:区分"预期内重试"和"异常错误"。
坑三:并发评估时触发评测 API 限流。批量脚本一次性丢 50 条过去,直接 429。解法是并发池限制在 5 以内,配合指数退避重试。
提示:逐 Span 评估的价值在于定位决策点,而不是重复"最终答案对比"。不要把 trace 里的细节全量丢进去,越聚焦效果越好。
6.3 我的一点体会
最有用的工作习惯是"先看统计、再选样本、后逐 Span"。全量逐 Span 会产出大量低信息量结果,先看分布能帮你判断该把评估火力集中在哪批 trace 上。
另外,路径健康分的权重一定要结合业务调。我这边带写操作的工具任务,necessity 的权重会调得更高,宁可误报,也要拦住高风险路径。
路径评估做久了你会发现,PASS 从一个"结束信号"变成了"开始信号"。每次看到全绿的结果,你还会忍不住追问一句:它到底是怎么走过来的?