把一个被测对象接进 NiceEval,就是给它写一个 adapter:defineAgent 包住一个 send 函数——接一次输入,驱动你的 agent,返回这一轮结果。接入的全部工作就这一个文件。
要让它跑起来还需要两样配套:experiment(评谁、怎么跑)和 eval(断言)——它们不是接入的一部分,但第一步会一起带到,让你立刻看到结果。之后事件流、多轮、HITL、tracing 都是 adapter 的可选增量,加哪个就解锁哪组断言,已写的 eval 不用改。
先选路:你的被测对象是什么?
AI SDK 应用
用 Vercel AI SDK(generateText 工具循环)写的应用:直接用内建工厂 aiSdkAgent,所有能力都已做好。不用读本文。
Coding agent CLI
评 claude-code / codex / bub 这类改文件的 coding agent:用内置 sandbox agent。
其它 AI Agent
自研 agent loop、LangGraph / OpenAI Agents SDK 应用、已部署 agent:优先通过 OTel 接入;接不了 OTel 再走本文的手写映射。
建议优先通过 OTel 接入:应用已经接了 OpenTelemetry(或愿意加几行埋点配置)的话,通过 OTel 接入可以跳过本文第二步的全部事件映射——工具断言和瀑布图直接从 span 派生。本文的手写映射路线留给接不了 OTel 的被测对象。两条路的第一步(最小接入)和第三步之后(多轮、HITL)是共用的。
第一步:跑通最小接入
假设项目已经 npx niceeval init 过(有 niceeval.config.ts 和 evals/ 目录)。
1. 写 adapter。 接入的正文就这一个文件:你的 agent 说了什么放一条 message 事件,结构化输出放 data:
// agents/my-bot.ts
import { defineAgent } from "niceeval/adapter";
export default defineAgent({
name: "my-bot",
async send(input, ctx) {
const r = await fetch(`${process.env.MY_BOT_URL}/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: input.text }),
signal: ctx.signal,
});
const body = await r.json();
return {
status: r.ok ? "completed" : "failed",
data: body,
events: [{ type: "message", role: "assistant", text: body.reply }],
};
},
});
agent runtime 就在当前代码库里的话,fetch 换成直接调用即可。
2. 配套:注册进一个 experiment。 experiment 回答”评谁、跑几次”,adapter 直接 import:
// experiments/my-bot.ts
import { defineExperiment } from "niceeval";
import myBot from "../agents/my-bot.ts";
export default defineExperiment({
description: "my-bot 基线",
agent: myBot,
runs: 1,
});
3. 配套:写第一条 eval,然后跑:
// evals/refund-policy.eval.ts
import { defineEval } from "niceeval";
import { includes } from "niceeval/expect";
import { z } from "zod";
export default defineEval({
description: "退款政策问答",
async test(t) {
const turn = await t.send("你们的退款政策是什么?");
t.succeeded();
t.check(t.reply, includes("30 天"));
turn.outputMatches(z.object({ reply: z.string().min(1) }));
t.judge.autoevals.closedQA("回答是否说明了退款期限?").atLeast(0.7);
},
});
npx niceeval exp my-bot # 跑这个 experiment 下的全部 eval
npx niceeval exp my-bot refund # 只跑 ID 以 refund 开头的
npx niceeval view # 本地查看器里看结果
到这里第一条 eval 已经跑通:文本断言、结构化断言、judge 评分都能用。t.send() 也可以调多次——但在做第三步之前,第二轮不会记得第一轮说了什么,每次 send 是独立的一轮。
同一个 agent 要对着本地和生产分别跑的话,写两个 experiment 文件,URL 走环境变量或 adapter 配置:
MY_BOT_URL=http://localhost:3000/chat npx niceeval exp local
MY_BOT_URL=https://api.example.com/chat npx niceeval exp prod
不要把 URL 放进 CLI 位置参数——experiment 名之后的位置参数只用于过滤 eval ID。
注意这里一个能力位都没写:不声明 = 不承诺,此时只有基础断言可用,这是诚实的默认。后面每做到一个能力,要么由实现自动证明(用官方转换器、写 tracing 块),要么你验证过了亲手声明一行。哪些能自动、哪些要声明,见能力位参考。
第二步:吐事件流,解锁工具断言
应用已接 OTel 的话本节整个跳过——通过 OTel 接入,事件从 span 派生,不用手写映射。
把你的 agent”这一轮调了什么工具”翻成标准事件,工具断言就可用了。核心是三种事件(完整词汇见事件流参考):
{ type: "message", role: "assistant", text: "..." } // 说了什么
{ type: "action.called", callId: "c1", name: "get_weather", input: {...} } // 调了什么
{ type: "action.result", callId: "c1", output: {...}, status: "completed" } // 结果如何
agent 返回里带工具调用记录的话,映射就是一段小循环(示例)。要点三条:按真实顺序排、callId 配对、全部调用都在流里。前两条错了断言会失真;第三条做到并验证过之后,声明 capabilities: { toolObservability: true }——手工映射的完整性只有你知道,这是少数要亲手声明的能力(用 fromAiSdk 这类官方转换器则自动带证明,不用声明)。之后:
export default defineEval({
description: "查天气要走工具",
async test(t) {
await t.send("北京今天多少度?");
t.calledTool("get_weather", { input: { city: "北京" }, count: 1 });
t.toolOrder(["get_weather"]);
t.notCalledTool("send_email"); // 负断言:有完整事件流才可信
t.noFailedActions();
},
});
每轮能拿到 token 用量就顺手带上 usage({ inputTokens, outputTokens }),t.maxTokens() / t.maxCost() 与成本报表就都有了。
第三步:conversation——让第二轮接得上第一轮
t.send() 本来就能连调多次,问题是第二轮接不接得上第一轮的上下文。conversation 这个能力位管的就是这件事。实现的落点是 ctx.session,它只有两个字段:
interface AgentSession {
id?: string; // 可写:adapter 回传,供下一轮续接
readonly isNew: boolean; // 这条会话线的第一轮为 true,之后为 false
}
约定很短:isNew === true 时开新会话,把新 id 写回 ctx.session.id;isNew === false 时按 ctx.session.id 续接。具体怎么写,取决于你的 agent 接口把对话历史存在哪一边——分两种情况。
服务端存历史(OpenAI Responses API 这类:请求只带新消息和上一轮的 id)。id 直接透传就行:
async send(input, ctx) {
const r = await callMyBot({
message: input.text,
previousResponseId: ctx.session.isNew ? undefined : ctx.session.id,
}, ctx.signal);
ctx.session.id = r.responseId; // 写回,下一轮续接
return { status: "completed", data: r, events: toStreamEvents(r) };
}
你的 agent 有原生会话 id(--resume <id>、session_id 参数)的,也是这个写法。
客户端带全量历史(OpenAI Chat Completions 这类:服务端无状态,每次请求要发完整 messages)。历史由 adapter 自己存,拿 ctx.session.id 当 key:
const sessions = new Map<string, MyMessage[]>();
async send(input, ctx) {
if (ctx.session.isNew) ctx.session.id = crypto.randomUUID();
const history = sessions.get(ctx.session.id!) ?? [];
history.push({ role: "user", content: input.text });
const body = await callMyBot(history, ctx.signal);
history.push(...body.newMessages);
sessions.set(ctx.session.id!, history);
return { status: "completed", data: body, events: toStreamEvents(body) };
}
两种写法对 eval 侧完全一样。声明 conversation: true 后,跨轮记忆与会话隔离断言可用:
export default defineEval({
description: "跨轮记忆 + 新会话隔离",
async test(t) {
await t.send("我叫小明。");
await t.send("我叫什么?");
t.check(t.reply, includes("小明"));
const fresh = t.newSession(); // 全新会话线
await fresh.send("我叫什么?");
// 新会话不应知道名字 —— 隔离断言
},
});
常见错误是忽略 isNew 一律续接:t.newSession() 造出的”新会话”实际共享上下文,隔离断言全部失真,而且不会报错。写完先用上面这条 eval 验一下隔离。
HITL 是什么,哪些 agent 不需要
HITL(human-in-the-loop,人工介入)指 agent 在一轮执行中间停下来,等人给出输入再继续。最常见的是敏感操作前的审批——部署确认、发邮件、危险工具调用——也可能是向人索要缺失的信息。在 NiceEval 里,它表现为一轮 send 以 status: "waiting" 结束;eval 用 t.respond() 替人回答,agent 拿到回答后继续跑。
不是每个 agent 都有这条路径。下面几种情况直接跳过 HITL,不用实现,也不用管 waiting 状态:
- 每轮一口气跑完的 agent:问答、检索、翻译这类单发即回的服务,执行中没有任何等人的环节;
- 工具全部自动执行的 agent:没有审批门,模型决定调就调。内置的
claude-code / codex / bub 在沙箱里就是这样跑的(跳过权限确认),所以它们都不做 HITL;
- 审批发生在你的执行循环之外:比如工单系统里人工复核 agent 的产出。那是另一个系统的流程,不经过
send,eval 评不到也不该评。
要做 HITL 的判断标准只有一条:你的 agent 执行循环里存在”停下来等人、拿到回答再继续”的分支,而你想把这条分支(批准、拒绝)写进 eval。
接 HITL:停轮等人,审批流断言可用
在会话续接之上加两条行为:
- agent 停下时,
send 返回 status: "waiting",并且每个待回答的问题吐一条 input.requested 事件;
- 下一次
send(就是 t.respond 的回答,adapter 看到的只是一次带 resume 的普通 send)把人的回答交回 agent。
// agent 停在审批上时,本轮这样返回:
return {
status: "waiting",
events: [
...toStreamEvents(body),
{
type: "input.requested",
request: {
id: body.approvalId,
action: "send_email", // 停在哪个动作上
input: { to: "boss@corp.com" }, // 该动作的入参
options: [{ id: "approve" }, { id: "deny" }],
},
},
],
};
eval 侧的审批流从此可写:
export default defineEval({
description: "发邮件要过人工审批",
async test(t) {
const first = await t.send("给老板发一封周报邮件。");
t.check(first.status, equals("waiting"));
const req = t.requireInputRequest({ action: "send_email" });
await t.respond("approve"); // 批准;拒绝就 respond("deny")
t.calledTool("send_email", { status: "completed" });
},
});
人否决和工具故障是两回事:拒绝的调用把 action.result 的 status 置 "rejected"(而不是 "failed"),noFailedActions() 依然通过,calledTool(..., { status: "rejected" }) 可精确断言。
tracing:调用瀑布图
先分岔:你的应用已经接了 OpenTelemetry 吗?
- 已经接了(AI SDK telemetry、LangGraph、OpenLLMetry / OpenInference、自己埋的 gen_ai):走通过 OTel 接入——
events: otelEvents() 一行,事件流和瀑布图都从 span 来,连上面第二步的手工映射都省了。
- 没接,但想要瀑布图:让 agent 把 span 发到 NiceEval 给的端点。声明
capabilities: { tracing: true } 后,每次运行会起一个本机 OTLP 接收器,端点经 ctx.telemetry 交给你:子进程 / CLI 型把 ctx.telemetry.env(标准 OTEL_* 环境变量,ready-to-spread)注入子进程环境;手搓按 OTLP/JSON 直接 POST 也行(参考仓库 examples/zh/ai-sdk-v7/agent/otlp.ts,几十行)。
export default defineAgent({
name: "my-bot",
capabilities: { conversation: true, toolObservability: true, tracing: true },
async send(input, ctx) {
const body = await callMyBot(history, ctx.signal, {
otlpEndpoint: ctx.telemetry?.endpoint, // 应用把这一轮的 span 发到这里
});
// ...
},
});
跑完后 npx niceeval view 直接出瀑布图:每轮多久、模型调用和工具调用各占多少、谁套着谁——还能跨 agent / 跨模型叠着比。
参考实现
内建的 aiSdkAgent 把上面所有能力全部做满——仓库 examples/zh/ai-sdk-v7 用六条 eval 逐个演示(结构化输出、事件流断言、多轮隔离、HITL 批准 / 拒绝、多模态、trace)。写自己的 adapter 时可以对着它逐项核对行为。
相关阅读