Langfuse 实战:LLM 应用链路与成本可观测

Langfuse 是目前开源生态里最顺手的 LLM 应用可观测平台。当你的 RAG 或 Agent 服务上线后,最怕两件事:用户说「答得不对」,你却不知道错在检索、提示词还是模型;月底账单突然翻倍,你也说不清钱花在哪个功能上。传统监控看不到这两层,Langfuse 正是补这块缺口的工具。

本文不谈概念堆砌,直接给出可落地的路径:自托管部署、Python SDK 埋点、LangChain 一行接入、Token 成本归因、提示词版本管理、线上打分,以及我在生产环境踩过的五个坑。

一、传统监控为什么看不住 LLM 应用

如果你已经用 Prometheus 看指标、用 OpenTelemetry 串链路,会发现它们在 LLM 场景下并非无效,而是「粒度不对」。一次问答请求在 APM 里可能只是一个耗时 4.2 秒的 HTTP span,但业务上它其实包含:改写用户问题、向量库召回 8 个片段、重排序、拼装提示词、调用模型、解析 JSON、失败后重试一次。APM 只告诉你「慢」,不会告诉你「召回的 8 个片段里有 6 个是噪声」。

1.1 三件传统 APM 看不见的事

第一是输入输出内容本身。排查 LLM 问题必须看到当时那一版提示词的完整文本、模型返回的原始字符串。指标系统天生不存内容,日志系统存了但没法按会话串起来看。

第二是Token 与花费。CPU 和内存是包月资源,Token 是按次计费的变动成本。哪个功能、哪个用户、哪个模型吃掉了预算,需要一套按调用维度累加的账本,而不是一条 QPS 曲线。

第三是质量。接口返回 200 不代表答对了。LLM 应用唯一有意义的健康指标是「回答有没有用」,这需要人工标注、用户点赞点踩、模型自动评分三种信号回流到同一条链路上。

换个说法:Prometheus 与 Grafana 管的是「机器还活着吗」,OpenTelemetry 链路追踪 管的是「请求走了哪些服务」,而 Langfuse 管的是「这次回答是怎么生成的、值多少钱、好不好」。三者互补而非替代。

二、四个核心概念:Trace / Observation / Session / Score

Langfuse 的数据模型很轻,理解四个词就够用。埋点前先把它们和自己的业务对上号,后面查数据会顺畅很多。

概念含义对应业务常犯的错
Trace一次完整的业务请求用户问一个问题把每次模型调用当成一个 Trace,链路碎成一片
ObservationTrace 内的一个步骤,分 span / generation / event召回、重排、模型调用只埋模型调用,检索环节成黑盒
Session多轮对话的串联一个会话窗口不传 session_id,多轮上下文无法回溯
Score挂在 Trace 或 Observation 上的评分点赞、人工标注、自动评测只看耗时不回流质量,无法做版本对比

其中 generation 是专门给模型调用用的 Observation 类型,它额外带 model、input、output、usage 字段,成本核算全靠它。span 用于普通步骤,event 用于打一个瞬时标记(比如「命中缓存」)。

三、自托管部署:Docker Compose 起一套

Langfuse 有云版,但国内团队更常见的是自托管——数据不出内网,提示词和用户问题都算敏感资产。新版本依赖 PostgreSQL(业务数据)、ClickHouse(分析型存储)、Redis(队列)和 S3 兼容对象存储(大字段),因此不要试图只起一个容器。

# 官方仓库自带完整编排,直接用最省事
git clone https://github.com/langfuse/langfuse.git
cd langfuse
# 生产环境务必改掉默认密钥
cp .env.example .env
# 三个必须自己生成的变量
openssl rand -hex 32   # NEXTAUTH_SECRET
openssl rand -hex 32   # SALT
openssl rand -hex 32   # ENCRYPTION_KEY
docker compose up -d
# 观察启动,web 容器 healthy 后访问 3000 端口
docker compose ps
docker compose logs -f langfuse-web --tail 50

首次访问 http://server-ip:3000 注册第一个账号即成为实例管理员,然后新建 Organization 与 Project,在项目设置里拿到 pk-lf-sk-lf- 一对密钥。如果对外暴露,前面挂一层反向代理并配好 HTTPS,做法可参考站内的 Nginx 反向代理实战

3.1 资源与保留策略

一台 4C8G 的机器足够支撑日均几万条 Trace。真正膨胀的是 ClickHouse 与对象存储里的原始输入输出——一条 RAG Trace 带十几个片段,单条几十 KB 很常见。上线第一天就把保留期定下来(例如明细留 30 天、聚合指标留一年),别等磁盘告警才处理。磁盘被写满的连锁反应有多难看,可以看 磁盘 I/O 打满导致服务雪崩 那一篇。

四、Python SDK 埋点:装饰器 + 手动 generation

最省力的接法是 @observe 装饰器:函数进出自动生成 Observation,嵌套调用自动形成父子关系。真正需要精细控制的只有模型调用那一层,因为要把 model、usage、成本喂进去。

import os
from langfuse import observe, get_client
from openai import OpenAI

os.environ['LANGFUSE_PUBLIC_KEY'] = 'pk-lf-xxx'
os.environ['LANGFUSE_SECRET_KEY'] = 'sk-lf-xxx'
os.environ['LANGFUSE_HOST'] = 'http://langfuse.internal:3000'

lf = get_client()
client = OpenAI()

@observe(name='retrieve')
def retrieve(question: str):
    # 这里写你的向量召回;返回值会作为 Observation 的 output 被记录
    docs = vector_store.search(question, top_k=8)
    return [d.text for d in docs]

@observe(name='answer', as_type='generation')
def answer(question: str, docs: list):
    prompt = '根据资料回答问题。\n资料:\n' + '\n---\n'.join(docs) + '\n问题:' + question
    resp = client.chat.completions.create(
        model='gpt-4o-mini',
        messages=[{'role': 'user', 'content': prompt}],
        temperature=0.2,
    )
    # 把用量与模型名回填到当前 generation,成本才能算准
    lf.update_current_generation(
        model='gpt-4o-mini',
        usage_details={
            'input': resp.usage.prompt_tokens,
            'output': resp.usage.completion_tokens,
        },
    )
    return resp.choices[0].message.content

@observe(name='rag-qa')
def rag_qa(question: str, user_id: str, session_id: str):
    lf.update_current_trace(user_id=user_id, session_id=session_id,
                            tags=['rag', 'prod'], metadata={'tenant': 'acme'})
    docs = retrieve(question)
    return answer(question, docs)

if __name__ == '__main__':
    print(rag_qa('报销单多久到账', user_id='u_1024', session_id='s_77'))
    lf.flush()   # 短生命周期进程必须 flush,否则数据留在缓冲区

三个细节值得强调。其一,user_idsession_id 必须在入口就写进 Trace,事后补不回来;后续做用户级成本分摊、会话级质量分析全靠它们。其二,tags 用来区分环境与功能模块,是后台过滤最高频的维度,建议统一约定命名。其三,脚本、Celery 任务、Serverless 这类短生命周期进程一定要 flush(),SDK 是异步批量上报的,进程退出太快会丢数据。

4.1 LangChain / LlamaIndex 一行接入

如果应用是用 LangChain 搭的,不必逐个函数改造,用回调处理器即可把整条 Chain 的每一步自动上报,包括工具调用与中间思考步骤。这对调试 Agent 尤其有用——工具选错、参数拼错都能一眼看到。相关编排写法可回看 LangChain Agent 实战

from langfuse.langchain import CallbackHandler

handler = CallbackHandler()
result = agent_executor.invoke(
    {'input': '统计上周订单金额 TOP5 的门店'},
    config={
        'callbacks': [handler],
        'metadata': {
            'langfuse_user_id': 'u_1024',
            'langfuse_session_id': 's_77',
            'langfuse_tags': ['agent', 'sql-tool'],
        },
    },
)

让 Agent 直接生成 SQL 查数据库的场景,链路可观测性几乎是刚需——生成的语句到底动了哪张表、跑了多久、是否被安全闸拦下,全要留痕。这套安全执行的做法在 Text2SQL 落地实战 里有完整拆解。

五、Token 成本归因:把调用折算成钱

Langfuse 内置了主流模型的价目表,只要 generation 里的 model 名能匹配上,成本自动算出来。真正的问题出在两类情况:一是走了自建网关,model 名被改成了 internal-chat-v2 这类别名;二是用了自部署的开源模型,官方价目表里根本没有。

解法是在项目设置里新建自定义模型定义:配置匹配用的正则、输入输出单价与计价单位。自部署模型没有账单,但强烈建议按 GPU 摊销折算出一个「影子单价」录进去——否则做自建与调用 API 的比价时无据可依。这块的算法逻辑可以参考 大模型推理成本优化

# 自建网关场景:显式上报用量与实际模型,避免成本落到 unknown
lf.start_as_current_generation(
    name='gateway-call',
    model='internal-chat-v2',          # 与自定义模型定义的正则匹配
    model_parameters={'temperature': 0.2, 'max_tokens': 1024},
    input=messages,
    usage_details={'input': 1820, 'output': 356, 'cache_read_input': 900},
    cost_details={'total': 0.0043},    # 也可直接上报金额,优先级高于价目表
)

成本看板真正有价值的是拆维度看。下面这张表是我们做预算复盘时固定看的四个切面,每次都能发现一两处意料之外的开销。

切面怎么看典型发现
按 tag按功能模块聚合花费一个边缘的摘要功能吃掉三成预算
按 user_id找出重度用户少数账号被脚本刷接口,需要限流
按 model对比不同模型单位成本简单意图分类还在用旗舰模型
按输入输出比看提示词是否过胖历史对话全量拼接,输入 Token 是输出的 12 倍

最后一行是最常见也最好治的浪费。把全量历史改成滑动窗口加摘要,把重复的系统提示词抽出来利用缓存,输入 Token 常能直接砍掉一半。提示词组织的取舍在 大模型上下文工程实战 里讲得更细。

六、提示词版本管理与线上打分

把提示词硬编码在代码里,意味着每次改一个字都要走发版流程,运营和产品同学永远插不上手。Langfuse 的 Prompt Management 把提示词变成带版本与标签的资源:代码按标签拉取,改文案不发版,且每条 Trace 会记录当时用的是哪个版本——这才让「新版提示词是不是更好」变成可验证的问题。

// 拉取带 production 标签的提示词并编译变量
const prompt = await langfuse.getPrompt('rag-answer', undefined, {
  label: 'production',
  cacheTtlSeconds: 60,        // 本地缓存,避免每次请求都打网络
});
const compiled = prompt.compile({ question, context });

// 用户点踩时回流一个分数,与 traceId 绑定
await langfuse.score({
  traceId,
  name: 'user-feedback',
  value: 0,                   // 1 有用 / 0 没用
  comment: '答案与实际报销政策不符',
});

分数的来源建议同时铺三条:用户显式反馈(点赞点踩,量小但最准)、模型自动评分(用一个模型判断回答是否有事实依据,覆盖面大但有偏差)、人工抽检(每天抽 20 条标注,作为校准另外两条的基准)。三者对齐后,才能在灰度两个提示词版本时用数据决策,而不是靠感觉。

需要区分清楚的是:Langfuse 的 Score 面向线上真实流量,衡量的是「这次回答好不好」;而离线基准测试衡量的是「这套系统的能力上限在哪」,两者搭配才完整。离线评测怎么设计,参考 AI Agent 评测:如何量化智能体真实能力;检索环节的质量指标则在 RAG 进阶实战 里有专门讨论。

七、生产落地的五个坑

下面五条都是真踩过的,按发生频率排序。

表现处置
数据丢失本地跑有数据,容器里没有进程退出前 flush;Web 服务在 shutdown 钩子里调用
敏感信息入库身份证号、手机号被完整记录上报前脱敏,或对特定字段关闭 input/output 记录
成本落到 unknown看板有调用无花费补自定义模型定义,或直接上报 cost_details
Trace 粒度过碎一次问答变成七条互不相关的 Trace入口统一开 Trace,子步骤用 Observation 嵌套
存储膨胀ClickHouse 与对象存储月增几百 GB设保留期,超长输入输出截断后再上报

脱敏这条尤其要提前想清楚。可观测的价值来自记录内容,而合规要求恰好是限制记录内容,两者天然冲突。务实的做法是:在 SDK 上报前做一层正则脱敏(手机号、邮箱、证件号),并把「是否记录完整 input」做成按环境开关——开发环境全记,生产环境只记结构化摘要加长度。

八、与既有可观测体系怎么拼

不要把 Langfuse 当成第二套 APM。合理的分工是:机器与接口层面的指标告警继续留在 Prometheus 与 Alertmanager;跨服务的请求链路继续走 OpenTelemetry;Langfuse 只负责 LLM 语义层。做关联的关键是把两侧的 ID 互相带上——在 Trace 的 metadata 里写入 OTel 的 trace_id,在 OTel span 的属性里写入 Langfuse 的 trace_id,排查时就能双向跳转。

告警侧也别重复建设。LLM 场景值得单独设的告警只有三类:单位时间成本突增(防刷与死循环)、平均评分下滑(质量退化)、生成失败率上升(模型或网关异常)。其余沿用现有告警体系即可,避免又造出一堆没人看的告警——告警治理的方法论见 On-call 值班与告警治理实战

小结

LLM 应用的可观测不是把日志再打一遍,而是补上三样传统监控天生缺失的东西:完整的输入输出、按调用累计的钱、可回流的质量分。Langfuse 用 Trace / Observation / Session / Score 四个概念把它们串在一起,自托管一套 Compose、埋一个装饰器、接一个回调,一天内就能跑通最小闭环。

落地顺序上我的建议是:先接入拿到链路,再补 user_id 与 tags 拿到成本归因,最后铺 Score 做质量闭环。三步走完,提示词改动与模型替换才第一次有了可验证的依据,而不是继续靠拍脑袋上线。

上一篇 Text2SQL 落地实战:自然语言转 SQL 与安全执行
下一篇 LiteLLM 多模型网关:统一接入与成本路由