t.calledTool 等)和调用瀑布图(niceeval view)都从 span 派生。
原理(一段话)
NiceEval 每次运行起一个本机 OTLP 接收器。你的 adapter 声明events: otelEvents(),send 只管把输入交给应用、把最终结果拿回来;本轮收到的 span 自动派生成标准事件流喂给断言,同一批 span 顺手画成瀑布图。主流埋点格式(AI SDK ai.*、GenAI semconv、OpenInference、OpenLLMetry、LangSmith 混合)自动识别,也可以显式指定(见下)。
otelEvents() 是怎么工作的
events: otelEvents() 本身不是运行时注入——它构造出来只是一个”事件来源”声明,adapter 文件加载时 NiceEval 就读到了。动态的部分在运行期,按时间顺序:
- 每次运行起一个接收器,整个被测对象共享。你的应用只有一条 OTel 管线、一个导出目标,并行跑多条 eval 也是发往同一个端点——NiceEval 不会要求应用”给每条 eval 发不同地方”(标准 OTel SDK 做不到)。端点经
ctx.telemetry交给send。(sandbox agent 例外:每个沙箱是独立进程,各有各的接收器。) - span 归属到轮,两条路:
- traceparent(推荐,并发安全):
send发请求时把ctx.telemetry.headers(W3C trace context)spread 进请求头,应用的埋点支持 context 传播的话(标准 OTel HTTP 服务端埋点都支持),本轮 span 自动挂到 NiceEval 给的 trace 下,按 traceId 精确归属; - 时间窗口(兜底):应用不传播 trace context 时按 send 前后的时间窗归属。窗口只在串行下可靠,所以这种情况 NiceEval 会把这个 agent 的 eval 并发降为 1 并提示,不会静默混流。
- traceparent(推荐,并发安全):
- 归属结算后派生事件。按格式识别把 span 翻成标准事件流喂断言;你
send返回里如果也带了events,两边按时间戳合并。应用侧要及时导出(SimpleSpanProcessor或每轮 flush,见双发一节)。
gen_ai span,各认各的,不冲突。
格式怎么选:默认自动,可显式指定
不知道自己的埋点产的是什么格式?不用管,默认自动识别。知道、或者想要更准的报错时,显式指定官方格式模块:otel.aiSdk(ai.*,legacy experimental_telemetry)、otel.genAi(GenAI semconv,含 @ai-sdk/otel、OpenClaw 等原生标准输出)、otel.openInference、otel.openLLMetry、otel.langsmith。每次运行结束会在日志里报告本轮 span 被识别成了什么格式;一条都没识别出来时,warning 会列出收到的 span 名方便排查。
接法
1. adapter 侧——send 退化成纯收发:
端点怎么交给应用
NiceEval 的接收端点默认每次运行动态分配。但不要求你的应用会”运行时换端点”——标准 OTel SDK 做不到这件事(OTEL_* 环境变量只在进程启动时读一次)。按部署形态选:
-
子进程 / CLI / 由 NiceEval 拉起的服务:什么都不用做。
ctx.telemetry.env(标准OTEL_*环境变量,ready-to-spread)注入进程环境,每次 run 是新进程、读到新端点——“每次 run 替换端点”在这条路上是自动的。 -
同进程 runtime(
aiSdkAgent/ 直调):exporter 做成可切换的(见下面双发一节的SwitchableExporter),每轮point(ctx.telemetry.endpoint)。 -
你自己长驻的服务:用固定端口模式。在
niceeval.config.ts里钉住接收端口(或设NICEEVAL_OTLP_PORT):服务启动时一次性配OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces,之后跑多少次 eval 都不用改——动态性收到 NiceEval 这边。代价:端口共享意味着同一台机器同时只能跑一个 niceeval 进程(轮次归属靠时间窗口,两个进程的 span 会混流);OTel Collector 扇出场景同理指向这个固定端点。
- AI SDK
- LangGraph / LangChain
- OpenLLMetry
- OpenInference
- 自己埋的 gen_ai
推荐官方 OTel 集成(exporter 走标准 OTel Node SDK,endpoint 用注入的
@ai-sdk/otel,产标准 GenAI 语义);老的 experimental_telemetry 也能被识别:OTEL_EXPORTER_OTLP_ENDPOINT。可跑示例:examples/zh/ai-sdk-v7。npx niceeval view,瀑布图也在——数据源是同一批 span。
已经有自己的 OTel 后端?双发,不用换
你的应用多半已经把 trace 发给自己的观测后端(Langfuse / SigNoz / 生产 collector)。接 NiceEval 不需要换后端、也不需要第二套埋点:OTel 的 TracerProvider 支持挂多个 SpanProcessor——同一批 span,两个出口。唯一要处理的是 NiceEval 的端点每次运行才知道(ctx.telemetry.endpoint),所以第二个出口做成可切换的:
send 每轮把本次端点设进去、结束时 flush:
- 线上零影响。不在 eval 里跑时
telemetry是undefined,NiceEval 出口静默关闭——同一份埋点代码线上线下通用,不需要 if-eval 分支。 - eval 场景用
SimpleSpanProcessor+ 每轮forceFlush。评测在意的是”这一轮的 span 及时到齐”(轮次归属靠时间窗口),不在意导出吞吐;BatchSpanProcessor的缓冲会让 span 跨轮迟到。 - AI SDK 推荐用官方 OTel 集成(
@ai-sdk/otel):产出的就是标准 GenAI 语义(chat {model}/execute_tool {tool}),NiceEval 直接信任;老的experimental_telemetry(ai.*方言)也在自动识别范围内。 - 长驻服务不方便改进程内代码的话,两条路:固定端口模式(见「端点怎么交给应用」,服务把第二个 exporter 一次性指到固定端点);或 OTel Collector 扇出——应用只发给 collector,collector 配两个 exporter(你的后端 + NiceEval 的固定端点)。Collector 的代价是多运维一个组件,本地开发不如进程内双发轻。
aiSdkAgent 的话,这一节整个可以跳过:工厂已经内置了一个比全局 SwitchableExporter 更干净的变体——按 endpoint 缓存 provider,每轮经 telemetry.integrations 传入绑定该 endpoint tracer 的集成(per-call 覆盖全局注册,并行 attempt 各用各的出口,天然不串流),轮末自动 flush。声明 capabilities: { tracing: true },generate 把收到的 telemetry 原样传给 generateText 即可;要双发就设 otlpBackendUrl。可跑示例:examples/zh/ai-sdk-v7。
一件必须你自己判断的事:负断言
otelEvents() 会自动给你正断言(calledTool、toolOrder、event……span 里有就能断)。但 notCalledTool / maxToolCalls 这类负断言依赖”事件是全量的”——你的埋点是否覆盖了应用的全部工具层,NiceEval 无法替你验证(典型缺口:只埋了 LLM 调用、没埋工具执行)。确认覆盖完整后自己声明:
边界
- 多轮会话、HITL 不归 span 管。span 没有”等人输入”语义,会话续接也是应用协议的事——这两样照常在
send里做(教程第三步与 HITL 节)。 - 消息文本取决于生态。AI SDK / LangSmith / OpenLLMetry / OpenInference 默认记录输入输出;OTel 官方 instrumentation 默认不采内容,要设
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT。没内容时messageIncludes会正常失败(不会静默)。 - 收不到 span 会有 warning。整轮 0 span 通常是端点没接上(env 没注入、服务没重启),NiceEval 会提示而不是默默全过。
相关阅读
- 接入你的 agent —— 没接 OTel 的应用走这条路(手写事件映射)。
- 能力位参考 —— 为什么负断言要你亲自声明。
- 事件流参考 —— span 派生出的事件长什么样。