4.给 RAG 系统装上“黑匣子“:LangGraph + Langfuse 全链路可观测性实战

前言

本文是 RAG 实战系列的第四篇。前几篇我们用 LangGraph 搭建了完整的 RAG 查询图(query变换 → 检索 → 文档评估 → 重排序 → 生成 → 幻觉检测),但有一个问题始终困扰着我:每次 LLM 调用到底花了多少时间?输入输出是什么?token 消耗了多少? 这些信息在日志里根本看不清楚。

这一篇,我们接入 Langfuse —— 一个开源的 LLM 可观测性平台,给 RAG 系统装上"黑匣子"。

一、为什么需要 Langfuse

RAG 系统不是一次 LLM 调用,而是一条多步骤的处理链。以我们的 LangGraph 查询图为例,一次查询最多涉及 4 次 LLM 调用

用户提问
  → [LLM] Query 变换(rewrite/hyde)
  → 检索(vector/bm25/hybrid)
  → [LLM] 文档相关性评估
  → Reranker 重排序
  → [LLM] 生成回答
  → [LLM] 幻觉检测
  → 输出 / 重试 / 兜底

没有可观测性工具,你只能看到最终的回答,中间发生了什么完全是黑盒。接入 Langfuse 后,你可以在面板上看到:

  • 完整调用链路:每个节点的执行顺序和耗时
  • 每次 LLM 调用的输入/输出:prompt 长什么样,模型回了什么
  • Token 消耗和延迟:哪个节点最慢,哪个最费 token
  • 重试轨迹:幻觉检测失败后重试了几次,最终走了兜底还是通过

二、整体设计

2.1 核心思路:一次查询 = 一条 Trace

Langfuse 的核心概念是 Trace(链路)。我的设计是:

  • 每次 RAG 查询创建一个 CallbackHandler
  • 这个 handler 通过 LangGraph 的 State 在所有节点间传递
  • 每个涉及 LLM 调用的节点都使用同一个 handler
  • Langfuse 自动将同一个 handler 的所有 LLM 调用归到同一条 Trace 下
class RAGState(TypedDict):
    question: str
    search_query: str
    # ... 其他字段 ...
    # Langfuse 追踪:handler 在图中传递,所有节点共享同一条 trace
    langfuse_handler: Any

2.2 架构图

CSDN 支持 Mermaid 渲染,直接粘贴以下代码块即可显示流程图。如果你的编辑器不支持,可以把代码粘贴到 mermaid.live 在线预览并导出图片。

Langfuse localhost:3000

LangGraph RAG 状态图

FastAPI 入口

有相关文档

无相关文档

通过

失败 < 2次

失败 ≥ 2次

handler 注入 State

自动上报

自动上报

自动上报

POST /query
(question, session_id, user_id)

创建 Langfuse CallbackHandler

🔗 transform_query
callbacks → LLM

retrieve
(vector / bm25 / hybrid)

🔗 grade_documents
callbacks → LLM

rerank
(Reranker API)

🔗 generate
callbacks → LLM

🔗 check_hallucination
callbacks → LLM

fallback 兜底回答

输出结果

Trace: rag-query

Generation: grade_documents

Generation: generate

Generation: check_hallucination

Generation: generate retry

2.3 设计原则

  1. 零侵入:Langfuse 未配置时,所有功能正常工作,callbacks 为空列表
  2. 共享 handler:同一次查询的所有 LLM 调用归到同一条 Trace
  3. 环境变量驱动:Langfuse v4 SDK 通过环境变量读取配置,不在代码中硬编码密钥

三、Langfuse 部署(Docker Compose 一键启动)

Langfuse v3 的架构比 v2 复杂不少,需要 6 个服务:

服务作用端口
langfuse-web前端 + API3000
langfuse-worker后台任务处理3030
langfuse-db (PostgreSQL)元数据存储5433
langfuse-clickhouseTrace 数据分析8123/9000
langfuse-minioS3 兼容对象存储9090
langfuse-redis队列 & 缓存6380

我写了一个独立的 docker-compose-Langfuse.yml,一键启动:

cd rag-infra
docker compose -f docker-compose-Langfuse.yml up -d

等待约 30 秒,所有容器启动完成后,访问 http://localhost:3000 进入 Langfuse。

踩坑记录:Langfuse v3 要求 ENCRYPTION_KEY 必须是 64 位 hex 字符串(256 bits),用 openssl rand -hex 32 生成。如果用全零占位会报错启动不了。

3.1 注册管理员账号

首次访问会看到登录页面,点击底部的 Sign up 链接进入注册页面,填写邮箱和密码完成注册。第一个注册的账号自动成为管理员。

3.2 创建项目

登录后,点击 New Project,输入项目名称(比如 RAG-demo),点击创建。

3.3 获取 API Key

进入项目后,点击左侧 Settings → API Keys,可以看到系统自动生成的 Public KeySecret Key

把这两个 Key 填入项目根目录的 .env 文件:

LANGFUSE_SECRET_KEY=sk-lf-xxx   # 替换为你的 Secret Key
LANGFUSE_PUBLIC_KEY=pk-lf-xxx   # 替换为你的 Public Key
LANGFUSE_HOST=http://localhost:3000

四、代码实现

4.1 配置层(config.py)

# Langfuse 配置
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY", "")
LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY", "")
LANGFUSE_HOST = os.getenv("LANGFUSE_HOST", "http://localhost:3000")

4.2 LLM 层(llm.py)—— 兼容 Langfuse v2/v4

Langfuse v4 SDK 有一个重大变化:CallbackHandler 不再通过构造函数接受 secret_keyhost 等参数,改为读取环境变量。import 路径也从 langfuse.callback 变成了 langfuse.langchain

# 兼容 v4 和 v2 的 import
try:
    from langfuse.langchain import CallbackHandler as LangfuseCallbackHandler
except (ImportError, ModuleNotFoundError):
    try:
        from langfuse.callback import CallbackHandler as LangfuseCallbackHandler
    except (ImportError, ModuleNotFoundError):
        LangfuseCallbackHandler = None

# Langfuse v4 通过环境变量读取配置
if LANGFUSE_SECRET_KEY:
    os.environ.setdefault("LANGFUSE_SECRET_KEY", LANGFUSE_SECRET_KEY)
if LANGFUSE_PUBLIC_KEY:
    os.environ.setdefault("LANGFUSE_PUBLIC_KEY", LANGFUSE_PUBLIC_KEY)
if LANGFUSE_HOST:
    os.environ.setdefault("LANGFUSE_HOST", LANGFUSE_HOST)

get_langfuse_handler() 函数负责创建 handler,失败时返回 None 不影响主流程:

def get_langfuse_handler(**kwargs):
    if not _is_langfuse_enabled():
        return None
    try:
        return LangfuseCallbackHandler()
    except Exception as e:
        logger.warning(f"Langfuse handler 创建失败: {e}")
        return None

4.3 RAG 图(rag_graph.py)—— 全节点接入

关键改动:在 RAGState 中新增 langfuse_handler 字段,通过辅助函数 _get_callbacks() 提取:

def _get_callbacks(state: RAGState) -> list:
    handler = state.get("langfuse_handler")
    return [handler] if handler else []

每个涉及 LLM 调用的节点都通过 config={"callbacks": callbacks} 传入:

def grade_documents(state: RAGState) -> dict:
    llm = get_llm()
    callbacks = _get_callbacks(state)  # 从 state 中取 handler
    
    chain = grade_prompt | llm | StrOutputParser()
    raw_result = chain.invoke(
        {"question": question, "documents": doc_list},
        config={"callbacks": callbacks},  # 传入 Langfuse callback
    )

入口函数 query_rag() 负责创建 handler 并注入到 initial_state:

def query_rag(question, ..., session_id=None, user_id=None):
    langfuse_handler = get_langfuse_handler(...)
    
    initial_state = {
        "question": question,
        # ... 其他字段 ...
        "langfuse_handler": langfuse_handler,  # 注入 handler
    }
    
    final_state = rag_graph.invoke(initial_state)

4.4 API 层(main.py)—— 支持 session_id / user_id

class QueryRequest(BaseModel):
    question: str
    retrieval_mode: Literal["vector", "bm25", "hybrid"] = "hybrid"
    query_transform: Literal["none", "rewrite", "hyde"] = "none"
    use_reranker: bool = False
    top_k: int = 5
    session_id: str | None = None   # 新增:会话追踪
    user_id: str | None = None      # 新增:用户标识

健康检查也加入了 Langfuse 状态:

{
  "status": "ok",
  "version": "0.7.0-langfuse",
  "services": {
    "llm": "ok",
    "embedding": "ok",
    "chroma": "ok (12 docs)",
    "langfuse": "ok (enabled)"
  }
}

五、验证测试

5.1 启动服务

# 1. 启动 Langfuse
cd rag-infra && docker compose -f docker-compose-Langfuse.yml up -d

# 2. 配置 .env
LANGFUSE_SECRET_KEY=sk-lf-xxx   # 从 Langfuse 面板获取
LANGFUSE_PUBLIC_KEY=pk-lf-xxx
LANGFUSE_HOST=http://localhost:3000

# 3. 启动 FastAPI
uvicorn app.main:app --reload

5.2 发送测试请求

curl -X POST http://localhost:8000/query \
  -H "Content-Type: application/json" \
  -d '{
    "question": "什么是RAG?",
    "session_id": "test-session-001",
    "user_id": "niannian"
  }'

返回结果中的 graph_steps 字段记录了完整的执行轨迹:

{
  "graph_steps": [
    "query_passthrough",
    "retrieve(hybrid) → 4 docs",
    "grade_documents(batch) → 4/4 relevant",
    "rerank_skipped",
    "generate",
    "hallucination_check → fail",
    "generate",
    "hallucination_check → fail",
    "fallback"
  ]
}

5.3 在 Langfuse 面板中查看

打开 http://localhost:3000,点击左侧 Tracing 菜单,可以看到:

Trace 列表视图:每条 trace 对应一次 RAG 查询,显示总耗时、状态、输入输出。

Trace 详情视图(点击某条 trace 进入):

这是最有价值的视图。你能看到一条 trace 下的所有 LLM 调用,以树状结构展开:

rag-query (Trace)
  ├── ChatOpenAI (Generation) — grade_documents
  │     Input: "你是一个文档相关性评估专家..."
  │     Output: "yes\nyes\nyes\nyes"
  │     Tokens: 1,234 input / 8 output
  │     Latency: 1.2s
  │
  ├── ChatOpenAI (Generation) — generate
  │     Input: "你是一个知识库问答助手..."
  │     Output: "RAG是检索增强生成技术..."
  │     Tokens: 2,048 input / 256 output
  │     Latency: 3.5s
  │
  ├── ChatOpenAI (Generation) — check_hallucination
  │     Input: "你是一个事实核查专家..."
  │     Output: "no"
  │     Latency: 0.8s
  │
  ├── ChatOpenAI (Generation) — generate (retry)
  │     Latency: 3.2s
  │
  └── ChatOpenAI (Generation) — check_hallucination (retry)
        Output: "no"
        → 触发 fallback

六、从 Langfuse 面板中我们能看到什么

在这里插入图片描述
在这里插入图片描述

6.1 性能瓶颈一目了然

通过每个 Generation 的 Latency,可以立刻定位最慢的环节。通常 generate 节点最慢(因为输出最长),而 grade_documentscheck_hallucination 相对较快(只输出 yes/no)。

6.2 Token 消耗分布

Langfuse 自动统计每次调用的 input/output token 数。你会发现 grade_documents 的 input token 很高(因为要把所有检索到的文档都塞进 prompt),这是优化的重点方向。

6.3 幻觉检测的重试链路

这是最有意思的部分。Langfuse 记录了每次 LLM 调用的完整 Input 和 Output,你可以逐个展开查看:

  • 第一次生成的回答是什么
  • 幻觉检测的 Input(prompt + 参考文档 + 回答)和 Output(yes/no)
  • 重试后生成了什么不同的回答
  • 最终是通过了检测还是走了 fallback

Langfuse 本身不会告诉你"为什么"幻觉检测判了 no,但它把所有原材料都摆在你面前,让你自己对比分析。

实际案例:在我的测试中,问"什么是RAG?",generate 节点生成了一段基于文档的回答(提到了减少幻觉、知识可热更新、回答可溯源等),但 check_hallucination 节点却输出了 no
在这里插入图片描述

如果没有 Langfuse,你只会看到最终的兜底回答"抱歉,知识库中没有找到相关信息",完全不知道哪里出了问题。但现在,点开 Langfuse 中这个 Generation 的详情,你能看到:

  1. Input 中的 documents 字段:完整的参考文档内容 —— RAG 是检索增强生成技术……减少幻觉……知识可热更新……
  2. Input 中的 answer 字段:生成的回答 —— RAG 的主要优势包括:减少幻觉、知识可热更新、回答可溯源……
  3. Outputno

你自己肉眼对比一下 documents 和 answer —— 回答的内容明明就来自参考文档,没有编造,为什么模型判了 no

到这一步,Langfuse 的作用已经完成了:它帮你把问题精确定位到了 check_hallucination 这一个节点。你知道了"不是检索的问题,不是生成的问题,就是幻觉检测这一步判错了"。

接下来需要回到代码里看这个节点的 prompt(Langfuse 的 Input 记录的是传给 LLM 的变量值,prompt 模板本身需要看代码):

# app/rag_graph.py - check_hallucination 节点
"请判断以下回答是否完全基于提供的参考文档,没有编造信息。"

为什么内容吻合却判了 no?可能的原因有几个:

  • prompt 措辞过严:“完全基于"要求太高,回答对原文做了概括和重组,模型可能认为不算"完全基于”
  • 模型判断能力不足:不同模型对"有依据"的理解不同,换一个模型可能就通过了
  • 回答中存在微妙的推理延伸:比如"知识可热更新"是对"只需更新知识库,无需重新训练模型"的概括,模型可能认为这算"改写"

具体是哪个原因,可以通过修改 prompt 措辞、换模型、或者简化回答来逐一排除。但关键是 —— 没有 Langfuse,你连排查方向都没有

总结这次排查路径

  1. Langfuse 面板:看到 check_hallucination 输出 no → 定位到出问题的节点
  2. Langfuse 面板:对比 Input 中的 documents 和 answer → 确认内容是吻合的,不是生成的问题
  3. 回到代码:看 prompt 模板 → 发现"完全基于"措辞过严

没有 Langfuse,第 1 步和第 2 步都做不到,你只能对着一个兜底回答干瞪眼。

6.4 变量内容审查

每个 Generation 的 Input 都记录了传给 LLM 的完整变量值。你可以检查:

  • 检索到的文档内容是否真的和问题相关
  • 上下文是否太长导致模型"迷路"
  • 不同节点之间传递的数据是否符合预期

6.5 会话维度分析

通过 session_id,可以在 Langfuse 的 Sessions 视图中查看同一用户的多轮对话,分析用户的提问模式和系统的回答质量。

七、踩坑总结

问题原因解决方案
Langfuse 启动报 500ENCRYPTION_KEY 不是 64 位 hexopenssl rand -hex 32 生成
langfuse 显示 disabledSDK 未安装pip install langfuse
import 报错 No module named 'langfuse.callback'Langfuse v4 改了 import 路径改用 from langfuse.langchain import CallbackHandler
Handler 创建成功但 Trace 页面无数据v4 不再通过构造函数传 secret_key改用环境变量 os.environ.setdefault(...)
docker compose restart 不生效restart 不重新读取 ymldown + up 代替

八、完整代码

本文代码已开源:github.com/daixueyun3377/RAG-demodev-Langfuse 分支。

git clone https://github.com/daixueyun3377/RAG-demo.git
git checkout dev-Langfuse
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值