跳转到主要内容
自定义报告的积木——指标、计算函数、双面组件——脚下还有一层:niceeval/results,落盘 artifact 的 parser。官方两扇门和全部报告积木读的都是这一层,没有私有数据通道;报告表达不了的口径,下到这层直接拿数据算。 什么时候下到这层:
  • 口径连计算函数都折不出。 表格、矩阵、成绩单、散点都是「折叠」;分布类的看法(直方图、逐事件分析)不是折叠,要自己遍历 attempt。
  • 把结果喂进自己的系统。 数据仓库、告警、内部平台——吃类型化数据,不吃磁盘布局。
  • 把别家平台的结果转成 NiceEval 格式。 写入面保证「写出去的就是读得回的」,转完 niceeval show / view / 报告积木全套直接能用。
这一层只管数据的读与写,不预设任何看法:不合并、不聚合、不去重,忠实反映磁盘。「怎么看」归上层的报告积木。

读:openResults

输入是 .niceeval/ 目录(或 copySnapshots 产出的结果根目录,同一种布局),输出是四层数据:实验 → 结果快照(单次跑的实验)→ 评估 → attempt。你从此不碰路径、不判断文件存在性、不解析 JSON:
第一层,实验——同一个 experiment id 的历次运行归在一起:
第二层,快照——单次跑的实验。它是谁、什么时候跑的、用什么写的,都在这一层:
第三层,评估——这次实验里的每道题,attempt(重试历史)挂在题下面:
第四层,attempt——瘦身条目直接在手上,重 artifact 全部懒加载。唯一带 Handle 后缀的类型就是它:上面三层是纯数据,这一层的方法会碰磁盘:
三条要点:
  • 懒加载即存在性判断。 artifact 缺失返回 null,不抛错——remote agent 没有 diff.json 时,await attempt.diff() 就是 null,你据此决定跳过还是报缺。
  • 读不了的落盘不静默。 版本不兼容、目录损坏的快照进 skipped 并带原因;要不要展示由你定,但缺口永远被算出来。
  • 同进程内按句柄记忆化。 两处都读同一个 diff() 不会把上百 MB 读两遍。
attempt.result.error 是让 Attempt 进入 errored 的唯一致命执行错误,包含稳定 code、人可读 message、发生错误的 lifecycle operation,以及可选的有限 cause/stack。attempt.result.diagnostics 可以与任意判定共存,保存运行仍可继续或收尾时发现的问题。瞬时 progress 不落盘;OTel trace 也不是错误存储的前提。

超大输出会被截断

Agent 跑一条命令,输出可以大得离谱——一次递归 grep 扫进 node_modules,撞上压缩过的 JS 文件,单行就有几 MB。这种输出会同时进 events.jsontrace.json,不管的话一个 attempt 就能占上百 MB。 所以 NiceEval 落盘时会削:events.jsontrace.json 里任何超过 256 KiB 的字符串,只保留前 256 KiB,末尾留一行说明:
这不影响判定。 断言读的是运行时的完整输出,截断只发生在写文件的那一刻——落盘的 artifact 是证据,不是评分的输入。断言该过还是过,该挂还是挂。 要在自己的报告里如实标出「这里少了东西」,别去匹配上面那行文本,读结构化字段:被截断的事件和 span 都带 truncated,里面是被截断的位置和原始字节数。
这条上限管的是单个字符串值,不是整个 JSON 文件。一个文件可以有很多正常值;diff.json 和源码也不能截断,因为它们要保持完整语义。所以 .niceeval/ 适合做本地事实根,不默认适合直接提交进 Git。发布前用下面的 copySnapshots 做 artifact 选择和整文件大小检查。 截断发生在持久化边界,不能替 agent runtime 限制发给模型的工具输出。如果 runtime 先把 50 MB 工具结果完整塞进模型请求并收到 413,NiceEval 仍会把 Attempt 记为 errored;这里只保证失败后的 events / trace 不再被同一段输出撑爆。

版本:谁写的、读不读得了

每个快照都带自己的出身:producer 是写这份结果的工具与版本(niceeval 自己,或经写入面转换的第三方 harness),schemaVersion 是磁盘格式版本。格式只在破坏兼容时递增版本,读取器只认相同版本——版本不兼容的落盘不解析、不迁移、不猜,整个 run 进 skipped
"incomplete" 是极小概率的一种:有 attempt 落盘、却没有 snapshot.json——只可能出现在「快照目录建好、元数据还没写完」的极小窗口里进程死亡,或人为删了文件。它和「进程中断」的常态不是一回事:进程中断的常态是未收尾快照snapshot.json 在,只是缺 completedAt),这种快照能正常读,已落盘的 attempt 全部可见,只是 results.latest() 选中它时会带一条 unfinished-snapshot 警告(见下文),不会被归进 skipped 旧版本的结果不会丢:磁盘上原样还在,用写它的那个工具就能看——producer.name"niceeval" 时,提示用户 npx niceeval@<producer.version> view;是第三方 harness 时如实报出它的名字和版本,别拼一句错误的 npx 命令。skipped 给你的信息刚好够做对这个分支。

快照就是这个目录

快照 = 单次跑的实验,物理上就是一个快照目录(.niceeval/<experiment>/<snapshot>/),没有更低一层。niceeval exp compare 一次 CLI 调用会给涉及的每个实验各开一个独立的快照目录,互相不合并、没有跨实验的聚合落盘——「每个 experiment 最新一次」因此天然是快照粒度:周一跑了整组 compare,周二只重跑 compare/bub-gpt-5.4,bub 的最新快照落在周二,codex 的还在周一,exp.latest 各自反映各自的历史。

选快照:results.latest()

多数场景先回答「每个实验现在的最新结果是什么」,选择器替你挑「每个实验最新一次」——这也是默认报告的口径。返回的是一个 Scope,快照和警告绑在一起走:
「最新」可能残缺:只重跑一道题是正常的 debug 姿势,它产出的最新快照就只有一道题。选择器把每个选中快照的覆盖与该实验的历史并集(exp.evalIds)对比,缩水就写进 warnings
字段供程序判断——CI 里「覆盖缩水就 fail」直接判 covered < total,不解析文本;message 是渲染好的英文句子,要展示就原样打。渲染与否在你,缺口永远被算出来。警告不止这一种:快照落后于 Scope 中最新的落盘进 stale-snapshot、选中的快照没收尾(进程中断)进 unfinished-snapshot,每种都带 kind、可判断的结构化字段和渲染好的 message Scope 是报告积木和下文 copySnapshots 的通用输入:收 Scope 时 warnings 随行(ScopeWarnings 组件会如实展示),手工挑的 Snapshot[] 数组照收。微调官方口径不用降级成裸数组:latest.filter((s) => s.experimentId !== "compare/broken") 返回新 Scope——快照被删减,warnings 修剪到幸存的实验,provenance 不丢。filter 只做删减;「换成该实验上一个完整快照」这类替换式重挑不是它的事,回到 exp.snapshots 自己拿——手工挑的数组没有挑选过程,自然没有 warnings 可带,也如实。

一个真实脚本:分布不是折叠

「每个 agent 的 shell 命令数分布」画不成表格——那是分布,不是折叠。直接遍历:
即使在这条最深的路径上也不碰磁盘布局:路径拼接、存在性、版本过滤、快照切分全被库消化。要把这份分布摆进报告页,用 defineComponent 包一个双面组件即可。 一条跨快照累计时的义务:NiceEval 默认把上一轮已有确定判定(passed / failed)、且评估用例代码和配置没变的结果携带合入新快照(--force 全部重跑),同一个 attempt 因此可能存在于多份落盘。携带条目不是空壳:它带着原快照的 startedAt(身份锚)与 artifactBase(指向原快照 attempt 目录的相对路径),懒加载按候选顺序回退——先本快照的 attempt 目录,再 artifactBase 指向的原快照目录(原快照被清理后如实返回 null);ref 指向条目所在的落盘,即携带入的那份新快照。身份键 (experimentId, evalId, attempt, startedAt) 的四个字段都在数据上——前两个是 attempt 的直达字段,序号与 startedAtattempt.result 上。reader 忠实反映这份重复;跨快照聚合前用 dedupeAttempts 按身份键去重,重复保留最新快照里的那份——报告积木的计算函数内置这条,自己写脚本时记得过一遍:

写:createResultsWriter

写入面和读取面是同一组类型的两半,签名就是 roundtrip 的证明:reader 的 attempt.result 由两部分拼成——快照级字段(experimentId / agent / model / startedAt / producer)来自 writer.snapshot() 的一次声明,其余全部字段就是 writeAttempt 第一个参数的类型;第二个参数是 reader 懒加载能拿到的那几样 artifact 的类型。「writeAttempt 参数 + snapshot() 声明 = reader 读回的全部」由类型拼合背书:快照级字段不在 attempt 参数的类型里,不存在「谁的值为准」。用它把别家平台、自研 harness 的结果转成 NiceEval 格式:
writer.snapshot() 就是读取面「实验 → 快照」层次的镜像:转多个 experiment 就开多个快照目录,experimentId / agent / model / startedAt 这些快照级元数据在这里声明一次,不用塞进每条 attempt;可选的 knownEvalIds(该实验已知的评估用例并集)也在这里声明——它是残缺检测的分母,转换只覆盖部分题目时如实交代全集,下游的覆盖警告就能算出来(copySnapshots 发布时会自动补记这个字段,见下文)。转完的目录就是标准结果目录:niceeval show / niceeval view 直接能看,报告积木直接能算,不用抄格式文档;producer 会原样出现在读取面的 snap.producer 上。每个文件恰好写入一次是写入面的核心承诺:snapshot.json 开跑即写、收尾只补 completedAt;result.json 与 artifact 随 attempt 完成落盘。进程中断只丢未完成的 attempt,已完成的判定与 artifact 已经在盘上——真正「有 attempt 落盘却没有 snapshot.json」的极端情况才归 skipped("incomplete"),未收尾但元数据齐全的快照能正常读,只带一条警告。

发布:copySnapshots

把选中的快照按格式感知地复制到发布目录——只带指定 artifact、只带选中的 attempt:
第一个参数收 Scope 或手工挑的 Snapshot[]——和报告积木同一个输入约定。artifacts 的合法值是 "events" | "trace" | "o11y" | "agentSetup" | "diff" | "sources";缺省带除 diff 外的五类。目标目录已存在且非空时报错,不静默覆盖——发布脚本要幂等就自己先清目标目录。 复制开始前,NiceEval 会规划全部目标文件并检查序列化后的大小。任一文件超过固定的 50 MiB,整次复制在创建目标目录前失败,错误会列出路径、实际大小和处理建议。你可以从 artifacts 排除那类证据;如果是旧版本留下的超大 events / trace,用当前版本重跑后再发布。这个检查既覆盖没有逐值截断的源码 / diff,也覆盖单值都正常但累计过大的 JSON,避免直到 git push 才撞上 Git host 的单文件限制。 大小预检只决定整次复制成功或失败,不会从一个超大文件中间删内容。复制忠实于源:artifact 按原字节复制,不重新序列化、不改写。唯一随行补记的是挑选时的覆盖事实partial-coverage 警告的分母是实验的历史并集,而发布目录没有历史——所以每个复制出的快照带上 knownEvalIds(复制时刻该实验已知的评估用例并集),reader 端把它并进 exp.evalIds 的计算(取本地历史与快照携带值的并集)。发布目录上重新 openResults().latest(),残缺警告被同一套机制重新算出来,不靠发布者转述。复制出的目录就是标准结果目录,niceeval view --results <目录> 直接能看;要让报告站随 push 自动更新,workflow 见通过 CI 发布报告

分层速览

每层都建立在下一层之上,同一份落盘 artifact 是唯一事实来源——上层的派生物删了随时可重算。