send 的契约:接收 TurnInput 和 AgentContext,返回 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。
确认应用接口形状
NiceEval 不定义新的应用协议。现有应用通常使用下列协议或其变体,内置转换器按这些响应形状提供:
以下步骤使用 Chat Completions 形接口。Responses 形接口和流式接口的替换实现分别在第二步和第四步给出。
第一步:发一条消息,拿到回复
最小的send 只做三件事:把 input.text 发给应用的接口,把回复放进一条 message 事件,报告本轮 status:
progress 不落盘;diagnostic 会随 Attempt 保存并可通过 locator 回顾。HTTP 连接失败或响应无法解析时直接抛错,runner 会记录错误发生在 agent.run,并把 Attempt 标为 errored。
这一步支持:t.reply、t.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 上和 events、status 并列的第四个字段 usage:应用接口回了用量就如实填上,运行器逐轮累加到会话线与整次运行。Chat Completions 形返回自带 usage,照抄进来——send 的其余部分和第二步完全一样:
Usage 的完整字段还有可选的 cacheReadTokens / cacheWriteTokens、请求次数 requests,以及 costUSD——网关回了实测成本就填它,优先于价格表估算。接口不回用量就整个不填 usage,其它断言不受影响。这段照抄也是过渡:下一步的官方转换器会连 usage 一起填好。
这一步支持:t.maxTokens() / t.maxCost() 评分器(maxCost 用 costUSD 或配置里的价格表估算),以及报告和 niceeval view 里的用量。
第四步:把工具解析成事件
应用的返回里不只有回复文本——Chat Completions 形返回的tool_calls 记录了这轮调过什么工具。Adapter 最重要的工作就是把接口的返回归一成标准事件流:本轮发生的每件事一个对象,按真实发生顺序排进 Turn.events,对象是下面十种类型之一(各字段的实际值,契约页有一轮的完整示例):
send 的其余部分和第二步完全一样:
events、status、连第三步手抄的 usage 都在返回值里,拿来直接 return:
内置转换器按响应形状工作,不假设具体应用协议。只有增量流且协议方没有现成 reducer 时才需要编写映射;映射只声明每一帧对应的操作,拼接、配对和落盘时机由
deltaStream 处理。
归一完成后,你吐哪种事件,评估用例作者就能写哪族断言:
Chat Completions 响应不保证包含完整过程记录。应用可能在服务端完成工具循环,只返回最终答案。因此,
fromChatCompletion 的返回不带完整性证明:calledTool 等正断言可用,notCalledTool 等负断言会提示证据不完整。Responses 协议要求 output 数组记录完整过程,fromResponses 的返回带完整性证明,负断言可信。两者的可信度差异来自接口契约。
这一步支持:t.calledTool()、t.toolOrder()、t.maxToolCalls()、t.noFailedActions() 等工具断言。
第五步:HITL
应用中途停下来等人(工具审批、等补充信息)时,send 两侧各有义务:
- 停轮:返回
status: "waiting",并且每个待回答的问题吐一条带稳定id的input.requested事件——t.parked()、t.requireInputRequest()读它,回答靠这个id对位。 - 回答轮:评估用例里的
t.respond(...)到 Adapter 是又一次普通的send(还是同一条会话线、同一份状态),人的裁决以结构化形式随input.responses到达,每条带requestId、optionId或text(形态见不同回答的入参)。Adapter 先把裁决交回应用,再接着取结果。被人拒绝的调用,action.result的status置"rejected"而不是"failed"——拒绝是人的决定、不是工具故障,noFailedActions()不误伤。
ctx.session 上:停轮时 ctx.session.hold(现场),回答轮开头 ctx.session.take() 取回——取到即清除,一次消费。
HITL 几乎总是发生在流式接口上(停在流中间),所以这一步的示例换成一个以 SSE 透传原生事件的应用——它同时用上前面几步的全部内容(完整可跑版本见 tier1 示例):
Pending、hold、开头的 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,之后跑多少次评估用例都不用再改:
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 不解释它们的含义,只随请求转发——应用按参数切换变体:
flags,npx niceeval exp 分别跑同一批评估用例,就是一组 A/B 对比。这是三档接入里的 Tier 3(要求应用配合暴露开关),投入与回报见 Tier;flags 与 model、runs 等其余 experiment 字段见写实验。
这一步支持:同一批评估用例跨变体的成绩对比。
对照:五个 t API 到 send 的形态
七步写完,回头看评估用例侧的五个驱动 API——它们到send 只是同一个函数收到不同字段,没有第二个要实现的方法:
相关阅读
- Adapter —
send的输入、输出、三个接入等级和能力来源。 - 接入你的 Agent — 最小接入、参数传递与可选能力。
- HITL — 停轮等人的完整概念:握手时序与两侧义务。
- Drive — 评估用例侧的
t.send()、t.newSession()与 HITL 用法。 - Assert — 标准事件流驱动的完整断言词汇。