LLM 应用上线后出了 bug,你怎么查?聊聊 Langfuse 全链路追踪的接入和踩坑
线上跑着一个 LLM Agent,用户反馈"回答不对"。你打开日志,看到一堆 INFO: request sent、INFO: response received。然后呢?
你不知道那次请求用了多少 token,不知道中间调了几次工具,不知道哪一步的 prompt 拼错了导致最终输出跑偏。传统日志系统是为 HTTP 请求设计的,一个请求对应一个响应。但 Agent 不是这样------一次用户提问可能触发 5 次 LLM 调用、3 次工具执行、2 次重试,整条链路散落在日志里,根本串不起来。
这就是 LLM 可观测性要解决的问题。我最近在生产环境接入了 Langfuse,踩了不少坑,这篇把接入过程和实际遇到的问题都记录下来。
Langfuse 是什么,不是什么
先说清楚边界。Langfuse 是一个开源的 LLM 可观测性平台,核心能力是 trace------把一次 Agent 执行的完整链路记录下来,包括每一步的输入、输出、耗时、token 用量和成本。
它不是 APM(Application Performance Monitoring),不替代 Datadog 或 Prometheus。它也不做模型训练或微调。把它理解成"LLM 专用的分布式追踪系统"就行。
架构上 Langfuse 分三层:
- SDK 层:Python/JS SDK 嵌入你的应用代码,采集 trace 数据
- 后端服务:接收 trace 数据,存入 PostgreSQL + ClickHouse
- Web UI:可视化 trace 链路,做成本分析和评分
部署:Docker Compose 一键起
Langfuse 支持 SaaS(cloud.langfuse.com)和自部署。生产环境我选了自部署,原因很简单------prompt 里有业务数据,不想过第三方。
bash
# 拉取 docker-compose 配置
git clone https://github.com/langfuse/langfuse.git
cd langfuse
# 创建环境变量文件
cat > .env << 'EOF'
DATABASE_URL=postgresql://postgres:postgres@db:5432/langfuse
NEXTAUTH_SECRET=your-secret-key-change-this
NEXTAUTH_URL=http://localhost:3000
SALT=your-salt-change-this
TELEMETRY_ENABLED=false
EOF
# 启动
docker compose up -d
启动后访问 localhost:3000,注册账号,在 Settings 里创建 API Key。拿到 LANGFUSE_PUBLIC_KEY 和 LANGFUSE_SECRET_KEY。
踩坑 #1:默认的 docker-compose.yml 里 PostgreSQL 没有持久化 volume。我第一次部署时忘了加,Docker 重启后数据全丢了。改成这样:
yaml
services:
db:
image: postgres:15
volumes:
- pgdata:/var/lib/postgresql/data # 加这一行
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: langfuse
volumes:
pgdata: # 声明 volume
最简接入:改一行 import
Langfuse 对 OpenAI SDK 做了 drop-in replacement。如果你的代码用的是 openai 库,接入成本低到离谱:
python
# 改之前
from openai import OpenAI
client = OpenAI()
# 改之后
from langfuse.openai import OpenAI
client = OpenAI()
只改了 import 路径。之后所有通过这个 client 发出的请求,Langfuse 会自动记录 trace,包括 model、messages、completion、token 用量、耗时。
环境变量里配好 Key:
bash
export LANGFUSE_PUBLIC_KEY="pk-..."
export LANGFUSE_SECRET_KEY="sk-..."
export LANGFUSE_HOST="http://localhost:3000" # 自部署地址
跑一个简单测试:
python
from langfuse.openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "用一句话解释什么是 KV Cache"}],
)
print(resp.choices[0].message.content)
去 Langfuse Web UI 的 Traces 页面,能看到这次调用的完整记录:输入 messages、输出 content、model 名称、prompt_tokens、completion_tokens、total_cost、latency。
这对单次调用已经够用了。但 Agent 不是单次调用。
Agent 多步追踪:@observe 装饰器
拿一个 RAG Agent 举例。用户提问后,Agent 先做向量检索,拿到相关文档,拼好 prompt,调 LLM 生成回答,最后可能还有格式化和安全检查。五六步串在一起,哪步出问题都可能导致最终回答跑偏。
用 Langfuse 的 @observe() 装饰器可以把这些步骤串成一条完整的 trace:
python
from langfuse.decorators import observe, langfuse_context
from langfuse.openai import OpenAI
client = OpenAI()
@observe()
def retrieve_docs(query: str) -> list[str]:
"""模拟向量检索"""
# 实际项目里这里会调 Milvus/Qdrant/Chroma
docs = [
"KV Cache 是 Transformer 推理时缓存已计算的 Key 和 Value 矩阵",
"PagedAttention 把 KV Cache 分页管理,减少显存碎片",
"vLLM 用 PagedAttention 实现了接近零浪费的显存分配",
]
return docs
@observe()
def build_prompt(query: str, docs: list[str]) -> list[dict]:
"""拼装 prompt"""
context = "\n".join(f"- {d}" for d in docs)
return [
{"role": "system", "content": f"根据以下资料回答问题:\n{context}"},
{"role": "user", "content": query},
]
@observe()
def generate_answer(messages: list[dict]) -> str:
"""调用 LLM"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0.3,
)
return resp.choices[0].message.content
@observe()
def rag_agent(query: str) -> str:
"""完整的 RAG Agent 入口"""
# 给这条 trace 加上用户 ID 和 session ID,方便后续按用户筛选
langfuse_context.update_current_trace(
user_id="user_12345",
session_id="session_abc",
tags=["rag", "production"],
)
docs = retrieve_docs(query)
messages = build_prompt(query, docs)
answer = generate_answer(messages)
return answer
# 运行
result = rag_agent("vLLM 的 PagedAttention 怎么减少显存浪费的?")
print(result)
在 Langfuse UI 里看到的是一棵树状结构:
scss
rag_agent (trace)
├── retrieve_docs (span, 12ms)
├── build_prompt (span, 0.3ms)
└── generate_answer (span, 1.2s)
└── OpenAI Chat Completion (generation)
├── model: gpt-4o
├── prompt_tokens: 187
├── completion_tokens: 92
└── total_cost: $0.0023
每一步的输入输出、耗时都能点开看。当某个用户反馈"回答不对"时,拿 session_id 一搜,整条链路一目了然------是检索没检到相关文档,还是 prompt 拼装的格式有问题,还是模型本身生成了错误内容。
踩坑 #2 :@observe() 装饰器在异步函数上的行为跟同步不一样。如果你用 async def,必须用 @observe() 而不是手动创建 span。我一开始在异步代码里手动调 langfuse.trace() 创建 trace,发现子 span 关联不上父 trace,因为 Langfuse 用的是 contextvars 做上下文传递,手动创建会丢失上下文链。
python
# 错误写法------异步函数里手动创建 trace
async def rag_agent(query: str):
trace = langfuse.trace(name="rag_agent") # 这样子 span 串不起来
docs = await retrieve_docs(query)
...
# 正确写法------用装饰器
@observe()
async def rag_agent(query: str):
docs = await retrieve_docs(query) # 子函数也要有 @observe()
...
成本追踪:每个月到底花了多少钱
Langfuse 自动根据 model 名称和 token 数量计算成本。内置了主流模型的价格表(GPT-4o、Claude 3.5 Sonnet、Gemini 等),更新挺及时的。
但如果你用的是代理 API 或者私有部署的模型,内置价格表就对不上了。这时候需要手动设置:
python
from langfuse import Langfuse
langfuse = Langfuse()
# 自定义模型的价格(每 1000 tokens 的美元价格)
langfuse.create_model(
model_name="my-fine-tuned-qwen",
input_price=0.002, # $0.002 / 1K input tokens
output_price=0.006, # $0.006 / 1K output tokens
unit="TOKENS",
)
配好之后,Dashboard 里能看到按天、按模型、按用户的成本聚合。我们上线第一周就发现一个问题:有个内部工具每次查询都拼了完整的上下文历史,导致单次请求的 prompt_tokens 平均 8000+,一天下来光这一个接口就烧了 <math xmlns="http://www.w3.org/1998/Math/MathML"> 40 。改成只带最近 5 轮对话后,成本降到 40。改成只带最近 5 轮对话后,成本降到 </math>40。改成只带最近5轮对话后,成本降到6/天。
踩坑 #3 :Langfuse SDK 默认是异步上报 trace 的,程序退出时如果不调 langfuse.flush(),最后几条 trace 会丢。在 Web 服务里问题不大(进程一直跑着),但在脚本和 Lambda 函数里必须显式 flush:
python
from langfuse.decorators import langfuse_context
def main():
result = rag_agent("some question")
print(result)
# 脚本结束前必须 flush,否则最后几条 trace 丢失
langfuse_context.flush()
if __name__ == "__main__":
main()
评分(Scoring):不只是记录,还能打分
trace 记录下来只是第一步。生产环境里你还想知道:这些回答的质量怎么样?
Langfuse 支持给 trace 打分,分三种方式:
1. 代码里自动打分
python
from langfuse.decorators import langfuse_context
@observe()
def rag_agent(query: str) -> str:
answer = generate_answer(...)
# 简单的自动评分:回答长度是否合理
score = 1.0 if 50 < len(answer) < 2000 else 0.5
langfuse_context.score_current_trace(
name="answer_quality",
value=score,
comment="基于长度的粗粒度评分",
)
return answer
2. 用 LLM 做评估(LLM-as-Judge)
python
@observe()
def evaluate_answer(question: str, answer: str, reference: str) -> float:
"""用另一个 LLM 评估回答质量"""
eval_prompt = f"""评估以下回答的质量,打分 0-1:
问题:{question}
参考答案:{reference}
待评估答案:{answer}
只输出一个 0 到 1 之间的数字。"""
resp = client.chat.completions.create(
model="gpt-4o-mini", # 评估用便宜模型就行
messages=[{"role": "user", "content": eval_prompt}],
temperature=0,
)
return float(resp.choices[0].message.content.strip())
3. 在 Web UI 里人工标注
Langfuse 的 UI 支持直接在 trace 上打分和写标注。我们的做法是每周抽样 50 条 trace,让团队里的人花半小时标注,主要标"回答准确性"和"是否产生幻觉"两个维度。这些标注数据后续可以导出,用来做 fine-tuning 的训练集。
生产环境的几个实际问题
性能开销:Langfuse SDK 的 trace 上报是异步的,对主流程几乎没影响。我们实测加了 Langfuse 后,P99 延迟增加不到 2ms。但注意别在高频循环里创建 trace------比如批处理 1000 条数据时,别每条都开一个 trace,而是把批处理作为一个 trace,每条数据作为子 span。
数据量:trace 数据量增长很快。我们每天大概 5 万次 LLM 调用,一个月下来 ClickHouse 里存了约 80GB 数据。Langfuse 有数据保留策略设置,建议生产环境配好自动清理,比如只保留最近 90 天。
采样:不是每条请求都需要记录 trace。Langfuse 支持采样率设置:
python
from langfuse.decorators import observe
@observe(sample_rate=0.1) # 只记录 10% 的请求
def rag_agent(query: str) -> str:
...
开发环境用 100%,预发布用 50%,生产环境看流量大小调整,一般 10%-30% 够用了。
踩坑 #4 :Langfuse 的 Python SDK 和 LangChain 集成时有个版本兼容问题。langfuse>=2.40 要配合 langchain>=0.2,如果你还在用 langchain 0.1.x,trace 里的 chain 步骤会显示为空。没有报错,就是悄悄地不记录。排查了两个小时才发现是版本问题。建议锁死依赖版本:
txt
# requirements.txt
langfuse==2.51.3
langchain==0.3.14
langchain-openai==0.3.6
跟其他方案对比
说几个常拿来对比的工具:
LangSmith:LangChain 官方出的,跟 LangChain 深度绑定。如果你项目全栈用 LangChain,LangSmith 的集成体验确实更顺滑。但 LangSmith 不开源,数据存在 LangChain 的服务器上。
OpenTelemetry GenAI Semantic Conventions :2025 年底 OTEL 社区发布了 GenAI 语义规范,定义了 gen_ai.request.model、gen_ai.usage.prompt_tokens 等标准 attribute。Langfuse 已经支持通过 OpenLLMetry 对接 OTEL,走标准协议。如果你的公司已经有 OTEL 的基础设施(Jaeger、Grafana Tempo),可以考虑直接用 OTEL 而不是单独部署 Langfuse。
Helicone:专注于 API 代理模式,在你的 LLM API 请求前面加一层代理来记录。接入更简单(只改 base_url),但功能没有 Langfuse 全。
我的建议:小团队、刚起步用 Langfuse(开源免费、功能全);中大团队、已有 OTEL 体系用 OpenTelemetry + 自己的后端;深度依赖 LangChain 的用 LangSmith。
小结
传统 APM 能告诉你"请求 500 了",但 LLM 应用的问题往往不是 500------是回答错了、花太多钱了、中间某一步的 prompt 拼炸了。这类问题只靠日志排查效率很低。
Langfuse 开源、自部署友好、SDK 设计简洁,改一行 import 就能跑起来。但真要在生产环境用好,trace 粒度、异步上报、采样率、数据保留都得提前想清楚。
上面提到的四个坑------Docker volume 忘挂、异步上下文丢失、flush 遗漏、版本兼容------都是我实际碰到的,记录在这里供参考。