四条原则
- 先写判据,再写代码。 先用一句话写下业务要求,再挑最短、最忠实的写法。不要先遍历日志算出一个数,再给它起一个业务名字。
- 事实用确定性检查,含义交给 Judge。 工具名、参数、次数直接检查;回答是否忠实、建议是否合理,才交给 Judge。
- 少造概念。 能用已有事实、简单条件和几行算术表达,就不要命名新的业务概念。少一个概念比少几行代码更重要。
- 不知道就是不知道。 缺记录、缺 token、材料超限时,保留“不可判定”,不要补成 0 或 false。
一切检查都是 t.check
NiceEval 只有一种检查:t.check(事实, Match)。事实是被检查的值,Match 是比较规则。调用时立即登记一条检查,返回的 handle 只配置这一条,比如 .gate()、.score()、.orStop()、.label()。
其它检查方法都是 t.check 的简写,读取的事实、比较规则和结果保存方式完全一样:
所以学会一套规则就够了:先选事实,再选 Match,最后在 handle 上决定它是门槛、计分还是停止条件。简写只是让常见的事实不用自己去取。
能用 Match 表达的,就用 Match
判据尽量写成 Match,而不是先在 JavaScript 里算出true 或一个数,再交给 check。Match 会保留三种结果:满足、不满足、不可判定。它还会记下比较规则和命中的证据,报告里能看到为什么通过、为什么失败。
手写计算会丢掉这些信息。calls.filter(...).length === 1 只剩一个布尔值,记录不完整时还会把未知项当成没有发生。
对照一下两种写法:
satisfies 和 defineValueMatch 留到组合器确实表达不了的时候再用。它们只比较一个值,诊断里只剩你写的名字。
Match 本身不带门槛、分值和控制流。同一个 Match 在通过制里可以是门槛,在计分制里可以是 5 分,这些都写在 handle 上,不写进 Match。
被测对象是对话式 Agent 时,读完本页就够了。被测对象提供客服记录、游戏状态这类业务事实时,再读自定义应用的评估判据。
Agent 类评估
Agent 通过t.send() 接收任务,返回消息、工具调用和 Turn 状态。这些事实 NiceEval 已经能直接读取,所以评估里直接用 Agent 的断言方法,不用再给工具名、参数或回复另写一套读取代码。
一个例子:查天气,再给出行建议
下面这条评估分两轮。第一轮让 Agent 查台北天气,第二轮让它根据查到的天气说要不要带伞。运行它需要两个前提:被测 Agent 提供get_weather 工具,输入里有 city 和 unit;项目已经配置好 Judge。
- 分数怎么来。 正确查询得 20 分,建议有依据得 80 分。用量和耗时只设门槛,不计分。
- 为什么查询要用确定性检查。 调了哪个工具、传了什么参数,是能直接读到的事实,不需要 Judge 猜。工具名、输入、状态和次数全写在一个
toolMatch上,calledTool只是把本轮的工具调用交给它比较。 - 这些检查其实是同一种。
succeeded、calledTool、closeQA、maxTokens和最后的t.check都登记一条检查,后面的.gate()、.score()、.orStop()用法也完全一样。 - 为什么建议要交给 Judge。 回复里出现“雨”或“伞”,不能证明建议正确。工具返回下雨、Agent 也建议带伞时,还要 Judge 判断理由有没有编造天气。
- 为什么用
.orStop()。 查询成功是第二轮的前提,查询失败时再问带伞就没有意义。如果你的任务允许查询失败后说明原因,就要为这条路径另写判据,不能照搬这里的前提。
先选范围,再选材料
同一个检查,放在哪一轮、给 Judge 哪些材料,结论可能完全不同。
给 Judge 的材料要够回答问题。传入
toolMatch 时只会选中工具调用;问题还涉及用户要求和回复时,就要传包含它们的完整历史。
评质量时不要先筛掉失败的调用、拒绝或澄清,它们本身就是表现的一部分。材料超过上限时,NiceEval 记为不可判定,不会截断后继续打分。
多轮任务里,先想清楚每项要求属于哪一轮、哪个 Session。要检查工具调用顺序,用 inOrder,见 Match 参考。注意,先后发生不等于后一个调用用到了前一个的结果;要证明数据依赖,还要检查输入和输出。
能确定的,不要交给 Judge
两种常见写法都选错了证据:judge("是否调用了天气工具?"):调没调用是能直接读到的事实,让模型再判断一遍只会多一个出错的机会。reply.includes("抱歉"):关键词看不出拒绝是否合理,这需要 Judge 结合上下文判断。
t.maxTokens(n) 和 t.maxCost(usd) 比较 Adapter 上报的用量,t.elapsedMs 是这次运行的墙钟时间,交给 atMost 比较即可。不要在评估里自己累计日志、维护模型价格表,也不要把缺失的 token 补成 0。Adapter 没有上报的调用不会出现在 t.usage 里,字段存在不代表所有外部调用都被记下了。
交付前的共同检查
不管被测对象是 Agent 还是自定义应用,交付前都按下面三步检查。分清停止、门槛和计分
check 登记一项检查,后面接的方法决定这项检查怎么用:
.gate():没满足就判不通过。.score(weight):满足时贡献分数。.orStop():没满足就停止后续步骤。它只管停,不代替.gate()。
defineScoreEval)里,普通检查默认只记录结果,加了 .score() 才计分、加了 .gate() 才成为门槛。通过制(defineEval)里,检查默认就影响通过与否。不要把两种题型的默认规则混用,详见通过制与计分制。
“满足就得分”的项,直接在检查上接 .score(n),不要先算出布尔值再换算成分数。比如“至少完成三项得 10 分”,写成 t.check(items, countWhere(matches(DoneItem), atLeast(3))).score(10)。
只有业务本身定义了比例公式,组合器又表达不了时,才自己算出一个 0 到 1 之间的比例。这时用 t.score(ratio, { weight: 50 }) 计分,贡献是 ratio × 50。只传一个数的 t.score(points) 表示直接加这么多分,两者含义不同。比例未知时,传入带不可用状态的值,这一项仍占 50 分的权重,不会被当成 0 分。
Judge 打分这类 0 到 1 的测量值,用 .gate(minimum) 设最低分,用 .orStop(minimum) 在低于最低分时停止。已经写了 .gate(minimum) 时,后面的 .orStop() 可以不传参数,沿用同一个最低分。证据不可用时保留不可用状态,不要先改成 false 或 0 再停止。
改写时不要顺手改变业务标准。比如“参与就加 5 分”和“每个人都必须参与”是两个要求,把 .score(5) 换成 .gate() 看起来更短,但要求变了。低分和零分可以是正确的结果,不要为了报告变绿而降低门槛或删掉失败项。
用真实运行验证
类型检查和构造通过,只说明写法没错,不能证明材料读取、取消、审计和业务判断都对。确认安装版本的导出和类型后,用pnpm exec niceeval exp <experiment> 真实跑一次,让检查拿到真实 Adapter 提供的数据。不要私下调用 Match 的内部求值函数,也不要伪造断言上下文。
跑完后,用 pnpm exec niceeval show @<attempt-locator> 打开这个 Attempt,把问题分成三类:
- 技术错误:调用或解析出了异常。
- 证据不足:结果是缺失、partial 或 unavailable。
- 业务失败:证据完整,但门槛没满足。
--dry 计划、类型检查、局部运行和真实端到端运行各验证了什么。文档构建通过不等于 Judge 验收通过;几个样本通过,也不等于长材料或整组任务都能通过。运行和读取结果的命令见反馈闭环。
写作自检
提交前,用下面这些问题检查正文,并拿同一个 Attempt 的结果核对答案:- 读者能说出目标、观察范围、每项通过条件和分值吗?
- 每项要求是“存在一次”“每一次都”还是“一次都没有”?完整性前提在哪里检查?
- 组合条件判断的是同一个事实吗?会不会混入其它 Attempt 的材料?
- 模型的选择、实际的执行和最终的结果分开检查了吗?成功属于被测对象本身吗?
- Judge 拿到了完整的相关材料吗?问题能根据这些材料回答吗?
- 缺记录、缺 token、未知费用和超出容量,都保留为不可判定,而不是零吗?
- 不打开函数实现,能读出状态、数量、阈值和组合关系吗?
- 每项检查都能读成“哪个事实,用哪个 Match 比较”吗?能用现成 Match 组合的,有没有写成手算的布尔值或计数?
- 自定义 Match 只在组合器表达不了时才出现吗?它有没有被起一个多余的业务名字?
- 得分和门槛都能直接看到吗?前提不满足时,依赖它的后续检查停下了吗?
- 验证经过了真实运行和公开的结果命令吗?局部检查和真实运行分开说明了吗?