LLM 观测与追踪:Langfuse/OpenTelemetry 生产落地实战
本文面向生产环境 LLM 应用的运维团队。不讲 LLM 怎么训练,只讲怎么把一次用户请求里发生的 LLM 调用、检索、tool call、prompt 版本、token 成本,用 trace/span 串起来看清楚。
1. LLM 观测 vs LLM 监控
监控(Monitoring)解决的是"系统整体健康吗"——Prometheus 抓 QPS、Grafana 看 P99、PagerDuty 打电话。LLM 观测(Observability)解决的是"这一次用户请求到底经历了什么"——同一句 prompt 里模型被调了几次、每次用了多少 token、命中了哪个 prompt 版本、检索召回了哪几条文档、tool call 失败时是网络还是参数。
两者不是替代关系,而是分层:
- Metrics:全局统计,用于告警与容量规划(P99 延迟、token/s、错误率)
- Traces:单次请求的完整链路,用于排障与归因
- Logs:细粒度事件,用于补充上下文
生产里最容易踩的坑是只做了监控——QPS 正常、延迟正常,用户却投诉"模型胡说八道",你打开监控图一片绿,一脸懵逼。这时候你需要的不是更多指标,而是一个 trace 视图能定位到具体哪一步崩了。
2. 三层模型:Trace / Span / Metric
| 层次 | 单位 | 生命周期 | 典型用途 | 存储代价 |
|---|---|---|---|---|
| Trace | 一次完整用户请求 | 秒级~分钟级 | 排障、成本归因、prompt 版本对比 | 高(全量文本) |
| Span | Trace 内的一个操作节点 | 毫秒级~秒级 | 定位瓶颈、串接子调用 | 高(含输入输出) |
| Metric | 聚合后的时间序列 | 秒~分钟粒度 | 告警、Dashboard、SLO | 低(数值型) |
一次典型的 RAG 请求对应一棵 span 树:
[Trace] user_session=8f3a...
├─ [Span] retrieve_kb 120ms chunks=5
├─ [Span] rerank 45ms
├─ [Span] chat.completions 1820ms model=gpt-4o tokens_in=3210 tokens_out=412 cost=$0.019
│ └─ [Span] http_request 1780ms vendor=azure
├─ [Span] fact_check 890ms model=gpt-4o-mini
└─ [Span] log_event 2ms user_feedback=thumbs_up
Metric 只告诉你 P99 是 3.2s,Trace 告诉你这 3.2s 里 60% 花在 fact_check 的第二次 LLM 调用——这就是观测的价值。
3. Langfuse 架构剖析
Langfuse 是一个开源的 LLM 可观测性平台,MIT 协议,可以完全自托管。核心组件:
| 组件 | 作用 | 技术栈 |
|---|---|---|
| langfuse-web | 前端 + BFF API | Next.js + NestJS |
| langfuse-worker | 异步处理事件、eval pipeline | NestJS worker |
| Postgres | 元数据、用户、prompt、trace 索引 | PostgreSQL 16 |
| ClickHouse | 事件流存储、span 树查询 | ClickHouse |
| Redis / Valkey | 事件队列、分布式锁 | Redis 7 |
| S3 / MinIO | 大 payload 存储(可选) | S3 兼容 |
关键设计:
- 事件驱动:SDK 上报的事件先进 Redis,worker 消费后写 ClickHouse,Web 只读 ClickHouse。这样上报 API 永不阻塞业务主路径。
- Prompt 管理内置:prompt 版本化存在 Postgres,SDK 拉取时带版本号,trace 记录用的是哪个版本——prompt regression 检测的基础。
- Cost tracking 自动:内置模型价格表(OpenAI、Anthropic、Azure、国产模型需手动加),按 token 单价算出每次调用成本。
- Eval pipeline:可以对指定 trace 触发 LLM-as-judge 或人工评分任务。
坦承:Langfuse 的官方 CDN 在国内访问偏慢,public-api.langfuse.com 拉 prompt 大概 400-800ms 延迟。生产环境必须自托管,别用 SaaS。
4. Langfuse 自托管完整部署
4.1 docker-compose.yml
生产级最小可运行配置,4C8G 内存即可起步:
version: "3.9"
services:
langfuse-web:
image: docker.io/langfuse/langfuse:3
restart: always
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
clickhouse:
condition: service_healthy
redis:
condition: service_healthy
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- DATABASE_DIRECT_URL=postgresql://postgres:postgres@db:5432/postgres
- NEXTAUTH_URL=http://langfuse.local:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- SALT=${SALT}
- ENCRYPTION_KEY=${ENCRYPTION_KEY} # openssl rand -base64 32
- TELEMETRY_ENABLED=false # 关掉他们家的遥测
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=false
- CLICKHOUSE_URL=http://clickhouse:8123
- CLICKHOUSE_MIGRATION_URL=clickhouse://clickhouse:9000
- CLICKHOUSE_TABLE=events
- S3_BUCKET=langfuse-bucket
- S3_ENDPOINT=http://minio:9000
- S3_ACCESS_KEY_ID=minio
- S3_SECRET_ACCESS_KEY=minio123
- S3_UPLOAD_BUCKET=langfuse-bucket
- S3_DOWNLOAD_BUCKET=langfuse-bucket
- UPLOAD_BUCKET=langfuse-bucket
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 5s
retries: 3
langfuse-worker:
image: docker.io/langfuse/langfuse-worker:3
restart: always
depends_on:
db:
condition: service_healthy
clickhouse:
condition: service_healthy
redis:
condition: service_healthy
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
- REDIS_URL=redis://redis:6379/0
- CLICKHOUSE_URL=http://clickhouse:8123
- CLICKHOUSE_TABLE=events
- S3_BUCKET=langfuse-bucket
- S3_ENDPOINT=http://minio:9000
- S3_ACCESS_KEY_ID=minio
- S3_SECRET_ACCESS_KEY=minio123
clickhouse:
image: docker.io/clickhouse/clickhouse-server:24.3
restart: always
ports:
- "8123:8123"
- "9000:9000"
volumes:
- ch_data:/var/lib/clickhouse
- ch_logs:/var/log/clickhouse-server
environment:
- CLICKHOUSE_DB=langfuse
- CLICKHOUSE_USER=clickhouse
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
- CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1
ulimits:
nofile:
soft: 262144
hard: 262144
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: docker.io/redis/redis:7-alpine
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
db:
image: docker.io/postgres:16
restart: always
ports:
- "5432:5432"
volumes:
- pg_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=***
- POSTGRES_DB=postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
minio:
image: docker.io/minio/minio:latest
restart: always
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minio
- MINIO_ROOT_PASSWORD=***
volumes:
- minio_data:/data
volumes:
ch_data:
ch_logs:
redis_data:
pg_data:
minio_data:
4.2 启动步骤
# 生成密钥
export NEXTAUTH_SECRET=$(openssl rand -base64 32)
export SALT=$(openssl rand -base64 32)
export ENCRYPTION_KEY=$(openssl rand -base64 32)
export CLICKHOUSE_PASSWORD=$(op…16)
# 首次启动需要初始化 S3 bucket
docker compose up -d minio
sleep 5
mc alias set local http://localhost:9000 minio minio123
mc mb -p local/langfuse-bucket || true
# 启动全部
docker compose up -d
# 打开 http://localhost:3000 完成初始管理员创建
4.3 生产加固清单
- 反代 + TLS:Caddy 或 Nginx 前置,
NEXTAUTH_URL改成公网域名 - S3 存储桶生命周期:
mc lifecycle设置 90 天冷归档,否则一年能攒 5TB - ClickHouse 分区:默认按天分区,跨月查询用
PARTITION BY toYYYYMM(timestamp)更好 - 备份策略:Postgres + ClickHouse 都要备份,ClickHouse 用
BACKUP DATABASE langfuse TO Disk('s3', 'backups/...') - 资源限制:给 clickhouse 设
--max_concurrent_queries=32,别让它把宿主内存吃光
5. Langfuse Python SDK 集成
5.1 安装与初始化
from langfuse import Langfuse, get_client
# 自托管地址
langfuse = Langfuse(
secret_key="sk-lf-xxx",
public_key="pk-lf-xxx",
host="http://langfuse.internal:3000", # 注意:不是官方 SaaS 域名
)
# 或者用环境变量
# LANGFUSE_HOST / LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY
langfuse = get_client()
5.2 @observe 装饰器(最推荐的集成方式)
from langfuse import observe, get_client
from openai import OpenAI
client = OpenAI()
langfuse = get_client()
@observe()
def retrieve_kb(query: str) -> list[dict]:
"""向量检索"""
return vector_db.search(query, top_k=5)
@observe(as_type="generation", model="gpt-4o", model_parameters={"temperature": 0.1})
def generate_answer(context: list[dict], question: str) -> str:
"""LLM 生成"""
messages = [
{"role": "system", "content": "你是运维助手,回答简洁准确。"},
{"role": "user", "content": f"参考文档:{context}\n问题:{question}"},
]
resp = client.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0.1,
)
return resp.choices[0].message.content
@observe()
def rag_pipeline(question: str, user_id: str):
"""一次完整的用户请求 = 一个 trace"""
langfuse.update_current_trace(
user_id=user_id,
session_id=f"session_{user_id}",
tags=["rag", "production"],
metadata={"entrypoint": "web-chat"},
)
context = retrieve_kb(question)
answer = generate_answer(context, question)
return answer
# 使用
answer = rag_pipeline("Kubernetes Pod Pending 怎么办?", user_id="u_12345")
langfuse.flush()
装饰器自动完成的事:
- 父 span 自动关联:
retrieve_kb和generate_answer被调时自动作为rag_pipeline的子 span - 异常自动捕获:抛异常时 span 状态标记为 ERROR,异常栈写入 span
- LLM 调用自动打点:
@observe(as_type="generation")会自动抓 token 数、model 名、prompt 内容
5.3 Prompt 管理(版本化)
# 在 Langfuse Web UI 里创建 prompt "rag-system-prompt",编辑时会自动 v1 → v2 → v3
system_prompt = langfuse.get_prompt("rag-system-prompt") # 拉取最新版
system_prompt_v2 = langfuse.get_prompt("rag-system-prompt", label="production")
# 或者指定版本号
system_prompt_v1 = langfuse.get_prompt("rag-system-prompt", version=1)
# 使用(Langfuse 会自动在 trace 里记录用了哪个版本)
messages = [
{"role": "system", "content": system_prompt.compile(
style="concise",
domain="kubernetes",
)},
{"role": "user", "content": question},
]
关键收益:每次上线改 prompt,都能在 Langfuse 里看到 v1 和 v2 的 P99 延迟、token 成本、人工评分、LLM-judge 分数对比——这是发现 prompt regression 的基础。
5.4 Eval Pipeline(自动化评估)
# 定义一个 eval 任务:用 gpt-4o 对生成结果打分
langfuse.create_score_definition(
name="answer_relevance",
data_type="NUMERIC",
min_value=0,
max_value=1,
description="答案与问题的相关性,0-1",
)
# 在 Web UI 里配置 eval pipeline:
# 1. 触发条件:tag = "production"
# 2. Target:generation span
# 3. Prompt: "作为评估员,判断答案是否准确回答了问题。输出 0-1 的分数。"
# 4. Model: gpt-4o-mini
# 5. 存储到 score name: answer_relevance
# 也可以在代码里手动打分
langfuse.create_score(
trace_id=current_trace_id,
name="answer_relevance",
value=0.85,
comment="人工复核:合格",
data_type="NUMERIC",
)
6. OTel OTLP gen_ai 命名空间
Langfuse SDK 已经帮你抓了不少,但跨服务的底层调用(HTTP、DB、缓存)还是需要 OpenTelemetry。OTel 官方从 2024 年起为 LLM 定义了 gen_ai 语义约定(Semantic Conventions for Generative AI),成为事实标准。
6.1 Instrumentation 初始化
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
provider = TracerProvider()
processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"))
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
# 自动 instrumentation
HTTPXClientInstrumentor().instrument()
SQLAlchemyInstrumentor().instrument()
Psycopg2Instrumentor().instrument()
tracer = trace.get_tracer("my_llm_app", version="1.0.0")
6.2 手工添加 gen_ai 属性
with tracer.start_as_current_span("llm.chat.completions") as span:
# 官方约定的属性名(都是 gen_ai.* 前缀)
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.request.temperature", 0.1)
span.set_attribute("gen_ai.request.max_tokens", 2048)
try:
resp = client.chat.completions.create(...)
span.set_attribute("gen_ai.response.model", resp.model)
span.set_attribute("gen_ai.usage.input_tokens", resp.usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", resp.usage.completion_tokens)
span.set_attribute("gen_ai.usage.total_tokens", resp.usage.total_tokens)
span.set_attribute("gen_ai.response.finish_reasons", resp.choices[0].finish_reason)
except Exception as e:
span.record_exception(e)
span.set_status(trace.Status(trace.StatusCode.ERROR))
raise
6.3 关键属性速查表
| 属性 | 类型 | 说明 |
|---|---|---|
gen_ai.system |
string | 提供方:openai / anthropic / azure_openai / cohere |
gen_ai.operation.name |
string | chat / text_completion / embeddings |
gen_ai.request.model |
string | 请求的模型名 |
gen_ai.request.temperature |
float | 采样温度 |
gen_ai.request.max_tokens |
int | 最大生成 token |
gen_ai.response.model |
string | 实际使用的模型(可能带 snapshot 后缀) |
gen_ai.usage.input_tokens |
int | 输入 token 数 |
gen_ai.usage.output_tokens |
int | 输出 token 数 |
gen_ai.response.finish_reasons |
string[] | stop / length / content_filter |
gen_ai.prompt |
any | 完整 prompt(默认不打,opt-in) |
gen_ai.completion |
any | 完整输出(默认不打,opt-in) |
注意:gen_ai.prompt 和 gen_ai.completion 默认是不打的,因为可能包含敏感信息。Langfuse 的 SDK 会打,OTel 原生 SDK 不会,需要通过 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY 环境变量打开。
7. 两套观测数据关联
生产里常见两种方案:
方案 A:Langfuse 作为 OTel Collector 的 backend
# otel-collector config.yaml
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
grpc:
endpoint: 0.0.0.0:4317
exporters:
# 传统 Jaeger(性能问题排查)
otlp/jaeger:
endpoint: jaeger-collector:4317
tls:
insecure: true
# Langfuse(LLM 语义)
otlp/langfuse:
endpoint: http://langfuse-web:3000/api/public/otel
headers:
Authorization: Basic ${LANGFUSE_OTLP_AUTH} # base64(pk:sk)
x-langfuse-ingestion-event-mode: batch
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, attributes/gen_ai_filter]
exporters: [otlp/jaeger, otlp/langfuse]
方案 B:SDK 层双发
Langfuse SDK 和 OTel SDK 各自独立初始化,两边都拿到同一个 trace_id:
from opentelemetry import trace
from langfuse import get_client
langfuse = get_client()
tracer = trace.get_tracer("my_app")
with tracer.start_as_current_span("user_request") as otel_span:
trace_id_hex = format(otel_span.get_span_context().trace_id, "032x")
# 把 OTel trace_id 塞给 Langfuse,两者对齐
langfuse.update_current_trace(
metadata={"otel_trace_id": trace_id_hex}
)
with langfuse.start_as_current_observation(
name="generate_answer", as_type="generation"
) as lf_span:
...
推荐:中大型团队用方案 A,Collector 层统一,SDK 不用感知后端差异;小团队用方案 B,简单直接。
8. 事故复盘 1:Langfuse ClickHouse 磁盘 IO 打满
症状:Langfuse Web UI 打开 trace 页面卡 15-30 秒,慢查询日志爆炸,MySQL/Postgres 也变慢。
排查:
# ClickHouse 上跑这个就能看到罪魁祸首
SELECT query_id, elapsed, read_rows, read_bytes, memory_usage
FROM system.processes
ORDER BY elapsed DESC LIMIT 10;
# 或者看慢查询
SELECT query, count(), avg(elapsed)
FROM system.query_log
WHERE type='QueryFinish' AND elapsed > 10
GROUP BY query
ORDER BY avg(elapsed) DESC;
根因:默认 ClickHouse 配置没有对 Langfuse 常用的 order by createdAt desc 走索引,全表扫描 5 亿行事件。
修复:
- ClickHouse 磁盘换成 NVMe,至少 1TB(events 表增长极快)
- 加
max_concurrent_queries=32限制并发 - 给
events表设置 TTL:ALTER TABLE events MODIFY TTL toDateTime(createdAt) + INTERVAL 90 DAY - Web UI 分页查询限制:Langfuse 前端可以设置
MAX_TIMELINE_SPANS=500
避坑:ClickHouse 单机跑 Langfuse 一年大约 200-500GB 数据,务必监控磁盘使用率,超 80% 立即扩容。
9. 事故复盘 2:多步 trace 关联失败
症状:Langfuse 里看到大量孤立 span,一个用户请求被拆成了 5-10 个 trace,无法看到完整链路。
排查:打开 Langfuse trace 详情,发现 user_request span 的 trace_id 和 retrieve_kb span 的 trace_id 完全不同。
根因:LangChain callback handler 在 async 环境下会丢失 contextvars。当 LangfuseChainCallbackHandler 在 asyncio 事件循环里跨 task 调用时,get_current_trace_id() 返回空。
修复:
# 错误做法:在 async 里直接 new callback
callback = LangfuseHandler() # 每次调用都新开 trace
# 正确做法 1:显式传递 trace_id
callback = LangfuseHandler(trace_context={"trace_id": current_trace_id})
# 正确做法 2:用 contextvars 绑定
import contextvars
from langfuse.decorators._decorator import _get_client_from_context
current = contextvars.copy_context()
current.run(lambda: chain.invoke(input, config={"callbacks": [LangfuseHandler()]}))
# 正确做法 3:升级到 Langfuse SDK >= 2.40,修好了 async contextvars 传递
避坑:Langfuse SDK 2.40 之前,async + LangChain 场景下请务必手动传递 trace_context,否则 trace 树会碎成渣。
10. 事故复盘 3:Prompt Regression 没发现
症状:周一上午客服团队集体反馈"模型答非所问了",翻查上线记录发现上周五上线了新 prompt 版本。
根因:Prompt v12 里加了一句"回答尽量简洁",但测试集里 30% 的问题需要详细回答,模型一律简短回复导致答非所问。没有自动 eval 兜底。
修复方案(三层防线):
# 层 1:Prompt 变更走 Git + CI
# .github/workflows/prompt-eval.yml
# on: pull_request -> files changed: prompts/**
# run: python scripts/eval_prompt.py --version $NEW_VERSION
# 层 2:Langfuse eval pipeline
# 触发条件:prompt_version > X AND trace.tag = "canary"
# Judge prompt:
"""
作为严格评估员,判断以下答案是否:
1. 直接回答了问题(0-1)
2. 没有幻觉(0-1)
3. 长度合理(不过于简短或冗长,0-1)
问题:{question}
答案:{answer}
输出 JSON:{"relevance": 0.8, "faithfulness": 0.9, "length": 0.7}
"""
# 层 3:Canary + Rollback
# 10% 流量走新 prompt,观察 1 小时
# 关键指标:answer_relevance 平均分下降 > 5% → 自动 rollback
避坑:Prompt 变更不是文档,是代码。必须走 code review + CI eval + canary,别指望人肉 review 能发现"简洁"这两个字带来的语义漂移。
11. 五个高价值观测场景
场景 1:成本归因到用户 / 功能 / 模型
# Langfuse 内置 dashboard:按 user_id、trace.tag、gen_ai.request.model 聚合成本
# 生产里常见结果:
# - 3% 的用户贡献 40% 的成本 → 加限流
# - gpt-4o 占总成本 70%,但 gpt-4o-mini 能覆盖 60% 场景 → 降级
# - Agent 功能单次调用成本 $0.15,超过 chatbot 40 倍 → 拆分或缓存
场景 2:延迟分解定位瓶颈
Trace 视图一眼看出:RAG 场景 60% 延迟在向量检索,不是 LLM 调用。这时候优化 LLM 是白搭,应该优化向量索引(HNSW 参数、量化、缓存)。
场景 3:Prompt 版本对比
Langfuse 里选两个 prompt version,并排看:延迟、token 成本、人工评分、LLM-judge 分数的分布差异。不是看平均值,是看分布尾部——P95 分数下降比平均值更能反映 regression。
场景 4:Eval-as-Service
把 eval pipeline 作为 CI gate:
# CI 步骤
- name: Run Langfuse eval
run: |
python eval.py --prompt-version ${NEW_VERSION} \
--dataset regression-tests.jsonl \
--threshold 0.95
# 退出码非 0 → PR block
场景 5:User Session 回放
同一 session_id 下的所有 trace 拼起来,可以完整回放用户一次会话里的每次交互、prompt 演进、反馈。做 prompt 优化时,选 20 个真实 session 人工复盘比抽象评估有效 10 倍。
12. FAQ
Q1:Langfuse 的自托管和 SaaS 差在哪? 功能基本对齐,但 SaaS 多了 SSO、更细粒度的 RBAC、企业级 SLA。国内合规场景必须自托管,数据不出内网。
Q2:Langfuse 支持国产模型吗?
支持。需要在 Web UI 的 Model Settings 里加模型配置(价格、上下文长度、多模态能力)。SDK 上传的 model 字段填模型名就行,比如 qwen-72b、deepseek-v3。
Q3:Trace 数据会不会敏感?怎么脱敏?
会。Langfuse SDK 提供 mask 参数,可以按正则或自定义函数脱敏:
langfuse = Langfuse(
mask=True,
mask_function=lambda k, v: v.replace("[email protected]", "***") if isinstance(v, str) else v,
)
生产环境一定要开 mask,尤其是 PII 和 prompt 内容。
Q4:Langfuse 和 LangSmith 怎么选? - 国内合规 / 数据不出内网 → Langfuse - 已经在用 LangChain 且能出网 → LangSmith 更省事 - 想深度自定义 eval pipeline → Langfuse - 需要 LangChain 官方深度集成 → LangSmith
Q5:ClickHouse 会不会太重了?有没有轻量方案? Langfuse 3.x 强依赖 ClickHouse。轻量方案两个:一是 Langfuse 官方提供的 SaaS,二是回到 2.x 版本用 Postgres + Redis,牺牲大规模性能换简单部署。3.x 生产环境别用别的方案。
Q6:trace_id 冲突了怎么办? Langfuse SDK 和 OTel SDK 独立生成 trace_id 时,两边不会自动关联。用本文第 7 节的方案 B:把 OTel 的 trace_id 手动塞到 Langfuse 的 metadata,或用 Collector 层的 OTLP exporter 统一路由。
落地顺序建议
如果你是第一天开始接入 LLM 观测,推荐这个顺序(每步都能独立产生价值):
- 先上 Langfuse SDK(1 小时):拿到基础的 trace 视图和成本统计
- 再上 OTel 自动 instrumentation(半天):把 HTTP、DB 调用接进来
- 加 prompt 管理(1 天):把散落在代码里的 prompt 抽出来版本化
- 配置 eval pipeline(1 天):LLM-as-judge 自动打分
- CI gate(半天):prompt 变更必须过 eval 才能合并
- 成本看板 + 告警(半天):按用户/功能/模型聚合,超预算告警
一周内可以把 LLM 观测从零做到生产级。核心心法:观测不是为了证明系统没问题,是为了在系统出问题时,你能在 30 分钟内定位到根因。