从 trace 到 eval:trace 设计、Agent 评测方法论、一个评测 harness 的实现,和它抓到的上游并发 bug
做 Agent 工程质量,我认一条主线:trace 是一等公民,测试断言、评测数据都从它而来。这条主线在近期 deepseek harness 发布之后,被我总结成为一个开源项目 dsh-eval-harness——给 dsh 插件跑回归的门禁工具。它最近在回归测试 DeepSeek Harness 时抓到一个上游自己都没发现的并发崩溃:三个进程同时启动,两个在 270 毫秒内 ENOENT 崩掉。
这篇文章按一条线走:trace 怎么设计才对(地基)→ 拿 trace 做 eval 要防什么(方法)→ 评测 harness 的关键决策与实现管线(工程)→ 它抓到上游 bug 的全过程(战果)。
一、trace 要在项目第一天就做
这个认知,是三个项目用三种姿势分别验证出来的。一个从第一天就做对了,一个晚了两个月补票,一个把它推到了我没预想到的远度。三个都匿名,但每个坑都是真的。
做对的:让「界面状态」成为可断言对象
最早做的是一个 Swift 终端 UI 库,渲染 AI 流式输出用的。终端程序有个经典难题:界面状态没法复现——断言脆弱的字节流等于没测,渲染逻辑一重构测试就全红。
这个项目的解法是第 2 个里程碑就内建了一个 VirtualTerminal:一个能在内存里解释 ANSI 转义序列的虚拟终端(字符网格 + 光标 + CSI 序列解析)。测试不再断言「输出了什么字节」,而是断言「最终屏幕状态」。配合事件溯源式的渲染模型——UI 状态完全由事件流驱动,不绑定任何业务模型——渲染的每个关键决策都变成可断言的诊断事件:什么时候全量重绘(带原因)、什么时候走增量更新、快速路径追加了多少行。
注意这里的 trace 形态不是一个日志框架,而是一个默认关闭的可选回调:库不写文件、不引依赖,「记到哪」完全交给消费方——未启用时不产生任何 I/O 和持久化开销。
这个库现在 793 个测试,渲染逻辑没有黑盒。它还抓过一次性能退化:热点分析发现宽度计算函数在混宽字符负载下的耗时是 ASCII 的 2.83 倍;修掉热点后,整条渲染 pipeline 的基准耗时下降约 15.5%,这个数字直接固化成了 CI 门禁——观测体系建得早,性能回归才有判据。
补票的:晚做两个月的代价
第二个项目是一个 Swift 原生 AI coding agent,我的反面教材。
项目启动时没有任何可观测性,全项目 3 处 print。两个月后我专门开了一个阶段补建全链路 trace:span 三元组(traceID/spanID/parentSpanID)、层级埋点从会话根一路到工具执行和 HTTP 请求、跨层父子关系用 @TaskLocal 隐式传播(不污染工具协议接口)、内置脱敏器、默认 NoOp、文件日志滚动截断。
补建本身很认真,但一接入就暴露了晚做的代价:CHANGELOG 里记录了一批 provider 的 span 生命周期 bug——有的 provider 在产出最终结果之后才结束 span,trace 时长系统性失真。这类 bug 在「没有 trace 就看不见」的状态里活了两个月。教训最后固化成这个项目的硬规则:所有 span 必须在事件流对外产生最终结果之前结束——注意这是该项目「trace 时长必须反映真实处理时间」的特定契约,不是通用原则;span 该在哪结束取决于你想观测的是生成、序列化还是分发耗时。
补得越认真,越说明该在第一天做。后补 trace 的成本不只是补代码,还有那段时间里你看不见的一切。
推到最远的:trace 即运行时
第三个项目是一个 Rust 写的个人 agent runtime,它把「trace 是一等公民」推到了我没预想到的远度:trace 不是旁路记录,是运行时状态机本体——计划步骤的状态直接由 trace 对象承载,写 trace 和推进状态是同一个动作。这带来一个必须显式接受的取舍:trace 写入失败时主流程只能 fail-fast,没有「静默降级」这条旁路——这是它和「零开销 NoOp」哲学的天然张力,选哪个取决于 trace 在你的系统里是数据还是状态。
每次运行落盘四个产物:机器读的完整 JSON(约 50 个字段,含每次 LLM 调用的完整提示词快照)、人读的 Markdown、只追加的索引、每日总览表。落盘格式带版本号,配合 serde 的 #[serde(default)] 与字段版本化策略保证旧报告永远可读——兼容不是 serde 白送的,是一组显式的工程决策。
它最有意思的是自我进化的方式:eval 流程会反向发现 trace 的字段缺口。有一次评测发现「恢复行为不可归因」——agent 重试了,但 trace 里看不出它做了什么恢复动作——于是补齐 recovery_action、replan_count 字段。trace 养 eval,eval 养 trace。
这个项目还沉淀出一个四层模型:日志 → 归因 → 评测 → 决策(Log → Attribution → Evaluation → Decision)。日志只是原料,归因成指标(成功率、回退率、上下文丢弃率……),指标进门禁,门禁决定合并。门禁从软到硬还有量化晋升门槛:连续 5 次 PR 无 FAIL、误报 WARN 不超过 20%,才允许升级——不是「感觉稳定了」,是「数据证明稳定了」。
好 trace 的标准
三个项目看下来,标准就浮出来了:
- 结构化、稳定的事件模型:事件名和字段先收住,比日志抽象做漂亮更重要;格式带版本号,旧数据永远可读
- 默认近乎零开销:不启用时是 NoOp(无 I/O、无持久化),诊断失败永远不许拖垮主流程
- 脱敏内建,但边界要说清:已识别的敏感模式(API key、token、家目录路径)在写入前遮蔽;但 prompt、工具参数、工具结果里可能携带任意敏感数据,trace 文件整体仍要按敏感数据对待
- trace 即测试断言:拓扑结构、错误分类、时序不变量都能拿 trace 回归——不进入测试体系的 trace 只是安慰剂
- 允许被评测反向塑造:字段缺口由 eval 流程发现,trace 和 eval 是共生关系
二、假通过:被测对象会装过,评测器也会
拿 trace 做 eval,最大的陷阱是假通过,至少三个形态。
第一种,agent 兜底答对,能力根本没被调用。你问它图片尺寸,它没调 read_image,用 bash 的 file 命令兜底答对了——结构断言全绿,但你想测的能力零覆盖。防御方式是断言路径而不是结果:tools_called 锁定必须真实调用,no_tool_errors 锁定工具必须真实成功,结果文本必须包含只有真实调用才会出现的标记。用例注释写得很直白:「即使 agent 用 bash 兜底答对也判 fail——防止假通过」。
第二种,工具报错被 agent 粉饰太平。工具返回了 error,agent 当作没看见,最终回答依然自信,单看最终文本完全正常。所以 trace 里的工具硬错误要单独提取出来,任何一条都足以判 FAIL。
第三种最容易被忽略:评测器自身的盲区。我们真踩过——harness 有个版本里,dsh 子进程崩了(exit≠0)但 trace 断言全过,用例照样 PASS;还有一次筛选条件笔误导致用例零命中,CI 空跑却显示绿色。现在的防御是:非零退出永远不许 PASS、筛选无命中直接报错、trace 解析跳行数增长要告警——因为断言可能跑在残缺数据上。
三、judge 也要被校准
结构断言是确定性的,可信。但语义断言(judge)是另一个 LLM,它也会犯错——而且它的漏判会直接变成门禁的假绿。这是「评测器盲区」的另一种形态:不是看不见,是看见了但判错。
校准的方法论不复杂,复杂在纪律。从真实报告里抽几十条输出,逐条亲手标 PASS/FAIL,让 judge 跑同一个集合,然后分开看两个数:TPR(真失败被抓到的比例)和 TNR(真通过没被冤枉的比例)。为什么不能只看总一致率——假设样本里 90% 都是 PASS,一个什么都放行的橡皮图章 judge 也能拿 90% agreement,但它漏掉了全部真实失败。Eugene Yan 对 LLM judge 的实测(《Evaluating the Effectiveness of LLM-Evaluators》)给出过一组对照:judge 在多数类样本上表现极好,在少数类(真实失败)上召回率大幅下降——正是总一致率掩盖的盲区。
两个纪律补充。其一,样本量决定可信度:真失败样本只有 10 条时,TPR=0.9 的 95% 置信区间大约宽到 0.55–1.0,「过线」没什么统计意义——要么把校准集扩到上百条、两类样本都充足,要么报告里带上分母(或 Wilson 置信区间),别只报点估计。其二,校准集和验证集分开:拿调过 rubric 的同一批数据做最终验证,分数会虚高。
校准还逼我修了一个 judge 自身的格式问题。同行给过我一个忠告:千万别让 LLM 先给答案——它会基于答案编理由,哪怕答案是错的。harness 的 judge 最初就是「首行判定、次行理由」的格式,这个格式本身就在诱导先定论后粉饰。改成「先写分析、末行判定」之后,至少格式诱导被消除了——当然,模型仍然可能先有了结论再补一份像样的分析,格式改变不了动机,只能不给它偷懒的借口。更硬的做法是要求分析引用 trace 里的具体工具调用,并对引用做二次校验,这我还在权衡。
四、harness 的关键决策,每个背后都有事故
dsh-eval-harness 的结构一句话:写 yaml 用例,headless 驱动真实 agent 会话,解析落盘的会话 trace,断言,对比基准出门禁。结构平淡,决策都在细节里。
写完之后我对着业界的公开实践(awesome-evals 这类社区清单和它们的实操手册)逐项核对过一遍:结构断言先行、二元判定、试验隔离、基准对比门禁、版本化报告、成本阈值——这些主流做法和 harness 的现有设计全部对得上。核对的价值不在自我确认,在于知道自己站在什么坐标系里:哪些是共识,哪些是我自己的选择。
最重要的决策是读真实 trace,不用 LLM 替身。替身的代价是测的不再是真实系统:提示词组装、工具协议、模型行为全被换掉;读落盘 trace 的代价是每次全量跑要烧真 token(12 条用例约 12 万,deepseek-chat 量级下成本可以忽略)。我选后者——评测的意义就是验证真实链路,替身测通过的系统上线照样崩。
第二个值得说的是重跑卫生。harness 支持失败重跑来治理偶发失败,但有个隐蔽陷阱:上一次尝试的文件副作用会让重跑假通过——用例要求「创建文件」,重跑时文件已经在那儿了。所以每条用例的工作区在每次尝试前清空重建;会话根目录复用,但按本次尝试的起点过滤,只采集这一次的 trace。
第三个是把「遮羞布」和「尺子」分开。失败重跑是遮羞布:它回答「这个用例最终能不能过」,让门禁不被偶发抖动打红。但它回答不了「这个用例单次成功率是多少」——我的一条用例(todo-tool)真实出现过首跑直接不调工具、重跑才过的情况,报告里只有一个不起眼的 flaky 标记。所以 harness 另有 trials 模式:跑满 n 次独立尝试、每次清工作区、不许重试,报告写出单次成功率、pass@k(给 k 次机会至少成一次的概率,用无偏组合估计)和 pass^k(k 次全成的概率)。这两个数会讲完全相反的故事:单次成功率 0.75 的用例,pass@10 约等于 1.0(看起来完美),pass^10 约为 0.056(几乎必挂)。两个前提必须说清:这些数基于「每次尝试独立同分布」的假设——真实环境里的限流、网络、模型状态漂移都会破坏这个假设;而且小样本的估计是估计,不是真实成功率,报告里必须带着 n 看。遮羞布管 CI 绿不绿,尺子管你敢不敢信它。
还有一个工程决策一句话带过:零依赖 YAML 子集解析器——用例格式是自定义的 yaml 子集,内置解析器只认这个子集,写超纲语法立刻报带行号的错。不引第三方库,一是供应链面最小化,二是用例格式完全可控。
五、实现管线:行为是怎么一步步变成数据的
管线四步:驱动 → 采集 → 提取 → 门禁。每一步都有真实的坑。
驱动层的基本动作是 fork 子进程:dsh --profile headless --patch <overlay> <提示词>。三个设计点。其一,隔离发生在配置层:每条用例需要独立的会话落盘根,最省事的写法是给子进程塞环境变量——但环境变量会泄漏到 agent 调用的工具里,污染被测行为,所以 harness 为每条用例生成一份 --patch overlay,按 row id 整体替换持久化配置里的落盘根。其二,每条用例一份工作区作 cwd、一份 session 根,目录名带加载序号而不是用例名的 slug——slug 化不是唯一键,「read image」和「read-image」会撞成同一个 slug,撞了就是两条用例的 trace 互相错捡。其三,可执行文件路径必须绝对:我真实踩过,runner 给每条用例起了独立 cwd,而我传了相对路径,11 条用例全部 spawn ENOENT——子进程在自己的工作区里找相对路径,当然找不到。现在 harness 启动前先用 --version 探针验证 dsh 可用,顺带把版本号记进报告,排障时直接区分「dsh 变了」还是「模型变了」。
采集层面对的是 dsh 的落盘格式:session.jsonl 每行一帧信封 {type, seq, time, data},默认还是压缩版 session.jsonl.zstd——多帧拼接的 zstd 容器。这里有两个反直觉的坑,都是实测出来的。第一个:把多帧拼接的容器一次性丢给 zstdDecompressSync,它只解出第一帧,不报错、静默截断——所以必须逐帧解。第二个:逐帧切分不能在字节流里搜魔数——zstd 帧魔数完全可能出现在压缩载荷内部,搜魔数会把一帧切成两半。正确的做法是按帧结构解析边界:帧头描述符给出内容尺寸字段长度,块头给出每块大小,顺着声明的尺寸走,魔数只在「上一帧结构结束的位置」校验。harness 的解码器就是这么写的(和上游 dsh-session 包里的 scanZstdFrames 同款思路),全部依赖 Node 内置 node:zlib,零外部依赖——注意 zstd 支持是较新版本 Node 才有的(22.15+/23.8+),旧 LTS 上没有。
尾帧是另一个故事。进程在写入过程中被 SIGKILL 时,最后一个帧很可能只写了一半。能恢复多少,上限由写入端的 flush 频率决定——没 flush 出来的字节根本不在文件里,谁也救不回。解码端能做的是容忍截断:harness 对残缺尾帧用 finishFlush: ZSTD_e_flush 解——这个参数的作用是把输入结尾当作一个 flush 点而不是要求的流尾,已刷出的块照常产出明文。恢复失败也只丢这一个尾帧,已完成的帧不受影响。顺带一提,Node 对截断帧的容错比想象中宽:不带任何选项解残缺帧也常常返回部分明文而不是报错——这意味着「靠异常发现尾帧残缺」不成立,trace 跳行数告警这条防线是必须的。
提取层把帧流变成结构化观测:turn 怎么结束的、调了哪些工具、每次调用的参数和结果文本、最终回答、token 用量、成功解析的帧数和跳过的坏行数。这层最大的教训是按真实落盘形状写代码,不按想象写:tool/result 在真实落盘里有三种形状(成功 / data.error{name,code} / 纯 isError 标记),最初按假设的形状提取,漏了后两种,工具硬错误就悄悄漏过了。现在用一份真实会话脱敏后做 fixture,快照测试把三种形状锁死。token 口径同样有讲究:多步会话里同一段缓存每步重复读回,所以 total 只算未命中缓存的 input + output + reasoning(落盘的 input 字段本身已排除缓存命中),缓存读写单列观察。这个口径是为门禁的漂移检测服务的;缓存读仍按折扣价计费,算成本账时别用这个 total。
门禁层的两个设计。一是报告带 schemaVersion:基准报告入库,是要长期活着的数据资产;loader 对旧版自动补默认值,对未知的未来版本直接拒绝比较,重复用例名、非法状态、summary 与用例不符一律拒跑——不让一份坏报告产出看似合法的判定。二是判定是带退出码的协议:PASS=0、FAIL=1、N/A=2、WARN=0(strict 模式 2)。一个有意的设计代价要说清:strict 模式下 WARN 和 N/A 同为 2,CI 只看退出码时分不出两者——取舍的理由是两者都意味着「不能当作干净通过」,需要分辨时看文本输出的 OVERALL 行。所有中间产物同时以文本行和 JSON 两种形态输出,人和 CI 各取所需。
这条管线没有一步是复杂的,但每一步都有一次真实事故兜底。把 agent 行为变成数据的过程里,最容易出错的地方从来不是解析,而是那些你以为不会出错的接缝。
六、破案:eval 抓到上游的并发 bug
上周 dsh 从 0.1.0-rc.6 升到 0.1.1-rc.2。我按惯例翻上游的 fix 记录找用例素材——每个修过的 bug 都是一条回归用例的种子。看中了图片尺寸准入的修复(0.1.0-rc.8 的 changelog 条目「修复图片尺寸过大或历史图片累计载荷过高导致模型请求失败」,rc.2 又进一步完善了图像预处理):修复前,边长超限的图片会被 read_image 原样写进会话历史,后续请求全被 provider 400 毒化;修复后应在准入时降采样。
用例红绿对照:同一张 2500x4 的 PNG、同一段提示词,rc.6 上 FAIL(无降采样标注),rc.2 上 PASS(结果文本含 downscaled from 2500x4)。用例收编,符合预期。
真正的发现在后面。升版后第一次全量跑,12 条用例、并发 3,两条用例首跑崩了、重跑才过。报告里的重试历史和 stderr 尾部——harness 专门记录的进程诊断字段——显示两个进程都是 ENOENT,但崩在不同位置:
Error: ENOENT: readlink '~/.dsh/profiles/node_modules/commander'
at ensureSymlink (dsh-app-boot/lib/index.js:380:7)
Error: ENOENT: unlink '~/.dsh/profiles/node_modules/@deepseek-ai/cordis-plugin-timer'
at ensureSymlink (lib/index.js:381:3)
dsh 每次启动会「治愈」一个共享符号链接目录(profiles/node_modules),换版后所有链接都要重指。三个进程同时做这件事,而 lstatSync → readlinkSync → unlinkSync 这个序列没有并发防护:A 进程 lstat 时链接还在,readlink 时已被 B 删掉,ENOENT。这是典型的 TOCTOU——检查(lstat)和使用(readlink/unlink)之间存在竞态窗口。而且这个序列还藏着一个更不显眼的后果:A 在 readlink 之后、unlink 之前,B 可能已经删掉旧链接并建好了新的,A 的 unlink 会把 B 刚建好的正确链接一并删掉——这一步不报错,目录就此处于半治愈状态。源码里 symlinkSync 的 EEXIST 分支有护栏,注释甚至明写「并发启动会治愈同一个目录,输掉竞争等于成功」:上游知道有并发,但只防了创建这一步,读和删裸奔。
分析丢给三个独立 agent 复核,三家全部确认结论,其中一家做了 16 路并发的控制实验,稳定复现 4 次崩溃。报告发到官方 Discussions(#4312),有社区成员据此写出了修复的参考实现,我在 macOS 侧做了对照验证:压力实验的单位是单个进程启动——未修版本 16 并发 × 3 轮共 48 次启动崩 23 次;修复版本同样 48 次零崩溃,加压到 24 并发 × 4 轮共 96 次依然零崩溃。
这个 bug 能被抓住,全靠前面那些「无聊」的决策:没有「并发 + 隔离」的执行设计,三个进程根本不会同时启动;没有重试历史,重跑过了就没人知道首跑崩过;没有 stderr 尾部采集,崩溃原因无从查起。质量基建的价值不在平时,在这种时刻一次性兑现。
七、收尾
trace 第一天就要做,它是后期所有质量工作的地基;用例从真实 bug 反推,上游的 fix 记录是最好的用例种子库;可复现等于隔离加钉版加全量 trace,三者缺一个,红绿对照就不成立;每个行为都要有数字,每个数字都要有门禁,否则「质量」只是形容词;评测器本身也要被评测,judge 上岗前先校准。
Agent 的工程质量不在提示词里,在 trace 里。
文中评测工具 dsh-eval-harness 已开源;bug 报告见 deepseek-ai/deepseek-harness 的 Discussions #4312。