跳转至

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 兼容

关键设计:

  1. 事件驱动:SDK 上报的事件先进 Redis,worker 消费后写 ClickHouse,Web 只读 ClickHouse。这样上报 API 永不阻塞业务主路径。
  2. Prompt 管理内置:prompt 版本化存在 Postgres,SDK 拉取时带版本号,trace 记录用的是哪个版本——prompt regression 检测的基础。
  3. Cost tracking 自动:内置模型价格表(OpenAI、Anthropic、Azure、国产模型需手动加),按 token 单价算出每次调用成本。
  4. 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 安装与初始化

pip install langfuse>=2.50.0
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()

装饰器自动完成的事:

  1. 父 span 自动关联:retrieve_kb 和 generate_answer 被调时自动作为 rag_pipeline 的子 span
  2. 异常自动捕获:抛异常时 span 状态标记为 ERROR,异常栈写入 span
  3. 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 亿行事件。

修复:

  1. ClickHouse 磁盘换成 NVMe,至少 1TB(events 表增长极快)
  2. 加 max_concurrent_queries=32 限制并发
  3. 给 events 表设置 TTL:ALTER TABLE events MODIFY TTL toDateTime(createdAt) + INTERVAL 90 DAY
  4. 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 观测,推荐这个顺序(每步都能独立产生价值):

  1. 先上 Langfuse SDK(1 小时):拿到基础的 trace 视图和成本统计
  2. 再上 OTel 自动 instrumentation(半天):把 HTTP、DB 调用接进来
  3. 加 prompt 管理(1 天):把散落在代码里的 prompt 抽出来版本化
  4. 配置 eval pipeline(1 天):LLM-as-judge 自动打分
  5. CI gate(半天):prompt 变更必须过 eval 才能合并
  6. 成本看板 + 告警(半天):按用户/功能/模型聚合,超预算告警

一周内可以把 LLM 观测从零做到生产级。核心心法:观测不是为了证明系统没问题,是为了在系统出问题时,你能在 30 分钟内定位到根因。