> ## Documentation Index
> Fetch the complete documentation index at: https://niceeval.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 接入你的 Agent

> 接入 = 写一个 adapter：一个 send 函数。本文带你写出它并跑通第一条 eval，再按需给 adapter 补事件流、多轮、HITL 和 tracing。

把一个被测对象接进 NiceEval，就是给它写一个 **adapter**：`defineAgent` 包住一个 `send` 函数——接一次输入，驱动你的 agent，返回这一轮结果。接入的全部工作就这一个文件。

要让它跑起来还需要两样配套：**experiment**（评谁、怎么跑）和 **eval**（断言）——它们不是接入的一部分，但第一步会一起带到，让你立刻看到结果。之后事件流、多轮、HITL、tracing 都是 adapter 的可选增量，加哪个就解锁哪组断言，已写的 eval 不用改。

## 先选路：你的被测对象是什么？

<CardGroup cols={3}>
  <Card title="AI SDK 应用" icon="bolt" href="/zh/reference/builtin-agents">
    用 Vercel AI SDK（`generateText` 工具循环）写的应用：直接用内建工厂 `aiSdkAgent`，所有能力都已做好。不用读本文。
  </Card>

  <Card title="Coding agent CLI" icon="terminal" href="/zh/guides/sandbox-agent">
    评 claude-code / codex / bub 这类改文件的 coding agent：用内置 sandbox agent。
  </Card>

  <Card title="其它 AI Agent" icon="plug">
    自研 agent loop、LangGraph / OpenAI Agents SDK 应用、已部署 agent：优先[通过 OTel 接入](/zh/guides/connect-otel)；接不了 OTel 再走本文的手写映射。
  </Card>
</CardGroup>

<Tip>
  **建议优先通过 OTel 接入**：应用已经接了 OpenTelemetry（或愿意加几行埋点配置）的话，[通过 OTel 接入](/zh/guides/connect-otel)可以跳过本文第二步的全部事件映射——工具断言和瀑布图直接从 span 派生。本文的手写映射路线留给接不了 OTel 的被测对象。两条路的第一步（最小接入）和第三步之后（多轮、HITL）是共用的。
</Tip>

## 第一步：跑通最小接入

假设项目已经 `npx niceeval init` 过（有 `niceeval.config.ts` 和 `evals/` 目录）。

**1. 写 adapter。** 接入的正文就这一个文件：你的 agent 说了什么放一条 `message` 事件，结构化输出放 `data`：

```ts theme={null}
// 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：

```ts theme={null}
// 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，然后跑：**

```ts theme={null}
// 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);
  },
});
```

```bash theme={null}
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 配置：

```bash theme={null}
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。

<Note>
  注意这里**一个能力位都没写**：不声明 = 不承诺，此时只有基础断言可用，这是诚实的默认。后面每做到一个能力，要么由实现自动证明（用官方转换器、写 `tracing` 块），要么你验证过了亲手声明一行。哪些能自动、哪些要声明，见[能力位参考](/zh/reference/capabilities)。
</Note>

## 第二步：吐事件流，解锁工具断言

<Note>
  应用已接 OTel 的话本节整个跳过——[通过 OTel 接入](/zh/guides/connect-otel)，事件从 span 派生，不用手写映射。
</Note>

把你的 agent"这一轮调了什么工具"翻成标准事件，工具断言就可用了。核心是三种事件（完整词汇见[事件流参考](/zh/reference/events)）：

```ts theme={null}
{ type: "message", role: "assistant", text: "..." }                        // 说了什么
{ type: "action.called", callId: "c1", name: "get_weather", input: {...} } // 调了什么
{ type: "action.result", callId: "c1", output: {...}, status: "completed" } // 结果如何
```

agent 返回里带工具调用记录的话，映射就是一段小循环（[示例](/zh/reference/events#一个完整的映射示例)）。要点三条：按真实顺序排、`callId` 配对、**全部**调用都在流里。前两条错了断言会失真；第三条做到并验证过之后，声明 `capabilities: { toolObservability: true }`——手工映射的完整性只有你知道，这是少数要亲手声明的能力（用 `fromAiSdk` 这类官方转换器则自动带证明，不用声明）。之后：

```ts theme={null}
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`，它只有两个字段：

```ts theme={null}
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 直接透传就行：

```ts theme={null}
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：

```ts theme={null}
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` 后，跨轮记忆与会话隔离断言可用：

```ts theme={null}
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("我叫什么?");
    // 新会话不应知道名字 —— 隔离断言
  },
});
```

<Warning>
  常见错误是忽略 `isNew` 一律续接：`t.newSession()` 造出的"新会话"实际共享上下文，隔离断言全部失真，而且不会报错。写完先用上面这条 eval 验一下隔离。
</Warning>

## 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：停轮等人，审批流断言可用

在会话续接之上加两条行为：

1. agent 停下时，`send` 返回 `status: "waiting"`，并且每个待回答的问题吐一条 `input.requested` 事件；
2. 下一次 `send`（就是 `t.respond` 的回答，adapter 看到的只是一次带 resume 的普通 send）把人的回答交回 agent。

```ts theme={null}
// 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 侧的审批流从此可写：

```ts theme={null}
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 接入](/zh/guides/connect-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`，几十行）。

```ts theme={null}
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`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/ai-sdk-v7) 用六条 eval 逐个演示（结构化输出、事件流断言、多轮隔离、HITL 批准 / 拒绝、多模态、trace）。写自己的 adapter 时可以对着它逐项核对行为。

## 相关阅读

* [通过 OTel 接入](/zh/guides/connect-otel) —— 已埋点应用的免映射路线（优先推荐）。
* [事件流参考](/zh/reference/events) —— 九种事件的字段与消费它们的断言。
* [能力位参考](/zh/reference/capabilities) —— 每一位干什么、为什么是声明。
* [内置 Agent 能力](/zh/reference/builtin-agents) —— 内置 adapter 各自做到了哪些能力。
* [defineAgent 参考](/zh/reference/define-agent) —— `defineAgent` / `defineSandboxAgent` 的完整参数。
* [编写 eval](/zh/guides/authoring) —— eval 侧的完整写法。
