Chapter 01 · Concepts

概念与边界:可观测性、eval、APM 三者别再搞混

起点已给出"可观测性 = 把一次运行还原成 span 因果树"的本质;这一章把这棵树的词汇与三方边界装进脑子。

本章你将建立的 schema

  • 看到"怎么知道 agent 好不好 / 出没出问题"的讨论,立刻分清它说的是可观测性 / eval / APM——三者时机、对象、判据全不同。
  • 把一次 agent 运行读成一棵 trace:invoke_agent 为根,chat / execute_tool / retriever 为子节点,靠 parent_span_id 相连。
  • 说得出一个 span 装什么、以及为什么 prompt / completion 放在 events(敏感且 opt-in)而不是 attributes。

大多数 SDK 文档的描述是:"接入之后你就有 trace 了。"这句话没错,但它跳过了三个真正重要的问题:那棵树由哪些字段构成?它和 eval、APM 的边界在哪?agent 为什么不能直接套传统 APM?本章沿这三条线逐一拆开。

§1三方边界:可观测性 ≠ eval ≠ APM

三者都是"知道系统在干什么"的手段,但时机、对象、判据完全不同——混用会导致用了工具却解决不了问题。

为什么要把边界划死

工程师最常踩的陷阱:在线上用 eval 的打分思路找根因(得分 0.4,然后呢?),或用 APM 的状态码思路判断 agent 对不对(200 OK,但答案是错的)。三方的数据流向和使用时机根本不同,混用只会让每个工具都发挥不出作用。

三方边界对比
维度 传统 APM 可观测性(本篇) eval(agent-eval)
时机 运行期 运行期 开发期 / CI
对象 单请求 单条真实运行 固定数据集
判据 状态码 + 延迟 还原"发生了什么 / 为什么 / 花了多少" 打分"这版 ≥ 上版吗"
输出 仪表盘 / 告警 span 树 + 每步的 I/O / token / 成本 / 延迟 分数 / 通过率 / 回归报告
参照系 确定性系统契约 非确定、语义失败可见 固定 golden set

APM 的契约在 agent 上为何失效

传统 APM 的核心假设是:状态码 = 健康信号。一个 HTTP 服务返回 200,APM 认为它正常。但 agent 违反这个假设,原因有四条:

  • 语义失败对状态码不可见。差旅助理 agent 返回 status 200,给员工订了一张票——但这张票违反了差旅政策,或目的地是"巴黎"而正解是"里昂"。HTTP 层没有错,LLM 的推理在语义层出了问题,APM 对此完全盲。
  • 非确定性打不了回归断言。同一个 prompt,同一个 seed,两次调用的输出未必逐字相同(温度 > 0 时更明显)。APM 期望的"同一请求 = 同一响应"契约不成立,所以不能像传统服务那样对输出做断言。
  • token 与成本是一等信号,APM 从没有。一次 search_flights 失败触发重试,可能多刷了 4 次 chat,成本翻倍,延迟翻倍——APM 的延迟图看到的是一条慢请求,看不到是哪个子步骤在烧 token。
  • 延迟来自外部 I/O,不是 CPU。agent 的延迟瓶颈 90% 是 LLM 推理(网络 RTT + 首 token 等待时间)和外部工具 API,不是本地计算。火焰图会照出一个巨大的"等待"空循环,找不到可优化的热点——它解决的是 CPU 密集型服务的问题,不是这里的问题。

这四条加在一起,决定了 agent 必须有自己的可观测性——而不是挂一个现成的 APM 就完事。

想一想

某团队的差旅助理 agent 每天跑 500 次,成本监控显示昨天成本涨了 80%,但 Datadog APM 的错误率 / 延迟仪表盘没有任何异常。如果手边有完整的 span 树,第一步该看哪里?

展开思路

首先按 invoke_agent 根 span 排序,找成本最高的那些运行;展开它们的子 span,比较各 chat span 的 gen_ai.usage.input_tokens + output_tokens;再看 execute_tool 的 span——重试导致同一工具被调多次时,会看到同名 execute_tool span 并列出现,超过 1 次就说明在重试。APM 看不到这层,因为它没有 token 信号,也没有工具调用的子树结构。

在线评估:三方的唯一接缝

三者之间存在一条接缝:在线评估(online evaluation)。scorer 对采样的生产 trace 打分、把分数写回 trace 成可查询字段,低分 trace 一键提拔进离线数据集,喂回 eval 的回归集——这是可观测性与 eval 相交的唯一点。第 3 章将详细展开;这里只需记住:这是运行期的外部 scorer 对生产 trace 打分,和 agent 推理时自己评自己纠错(Reflexion,属 agent-reasoning-patterns)完全不同。

运行期 开发期 / CI 传统 APM 状态码 + 延迟 确定性系统 可观测性(本篇) 运行期·单条真实运行 语义失败可见 token/成本/span 树 eval(agent-eval) 开发期·固定数据集 打分·回归·部署卡点 LLM-as-judge 状态码盲 在线评估(唯一接缝) 采样 trace → scorer → 写回分数 → 提拔进离线集 ✗ 语义 ✗ token ✗ 非确定 三者不竞争——可观测性把生产数据喂给 eval,eval 给部署卡门
图 1.1 三方边界定位图。注意:传统 APM 和可观测性都在运行期,但 APM 的判据(状态码)在语义失败、非确定性、token/成本面前全部盲——这三个维度是 agent 可观测性存在的理由。

§2trace 与 span:数据模型

一次 agent 运行 = 一个 trace(一棵 span 树);span 是树上的最小工作单元,靠 parent_span_id 相连,没有 parent_span_id 的就是根。

在分布式追踪体系里,trace 代表一次端到端运行,全局唯一 trace_id。span 是 trace 里的一个工作单元,携带以下核心字段:

  • trace_id:归属哪次运行(整棵树唯一)。
  • span_id:本 span 的唯一标识。
  • parent_span_id:父节点的 span_id;根 span 的该字段为空。
  • name:操作名,如 chat / execute_tool: search_flights。
  • kind:OTel 传输层分类(CLIENT / SERVER / INTERNAL),标记调用方向。
  • attributes:类型化键值对,携带结构化元数据(如 gen_ai.request.model)。
  • events:带时间戳的日志事件,携带大块或敏感内容(如 prompt / completion 文本)。
  • status:OK / ERROR + 可选错误消息。
  • start_time / end_time:用于计算 span 耗时。

这些 span 靠 parent_span_id 形成父子关系,整棵树就是这次运行的执行图——既是时序,也是因果。调试时沿父子链向上回溯,是定位根因的核心动作。

以「差旅助理 Agent」的一次典型运行为例,整棵 span 树如图 1.2 所示:

invoke_agent trace_id: t001 · parent: — chat 决定查差旅政策 chat 决定查航班 chat 选符合政策的票 chat 生成确认答复 retriever retrieve_policy execute_tool search_flights execute_tool book_flight 编排根 span LLM 调用 span 工具/检索 span 所有 span 共享同一 trace_id;parent_span_id 指向父节点
图 1.2 差旅助理一次运行的 span 树。注意:invoke_agent 是唯一的根(无 parent_span_id);每个 chat span 决定了下一步该调什么工具,工具 span 挂在对应 chat 下——顺着这棵树就能还原"为什么走这条路",而日志只能告诉你"走了这条路"。

§3agent 的 span 层级

OTel GenAI 用 operation name 区分 span 语义(invoke_agent / chat / execute_tool / retriever);OpenInference 在 OTel 传输层 kind 之上另加一套语义 kind,两套并存。

OTel 的传输层 kind(CLIENT / SERVER / INTERNAL)标记的是调用方向,不标记 LLM 操作的语义。GenAI semconv 在 name 字段里用 operation name 来区分语义:

  • invoke_agent:编排根,代表一次完整的 agent 运行。v1.41.0(2025-04)将其拆成 CLIENT 和 INTERNAL 两类,以区分跨进程调用与进程内调用。
  • chat:一次 LLM 调用(对应 chat completions API)。
  • execute_tool:工具调用,名称通常带工具名(如 execute_tool: search_flights)。
  • retriever:向量检索,如 retrieve_policy。
  • create_agent:agent 实例化(如初始化带工具和系统 prompt 的 agent)。
  • invoke_workflow:多 agent 编排时,调用一个下游 agent 或 workflow。

OpenInference(由 Arize 维护)在 OTel 之上加了一套语义 kind,放在 span 的 openinference.span.kind attribute 里,覆盖了 OTel 传输层 kind 无法表达的 LLM 专属语义:

OTel 传输层 kind vs OpenInference 语义 kind
维度 OTel kind(传输层) OpenInference span kind(语义层)
放在哪 span.kind 枚举 openinference.span.kind attribute
目的 标记调用方向(谁发起 / 谁响应) 标记 LLM 操作语义(这是 LLM 调用 / 工具 / 检索 / 评估…)
取值 CLIENT / SERVER / INTERNAL / PRODUCER / CONSUMER LLM / CHAIN / TOOL / RETRIEVER / RERANKER / AGENT / GUARDRAIL / EVALUATOR / PROMPT
标准状态 OTel 稳定 OpenInference spec,非 OTel 官方
后端要求 任何 OTel backend Phoenix 或支持 OpenInference 的 backend
实务提示

两套并存不冲突:一个 span 可以同时有 OTel kind=CLIENT(描述方向)和 openinference.span.kind=LLM(描述语义)。选哪套取决于后端:接 Phoenix / Arize 时 OpenInference 的 RETRIEVER / EVALUATOR 等 kind 会在 UI 里展开更多语义功能;接任意 OTLP backend 时纯 OTel GenAI semconv 就够用。

§4一个 span 装什么

gen_ai.* attributes 携带模型元数据与 token 计数;prompt / completion 文本放在 events 而不是 attributes——因为敏感、体积大、且 opt-in;成本不在 OTel semconv 里,得厂商自己算。

底层机制:为什么 prompt 在 events 不在 attributes

OTel 的 attributes 被设计为结构化、低基数、用于过滤与聚合的键值对;events 被设计为带时间戳的日志,适合携带大块、非结构化或敏感内容。prompt 文本往往数 KB,且含 PII,放进 attributes 会引发三个问题:① 被批量索引到搜索后端,无法细粒度访问控制;② 基数爆炸(每条 prompt 唯一,时序数据库无法聚合);③ 超出 SDK 默认的 attributes 截断阈值(4–64KB)被静默丢弃。因此 GenAI semconv 把它设计为 opt-in 的 event:默认不采集,工程师主动开启后才写入 gen_ai.input.messages / gen_ai.output.messages 两个事件名。

一个典型 chat span 的 attributes 清单(基于 OTel GenAI semconv v1.41.0,2025-04):

chat span attributes(JSON 示意) JSON
{
  "gen_ai.provider.name": "openai",
  "gen_ai.request.model": "gpt-4o",
  "gen_ai.response.model": "gpt-4o-2024-11-20",
  "gen_ai.usage.input_tokens": 1284,
  "gen_ai.usage.output_tokens": 312,
  "gen_ai.usage.reasoning.output_tokens": 0,
  "gen_ai.usage.cache_read.input_tokens": 800,
  "gen_ai.usage.cache_creation.input_tokens": 0,
  "gen_ai.response.finish_reasons": ["stop"],
  "gen_ai.response.time_to_first_chunk": 0.83
}

几个关键点:

  • reasoning.output_tokens:o1 / R1 类推理模型的额外 token 计数,v1.41 加入。
  • cache_read.input_tokens / cache_creation.input_tokens:prompt 缓存相关,v1.41 加入;可用于计算实际计费量。
  • time_to_first_chunk:流式响应的首 token 等待时间,v1.41 加入;是用户感知延迟的关键指标。
  • 成本不在 semconv:OTel 不定义 gen_ai.cost.*——有显式 usage 字段时,后端在 ingestion 阶段用 token 数 × 定价表算;没有 usage 字段时用对应 tokenizer 推算。这意味着成本数字在不同工具里可能有细微差异(定价表版本 / tokenizer 精度)。
属性名变更陷阱 · v1.38

OTel GenAI semconv v1.38.0(2024 末)弃用了 gen_ai.prompt 和 gen_ai.completion 两个旧属性名,替换为 gen_ai.input.messages / gen_ai.output.messages 事件名。OpenLLMetry 截至 2026-05 仍有遗留 bug(#3515)未完全迁移。接入工具后要检查实际写入的字段名,避免按旧名查询却查不到数据。

chat span name="chat" · kind=CLIENT · trace_id=t001 · span_id=s003 · parent=s001 attributes gen_ai.provider.name: openai gen_ai.request.model: gpt-4o gen_ai.response.model: gpt-4o-2024-11-20 gen_ai.usage.input_tokens: 1284 gen_ai.usage.output_tokens: 312 gen_ai.usage.cache_read.input_tokens: 800 gen_ai.response.finish_reasons: [stop] gen_ai.response.time_to_first_chunk: 0.83 低基数·可聚合·可过滤 成本 ← 后端 ingestion 时算 (semconv 不定义 gen_ai.cost.*) events(opt-in) name: gen_ai.input.messages timestamp: 2026-06-03T09:00:00Z body: [{role:system, …}, {role:user, content:…}] name: gen_ai.output.messages timestamp: 2026-06-03T09:00:01Z body: [{role:assistant, content:…, tool_calls:[…]}] 大体积·敏感·opt-in v1.38 起替换 gen_ai.prompt / gen_ai.completion(已弃用) status: OK | start: T+0.00s → end: T+1.43s 延迟 = LLM 外部 I/O,不是 CPU
图 1.3 一个 chat span 的解剖。注意:prompt / completion 文本在右侧 events(opt-in,默认不采集);左侧 attributes 只装低基数的结构化元数据;成本字段不在 span 里——后端 ingestion 时按 token 数 × 定价表计算。

§5分组与三支柱

多轮对话或跨请求的 session 用 gen_ai.conversation.id 分组;traces / metrics / logs 在 LLM 世界各自变形,但 trace(span 树)是主导。

分组:session / thread / conversation

一个 trace 代表一次请求-响应运行。当一个用户和 agent 进行多轮对话时,多条 trace 需要被归入同一个"会话"——这层分组各平台叫法不同:

分组概念的跨平台命名
平台 trace 的叫法 span 的叫法 LLM span 的叫法 会话分组的叫法
OTel GenAI trace span span(operation=chat) gen_ai.conversation.id
LangSmith trace(根 run) run llm run thread
Langfuse trace observation(span) generation session
Phoenix/Arize trace span LLM span session_id

各平台命名不同,但数据模型本质一致:会话 = 多条 trace 的逻辑分组,靠会话 ID 关联。user_id / tags / metadata 等业务标注从 trace 级下传到子 span,方便按用户、按功能模块查询。

三支柱在 LLM 世界的变形

可观测性的"三支柱"(traces / metrics / logs)在 LLM 世界各自发生了角色变化:

  • traces 主导。span 树是执行图,是"为什么"的直接载体。LLM 应用的调试、根因分析、成本归因全靠它。
  • metrics 退化为直方图。有意义的 metric 是 gen_ai.client.token.usage(token 分布)、operation.duration(操作耗时分布)、time_to_first_token(首 token 等待)。把 prompt 文本或 user-id 当 metric label 会引发基数爆炸(10 万+ 唯一值压垮时序数据库)。
  • logs 降级为 span 上的 events。在 OTel 框架下,原来单独存放的日志行("模型返回 stop")现在作为带时间戳的 event 附在 span 上,上下文更完整。独立的日志流(print / logging)不再是主要手段。
关键结论

trace 在 LLM 可观测性里的地位,远超传统分布式追踪里的地位。在传统服务里,metric 和 log 可以独立使用;在 LLM agent 里,token 归因、成本追踪、语义失败定位,都需要父子树结构才能完成——metric 看到"成本涨了",trace 才能告诉你"是哪个工具的重试在烧"。

自测

先合上教程独立作答,再展开答案对照。

  1. 某 agent 返回 HTTP 200,但用户反馈答案是错的。传统 APM 能不能发现这个问题?为什么?
    展开答案

    传统 APM 无法发现。APM 的判据是状态码(200 = 正常)和延迟(在阈值内 = 正常),它没有"答案对不对"这个维度。语义失败(LLM 给出了错误的内容)对状态码完全不可见——这正是 agent 可观测性存在的理由之一。

  2. 一个 span 的 parent_span_id 为空,说明什么?
    展开答案

    该 span 是 trace 的根 span(root span)。在 agent 的 span 树里,根通常是 invoke_agent span,代表一次完整的 agent 运行的起点。整棵树的所有 span 共享同一个 trace_id,根 span 没有父节点。

  3. 为什么 prompt / completion 文本放在 span 的 events 里,而不是 attributes?
    展开答案

    三个原因:① attributes 被设计为低基数、可聚合的结构化元数据,prompt 文本每条唯一(高基数),放入 attributes 会导致时序数据库基数爆炸;② prompt 往往数 KB 且含 PII,attributes 的批量索引无法做细粒度访问控制;③ SDK 默认会对超出阈值的 attributes 做静默截断,prompt 很可能被丢弃。events 是带时间戳的日志事件,设计用于大块/敏感内容,且 opt-in(默认不采集)。

  4. LangSmith 把 span 叫 "run",Langfuse 把 LLM span 叫 "generation"。这两个平台在数据模型层面是否有根本区别?
    展开答案

    命名不同,但数据模型本质相同:都是 trace(根)→ 中间 span → 叶子 span 的树结构,靠父子 ID 相连,共享 trace_id。LangSmith 的 run / thread 与 Langfuse 的 observation / generation / session 是对同一模型的不同术语包装。选型时关注的应是各自的功能(在线评估 / 数据集管理 / 自托管支持等),而不是命名差异。