1. 为什么“能跑通”和“能上线”之间隔着一整套可观测体系
我最早做 AI Agent 项目的时候,和大多数人一样,注意力全在“怎么把链路串起来”上:模型能调通、工具能触发、多轮对话不崩,就觉得这事成了。直到有一次线上环境里,一个本该三步走完的任务,用户反馈“等了半分钟只回了一句话”,我翻遍日志才发现,中间有一次工具调用超时被静默吞掉了,模型拿着空结果硬编了一个答案。那一刻我才真正意识到:Agent 系统的复杂度不在“能不能跑”,而在“跑的时候到底发生了什么”。
这就是可观测性要解决的问题。传统后端服务的可观测性,核心是日志、指标、链路追踪三件套,关注的是请求耗时、错误率、资源占用。但 AI Agent 系统完全是另一回事——它的执行路径是非确定性的,同一个输入,模型可能走三条不同的工具调用链;它的成本是按 token 计费的,一次失控的循环可能烧掉几十块钱;它的失败是语义级的,HTTP 200 不代表任务成功,模型可能一本正经地胡说八道。
Langfuse 就是冲着这些痛点来的。它是一个开源的 LLM 工程可观测平台,核心能力可以概括成四块:Trace(全链路追踪)、Span/Generation(细粒度节点记录)、Score(质量评估打分)、Dataset(数据集与实验对比)。你可以把它理解成“给 Agent 装了一个行车记录仪 + 黑匣子 + 体检报告生成器”。它解决的核心问题是:让每一次模型调用、每一次工具执行、每一轮对话的输入输出、耗时、成本、评分,全部可追溯、可对比、可复盘。
这篇文章适合谁看?如果你正在用 LangChain、LangGraph、Spring AI、或者自己手搓的 Agent 框架做项目,已经过了“Hello World”阶段,开始被线上问题、成本失控、效果不稳定折磨,那这篇就是写给你的。我会从整体设计思路讲到具体落地细节,包括 Trace 怎么埋、评测怎么做、并发怎么扛、踩过哪些坑,尽量把“工程实践”这四个字落到实处。
2. 整体设计思路:可观测性不是加个 SDK 就完事
2.1 先想清楚要观测什么,再谈用什么工具
很多人接 Langfuse 的方式是:看到官方文档说pip install langfuse,然后加两行环境变量,跑起来看到 Trace 列表里冒出来几条记录,就觉得接完了。这种做法的问题在于,你观测到的是框架默认给你的东西,而不是你真正需要的东西。
我在动手之前会先列一张“观测需求清单”,按优先级排:
| 观测维度 | 具体问题 | 对应 Langfuse 能力 |
|---|---|---|
| 链路完整性 | 一次用户请求经过了哪些节点?哪一步慢了? | Trace + Span 树 |
| 模型调用细节 | 用了哪个模型?输入输出是什么?token 多少? | Generation |
| 成本归因 | 哪个用户/哪个功能/哪个 Agent 最烧钱? | Metadata + Tags |
| 质量评估 | 这次回答好不好?有没有幻觉? | Score |
| 版本对比 | 换了 prompt 之后效果变好还是变差? | Dataset + Experiment |
| 异常定位 | 工具调用失败了几次?错误堆栈在哪? | Level + StatusMessage |
这张表决定了你的埋点策略。比如你关心成本归因,那每个 Trace 就必须带上userId、sessionId、feature这类 metadata;你关心版本对比,那 prompt 版本号就得作为 tag 打进去。埋点信息是在设计阶段定的,不是事后补的,因为很多上下文一旦出了函数作用域就丢了。
2.2 自托管还是云服务:一个绕不开的选型
Langfuse 提供两种部署形态:官方云服务和自托管。这个选择直接影响后面的架构。
云服务的优点是开箱即用,不用管数据库、不用管扩容,适合快速验证和小团队。但它有两个现实问题:一是数据要出你的网络边界,如果你的业务涉及敏感数据,合规上过不去;二是量大之后成本会上去,尤其是高频 Agent 场景,Trace 数量是普通 Web 请求的好几倍。
自托管的话,Langfuse 官方提供 Docker Compose 方案,核心组件是Postgres(存元数据)+ ClickHouse(存 Trace 和 Observation,量大时的主力)+ Redis(队列)+ S3/MinIO(存大对象,比如长文本输入输出)。这套组合的选型逻辑很清晰:Trace 数据是典型的“写多读少、按时间范围查、需要聚合”的时序型数据,ClickHouse 的列式存储和压缩比在这种场景下比 Postgres 强一个数量级;而元数据(用户、项目、评分定义)是关系型的,放 Postgres 更合适。
我自己的项目最后选了自托管,原因是数据敏感 + 量大。实测下来,单机 8C16G 跑这套组合,日均百万级 Observation 写入没什么压力,前提是 ClickHouse 的磁盘要用 SSD,机械盘在批量写入时会成为瓶颈。
提示:自托管部署时,
LANGFUSE_S3_*这一组配置千万别漏。默认情况下长文本会直接进 ClickHouse,字段膨胀很快,把大对象外置到对象存储能显著降低存储成本。
2.3 埋点粒度:Trace、Span、Generation 到底怎么分
这是最容易搞混的地方。我用一个具体例子说明。假设用户问“帮我查一下北京明天的天气,如果下雨就提醒我带伞”。
- Trace:整个用户请求,从接收到返回,一个 Trace。
- Span:Agent 的每一个逻辑步骤。比如“意图识别”是一个 Span,“调用天气工具”是一个 Span,“生成最终回复”是一个 Span。
- Generation:专门指模型调用,是 Span 的一种特化。它比普通 Span 多记录了 model、token 用量、prompt/completion 内容、温度参数等。
划分原则我总结成一句话:凡是调用模型的地方用 Generation,凡是执行逻辑的地方用 Span,一次完整请求用 Trace 包起来。工具调用本身不是模型调用,所以是 Span;但如果工具内部又调了一次模型(比如用模型做参数抽取),那这个内部调用又是一个 Generation,嵌套在工具 Span 下面。
这样分的好处是,你在 Langfuse 的树形视图里能一眼看出“时间花在哪、钱花在哪”。如果某个 Span 特别长但下面没有 Generation,说明是工具或 IO 慢;如果某个 Generation token 特别多,说明是 prompt 或上下文膨胀。
3. 核心细节解析:Trace 埋点的几个关键决策
3.1 用装饰器还是手动埋点
Langfuse 的 Python SDK 提供了@observe()装饰器,用起来很爽,加一行就能自动记录函数入参、出参、耗时。但我实际用下来,装饰器适合快速验证,生产环境更推荐手动埋点,原因有三个。
第一,装饰器会把函数的所有参数都序列化进 Trace,如果参数里有大对象(比如整个对话历史、大段文档),Trace 体积会爆炸,而且可能把不该记录的敏感字段也带进去。第二,装饰器的嵌套关系依赖调用栈,异步场景下(比如asyncio.gather并发调用)父子关系容易乱。第三,手动埋点虽然多写几行,但你能精确控制记录什么、不记录什么,以及 metadata 怎么打。
手动埋点的典型写法是这样:
from langfuse import Langfuse langfuse = Langfuse() def handle_user_query(user_id: str, query: str): trace = langfuse.trace( name="agent-query", user_id=user_id, metadata={"feature": "weather-assistant", "env": "prod"}, tags=["v2-prompt"], ) # 意图识别节点 intent_span = trace.span(name="intent-recognition", input={"query": query}) intent = classify_intent(query) intent_span.end(output={"intent": intent}) # 模型调用节点 generation = trace.generation( name="llm-call", model="gpt-4o-mini", input=[{"role": "user", "content": query}], ) response = call_llm(query) generation.end( output=response.content, usage={"input": response.usage.prompt_tokens, "output": response.usage.completion_tokens}, ) trace.update(output={"reply": response.content}) return response.content注意trace.update()这一步,它把最终输出补到 Trace 上。很多人忘了这步,结果 Trace 列表里只有输入没有输出,复盘的时候还得点进去一层层看,很费劲。
3.2 metadata 怎么设计才不浪费
metadata 是 Langfuse 里最灵活也最容易被滥用的字段。我的经验是,metadata 只放“你未来会用来筛选或聚合的维度”,不要什么都往里塞。
必放的几个:userId(成本归因)、sessionId(多轮对话串联)、feature或agentName(功能维度)、env(区分测试和生产)、promptVersion(版本对比)。这几个字段配合 Langfuse 的筛选器,基本能覆盖 80% 的排查场景。
不要放的:完整的对话历史(放 input 里)、大段文档内容(放对象存储,Trace 里只存引用)、任何密钥或个人信息。Langfuse 虽然支持数据脱敏,但最稳妥的做法是在埋点层就不记录敏感数据。
注意:
sessionId这个字段特别重要。多轮对话场景下,如果每轮都生成新的 Trace 而不带 sessionId,你就没法把一次完整会话串起来看。我见过有人排查“为什么第三轮回答突然变差”,结果发现前两轮的上下文根本没传进去,就是因为没有 session 视图。
3.3 异步和并发场景下的埋点陷阱
Agent 系统大量使用异步和并发,这是埋点最容易出问题的地方。核心陷阱是上下文传递。
Langfuse 的 SDK 依赖 contextvars 来维护当前 Trace 的上下文。在同步代码里这没问题,但在asyncio.create_task或者线程池里,contextvars 不会自动传递,导致子任务的 Span 挂不到父 Trace 上,变成孤立的 Trace。
解决办法是显式传递 trace 对象,而不是依赖隐式的上下文:
async def process_batch(trace, items): tasks = [process_one(trace, item) for item in items] await asyncio.gather(*tasks) async def process_one(trace, item): span = trace.span(name="process-item", input={"item": item}) # ... 处理逻辑 span.end(output={"result": result})这样每个子任务都显式持有父 trace 的引用,无论怎么调度,父子关系都不会丢。代价是代码里多传一个参数,但比起排查“Trace 断链”的痛苦,这点成本完全值得。
4. 实操过程:从零搭一套 Agent 可观测链路
4.1 环境准备与自托管部署
自托管我用的是官方 Docker Compose,但做了几处调整。先拉官方仓库:
git clone https://github.com/langfuse/langfuse.git cd langfuse官方 compose 文件里默认用 Postgres 存所有数据,量大的话要改成 ClickHouse 模式。关键环境变量:
# 数据库 DATABASE_URL=postgresql://postgres:password@postgres:5432/langfuse CLICKHOUSE_URL=http://clickhouse:8123 CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=password # 对象存储(存大文本) LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://minio:9000 LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minioadmin LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=minioadmin # 密钥(用于 SDK 认证) NEXTAUTH_SECRET=your-secret SALT=your-salt启动之后,进 Web 界面创建项目,拿到public key和secret key,这两个是 SDK 认证用的。
提示:
NEXTAUTH_SECRET和SALT一定要改成随机值,别用默认的。这两个是加密和会话签名用的,用默认值等于门没锁。
4.2 SDK 接入与第一个 Trace
Python 项目接入:
pip install langfuse环境变量配置:
LANGFUSE_PUBLIC_KEY=pk-lf-xxx LANGFUSE_SECRET_KEY=sk-lf-xxx LANGFUSE_HOST=http://your-langfuse-host:3000然后就是前面 3.1 节那段埋点代码。跑一次之后,去 Langfuse 界面应该能看到一条 Trace,点进去是树形结构,能看到 intent-recognition 和 llm-call 两个节点,以及各自的耗时和 token。
这里有个细节:SDK 是异步批量上报的,默认每 0.5 秒或攒够一定数量才发一次。所以程序跑完立刻去界面看可能看不到,要么等几秒,要么在程序结束前调langfuse.flush()。生产环境里进程常驻,这个问题不明显;但脚本类任务一定要记得 flush,否则最后几条 Trace 会丢。
4.3 用 Score 做质量评估
Trace 记录的是“发生了什么”,Score 记录的是“发生得好不好”。Langfuse 支持三种打分方式:人工打分、模型打分(LLM-as-a-Judge)、代码规则打分。
人工打分适合小批量抽检,在界面上点一下就行。模型打分适合大批量自动化,比如用一个便宜的模型给每次回答打 1-5 分。代码规则打分适合有明确标准的场景,比如“回答里必须包含订单号”。
模型打分的实现思路是:把 Trace 的输入输出取出来,喂给一个评估模型,让它按 rubric 打分,然后把分数写回对应的 Trace。
def score_trace(trace_id: str, query: str, answer: str): judge_prompt = f"""请给下面这个回答打分(1-5分),只输出数字。 用户问题:{query} 模型回答:{answer} 评分标准:准确性、完整性、是否有幻觉。""" score_result = call_judge_model(judge_prompt) langfuse.score( trace_id=trace_id, name="answer-quality", value=int(score_result), comment="auto-judged by gpt-4o-mini", )这里有个坑:评估模型本身也会产生成本,而且如果评估模型和被评估模型是同一个,会有“自己评自己”的偏差。我的做法是用一个不同厂商的便宜模型做 judge,比如主模型用 GPT-4o,judge 用 Claude Haiku 或者国产的轻量模型,交叉验证能减少偏差。
4.4 Dataset 与实验对比:换 prompt 不再靠感觉
这是 Langfuse 我觉得最被低估的功能。很多人换 prompt 的方式是:改一版,跑几个 case 看看,感觉不错就上线。这种方式的问题是没有基线,改好了不知道好多少,改差了不知道差在哪。
正确做法是建一个 Dataset,把有代表性的 case 固化下来(包括输入和期望输出),然后每次改 prompt 都跑一遍完整 Dataset,用 Score 对比。
# 创建数据集 dataset = langfuse.create_dataset(name="weather-agent-eval") # 添加测试用例 for case in test_cases: langfuse.create_dataset_item( dataset_name="weather-agent-eval", input=case["query"], expected_output=case["expected"], ) # 跑实验 for item in dataset.items: trace = langfuse.trace(name="eval-run", metadata={"promptVersion": "v3"}) output = run_agent(item.input) langfuse.score(trace_id=trace.id, name="exact-match", value=output == item.expected_output)跑完之后在界面上能直接看到 v2 和 v3 两个版本的得分对比。Dataset 的价值在于把“感觉”变成“数字”,尤其是 Agent 这种非确定性系统,没有量化对比根本没法判断改动是正向还是负向。
5. 常见问题与排查技巧实录
5.1 Trace 丢失或不完整
这是最高频的问题。表现是:明明调用了模型,但 Langfuse 里看不到,或者只看到一半。
排查顺序我总结成一张表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全没有 Trace | 环境变量没生效 | 打印langfuse.auth_check() |
| 只有部分 Trace | 进程退出前没 flush | 加langfuse.flush() |
| 子节点丢失 | 异步上下文没传递 | 检查是否显式传 trace |
| Trace 延迟出现 | 批量上报未触发 | 正常现象,等几秒或手动 flush |
| 报 401 | key 配错或 host 不对 | 检查 public/secret key 和 host |
langfuse.auth_check()这个方法是排查第一步,它会实际发一个请求验证认证,比看环境变量靠谱。
5.2 高并发下 Langfuse 成为瓶颈
Agent 系统扛并发的时候,如果埋点写得不好,Langfuse 的上报会拖慢主流程。我实测过一个反例:每个 Span 都同步等待上报完成,QPS 一上来延迟直接翻倍。
解决办法是确保上报是异步的,并且设置合理的批量参数。SDK 默认就是异步批量,但如果你在代码里手动调了flush(),就变成同步了。生产环境不要在请求路径里 flush,只在进程退出或定时任务里 flush。
另外,自托管的 Langfuse 后端也要能扛住写入量。ClickHouse 的批量写入参数可以调,max_insert_block_size和async_insert这两个参数对高并发写入影响很大。我一般会开async_insert=1,让 ClickHouse 自己攒批,写入吞吐能提升好几倍。
5.3 成本失控的定位方法
Agent 烧钱通常有三个原因:循环调用、上下文膨胀、模型选型不当。用 Langfuse 定位的思路是:
先按userId或feature聚合看总成本,找到异常高的维度;然后进到具体 Trace,看 Generation 的 token 分布。如果单个 Generation 的 input token 特别大,是上下文膨胀;如果 Generation 数量特别多,是循环调用;如果 token 正常但单价高,是模型选型问题。
我遇到过一次典型的循环调用:Agent 在工具返回空结果时,会重新规划再试,但重试逻辑没有上限,导致某些 case 下循环了十几次。Langfuse 的 Trace 树里能清楚看到十几个结构相同的 Span 串在一起,一眼就能定位。
提示:给 Agent 设置最大迭代次数是基本功,但光有上限不够,还要在 Langfuse 里对“迭代次数”打点,这样才能发现“哪些 query 总是触发多次迭代”,从 prompt 层面优化。
5.4 评测结果不稳定怎么办
LLM-as-a-Judge 的评分波动是常见问题。同一个回答,跑两次可能一个 4 分一个 5 分。这不是 Langfuse 的问题,是模型本身的不确定性。
缓解办法有三个:一是把 temperature 设成 0,减少随机性;二是用多个 judge 取平均,比如跑三次取中位数;三是把评分标准写得更具体,减少模型的自由发挥空间。我现在的做法是 rubric 写得非常细,比如“回答中包含具体温度数值得 1 分,包含穿衣建议得 1 分”,把主观判断拆成可验证的客观项。
6. 一些踩坑之后的个人体会
Langfuse 这类工具最大的价值,不是让你“看到”数据,而是逼着你在写代码之前就想清楚系统的关键路径和失败模式。我接完 Langfuse 之后最大的收获,其实是重新审视了一遍 Agent 的架构:哪些节点是必须的、哪些状态是应该传递的、哪些失败是应该重试的。这些问题在没有可观测性的时候,都是靠猜;有了之后,才有据可依。
如果让我给正在做 Agent 项目的人一个建议,那就是:别等到出问题才接可观测性,从第一个版本就接上。前期多花的这点时间,会在第一次线上事故的时候连本带利还给你。至于 Langfuse 本身,它的 API 设计足够简单,自托管也不复杂,真正需要花心思的是“埋什么、怎么埋、怎么用”,这部分没有标准答案,得结合你自己的业务慢慢磨。