send 传入 TurnInput 和 AgentContext,返回 Turn。这一页讲怎么写它——从”发一条消息拿到回复”的最小形态起步,一步一步加到完整接入。每一步只在上一步的代码上多几行,前面已有的部分在代码里用 // ……省略 标出;每一步末尾说明这几行解锁了哪些断言。改完任何一步都能立刻验证:把新解锁的断言写进一条 eval 重跑 npx niceeval exp,niceeval view 里能看到这一步新增的数据——多轮轨迹、用量、工具事件、待回答请求。
两个贯穿全文的原则:
- 接的位置永远是用户前端本来就在用的那个接口。 页面调哪个端点、收什么格式,Adapter 就调哪个端点、收什么格式——不为 eval 开新接口,也不 import 应用内部代码直调函数(为什么,见接入你的 Agent)。
- 你唯一真正手写的是 transport——URL、鉴权、请求体每家本来就不一样。解析(原始返回 → 标准事件流)有官方转换器,从
niceeval/adapter导出;编排(会话续接、HITL 暂停恢复)的状态槽就挂在ctx.session上,取用即可,不需要额外声明什么。
起点:你的应用接口是什么形状
NiceEval 不发明协议。你的应用大概率建在下面某种业界协议(或其变体)之上,官方件按这几种形状备齐:| 协议 | 谁在定义 | 一句话介绍 | 参考 |
|---|---|---|---|
| Chat Completions | OpenAI;业界事实标准,几乎所有模型服务商都提供兼容端点 | 无状态一问一答:客户端每轮发完整 messages 列表,一个 JSON 回来 | API 参考 |
| Responses / Open Responses | OpenAI(Responses API);Open Responses 是基于它的开放规范,OpenAI 发起、Hugging Face 等社区共建,用于多提供商互操作 | 有状态、面向 agent:请求带 previous_response_id 由服务端续接历史,output 数组承诺记录全过程 | API 参考 · Open Responses 规范 |
| AI SDK(Vercel) | Vercel;TypeScript 生态的事实标准 | 不是线上协议而是 SDK:generateText 返回完整结果,useChat 走它定义的 UI Message Stream 流协议 | generateText 参考 · UI Message Stream 协议 |
第一步:发一条消息,拿到回复
最小的send 只做三件事:把 input.text 发给应用的接口,把回复放进一条 message 事件,报告本轮 status:
t.reply、t.messageIncludes()、judge 的全部对话材料,以及 experiment 侧的模型对比(ctx.model 就是 experiment 声明的 model,运行器原样递给你、Adapter 不解释含义,只转发——不需要等到后面的步骤,也不需要应用配合做任何改造,分档见 Tier)。
它有两个明显的局限:每轮都是一场全新对话(第二次 t.send 接不上第一次),工具调用完全看不见。后面两步各解决一个。
第二步:接上之前的消息
运行器在会话上只承诺一件事:同一条会话线的每次send 拿到同一个 ctx.session,新会话线(eval 的第一轮,或 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:
| 接口的到达形状 | 官方件 | 你写什么 |
|---|---|---|
| Chat Completions 形返回 | fromChatCompletion(res) | 零映射 |
| Responses 形返回 | fromResponses(res) | 零映射 |
AI SDK generateText 的完整结果 | fromAiSdk(result) | 零映射 |
| 流式:SDK 原生事件透传(一帧一个完整单元) | fromClaudeSdkMessages() / fromPiAgentEvents() / fromCodexThreadEvents() | 零映射 |
流式:AI SDK 的 UI Message Stream(useChat 协议) | uiMessageStreamAgent() | 零映射(整个 Agent 都是现成的,连 send 都不用写) |
| 流式:逐 token / 逐参数增量 | 协议方官方 reducer;没有才用 deltaStream({ toOps }) | 一张”帧类型 → 操作”映射表 |
| 只有最终答案 | 手写几条 events | 一条 message 起步(第一步就是这个档) |
deltaStream 统一管。
归一完成后,你吐哪种事件,eval 作者就能写哪族断言:
| 你吐的事件 | 解锁的断言 |
|---|---|
message | t.reply、t.messageIncludes()、judge 的对话材料 |
action.called / action.result(靠 callId 配对) | t.calledTool() / t.toolOrder() / t.maxToolCalls() / t.noFailedActions()… |
input.requested(配合 status "waiting") | t.parked()、t.requireInputRequest()、t.respond()(第五步) |
thinking / compaction / error | 对应断言与 o11y 计数 |
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对位。 - 回答轮:eval 里的
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,之后跑多少次 eval 都不用再改:
ctx.telemetry.headers 是运行器每轮新生成的 W3C traceparent 头——把它 spread 进请求,应用这一轮产生的 span 就精确挂到本轮的 trace 下,并发跑多条 eval 时归属不混。回到主线的 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 不解释它们的含义,只随请求转发——应用按 flag 切换变体:
flags,npx niceeval exp 分别跑同一批 eval,就是一组 A/B 对比。这是三档接入里的 Tier 3(要求应用配合暴露开关),投入与回报见 Tier;flags 与 model、runs 等其余 experiment 字段见写实验。
这一步解锁:同一批 eval 跨变体的成绩对比。
对照:五个 t API 到 send 的形态
七步写完,回头看 eval 侧的五个驱动 API——它们到send 只是同一个函数收到不同字段,没有第二个要实现的方法:
| eval 侧 | 到 Adapter 时 | send 收到什么 |
|---|---|---|
t.send(text) | 一次 send | input.text;ctx.session 是这条会话线的自有状态 |
t.sendFile(path, text?) | 一次 send | 同上,外加 input.files(base64 的 InputFile[]);按应用接口的方式放进请求体,不支持多模态就忽略 |
t.newSession() | 不直接触发 send | 开第二条会话线;该线随后第一次 send 拿到一份全空的状态——和整个 eval 的第一次 send 形态完全相同 |
t.respond(...answers) | 一次普通的 send | input.text = 回答文本;input.responses 逐请求一条 { requestId, optionId }(自由文本回答则是 { requestId, text });还是同一条线、同一份状态 |
t.respondAll(optionId) | 一次普通的 send | 同上,每条待处理请求各一条回答;optionId 已在 eval 侧对每条请求校验过存在,写错直接抛,不会静默传给应用 |
相关阅读
- Adapter — 契约本身:
send传入什么返回什么、三个接入 Tier、能力从哪来。 - 接入你的 Agent — 接入全景:最小接入、参数通道与增量地图。
- HITL — 停轮等人的完整概念:握手时序与两侧义务。
- Drive — eval 侧视角:
t.send()、t.newSession()与 HITL 怎么用。 - Assert — 标准事件流驱动的完整断言词汇。