跳转到主要内容
把一个被测对象接进 NiceEval,就是给它写一个 adapterdefineAgent 包住一个 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.tsevals/ 目录)。 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.idisNew === 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 里,它表现为一轮 sendstatus: "waiting" 结束;eval 用 t.respond() 替人回答,agent 拿到回答后继续跑。 不是每个 agent 都有这条路径。下面几种情况直接跳过 HITL,不用实现,也不用管 waiting 状态:
  • 每轮一口气跑完的 agent:问答、检索、翻译这类单发即回的服务,执行中没有任何等人的环节;
  • 工具全部自动执行的 agent:没有审批门,模型决定调就调。内置的 claude-code / codex / bub 在沙箱里就是这样跑的(跳过权限确认),所以它们都不做 HITL;
  • 审批发生在你的执行循环之外:比如工单系统里人工复核 agent 的产出。那是另一个系统的流程,不经过 send,eval 评不到也不该评。
要做 HITL 的判断标准只有一条:你的 agent 执行循环里存在”停下来等人、拿到回答再继续”的分支,而你想把这条分支(批准、拒绝)写进 eval。

接 HITL:停轮等人,审批流断言可用

在会话续接之上加两条行为:
  1. agent 停下时,send 返回 status: "waiting",并且每个待回答的问题吐一条 input.requested 事件;
  2. 下一次 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.resultstatus"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 时可以对着它逐项核对行为。

相关阅读