==一次请求 = 一棵树==。根是 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向量检索、调用工具、一段业务逻辑、子链路
GenerationSpan 的特化版,专门记 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_idTrace把多次请求归到同一个用户,做人均成本 / 用户级排查update_current_trace(user_id=...)
session_idTrace把同一会话的多条 Trace 串成对话线,UI 里按 Session 聚合update_current_trace(session_id=...)
tagsTrace给整条请求打标签(环境 / 实验组 / 功能),用于筛选过滤update_current_trace(tags=[...])
metadataTrace 或 Observation挂任意结构化业务上下文(region、版本号、检索条数)update(metadata={...})
scoreTrace 或单个 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,即说明数据模型用对了。

搞清楚每个节点该落在树的哪一层,比急着写埋点代码更省时间——埋错层,整个成本与质量看板都是错的。

— 本章小结