跳转到主要内容
Adapter 定义了 send 的契约:接收 TurnInput 和 AgentContext,返回 Turn。本教程从发送一条消息开始,逐步加入完整接入所需的能力。每一步只增加少量代码,已有部分以 // ……省略 标出。步骤末尾列出新增数据支持的断言。完成任一步后,都可以把对应断言写进评估用例,重跑 pnpm exec niceeval exp,再用 pnpm exec niceeval show 在终端查看结果,或用 pnpm exec niceeval view 在浏览器里检查多轮对话、用量、工具事件和待回答的请求。 五个贯穿全文的原则:
  • 连接用户前端正在使用的接口。 Adapter 调用相同端点并接收相同格式,不为评估用例新建接口,也不 import 应用内部代码直接调用函数。具体原因见接入你的 Agent。
  • 把服务生命周期留在 Adapter 外。 先用项目自己的开发命令、部署脚本或 CI 启动并检查真实目标,再运行 Experiment。send 不 spawn 服务,评估用例也不安装、启动或替换被测系统。
  • 按最小边界提供凭证。 目标进程和 Adapter 各自只读取自己需要的凭证,不把秘密写入源码、评估工作区、事件或最终回复。
  • 只手写 transport。 URL、鉴权和请求体取决于应用。niceeval/adapter 提供从原始返回到标准事件流的转换器。ctx.session 提供会话续接与 HITL 暂停恢复所需的状态 API。
  • 运行反馈走 ctx,不直接写终端。 长步骤用 ctx.progress(...)。需要在运行后回顾的退化或异常上下文用 ctx.diagnostic(...)。无法继续时抛错。不要从 Adapter 调用 console.log/error 或写 process.stdout/stderr。

确认应用接口形状

动手写 send 之前,先确认你的应用接口长什么样。NiceEval 不定义新的应用协议,内置转换器按下面几种常见的响应形状提供。认出你的接口属于哪一种,后面就能直接用对应的转换器,少写映射代码: 以下步骤使用 Chat Completions 形接口。Responses 形接口和流式接口的替换实现分别在第二步和第四步给出。

第一步:发一条消息,拿到回复

先让评估用例能发一条消息、拿到一条回复。这是所有断言的起点。最小的 send 只做三件事:把 input.text 发给应用的接口,把回复放进一条 message 事件,报告本轮 status:
接口返回的响应能继续用、但缺了一部分信息时,用 ctx.diagnostic 留一条说明,不要把原始响应整个打印出来:
progress 只在运行时显示,不保存。diagnostic 会随这个 Attempt 的结果一起保存,运行结束后在 Attempt 详情里能看到。HTTP 连接失败或响应无法解析时直接抛错。这个 Attempt 会被判为 errored,错误阶段显示为 agent.run,用 pnpm exec niceeval show @<locator> 可以看到完整错误正文。 这一步支持:t.reply 的直接值检查(t.check(t.reply, includes(...)) 或 pattern(...))、Judge 的全部对话材料,以及 Experiment 侧的模型对比。ctx.model 来自 Experiment 的 model。运行器原样传递,Adapter 只负责转发。接入等级见 Tier。 它有两个明显的局限:每轮都是一场全新对话(第二次 t.send 接不上第一次),工具调用完全看不见。后面两步各解决一个。

第二步:接上之前的消息

第一步的 send 每轮都开一场新对话,评估用例的第二次 t.send 接不上第一次。要测多轮对话,send 得记住之前说过什么。 运行器只保证一件事:同一条会话线上的每次 send 拿到同一个 ctx.session,新会话线(评估用例的第一轮,或 t.newSession() 之后)拿到一个全新的。 怎么续接取决于应用接口:
  • 客户端每轮发完整历史(服务端不记状态,Chat Completions 形是典型):用 createSessionSlot<TMsg[]>() 声明一份 Adapter 私有的会话状态,再用 ctx.session.get(...) / ctx.session.set(...) 存取历史消息。
  • 服务端记历史(接口收一个会话 id,例如 Responses 形的 previous_response_id、各 SDK 的原生 session / thread):用 ctx.session.id 读取,用 ctx.session.capture(id) 写回。
本教程的接口是前一种:
这里不需要“第一轮”分支。新会话线上 ctx.session.get(historySlot) 返回 undefined,?? [] 把它变成空历史,第一轮自然就是空历史加一条新消息。也不需要在 defineAgent 上声明额外能力:用了 ctx.session,多轮就能续上;不用,每轮各是一场新对话。 应用接口收会话 id 时,历史留在服务端,Adapter 只记住 id。send 里只改两处:
capture 只在这条会话线还没记过 id 时生效。后端每轮重复回传 id,甚至因为分叉换了 id,都不会覆盖正在续接的会话。 这一步支持:多轮对话,以及 t.newSession() 的会话隔离。写一个两轮的评估用例,第二轮引用第一轮的内容。回复接不上时,先查请求里是否带上了历史或会话 id。

第三步:记录消费

答对了但多花十倍 token 的 Agent,不该和省着用的拿一样的分。要限制用量和费用,send 得把每轮的用量报上来。用量写在 Turn 的 usage 字段,和 events、status 并列。应用接口返回了用量就如实填上,运行器逐轮累加到会话线和这次运行。Chat Completions 形的返回自带 usage,照抄进来,send 的其余部分和第二步一样:
Usage 还有几个可选字段:cacheReadTokens / cacheCreationTokens、推理 token 数 reasoningTokens、请求次数 requests,以及 costUSD。每个字段只在接口真实提供时填。接口不返回用量,就整个不填 usage,其它断言不受影响。 costUSD 只填 Provider 或应用真实返回的美元费用。不要把上游估算、模型价目表或本地算出来的数写进去。 预算和 t.maxCost() 用的是另一个数:运行器根据 model、上报的 token 用量和价格表,自己算出 estimatedCostUSD。即使你填了 costUSD,这个估算也照常计算。价格表配置见 defineConfig 参考,预算用法见控制运行成本。 价格表里找不到这个模型的价格时,运行器算不出估算,t.maxCost() 的结果是 unavailable,不会把缺失当成 0。你显式配置了零费率、算出 estimatedCostUSD: 0 时,0 是有效的估算,照常和上限比较。 这一步支持:t.maxTokens() / t.maxCost() 断言,以及 niceeval show --usage 和 niceeval view 里的用量。show 里用量为空时,先查接口响应里有没有用量字段、send 有没有把它放进返回值。

第四步:把工具解析成事件

要断言 Agent 调了哪些工具、按什么顺序调,send 得把工具调用报上来。应用的返回里不只有回复文本,Chat Completions 形返回的 tool_calls 就记着这轮调过什么工具。 Adapter 最重要的工作就是把接口的返回翻译成标准事件流:这轮发生的每件事一个对象,按真实顺序放进 Turn.events。每个对象是下面几种类型之一,字段的实际取值见 Adapter 页的一轮完整示例:
翻译就是一张“返回字段 → 事件”的对照。手写出来是这样,send 的其余部分和第二步一样:
这段循环通常不用你写。返回是标准形状时,官方转换器一行就能替代上面全部代码。events、status,连第三步手抄的 usage 都在它的返回值里,直接 return:
接口是别的形状时,换成对应的转换器: 内置转换器只看响应形状,不关心应用的业务协议。只有接口是增量流、协议方又没有现成 reducer 时,才需要自己写映射。映射只说明每一帧对应什么动作,拼接、配对和何时形成完整事件由 deltaStream 处理。 你返回哪种事件,评估用例就能写哪类断言: Chat Completions 响应不保证列出全过程。应用可能在服务端跑完工具循环,只返回最终答案。所以 turnFromChatCompletion 不声明事件完整:calledTool 这类“调用过”的断言可用,notCalledTool 这类“没调用过”的断言会提示证据不完整。Responses 协议要求 output 数组列出全过程,turnFromResponses 声明事件完整,“没调用过”的断言可信。这个差别来自两种接口本身的约定。 这一步支持:turn.calledTool()、turn.toolOrder()、turn.maxToolCalls()、turn.noFailedActions() 等工具断言。需要跨 Turn 排序时,把调用对象从 turn 换成 session。跑完用 pnpm exec niceeval show @<locator> --execution 看这个 Attempt 的工具调用。工具没出现时,先查接口返回里有没有工具字段、映射有没有漏掉它。

第五步:HITL

应用会中途停下来等人,例如等工具审批或等用户补充信息。要测这类流程,send 要在两种轮次里各做一件事:
  • 等待的那一轮:返回 status: "waiting",并为每个待回答的问题返回一条带稳定 id 的 input.requested 事件。t.check(turn.status, equals("waiting")) 和 t.requireInputRequest() 读的就是它,回答也靠这个 id 对上问题。
  • 回答的那一轮:评估用例里的 t.respond(...) 到 Adapter 这边是又一次普通的 send,还在同一条会话线上,拿到同一份状态。回答随 input.responses 到达,每条带 requestId,以及 optionId 或 text(形态见不同回答的入参)。Adapter 先把回答交给应用,再接着取结果。被拒绝的工具调用,operation.finished 的 status 写 "rejected",不写 "failed"。拒绝是人的决定,不是工具故障,noFailedActions() 不会把它算成失败。
等待时还没读完的东西,例如一条读到一半的 SSE 流,也存在 ctx.session 上。Adapter 用 createSessionSlot<Pending>() 声明一份私有状态,等待时 ctx.session.set(...) 存进去,回答轮开头 ctx.session.take(...) 取回。取出后它就被清掉,只能取一次。 HITL 几乎总发生在流式接口上,应用停在流的中间。所以这一步的示例换成一个用 SSE 透传原生事件的应用,它同时用上前面几步的全部内容(完整可跑版本见 tier1 示例):
不需要 HITL 的接口,删掉和等待相关的三处(Pending、pendingSlot、开头的 take 分支),其余不变。等待、回答、继续执行的完整流程见 HITL。 这一步支持:t.check(turn.status, equals("waiting"))、t.requireInputRequest()、t.respond() / t.respondAll()。被拒绝的调用可以用 calledTool(toolMatch(..., { status: "rejected" })) 精确断言。t.requireInputRequest() 找不到请求时,先查等待的那一轮是否同时返回了 status: "waiting" 和 input.requested 事件。

第六步:接上 OTel trace

想在浏览器里看每一轮的模型调用和工具耗时,而应用已经有 OTel 埋点(标准 OTel HTTP 服务端埋点就够),就把它的 trace 接进来。接入分两部分:一部分在应用启动时配置,一次配好不再变;另一部分在 send 里,每轮都不同。 接收地址在应用启动时配置,不经过 send。 NiceEval 的 OTLP 接收地址每次运行都一样。在 niceeval.config.ts 里固定接收端口,应用启动时把 OTel exporter 指向这个地址,之后跑多少次评估用例都不用改:
send 里传的是这一轮的 trace context,不是接收地址。 ctx.telemetry.headers 是运行器每轮新生成的 W3C traceparent 头。把它展开进请求头,应用这一轮产生的 span 就会挂到这一轮名下,并发跑多条评估用例也不会混。回到本教程的 chat-app,send 里只多一行:
ctx.telemetry 只在配置了 OTel 接入时才有值。没配置时展开 undefined 也安全,这一行可以一直留着。不带这个头也能收到 span,但 NiceEval 只能按发送前后的时间判断 span 属于哪一轮,这个 Agent 的轮次会改成逐个执行。带上它,并发运行时 span 才能准确对上轮次。 这一步支持:niceeval view 中按轮显示的调用瀑布图,包括模型调用、工具执行、耗时和 token。断言仍然读取前几步返回的事件,span 只用于瀑布图。瀑布图为空或缺尾巴时怎么查,见 OTel 接入。

第七步:透传 experiment 的 flags(A/B 对比)

要比较两版 prompt 或两种策略,应用先把它们做成可以切换的配置。Experiment 声明 flags,运行器每轮通过 ctx.flags 原样交给 send。Adapter 不解释这些值,只随请求转发,由应用按参数切换:
两个 Experiment 文件各声明一份 flags,用 pnpm exec niceeval exp 分别跑同一批评估用例,就是一组 A/B 对比。这属于 Tier 3 接入,需要应用配合暴露开关,投入与回报见 Tier。flags 和 model、attempts 等其余字段见编写实验。 这一步支持:同一批评估用例在不同变体下的成绩对比。两边都跑完后,用 pnpm exec niceeval show --experiment <id> --experiment <id> 在终端对比,或用 pnpm exec niceeval view 在浏览器里看。两边成绩几乎一样时,先确认应用真的读了请求里的 flags。

对照:五个 t API 到 send 的形态

七步写完,再看评估用例里驱动对话的五个 API。它们到 Adapter 这边都是同一个 send 收到不同字段,不需要实现第二个方法:

相关阅读

  • Adapter — send 的输入、输出、三个接入等级和能力来源。
  • 接入你的 Agent — 最小接入、参数传递与可选能力。
  • HITL — 应用停下等人时的完整流程,以及评估用例和 Adapter 各自要做什么。
  • Drive — 评估用例侧的 t.send()、t.newSession() 与 HITL 用法。
  • Assert — 标准事件流驱动的完整断言词汇。