如果要给 Agent 搭一套可观测平台,应该从哪里开始?

8,513 字#langfuse #opentelemetry #可观测性 #rpi

假设你让一个编码 Agent“读一下配置文件,再把超时时间改成 30 秒”。它最后回复“已经改好了”,但你打开文件,发现内容没变。

问题出在哪一步?模型理解错了要求,工具读错了文件,还是写入失败了?如果只保存用户输入和最终回答,这几个原因很难分辨。

给 Agent 做可观测,就是把中间过程也记录下来:每次模型收到了什么、返回了什么,每次工具用了什么参数、执行结果如何,以及每一步花了多长时间。这样出了问题,就能沿着同一次任务往回查,而不是从一堆日志里猜。

Langfuse 是什么,能帮我们看到什么?

Langfuse 是一个面向大模型应用的开源平台,可以自建,也可以使用它的云服务。这里主要使用它的运行追踪功能:把 Agent 一次任务里的模型调用、工具调用、输入输出、耗时和 token 用量集中展示出来。

它不是 Agent 的执行引擎,也不会接上之后就自动知道你的工具做了什么。Agent 端需要在这些动作发生时采集数据,再发送给 Langfuse。平台负责接收、组织和展示这些记录。

自建 Langfuse 项目概览:运行次数、模型费用与观测记录分布

这张图来自自建实例的 rpi 项目。先看运行次数、模型用量和费用,再看 observations 随时间的分布,就能知道这个时间段里有没有任务运行、哪些时段出现了错误。图里的统计只代表截图时选定的时间范围,不是性能测试结论。

后面的截图同样来自该实例,截图时界面版本为 v4.49.0 OSS。画面裁掉了账号侧栏;工具错误图中的本地绝对路径做了脱敏。

沿着前面的例子,我们希望看到这样的过程。下面是示意,不是一次真实运行的截图或测试结果:

用户:把配置里的超时时间改成 30 秒
  ├── 第一次模型调用:决定读取配置
  ├── 读取文件:记录路径、参数、返回内容
  ├── 第二次模型调用:决定修改配置
  ├── 写入文件:记录修改内容、成功或错误
  └── 最后一次模型调用:生成回复

如果写入工具报错,但 Agent 仍然回复“已经改好了”,把工具结果和最后一次模型调用的输入放在一起,就可以继续检查:错误有没有传回模型,模型有没有正确处理它。

这些记录能帮助定位问题,但不会自动判断答案是否正确。评估还需要另外定义评分标准、准备评测样例;本文先把运行过程记录完整,不展开评估体系。

本文使用 Langfuse v4,也就是 Langfuse 的第 4 个主要版本。下面的数据模型和写入配置都以它为背景。版本号只是说明适用环境,不需要先了解旧版才能往下读。

本文的接入案例使用 rpi,一个用 Rust 实现的编码 Agent,能够调用模型、读取文件和执行工具。负责把它的运行数据送到 Langfuse 的是 rpi-langfuse,一个用原生 Rust 实现的 rpi 插件。它不是 Langfuse 服务本身,而是运行在 Agent 一侧的数据采集与上报组件。

你不一定使用 rpi,也可以把后面的“模型请求开始、工具调用结束”等位置,对照到自己的 Agent 实现里。

先记录什么:一次任务和其中的每个动作

先用三个概念给上面的过程命名:

  • Trace(追踪记录):一段完整的执行过程。本文把 Agent 的一轮运行作为一条 trace。
  • Observation(观测记录):过程里的一次具体动作,例如一次模型请求、一次工具执行。
  • Session(会话):多轮运行的分组。同一个用户连续发几条消息,可以产生多条 trace,再归入同一个 session。

在 Langfuse v4 的数据模型里,一条 trace 可以理解为一组共享同一个 trace_id 的 observations。每条 observation 再通过 parentObservationId 记录它属于哪个上层动作:

trace_id = t-001
└── agent turn                 span / chain
    ├── call deepseek          generation
    ├── read_file              tool
    └── call deepseek          generation

Session 是更外层的分组。比如一个用户连续发送了多轮消息,每轮可以是一条 trace,几条 trace 再通过同一个 session_id 归到同一个会话里。

Langfuse 官方文档把 v4 的存储描述成一张 observations 表。每一行是一条 observation,同时带有 trace 级属性的副本:

observationtypetrace_idparenttrace 级属性
Agent turnSPANt-001无session、user、tags
模型调用GENERATIONt-001Agent turnsession、user、tags
工具调用TOOLt-001Agent turnsession、user、tags

这解释了一个很容易漏掉的细节:如果 session_id 只写在根 span 上,子 observation 那一行可能没有这个值。之后按 session 筛选或聚合时,结果就不完整。

所以 rpi-langfuse 在序列化每个 span 时,都会把需要参与筛选的 trace 级属性一起带上,而不是只给根 span 写一份。

点开一条记录,实际能看到什么?

同一条 Trace 下的调用列表,以及选中模型请求的输入输出

左侧是这轮运行里的模型请求和工具执行,右侧是选中的一次 LLM Call。这个页面把整轮任务的总耗时、总用量,和单次模型请求的耗时、用量分开显示,排查时不要混为一谈。

图中这次模型请求的 Input 展示了 todo 工具的返回信息,Output 是模型给出的最终回复。至少可以核对“模型收到的记录里有没有工具结果”以及“它最终怎样回答”。不过,界面里展示的 input 是否包含完整请求上下文,还取决于 Agent 端上报了哪些内容,不能仅凭这一张图断言完整 messages 都已记录。

一次 bash 工具执行失败:命令参数、错误状态和编译器返回信息

再选中同一轮运行里的红色 ERROR 记录,就能看到失败的是一次 cargo check:Input 是命令和超时参数,Output 里有 unclosed delimiter 的编译错误,以及退出码 101。

这里的 ERROR 表示这一步工具执行失败,不代表整轮任务一定失败。左侧还保留了后续模型和工具动作,可以继续查它是否尝试修复。截图展示的是实际编译错误,与开头“修改超时时间”的假设场景不是同一个任务。

模型调用和工具调用,为什么要区分类型?

耗时相同的两条记录,排查方向可能完全不同:模型调用慢,要看模型响应和 token 数;工具执行慢,要看工具参数和返回结果。类型就是用来说明“这一步在做什么”的。

Langfuse 可以用下面三类描述这些动作:

  • span:Agent 一轮运行、工作流或普通操作
  • generation:一次 LLM 请求
  • tool:一次工具调用

但类型由上报端指定,并不是名字里有 Tool: 就自动变成 tool。本次截图的数据来自 rpi-langfuse 0.4.1,工具记录实际按 span 上报;前面的 TOOL 表格是类型划分示意,不是对截图实际枚举值的抄录。验证接入时,工具名称和 observation 类型都需要核对。

Langfuse 还支持 event、embedding、agent、chain、retriever、guardrail、evaluator 等类型。通过 OTLP 上报时,类型写在 langfuse.observation.type 属性里;读回 v2 API 时通常显示为大写枚举,例如 GENERATION。

Agent 端怎么采集:在动作开始和结束时留下记录

记录不会凭空出现在平台里。要在 Agent 一轮运行开始、模型请求开始和结束、工具执行开始和结束这些位置采集。

在这个案例里,采集工作由 rpi-langfuse 插件负责。rpi 在运行过程中提供模型请求、工具调用等生命周期事件,插件在这些事件发生时记录输入、输出和时间,再把记录组织好,发送给 Langfuse。

可以把三者的分工理解为:

rpi:执行 Agent 任务
  ↓ 生命周期事件
rpi-langfuse:用原生 Rust 实现的插件,负责采集和上报
  ↓ 运行记录
Langfuse:接收、组织和展示观测数据

插件内部用 span 保存一次操作的记录:开始时记下输入和开始时间,结束时补上输出、结束时间,以及模型调用的 token 用量。记录完成后,再转换成 OTLP 报文导出。Span 是 OpenTelemetry 对一次操作记录的称呼;发送到 Langfuse 后,它对应一条 observation。

大致对应关系如下:

rpi 生命周期Langfuse observation记录内容
BeforeAgentStart根 spanprompt、session、Agent 名称
BeforeProviderRequestgeneration模型、messages、参数
AfterProviderResponsegeneration 收尾output、token usage、TTFT
ToolCalltool工具名、参数、调用开始时间
ToolResulttool 收尾返回值、错误、结束时间
MessageEnd根 span 收尾整轮执行的结束时间

实现上,扩展里有一个 span 缓冲区:

Agent 开始
  └── 创建根 span
      ├── 模型请求开始:创建 generation
      ├── 模型响应回来:补 output 和 usage
      ├── 工具调用:创建 tool
      ├── 工具返回:补 output 并结束 tool
      └── 本轮结束:结束根 span

只导出已经结束的 span

这里有两个实际考虑。

第一,模型的 input 在请求开始时就有了,但 output 和 usage 要等响应回来才知道。过早发送,会得到一条没有结束时间、没有输出的 generation。

第二,Agent 可能在一轮里调用多个工具。工具 span 不能脱离当前的 trace_id,否则 Langfuse 里会出现几条互相无关的 trace。

因此扩展内部先维护完整的 span 记录,等它具备起止时间后,再转换成 OTLP 的 span。这个转换发生在导出时,trace 名称、session、tags 和 metadata 也在这里统一补到每个 observation 上。

把记录送进平台:OpenTelemetry 和 OTLP 是什么?

现在有了运行记录,接下来需要约定发送格式。OpenTelemetry 是一套采集和传输观测数据的标准及工具;OTLP 是它用于传输这些数据的协议。可以简单理解为:Agent 不用自己发明一套报文,而是按约定的格式发送 span,让平台能够识别。

Langfuse 支持这种写入方式。Python 和 JavaScript/TypeScript 应用可以优先使用官方 SDK;本文的 Rust 扩展直接发送 OTLP JSON,所以后面会拆开看请求体。OTLP 并非 v4 才出现的能力,这里说明的是使用它写入 v4 数据路径的配置。

以下操作假定你已经有一个可访问的 Langfuse v4 实例,并拿到了项目的 public key 和 secret key。自建服务器的安装步骤不在本文范围内;先把一条运行记录接通,再扩展到所有模型和工具调用。

写入和读取路径如下:

rpi-langfuse
    │
    │ OTLP/HTTP JSON
    ▼
POST /api/public/otel/v1/traces
    │
    ▼
Langfuse v4 observations
    │
    ├── GET /api/public/v2/observations
    └── GET /api/public/v2/metrics

请求头至少需要这些:

Authorization: Basic <base64(public-key:secret-key)>
Content-Type: application/json
x-langfuse-ingestion-version: 4

Basic Auth 的内容就是 public key 和 secret key,中间用冒号连接:

echo -n "pk-lf-xxx:sk-lf-xxx" | base64 -w 0

x-langfuse-ingestion-version: 4 很重要。直接使用 OTLP exporter 时,如果没有这个 header,数据可能进入兼容旧版本的路径,v2 读取接口不会马上看到数据。官方文档对延迟上限在不同页面有 10 分钟和 15 分钟两种表述,所以排查时不要把具体分钟数当成 SLA,先检查 header 是否真的被代理转发。

OTLP 端点支持 HTTP JSON 和 protobuf,也支持 gzip;目前不支持 gRPC。rpi-langfuse 使用的是 JSON,原因很简单:调试时可以直接抓请求体看报文。

一条 OTLP trace 报文

OTLP 的外层结构不是 Langfuse 专属格式,而是 OpenTelemetry 的 ExportTraceServiceRequest:

{
  "resourceSpans": [
    {
      "resource": {
        "attributes": [
          {"key": "service.name", "value": {"stringValue": "rpi"}},
          {"key": "telemetry.sdk.language", "value": {"stringValue": "rust"}}
        ]
      },
      "scopeSpans": [
        {
          "scope": {"name": "rpi-langfuse", "version": "0.4.1"},
          "spans": [
            {"...": "这里放具体 span"}
          ]
        }
      ]
    }
  ]
}

三层的职责可以简单记成:

  • resource:哪个服务产生了数据,例如 service.name = rpi
  • scope:哪个埋点库产生了数据,例如 rpi-langfuse
  • spans:具体发生了什么

真正和 Langfuse observation 对应的是 spans 里的对象。

一个 generation span

下面是删减后的模型调用报文:

{
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "spanId": "00f067aa0ba902b7",
  "parentSpanId": "a3ce929d0e0e4736",
  "name": "LLM Call",
  "kind": 1,
  "startTimeUnixNano": "1791177524742000000",
  "endTimeUnixNano": "1791177530901000000",
  "attributes": [
    {"key": "langfuse.observation.type", "value": {"stringValue": "generation"}},
    {"key": "langfuse.observation.model.name", "value": {"stringValue": "deepseek-v4.1-flash"}},
    {"key": "langfuse.observation.model.parameters", "value": {"stringValue": "{\"temperature\":0.7}"}},
    {"key": "langfuse.observation.input", "value": {"stringValue": "[{\"role\":\"user\",\"content\":\"...\"}]"}},
    {"key": "langfuse.observation.output", "value": {"stringValue": "{\"content\":\"...\"}"}},
    {"key": "langfuse.observation.usage_details.input", "value": {"intValue": "2929"}},
    {"key": "langfuse.observation.usage_details.output", "value": {"intValue": "374"}},
    {"key": "langfuse.trace.name", "value": {"stringValue": "rpi Turn"}},
    {"key": "langfuse.session.id", "value": {"stringValue": "session-001"}}
  ]
}

这里最值得注意的是四件事。

ID 是 W3C 格式

traceId 必须是 32 位小写十六进制字符串,spanId 是 16 位小写十六进制字符串。像 obs-0000000000000001 这样的自造 ID 不能直接拿来当 OTLP ID。

parentSpanId 为空表示物理根 span。它只决定物理父子关系。Langfuse v2 还有一个 isRootObservation,表示语义上的 application root,不能把这两个概念混为一谈。

时间是纳秒,而且 JSON 里通常是字符串

startTimeUnixNano 和 endTimeUnixNano 是 Unix 纳秒时间。在 OTLP JSON 映射中,64 位整数通常序列化成字符串。

Langfuse 对缺失或无法解析的时间会做补齐,但这只是在避免整条请求失败:如果只传了一端,另一端可能被复制,最后得到零时长 observation。rpi-langfuse 会在导出前检查每个 span 的起止时间。

结构化输入要先转成字符串

OTLP span attribute 是带类型标签的标量值:

{"stringValue": "text"}
{"intValue": "123"}
{"doubleValue": 0.5}
{"boolValue": true}

它不是一个可以直接塞任意 JSON 对象的字段。因此 input、output、model.parameters 和 usage_details 在报文里通常是 JSON 字符串,Langfuse 再按自己的字段映射处理。

Langfuse 的属性名决定数据去哪

最常用的属性可以这样对照:

Langfuse 字段OTLP 属性
observation typelangfuse.observation.type
observation inputlangfuse.observation.input
observation outputlangfuse.observation.output
modellangfuse.observation.model.name
model parameterslangfuse.observation.model.parameters
token usagelangfuse.observation.usage_details
trace namelangfuse.trace.name
userlangfuse.user.id
sessionlangfuse.session.id
releaselangfuse.release
versionlangfuse.version
trace metadatalangfuse.trace.metadata.<key>

trace 级属性要在每个需要筛选或聚合的 span 上重复写入。rpi-langfuse 的做法类似这样:

for span in spans {
    span.attributes.push(attr_str(
        "langfuse.trace.name",
        &trace.name,
    ));

    if let Some(session_id) = &trace.session_id {
        span.attributes.push(attr_str(
            "langfuse.session.id",
            session_id,
        ));
    }

    for (key, value) in &trace.metadata {
        span.attributes.push(attr_str(
            &format!("langfuse.trace.metadata.{key}"),
            value,
        ));
    }
}

如果只是把普通的 http.method 直接放进 span,它会落到 metadata.attributes 这类 catch-all 位置,能查看,但不一定能作为 Langfuse metadata 的一级字段筛选。需要筛选的字段,显式使用 langfuse.trace.metadata.<key>。

Token 用量怎么记:缓存命中的部分不要算两遍

模型响应里的 usage,就是这次请求的用量统计。Token 可以先理解为模型处理文本的计量单位;输入和输出用量能帮助我们判断一次请求处理了多少内容,也是计算费用的依据之一。

这里容易弄混的是:有些供应商返回的“输入总数”已经包含了“命中缓存的输入数”。缓存命中表示一部分输入复用了缓存,不是又多处理了一份独立输入。供应商可能对普通输入和缓存输入采用不同价格,所以记录时需要把它们分开。

用一个小例子看:输入总共 100 个 token,其中 80 个命中缓存,那么没有命中缓存的输入就是 20 个。如果把 100 和 80 当作两项互不重叠的输入用量相加,就变成了 180,重复算了缓存的部分。

对应到下面这组示例数据,假定供应商的输入总数包含缓存命中量,而且没有缓存写入用量:

供应商返回的字段数值含义
prompt_tokens17903全部输入 token,包含缓存命中的部分
cached_tokens17817输入总数里命中缓存的部分
completion_tokens188模型输出 token

所以未命中缓存的输入是:

17903 - 17817 = 86

插件最终上报的分类用量是:

input                   = 86     # 未命中缓存的输入
cache_read_input_tokens = 17817  # 命中缓存的输入
output                  = 188    # 模型输出

前两项相加仍然是 17903,而不是 35720。这就是原先“互斥桶”想表达的意思:每个 token 只归到一个类别里,不要让一个类别既包含总数,又和它的子项重复相加。

这里还要分清 rpi 和插件的职责。对当前源码里的 OpenAI Completions 适配器来说,rpi 先处理供应商字段:从 prompt_tokens 扣除缓存读取和缓存写入用量,得到内部 Usage.input;rpi-langfuse 再把这些已分类的用量转换成 Langfuse 字段。不是插件收到 prompt_tokens 后重新扣一次,否则又会少算。

不同供应商的字段含义可能不同,不能照着这个减法套所有响应。排查时先确认供应商的输入数是否包含缓存,再检查适配器和插件有没有重复拆分。费用还取决于模型价格配置或上报的成本数据,token 数正确不等于费用配置也一定正确。

对照源码看这两步

以上函数关系对照了本次本地源码;链接指向 main,后续代码和行号可能变化,因此以函数名定位。

写入之后怎么确认:把同一条 Trace 查回来

写入成功不代表每个字段都放对了。可以先在界面检查这轮运行是否出现、模型和工具是否挂在同一个任务下,再用 observations API 核对具体字段。

这里的 v2 是读取 API 的版本号,不是 Langfuse 服务器的版本号。Langfuse v4 的这条查询路径仍然叫 /api/public/v2/observations。

下面的域名、密钥、trace ID 和时间区间都需要换成自己的值:

curl -G -u "pk-lf-xxx:sk-lf-xxx" \
  "https://langfuse.example.com/api/public/v2/observations" \
  --data-urlencode "traceId=4bf92f3577b34da6a3ce929d0e0e4736" \
  --data-urlencode "fields=core,basic,io,usage,model,trace_context" \
  --data-urlencode "fromStartTime=2026-10-05T00:00:00Z" \
  --data-urlencode "toStartTime=2026-10-06T00:00:00Z" \
  --data-urlencode "limit=100"

v2 返回的是 observation 行,不是一个完整的 trace 对象。要还原一条 trace,需要按 traceId 把这些行重新组织起来。

常用字段组如下:

core          id、traceId、startTime、endTime、parentObservationId、type
basic         name、level、userId、sessionId、isRootObservation
io            input、output
model         model、modelParameters
usage         usageDetails、costDetails
metrics       latency、timeToFirstToken
trace_context tags、release、traceName

没有请求的字段组通常不会出现在响应里,不是返回 null。所以想验证 tags、release 或 trace name,记得加上 trace_context。

rpi-langfuse 的最小验证方式

部署好扩展后,先跑一轮很小的任务,例如让 Agent 读取一个不含敏感信息的测试文件。用这条测试 trace 确认四件事:

  1. OTLP 端点返回成功。
  2. v2 observations API 能按 traceId 查到 observation。
  3. parentSpanId 和起止时间符合预期。
  4. model、input、output、usage 以及 session 等属性出现在正确的 observation 上。

排查顺序也按这个来:

HTTP 4xx / 401
  └── 查 URL、Basic Auth、header

HTTP 200 但查不到
  └── 查 x-langfuse-ingestion-version: 4
  └── 查 fromStartTime / toStartTime
  └── 查是不是读了旧 API

能查到但结构不对
  └── 查 traceId / spanId 格式
  └── 查 parentSpanId
  └── 查 observation.type
  └── 查 input/output 是否是字符串

数据重复或成本偏高
  └── 查是否同时发送了 legacy 和 OTLP
  └── 查 usage 桶是否重叠

接通之后,先用它回答一个具体问题

回到开头那个“回复改好了,文件却没变”的例子。接通观测链路后,先找到对应的一轮运行,再依次检查工具参数、工具返回值和后续模型输入。这些记录能帮我们缩小排查范围,而不是直接宣判模型或工具有问题。

Langfuse 负责接收和展示记录,Agent 端仍然需要负责把数据采集完整:

  • 在 Agent 生命周期里正确划分根 span、generation 和 tool。
  • 维护 trace_id、span_id、parent_span_id 的关系。
  • 在导出前补齐 trace 属性、输入输出、时间和 usage。

把这三层分开后,报文就没那么神秘了:

Agent 生命周期
    ↓
rpi-langfuse 内部 span
    ↓
OTLP ExportTraceServiceRequest
    ↓
Langfuse observation rows
    ↓
v2 observations / metrics

接入前还要留意数据边界:模型输入和工具输出可能包含源代码、个人信息或凭据。先确定哪些字段允许上报,做脱敏或截断,再扩大采集范围。可观测不等于把所有原始数据毫无保留地上传。

第一次接入不必追求大而全。先让一轮任务中的模型调用和工具调用出现在同一条 trace 下,能看清输入、输出、耗时和错误,就已经有了排查下一次问题的起点。

最后补一句:已有 v3 接入,迁移时要注意什么?

如果是第一次接入,按前面的 v4 路径验证即可,不必先研究旧接口。

如果已有 Langfuse v3 的接入代码,则要检查它使用哪条写入路径。OTLP 在 v3 时就已经受到支持,不能把区别简单理解成“v3 只能发事件,v4 才能发 span”。这里需要迁移的是 rpi-langfuse 原先使用的 legacy ingestion 路径:向 /api/public/ingestion 发送 trace-create、generation-create、generation-update 等事件。

原稿记录的接入错误就是发生在这条旧路径上:

400 Event type "trace-create" is not accepted by /api/public/ingestion
    when LANGFUSE_MIGRATION_V4_WRITE_MODE is events_only

这条响应说明,在该实例的 events_only 迁移配置下,这个端点不接受 trace-create。它不是“为什么所有人都该用 OTLP”的论据,也不是理解 Langfuse 的起点。对应的处理是检查旧接入代码,改用前面介绍的 OTLP 写入方式,并验证新路径的数据和字段,避免无意中双写。


版本边界:本文以 Langfuse v4 的 OTLP 写入配置为背景。服务端版本、SDK 版本和 URL 中的 API 版本号不是同一回事;旧接口的兼容行为还受实例迁移配置影响,部署时以当前文档和实际响应为准。rpi-langfuse 的事件名称及实现行为沿用原稿提供的项目素材,本次没有重新校验对应源码版本。

参考资料: