==一次请求 = 一棵树==。根是 Trace,枝干是 Observation。决定建哪种 Observation 的唯一标准:这一步是不是在调大模型?是 → Generation;不是、但有耗时(检索 / 工具 / 子流程)→ Span;不是、且是个瞬时事件(缓存命中、规则触发)→ Event。
四种节点:各自管什么
| 概念 | 是什么 | 关键字段 | 典型场景 |
|---|---|---|---|
| Trace | 一次端到端请求的根容器,整棵树只有一个 | id、name、input、output、user_id、session_id、tags、metadata | 用户发来一条问题、一次 API 请求、一个 Agent 任务 |
| Span | 任意有起止时间的工作单元(非 LLM) | id、name、start_time、end_time、input、output、parent | 向量检索、调用工具、一段业务逻辑、子链路 |
| Generation | Span 的特化版,专门记 LLM 调用 | model、model_parameters、input(messages)、output、usage_details、cost_details | 调 OpenAI / Anthropic / 本地模型生成回答 |
| Event | 无时长的瞬时打点,只有一个时间戳 | id、name、input、metadata、parent | 缓存命中、护栏拦截、状态切换标记 |
口诀调模型用 Generation,有耗时用 Span,瞬时点用 Event,最外层永远是 Trace。
树是怎么连起来的:trace_id 与 parent_observation_id
Observation 之间的父子关系不靠代码缩进,而靠两个 id 字段显式声明。每个 Observation 都带一个 trace_id(属于哪棵树)和一个 parent_observation_id(父节点是谁,为空则直接挂在 Trace 根上)。Langfuse 服务端收到这些扁平记录后,按 id 关系重建出树形结构。理解这点很重要:只要 id 对得上,跨函数、跨线程、甚至跨服务上报的节点都能拼进同一棵树。
# 安装(v3 SDK):pip install "langfuse>=3.0.0"
# 环境变量:LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST
from langfuse import get_client
langfuse = get_client() # 自动读取上面三个环境变量
# 1) 手动建一棵树,观察 id / parent 是怎么串起来的
# start_as_current_span 会把这个 span 设为「当前上下文」,
# 其内部再创建的 observation 自动以它为 parent。
with langfuse.start_as_current_span(
name="rag-request", # 这个 span 是整棵树的第一个工作单元
input={"question": "Langfuse 怎么记成本?"},
) as root_span:
# 2) 在 root_span 上下文里建一个检索 Span —— parent 自动 = root_span
with root_span.start_as_current_span(
name="retrieve-docs",
input={"query": "langfuse cost tracking"},
) as retrieval:
docs = ["doc-1", "doc-2"] # 假装做了向量检索
retrieval.update(output={"docs": docs})
# 3) 建一个 Generation(LLM 调用),parent 也自动 = root_span
with root_span.start_as_current_generation(
name="answer-llm",
model="gpt-4o", # ← Generation 专属,Span 没有
input=[{"role": "user", "content": "根据文档回答"}],
model_parameters={"temperature": 0.2},
) as gen:
answer = "Langfuse 靠 Generation 上报 usage 来统计 token。"
gen.update(
output=answer,
usage_details={"input": 320, "output": 48}, # ← token 用量
)
root_span.update(output={"answer": answer})
# 4) 在 Trace 根上挂横切维度(user / session / tags)
langfuse.update_current_trace(
user_id="user-42",
session_id="chat-session-001",
tags=["rag", "prod"],
metadata={"region": "cn"},
)
langfuse.flush() # 进程退出前 flush,确保异步队列里的数据都上报
# 现在 Langfuse UI 里会看到:rag-request 下挂着 retrieve-docs(Span) + answer-llm(Generation)
横切维度:Session / User / Tags / Metadata / Score 挂在哪
| 维度 | 挂在哪一层 | 作用 | 怎么写 |
|---|---|---|---|
| user_id | Trace | 把多次请求归到同一个用户,做人均成本 / 用户级排查 | update_current_trace(user_id=...) |
| session_id | Trace | 把同一会话的多条 Trace 串成对话线,UI 里按 Session 聚合 | update_current_trace(session_id=...) |
| tags | Trace | 给整条请求打标签(环境 / 实验组 / 功能),用于筛选过滤 | update_current_trace(tags=[...]) |
| metadata | Trace 或 Observation | 挂任意结构化业务上下文(region、版本号、检索条数) | update(metadata={...}) |
| score | Trace 或单个 Observation | 质量打分:人工 / LLM-judge / 用户反馈,可数值可分类 | create_score(...) / score_current_trace(...) |
口诀User/Session/Tags 只活在 Trace 顶层;Metadata 和 Score 既能挂顶层、也能挂某个具体节点。
# Score 的两种落点:整条 Trace vs 某个具体 Observation
from langfuse import get_client
langfuse = get_client()
with langfuse.start_as_current_span(name="qa-request") as span:
with langfuse.start_as_current_generation(
name="llm-call", model="gpt-4o",
input=[{"role": "user", "content": "2+2=?"}],
) as gen:
gen.update(output="4", usage_details={"input": 8, "output": 1})
# (A) 把分数打在「这次 LLM 生成」上 —— 评的是单个 observation 的质量
gen.score(name="correctness", value=1.0, comment="答案正确")
# (B) 把分数打在「整条 Trace」上 —— 评的是这次请求的整体表现
# score_trace 评当前上下文所属的 trace
langfuse.score_current_trace(
name="user-feedback",
value="good", # 分类型分数(categorical)
data_type="CATEGORICAL",
)
langfuse.flush()
# UI 里:correctness 显示在 llm-call 这个 generation 行;
# user-feedback 显示在整条 trace 的汇总区。
把它放进一棵真实的 RAG 树看落点
下面是一次「用户提问 → 检索 → 重排 → 生成 → 校验」的 RAG 请求,展开成 Observation 树后每个节点的类型。注意每一行的类型选择都对应前面那张决策表:调模型的两步是 Generation,有耗时的检索 / 重排 / 校验是 Span,缓存命中是瞬时 Event。
Trace: "rag-chat-request" ← 根容器(一次完整请求)
│ user_id=user-42 session_id=chat-001 ← 横切维度挂在 Trace 上
│ tags=[rag, prod] metadata={region: cn}
│
├─ Event: "cache-miss" ← 瞬时点,无时长
│
├─ Span: "retrieve" ← 工作单元(向量检索,有耗时)
│ ├─ Generation: "embed-query" ← LLM 调用(嵌入模型)→ 记 usage
│ │ model=text-embedding-3-small
│ └─ Span: "vector-search" ← 子工作单元(查向量库)
│
├─ Span: "rerank" ← 工作单元(重排,有耗时)
│
├─ Generation: "answer" ← 核心 LLM 调用 → 记 token + cost
│ model=gpt-4o usage={input:1840, output:96}
│ └─ score: "hallucination" = 0.02 ← Score 挂在这个 generation 上
│
└─ Span: "guardrail-check" ← 工作单元(输出校验)
trace 级 score: "user-feedback" = good ← Score 也可挂在整条 Trace 上
✓推荐做法
- LLM / 嵌入调用一律建成 Generation,并填 model + usage_details,成本统计才有数据
- 用 with 上下文(start_as_current_span/generation)自然形成嵌套,少手写 id
- user_id / session_id 一上来就在 Trace 根上设好,方便按用户、按会话回溯
- 评单步质量用 observation.score,评整体表现用 score_current_trace
✗不推荐
- 不要把 LLM 调用建成普通 Span——会丢掉 token 与成本统计
- 不要把检索 / 工具调用建成 Generation——usage 字段对它无意义且会污染成本数据
- 不要靠代码缩进表达父子关系,真正决定树形的是 trace_id + parent_observation_id
- 不要忘记进程结束前 flush(),短脚本里异步队列可能还没上报就退出了
⚠常见误区
- Generation 漏填 usage_details:Dashboard 的 token / 成本曲线会是空的(Langfuse 不会替你猜)
- 在异步 / 多进程里直接新建节点而不传 trace_context:会断成两棵互不相连的树
- 把大段原文塞进 metadata:metadata 适合结构化小字段,长文本应放 input/output
在 Langfuse UI 打开一条 Trace,能看到完整嵌套树、Generation 行有 token/成本数字、Trace 头部有 user/session/tags,即说明数据模型用对了。
搞清楚每个节点该落在树的哪一层,比急着写埋点代码更省时间——埋错层,整个成本与质量看板都是错的。
— 本章小结