跳转到主要内容
如果你的被测应用已经接了 OpenTelemetry——用 AI SDK 的 telemetry、LangGraph 的 LangSmith 导出、OpenLLMetry / OpenInference 自动埋点,或自己按 GenAI 语义埋的点——那么 NiceEval 需要的数据你已经在生产了。不用写事件映射:让应用把 span 发给 NiceEval,工具断言(t.calledTool 等)和调用瀑布图(niceeval view)都从 span 派生。 这是 Tier 1(无侵入)接入的观测手段:应用代码一行不改,观测和模型对比 experiment 都能做(见接入分两个 Tier)。要对照应用内部的功能变体(feature A/B test)则属于 Tier 2,OTel 替代不了。

原理(一段话)

NiceEval 每次运行起一个本机 OTLP 接收器。你的 adapter 声明 events: otelEvents()send 只管把输入交给应用、把最终结果拿回来;本轮收到的 span 自动派生成标准事件流喂给断言,同一批 span 顺手画成瀑布图。主流埋点格式(AI SDK ai.*、GenAI semconv、OpenInference、OpenLLMetry、LangSmith 混合)自动识别,也可以显式指定(见下)。

otelEvents() 是怎么工作的

events: otelEvents() 本身不是运行时注入——它构造出来只是一个”事件来源”声明,adapter 文件加载时 NiceEval 就读到了。动态的部分在运行期,按时间顺序:
  1. 每次运行起一个接收器,整个被测对象共享。你的应用只有一条 OTel 管线、一个导出目标,并行跑多条 eval 也是发往同一个端点——NiceEval 不会要求应用”给每条 eval 发不同地方”(标准 OTel SDK 做不到)。端点经 ctx.telemetry 交给 send。(sandbox agent 例外:每个沙箱是独立进程,各有各的接收器。)
  2. span 归属到轮,两条路:
    • traceparent(推荐,并发安全)send 发请求时把 ctx.telemetry.headers(W3C trace context)spread 进请求头,应用的埋点支持 context 传播的话(标准 OTel HTTP 服务端埋点都支持),本轮 span 自动挂到 NiceEval 给的 trace 下,按 traceId 精确归属;
    • 时间窗口(兜底):应用不传播 trace context 时按 send 前后的时间窗归属。窗口只在串行下可靠,所以这种情况 NiceEval 会把这个 agent 的轮次串行执行并在日志里提示,不会静默混流;一旦确认 traceparent 生效,自动恢复并发。
  3. 归属结算后派生事件。按格式识别把 span 翻成标准事件流喂断言;你 send 返回里如果也带了 events,两边按时间戳合并。应用侧要及时导出(SimpleSpanProcessor 或每轮 flush,见双发一节)。
格式识别是逐 span 的:一条流里混着 AI SDK 的 span 和你手工埋的 gen_ai span,各认各的,不冲突。

格式怎么选:默认自动,可显式指定

不知道自己的埋点产的是什么格式?不用管,默认自动识别。知道、或者想要更准的报错时,显式指定官方格式模块:
import { otelEvents, otel } from "niceeval/adapter";

// 默认:逐 span 自动识别
events: otelEvents()

// 显式指定:接不到数据时报错直接说"收到 37 条 span,0 条命中 ai.* 格式",
// 而不是笼统的"没有派生出事件"
events: otelEvents({ dialects: [otel.aiSdk] })

// 应用有私有埋点:自己实现一个格式模块,与官方的混用
events: otelEvents({ dialects: [myFormat, otel.genAi] })
官方格式模块:otel.genAi(GenAI semconv,含 @ai-sdk/otel、OpenClaw 等原生标准输出)、otel.codex(codex CLI 原生 OTLP,config.toml 的 [otel] 块——工具名 / call_id / usage 从 span 派生;span 上没有工具 I/O 和消息文本,可跑示例见 Codex SDK 如何接入)、otel.aiSdkai.*,legacy experimental_telemetry)、otel.openInferenceotel.openLLMetryotel.langsmith。每轮结束在日志里报告本轮 span 被识别成了什么格式;一条都没识别出来时,warning 会列出收到的 span 名方便排查。自定义方言实现 OtelDialect 契约(name / matches(span) / derive(span),类型从 niceeval/adapter 导入)即可与官方模块混用。

接法

1. adapter 侧——send 退化成纯收发:
// agents/support-bot.ts
import { defineAgent, otelEvents } from "niceeval/adapter";

export default defineAgent({
  name: "support-bot",
  events: otelEvents(),        // 事件流 + trace 都从本轮收到的 span 来
  async send(input, ctx) {
    const r = await fetch(`${process.env.BOT_URL}/chat`, {
      method: "POST",
      // traceparent 随请求带过去:本轮 span 挂到 NiceEval 的 trace 下,并发归属才精确
      headers: { "content-type": "application/json", ...ctx.telemetry?.headers },
      body: JSON.stringify({ message: input.text }),
      signal: ctx.signal,
    });
    return { data: await r.json(), status: r.ok ? "completed" : "failed" };
    // 不写 events —— otelEvents 负责
  },
});

端点怎么交给应用

NiceEval 的接收端点默认每次运行动态分配。但不要求你的应用会”运行时换端点”——标准 OTel SDK 做不到这件事(OTEL_* 环境变量只在进程启动时读一次)。按部署形态选:
  • 子进程 / CLI / 由 NiceEval 拉起的服务:什么都不用做。ctx.telemetry.env(标准 OTEL_* 环境变量,ready-to-spread)注入进程环境,每次 run 是新进程、读到新端点——“每次 run 替换端点”在这条路上是自动的。
  • 你自己长驻的服务:用固定端口模式。在 niceeval.config.ts 里钉住接收端口(或设 NICEEVAL_OTLP_PORT):
    // niceeval.config.ts
    export default defineConfig({
      telemetry: { port: 4318 },   // 接收器固定监听 http://localhost:4318/v1/traces
    });
    
    服务启动时一次性配 OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces,之后跑多少次 eval 都不用改——动态性收到 NiceEval 这边。代价:端口共享意味着同一台机器同时只能跑一个 niceeval 进程(轮次归属靠时间窗口,两个进程的 span 会混流);OTel Collector 扇出场景同理指向这个固定端点。
2. 应用侧——按你的埋点生态各自几行配置:
推荐官方 OTel 集成(@ai-sdk/otel,产标准 GenAI 语义);老的 experimental_telemetry 也能被识别:
import { generateText } from "ai";

const result = await generateText({
  model, tools, messages,
  experimental_telemetry: { isEnabled: true },
});
exporter 走标准 OTel Node SDK,endpoint 用注入的 OTEL_EXPORTER_OTLP_ENDPOINT。可跑示例:应用侧埋点见 examples/zh/origin/ai-sdk-v7src/backend/otel.ts,官方 @ai-sdk/otel),接入后的完整评测项目见 examples/zh/tier1/ai-sdk-v7,前后代码 diff 见AI SDK v7 如何接入
3. 写 eval——和手写映射的 adapter 完全一样:
export default defineEval({
  description: "查订单要走 lookup_order 工具",
  async test(t) {
    await t.send("帮我查订单 42 到哪了");
    t.calledTool("lookup_order", { input: { orderId: "42" } });
    t.toolOrder(["lookup_order"]);
    t.noFailedActions();
    t.maxTokens(8000);           // usage 从模型 span 聚合,顺带就有
  },
});
跑完 npx niceeval view,瀑布图也在——数据源是同一批 span。

已经有自己的 OTel 后端?双发,不用换

你的应用多半已经把 trace 发给自己的观测后端(Langfuse / SigNoz / 生产 collector)。接 NiceEval 不需要换后端、也不需要第二套埋点:OTel 的 TracerProvider 支持挂多个 SpanProcessor——同一批 span,两个出口:
// instrumentation.ts —— 应用启动时初始化一次:一份埋点,两个出口
import { NodeTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";

export const provider = new NodeTracerProvider({
  spanProcessors: [
    // 出口 1:你自己的后端,一直发
    new SimpleSpanProcessor(new OTLPTraceExporter({ url: process.env.MY_COLLECTOR_URL })),
    // 出口 2:NiceEval。端点来自「端点怎么交给应用」一节:
    // 子进程模式读每次 run 注入的 env;长驻服务用固定端口模式,值不变
    ...(process.env.OTEL_EXPORTER_OTLP_ENDPOINT
      ? [new SimpleSpanProcessor(new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }))]
      : []),
  ],
});
provider.register();
要点四条:
  • 线上零影响。线上没有 OTEL_EXPORTER_OTLP_ENDPOINT 时第二个出口根本不存在——同一份埋点代码线上线下通用,不需要 if-eval 分支。
  • eval 场景用 SimpleSpanProcessor。评测在意的是”这一轮的 span 及时到齐”(轮次归属需要),不在意导出吞吐;BatchSpanProcessor 的缓冲会让 span 跨轮迟到(AI SDK v7 示例实测过:瀑布图偶发缺尾巴就是它)。
  • AI SDK 推荐用官方 OTel 集成@ai-sdk/otel):产出的就是标准 GenAI 语义(chat {model} / execute_tool {tool}),NiceEval 直接信任;老的 experimental_telemetryai.* 方言)也在自动识别范围内。
  • 不方便改应用代码的话,OTel Collector 扇出——应用只发给 collector,collector 配两个 exporter(你的后端 + NiceEval 的固定端点)。代价是多运维一个组件。

一件必须你自己判断的事:负断言

otelEvents() 会自动给你正断言calledTooltoolOrderevent……span 里有就能断)。但 notCalledTool / maxToolCalls 这类负断言依赖”事件是全量的”——你的埋点是否覆盖了应用的全部工具层,NiceEval 无法替你验证(典型缺口:只埋了 LLM 调用、没埋工具执行)。确认覆盖完整后自己声明:
capabilities: { toolObservability: true },   // 我确认埋点盖住了全部工具
events: otelEvents(),
不确定就不声明——正断言照用,负断言不可信的风险不背。详见能力位参考

边界

  • 多轮会话、HITL 不归 span 管。span 没有”等人输入”语义,会话续接也是应用协议的事——这两样照常在 send 里做(教程第三步与 HITL 节)。
  • 审批门控的工具经常没有 span。被拒绝的调用从没真正执行,自然没有 execute_tool span;有的集成连”经审批后恢复执行”那条路径也不产 span(实测 @ai-sdk/otelneedsApproval 工具如此)。这类调用的 action.called/action.result 由 adapter 在 send 返回的 events 里手动补,NiceEval 会把它们和 span 派生的事件按时间戳合并——两个可跑示例(AI SDK v7LangGraph)的 adapter 都有现成写法。
  • 消息文本取决于生态。AI SDK / LangSmith / OpenLLMetry / OpenInference 默认记录输入输出;OTel 官方 instrumentation 默认采内容,要设 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT。没内容时 messageIncludes 会正常失败(不会静默)。另一个实测坑:LangChain ChatOpenAI 经 LangSmith OTel 导出的 completion 形状,otel.langsmith 方言目前解析不出 message 文本——adapter 可以照常从应用自己的流里补 message 事件(LangGraph 示例就是这么做的)。
  • 收不到 span 会有 warning。整轮 0 span 通常是端点没接上(env 没注入、服务没重启),NiceEval 会提示而不是默默全过。

相关阅读