t.send("帮我发帖") 很别扭。更直接的做法是用 defineAdapter 把应用自己的方法交给评估用例:返回对象里的每个操作,都会变成评估用例里带类型的 t.post(...)、t.generateImage(...)。
对话式 Agent 用的 defineAgent 是 Adapter 的一种特例,要实现 send、多轮会话和 Turn。自定义应用不需要实现这些。
定义应用连接
先告诉 NiceEval 怎么创建你的应用、评估要用到它的哪些操作。下面连接一个已有的社交应用。createSocialApp 属于你的应用,返回对象逐个列出评估需要的操作。
application.ts
create,Attempt 结束时执行登记过的 onCleanup 回调。
- 每个 Attempt 要不要拿到独立的应用实例,由你的
create决定。运行器不会替你复制实例。 - 没登记
onCleanup的资源,运行器不会释放。取得资源后立即登记。 - 把
signal传进应用的网络请求和长任务,超时或取消时它们才会停下。
t 的类型。返回对象里没有 send 时,在评估用例里写 t.send 会报类型错误。check 等 NiceEval 自带的名称不能被覆盖。已有的类实例,用上面的闭包写法逐个暴露方法。
调用应用动作并自定义检查
接下来在评估用例里调用这些操作,再检查结果。test(t) 里既有应用的操作,也有 NiceEval 的检查方法。应用返回的 Post、Reply 等对象,可以直接交给 defineValueMatch 定义的自定义 Match。
evals/publish.eval.ts
defineValueMatch 只描述一个值怎样算匹配。t.check 登记一条断言,.gate() 表示这条不通过,整个评估用例就不通过。字符串、数值和 Schema 匹配器也都能直接用。
需要累计分数时,改用 social.defineScoreEval,再在检查上调用 .score(points)。语气、人物一致性、内容质量这类开放式标准可以交给 Judge。
在连接处定义断言方法
好几个评估用例都要做同一种检查时,把它写成 Adapter 上的断言方法,评估用例里一行就能调用。在assertions 里定义这些方法,它们拿到当前 Attempt 的 app 和 check,返回 check 的结果。
application.ts
evals/publish.eval.ts
hasPublishedText() 和直接写 t.check 的效果一样,计分和报告里的展示也一样。defineEval 里的断言方法没有 .score(),defineScoreEval 里的有。post() 这样的操作方法仍然只调用应用。
写断言方法时注意几点:
- 在方法被调用时才读取要检查的数据。
assertions只负责组装方法,不能在里面提前调用check。 - 返回对象里只放函数,不放 getter 或其它值,方法名也不能和应用操作或 NiceEval 自带的名称重名。
- 方法同步返回
check的结果,不返回 Boolean 或 Promise。异步操作和等待放在应用方法里,在检查前先完成。 - 数据不完整时报一个明确的错误,不要当成“什么都没发生”来判断。
- 参数写成具体类型。泛型或重载的断言方法无法调用,这类检查改用独立的 Match 加
t.check。应用操作本身可以是泛型方法。
选择实现并运行
写一个 Experiment 选定这个应用,然后运行。experiments/local.ts
pnpm exec niceeval show 在终端看结果,或用 pnpm exec niceeval view 在浏览器里看。
Experiment 选的应用必须提供评估用例用到的操作。缺了操作时,NiceEval 在创建应用之前就报错,先核对 create 的返回对象。
操作的顺序、参数和重复次数都由评估代码决定。应用操作失败时不会像 Agent 消息那样自动重试。你也可以把某个操作命名为 send,它的参数和返回值仍由应用决定,不需要构造 Turn。
用相同评估比较两个实现
想知道换一套实现后效果是变好还是变差,就让两套实现跑同一批评估用例。先声明它们共同的接口,再分别提供实现:social.defineEval 编写,两个 Experiment 分别选 baseline 和 candidate。评估用例里只能用共同接口里的方法。两次分别调用 defineAdapterContract 得到的接口即使同名,也不能互换。
两套实现要共用断言方法时,在创建实现前调用 withAssertions:
withAssertions 返回的接口创建实现和评估用例,各实现就会用同一套检查。withAssertions 返回的是一个新接口,在它之前创建的实现拿不到这些方法。
什么时候沿用上次结果
再次运行时,NiceEval 会判断能否沿用上次的结果。参与判断的有 Adapter 的name、contract、behaviorRevision,以及 Experiment 的 flags。评估用例文件和它在项目内用相对路径静态 import 的文件一改,这道题就会重跑。
Experiment 单独选择的实现、动态 import、外部包和远端服务,NiceEval 看不到它们的变化。改了这些行为时,提高 behaviorRevision,或者用显式配置体现变化。没有声明 behaviorRevision 的自定义 Adapter 每次都重跑,不沿用上次结果。用 pnpm exec niceeval exp local --dry 可以先看哪些会沿用、哪些会重跑,见修改后只重跑受影响的评估。
保存应用执行轨迹
评估失败时,你想知道应用内部到底做了什么。把应用的执行日志交给 NiceEval,之后就能用niceeval show 逐条查看,不必再去应用那边翻日志。
应用结束、拿到完整日志后,在 Adapter 里调用 ctx.recordTrace。每个事件保留自己的类型、执行者、摘要和已知的关联,不需要包装成聊天消息。下面的事件只表示发帖请求已被接受,真正发布完成要另记一条事件。
key用来在这次提交的事件之间互相引用。source.eventId只填应用真实提供的 ID,没有就省略。schema.id标明应用的事件格式,只填id,不填revision。- 返回的
receipt.events给出每个事件的eventId,查看时用它展开。同一个traceId再次提交相同内容,返回同样的eventId;内容不同会被拒绝。 - 摘要有长度上限。大段输入放进附件,用
evidence的pointer指向附件里的具体位置,查看时直接定位,不用扫完整份日志。
collection.state 描述日志是否完整,和运行有没有被取消无关。取消后仍拿到了完整日志,就写 complete。缺日志时写 partial,并在 limitations 里说明缺了什么,不要用空列表表示“不知道”。超出 scopes 采集范围的事件也保留下来,标成 excluded,判断不了的标成 unknown。提交执行日志本身不影响判分。
onCleanup 回调有自己的时限,在里面也能提交执行日志。保存后的执行日志和附件随结果文件 .niceeval/record.sqlite 一起保存,查看时不会再调用应用或模型。
归档大附件
应用的原始日志、网络响应这类大文件,可以作为附件和结果一起保存,排查时再取出来看。用ctx.attach 把文件、网络响应或生成器作为字节流交给 NiceEval,NiceEval 不解析其中的格式:
artifactId、原始字节的 sha256 和 byteLength。拿到回执后,NiceEval 已经有了自己的副本,之后删除或改写源文件不影响附件。附件在这个 Attempt 的结果保存时一起写进结果文件,不会上传到任何远端服务。
大小限制:
- 字节流每块最多 1 MiB,单个附件最多 1 GiB,每个 Attempt 的附件合计最多 2 GiB,同时最多提交 4 个流。
- 字符串和数组形式的附件单个最多 64 MiB,合计最多 256 MiB。
- 作为执行日志里
pointer证据的附件,单个不能超过 64 MiB。
signal,并释放自己的文件或连接。
在 ctx.onCleanup 里提交附件时,用 cleanup 回调提供的 signal,不要用已经取消的 ctx.signal。提交失败时,即使你捕获了错误,这个 Attempt 也会显示采集失败,之前成功提交的附件仍然保留。cleanup 总共只有 30 秒,大文件应在运行过程中提交。
阅读结果
用pnpm exec niceeval show 查看这次运行。每条 t.check 保存了被检查的值(有长度上限)和判定。
NiceEval 没有采集到的会话、工具调用和费用会显示为缺失,不会填成零。没有 Turn 不代表用了零 token,Judge 的费用也不等于整个应用的费用。
因为拿不到应用的完整费用,自定义应用不能设置 budget,也不能声明只有 Agent 才有的 Sandbox 或会话能力。生命周期 Plugin 需要 Agent 的执行准备步骤,在自定义应用上配置它们会在运行前报错。
完整可运行的模拟 X 示例位于 LLM X 源码。
它由 LLM 生成人物、个人主页、推文、配图和后续社交动态,并使用应用原生方法进行评估。