跳转到主要内容
Adapter 定义了 send 的契约:接收 TurnInputAgentContext,返回 Turn。本教程从发送一条消息开始,逐步加入完整接入所需的能力。每一步只增加少量代码,已有部分以 // ……省略 标出;步骤末尾列出新增数据支持的断言。完成任一步后,都可以把对应断言写进评估用例,重跑 npx niceeval exp,再在 niceeval view 中检查多轮轨迹、用量、工具事件或待回答请求。 三个贯穿全文的原则:
  • 连接用户前端正在使用的接口。 Adapter 调用相同端点并接收相同格式,不为评估用例新建接口,也不 import 应用内部代码直接调用函数。具体原因见接入你的 Agent
  • 只手写 transport。 URL、鉴权和请求体取决于应用。niceeval/adapter 提供从原始返回到标准事件流的转换器;ctx.session 提供会话续接与 HITL 暂停恢复所需的状态 API。
  • 运行反馈走 ctx,不直接写终端。 长步骤用 ctx.progress(...);需要在运行后回顾的退化或异常上下文用 ctx.diagnostic(...);无法继续时抛错。不要从 Adapter 调用 console.log/error 或写 process.stdout/stderr
一次 t.send 的完整往返:评估用例调用 t.send,运行器组装 TurnInput 与 ctx,adapter 调用你的应用并返回标准事件流 Turn。

确认应用接口形状

NiceEval 不定义新的应用协议。现有应用通常使用下列协议或其变体,内置转换器按这些响应形状提供: 以下步骤使用 Chat Completions 形接口。Responses 形接口和流式接口的替换实现分别在第二步和第四步给出。

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

最小的 send 只做三件事:把 input.text 发给应用的接口,把回复放进一条 message 事件,报告本轮 status
如果接口返回了可继续处理、但证据不完整的响应,报告 diagnostic 而不是把原始响应全部打印出来:
progress 不落盘;diagnostic 会随 Attempt 保存并可通过 locator 回顾。HTTP 连接失败或响应无法解析时直接抛错,runner 会记录错误发生在 agent.run,并把 Attempt 标为 errored 这一步支持t.replyt.messageIncludes()、Judge 的全部对话材料,以及 Experiment 侧的模型对比ctx.model 来自 Experiment 的 model;运行器原样传递,Adapter 只负责转发。接入等级见 Tier 它有两个明显的局限:每轮都是一场全新对话(第二次 t.send 接不上第一次),工具调用完全看不见。后面两步各解决一个。

第二步:接上之前的消息

运行器在会话上只承诺一件事:同一条会话线的每次 send 拿到同一个 ctx.session,新会话线(评估用例的第一轮,或 t.newSession() 之后)拿到一个全新的。 会话续接方式取决于应用接口形状。ctx.session 为两种常见模式分别提供一对存取器:
  • 客户端带全量历史(服务端无状态,每轮发完整消息列表:Chat Completions 形是典型)→ ctx.session.history<TMsg>()
  • 服务端记历史(接口收一个会话 id:Responses 形的 previous_response_id、各 SDK 的原生 session / thread)→ ctx.session.id + ctx.session.capture(id)
主线的接口是前者:
注意这里没有”第一轮”分支:新会话线的 history.get() 自然返回空数组——“第一次发”的形态是新会话线的自然结果,不是要你判断的条件。也不需要在 defineAgent 上声明任何东西:接了 ctx.session,多轮就续得上;没接,每轮各是一场新对话。 应用接口收会话 id 的话,历史在服务端,Adapter 只记 id——send 里只改两处:
capture 只在还没记过 id 时落地,后端重复回传(甚至因 fork 变了)也不会覆盖正在续接的线。 这一步支持:多轮对话,以及 t.newSession() 的会话隔离。

第三步:记录消费

答对了但烧掉十倍 token 的 agent,不该跟省着用的拿一样的分。消费是 Turn 上和 eventsstatus 并列的第四个字段 usage:应用接口回了用量就如实填上,运行器逐轮累加到会话线与整次运行。Chat Completions 形返回自带 usage,照抄进来——send 的其余部分和第二步完全一样:
Usage 的完整字段还有可选的 cacheReadTokens / cacheWriteTokens、请求次数 requests,以及 costUSD——网关回了实测成本就填它,优先于价格表估算。接口不回用量就整个不填 usage,其它断言不受影响。这段照抄也是过渡:下一步的官方转换器会连 usage 一起填好。 这一步支持t.maxTokens() / t.maxCost() 评分器(maxCostcostUSD 或配置里的价格表估算),以及报告和 niceeval view 里的用量。

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

应用的返回里不只有回复文本——Chat Completions 形返回的 tool_calls 记录了这轮调过什么工具。Adapter 最重要的工作就是把接口的返回归一成标准事件流:本轮发生的每件事一个对象,按真实发生顺序排进 Turn.events,对象是下面十种类型之一(各字段的实际值,契约页有一轮的完整示例):
解析就是一张”返回字段 → 事件”的映射。手写出来长这样——send 的其余部分和第二步完全一样:
但这段循环通常不用你写。返回是标准形状时,官方转换器一行顶替上面全部——eventsstatus、连第三步手抄的 usage 都在返回值里,拿来直接 return
接口不是这个形状,就换对应的件: 内置转换器按响应形状工作,不假设具体应用协议。只有增量流且协议方没有现成 reducer 时才需要编写映射;映射只声明每一帧对应的操作,拼接、配对和落盘时机由 deltaStream 处理。 归一完成后,你吐哪种事件,评估用例作者就能写哪族断言: Chat Completions 响应不保证包含完整过程记录。应用可能在服务端完成工具循环,只返回最终答案。因此,fromChatCompletion 的返回不带完整性证明:calledTool 等正断言可用,notCalledTool 等负断言会提示证据不完整。Responses 协议要求 output 数组记录完整过程,fromResponses 的返回带完整性证明,负断言可信。两者的可信度差异来自接口契约。 这一步支持t.calledTool()t.toolOrder()t.maxToolCalls()t.noFailedActions() 等工具断言。

第五步:HITL

应用中途停下来等人(工具审批、等补充信息)时,send 两侧各有义务:
  • 停轮:返回 status: "waiting",并且每个待回答的问题吐一条带稳定 idinput.requested 事件——t.parked()t.requireInputRequest() 读它,回答靠这个 id 对位。
  • 回答轮:评估用例里的 t.respond(...) 到 Adapter 是又一次普通的 send(还是同一条会话线、同一份状态),人的裁决以结构化形式随 input.responses 到达,每条带 requestIdoptionIdtext(形态见不同回答的入参)。Adapter 先把裁决交回应用,再接着取结果。被人拒绝的调用,action.resultstatus"rejected" 而不是 "failed"——拒绝是人的决定、不是工具故障,noFailedActions() 不误伤。
“停轮时读了一半的现场”(比如一条读到一半的 SSE 流)也存在 ctx.session 上:停轮时 ctx.session.hold(现场),回答轮开头 ctx.session.take() 取回——取到即清除,一次消费。 HITL 几乎总是发生在流式接口上(停在流中间),所以这一步的示例换成一个以 SSE 透传原生事件的应用——它同时用上前面几步的全部内容(完整可跑版本见 tier1 示例):
不需要 HITL 的接口,删掉停轮现场相关的三处(Pendinghold、开头的 take 分支)即可,其余不变。停轮 / 回答 / 续跑的完整心智模型见 HITL 这一步支持t.parked()t.requireInputRequest()t.respond() / t.respondAll(),以及 calledTool(..., { status: "rejected" }) 的精确断言。

第六步:接上 OTel trace

应用已经埋了点(标准 OTel HTTP 服务端埋点即可)的话,接入分两半——一半是启动期配置,一半在 send 里。分清它们就是分清「不变的」和「每轮变的」。 端点是启动期配置,不从 send 传。 NiceEval 的 OTLP 接收地址每次运行都一样,所以它不走 ctx:在 niceeval.config.ts 里把接收端口钉住,应用启动时把自己的 OTel exporter 指向这个固定 URL,之后跑多少次评估用例都不用再改:
send 里传的是本轮的 trace context,不是端点。 ctx.telemetry.headers 是运行器每轮新生成的 W3C traceparent 头——把它 spread 进请求,应用这一轮产生的 span 就精确挂到本轮的 trace 下,并发跑多条评估用例时归属不混。回到主线的 chat-app,send 里只多一行:
ctx.telemetry 只在配置了 OTel 接入时出现,没配时 spread 一个 undefined 也安全,这行可以常驻。不带这个头 span 也能收到,但归属退化成时间窗口、该 agent 的轮次会降为串行——带上它是并发下归属准确的来源。 这一步支持niceeval view 中按轮显示调用瀑布图,包括模型调用、工具执行、耗时与 token。断言仍读取前几步产生的事件;span 只用于瀑布图。接收器配置和 span 归属规则见 OTel 接入

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

应用把变体暴露成可切换的配置后,experiment 声明 flags,运行器每轮经 ctx.flags 原样递给 send;Adapter 不解释它们的含义,只随请求转发——应用按参数切换变体:
两个 experiment 文件各声明一份 flagsnpx niceeval exp 分别跑同一批评估用例,就是一组 A/B 对比。这是三档接入里的 Tier 3(要求应用配合暴露开关),投入与回报见 Tierflagsmodelruns 等其余 experiment 字段见写实验 这一步支持:同一批评估用例跨变体的成绩对比。

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

七步写完,回头看评估用例侧的五个驱动 API——它们到 send 只是同一个函数收到不同字段,没有第二个要实现的方法:

相关阅读

  • Adaptersend 的输入、输出、三个接入等级和能力来源。
  • 接入你的 Agent — 最小接入、参数传递与可选能力。
  • HITL — 停轮等人的完整概念:握手时序与两侧义务。
  • Drive — 评估用例侧的 t.send()t.newSession() 与 HITL 用法。
  • Assert — 标准事件流驱动的完整断言词汇。