前言
本文是 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 在线预览并导出图片。
2.3 设计原则
- 零侵入:Langfuse 未配置时,所有功能正常工作,callbacks 为空列表
- 共享 handler:同一次查询的所有 LLM 调用归到同一条 Trace
- 环境变量驱动:Langfuse v4 SDK 通过环境变量读取配置,不在代码中硬编码密钥
三、Langfuse 部署(Docker Compose 一键启动)
Langfuse v3 的架构比 v2 复杂不少,需要 6 个服务:
| 服务 | 作用 | 端口 |
|---|---|---|
| langfuse-web | 前端 + API | 3000 |
| langfuse-worker | 后台任务处理 | 3030 |
| langfuse-db (PostgreSQL) | 元数据存储 | 5433 |
| langfuse-clickhouse | Trace 数据分析 | 8123/9000 |
| langfuse-minio | S3 兼容对象存储 | 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 Key 和 Secret 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_key、host 等参数,改为读取环境变量。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_documents 和 check_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 的详情,你能看到:
- Input 中的 documents 字段:完整的参考文档内容 —— RAG 是检索增强生成技术……减少幻觉……知识可热更新……
- Input 中的 answer 字段:生成的回答 —— RAG 的主要优势包括:减少幻觉、知识可热更新、回答可溯源……
- Output:
no
你自己肉眼对比一下 documents 和 answer —— 回答的内容明明就来自参考文档,没有编造,为什么模型判了 no?
到这一步,Langfuse 的作用已经完成了:它帮你把问题精确定位到了 check_hallucination 这一个节点。你知道了"不是检索的问题,不是生成的问题,就是幻觉检测这一步判错了"。
接下来需要回到代码里看这个节点的 prompt(Langfuse 的 Input 记录的是传给 LLM 的变量值,prompt 模板本身需要看代码):
# app/rag_graph.py - check_hallucination 节点
"请判断以下回答是否完全基于提供的参考文档,没有编造信息。"
为什么内容吻合却判了 no?可能的原因有几个:
- prompt 措辞过严:“完全基于"要求太高,回答对原文做了概括和重组,模型可能认为不算"完全基于”
- 模型判断能力不足:不同模型对"有依据"的理解不同,换一个模型可能就通过了
- 回答中存在微妙的推理延伸:比如"知识可热更新"是对"只需更新知识库,无需重新训练模型"的概括,模型可能认为这算"改写"
具体是哪个原因,可以通过修改 prompt 措辞、换模型、或者简化回答来逐一排除。但关键是 —— 没有 Langfuse,你连排查方向都没有。
总结这次排查路径:
- Langfuse 面板:看到
check_hallucination输出no→ 定位到出问题的节点 - Langfuse 面板:对比 Input 中的 documents 和 answer → 确认内容是吻合的,不是生成的问题
- 回到代码:看 prompt 模板 → 发现"完全基于"措辞过严
没有 Langfuse,第 1 步和第 2 步都做不到,你只能对着一个兜底回答干瞪眼。
6.4 变量内容审查
每个 Generation 的 Input 都记录了传给 LLM 的完整变量值。你可以检查:
- 检索到的文档内容是否真的和问题相关
- 上下文是否太长导致模型"迷路"
- 不同节点之间传递的数据是否符合预期
6.5 会话维度分析
通过 session_id,可以在 Langfuse 的 Sessions 视图中查看同一用户的多轮对话,分析用户的提问模式和系统的回答质量。
七、踩坑总结
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Langfuse 启动报 500 | ENCRYPTION_KEY 不是 64 位 hex | 用 openssl rand -hex 32 生成 |
langfuse 显示 disabled | SDK 未安装 | 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 不重新读取 yml | 用 down + up 代替 |
八、完整代码
本文代码已开源:github.com/daixueyun3377/RAG-demo,dev-Langfuse 分支。
git clone https://github.com/daixueyun3377/RAG-demo.git
git checkout dev-Langfuse

8075

被折叠的 条评论
为什么被折叠?



