没有比"线上排查了半天,最后发现日志一切正常"更让人崩溃的时刻了。上个月我们一个问答类服务的用户连续反馈三次"回答明显跑偏",我拿着日志系统里搜出来的 request_id 逐条翻记录:请求进来了、响应回去了、状态码 200、没有异常堆栈,甚至连返回内容都完整存在。可问题是,这三次请求为什么都返回了错误答案?request_id 只能告诉我"这是哪一次请求",但回答不了"这次请求在内部到底经历了什么"。这也是我从 request_id 日志逐步走向 Langfuse 全链路 trace 的直接原因。这篇是系列第二篇,上一篇聊了结构化日志和 request_id 的规范落地,这篇重点记录我如何把它升级成可视化 trace,以及在 RAG、流式输出、Agent 工具调用这些典型场景里踩过的坑。适合正在做 LLM 应用可观测性、或者被"日志正常但结果不对"折磨过的同学参考。
1. request_id 日志在现场排障中的天花板
1.1 一个典型的"日志一切正常"事故现场
先复盘一下开头那个事故,因为它是理解为什么要做 trace 的最佳案例。用户反馈"账号安全问题回答错误",我们按流程拿到这次请求的 request_id,去日志平台检索。日志显示链路完整:请求进入网关、命中我们的服务、调用了一个外部模型接口、拿到响应、返回给用户。耗时 4.2 秒,状态正常,所有日志片段都能通过 request_id 串起来。
但当我试图回答"为什么答案是错的"时,日志给不出任何信息。它没有记录这次请求具体用了哪个版本的 prompt、检索阶段召回了哪些文档、相似度分数是多少、模型完整输入输出是什么。也就是说,request_id 体系保证的是"可定位到一次请求",而不是"可还原一次请求的真实执行过程"。对于传统 CRUD 服务,前者基本够用;但对于 LLM 应用,请求内部是一个多阶段决策过程,任何一个环节静默出错,最终结果都可能偏掉,而且不抛异常。
1.2 LLM 应用给可观测性加了哪三座大山
传统后端日志里,一个请求通常对应一个线程、一段明确的业务逻辑。LLM 应用把这件事变复杂了,我总结为三点:
- 外部依赖从"数据库"变成了"不可控的模型服务"。模型接口可能超时、降级、返回空内容、甚至悄悄改变行为,这些不做特殊记录根本看不出来。
- 业务链路从"线性处理"变成了"多阶段决策"。RAG 要经过查询改写、向量检索、重排、拼装上下文、模型生成;Agent 要经过多轮思考、工具调用、结果观察。每一跳都可能引入错误。
- 内容本身成为排查对象。传统日志关注的是状态码和异常,LLM 应用则必须关注 prompt 怎么拼的、上下文里混入了什么、检索结果排名如何。这些信息量级很大,用普通文本日志记录既难阅读又难检索。
所以我逐渐意识到,request_id 仍然要保留,它是业务侧的"单据编号";但需要一个新的载体来承载"单据处理过程"的完整记录,这就是 trace。
1.3 为什么选择 Langfuse 而不是自研一套
当时我们调研过两条路:一是基于 OpenTelemetry 自己做 span 模型和可视化后端;二是直接使用 Langfuse 这类面向 LLM 应用的追踪平台。自研的问题是,光解决"模型调用记录"这一个点,就要设计数据模型、存储方案、UI 展示,至少要两到三个迭代周期。而 Langfuse 天生就是围绕 LLM 场景设计的:trace 结构、generation 概念、token 统计、成本估算、评分功能都是现成的。而且它可自托管,数据敏感度高的团队可以部署在自己环境里。我的选择是:底层日志保留 request_id 和结构化原生日志,上层用 Langfuse 承担"全链路可视化"职责,两者通过 request_id 关联。
2. Langfuse 的追踪模型:先弄清 trace、span 与 generation 的关系
2.1 trace 是整个追踪体系的根节点
Langfuse 里的 trace 代表一次完整的交互或请求,通常从用户请求进入服务开始,到响应返回结束。一个 trace 可以嵌套多个子单元,整体呈树状结构。和日志系统里 request_id 的作用类似,trace 是查看链路时的最上层入口。但差别在于,trace 不只是"聚合日志的标签",它本身带有生命周期概念——可以记录输入、输出、耗时、元数据,并在 UI 里以瀑布图形式展示。
实际操作中,我建议一个业务请求只创建一个 trace,不要一个方法建一个。trace 是给人看的"一次会话"的单位,建多了会把链路切碎。多轮对话场景可以用 session_id 把多个 trace 串联成会话,而不是强行塞进一个 trace 里。
2.2 span 负责"耗时和过程",generation 专门记录模型调用
trace 下面最重要的两种子单元是 span 和 generation。span 表示一段有开始和结束的操作区间,适合包裹检索、重排、工具调用等业务步骤。generation 是语义更特殊的 span,专用于 LLM 生成类操作,额外记录模型名称、模型参数、输入消息、输出内容、token 用量。
说一个我最初常犯的错误:把模型调用也记成普通 span。这样做的后果是 token 用量和成本完全统计不出来,trace 的模型维度分析直接废掉。正确做法是,凡是"调用了模型"的步骤,一律使用 generation;凡是"纯粹的业务过程、计算过程、IO 过程",使用 span。如果某个 span 内部又调用了模型,那么这个 generation 就嵌套在 span 内部,树状层级完全与代码调用结构对应。
2.3 session 与 score:从"单次追踪"到"长期观测"
除了 trace、span、generation 之外,还有两个概念在实际使用中价值很高。一个是 session,用来关联同一个用户的多次互动;另一个是 score,用于给 trace 或某个 span 打分。我们的做法是,每次用户对回答点了赞或踩,就把用户反馈转化为 score 绑定到对应 trace 上。这样一来,Langfuse 看板里可以直接看到"低分 trace 的分布"、"哪些模型组合更容易产生低分回答"。这个能力在传统日志体系里几乎没法低成本实现。
关于部署,我们用的是自托管模式,Langfuse 服务端依赖 PostgreSQL 和 ClickHouse,数据量大了以后查询性能主要靠 ClickHouse 支撑。如果你只是想在本地快速验证,官方文档里有 docker compose 一键部署脚本,几分钟就能跑起来。多数团队选择自托管的原因很简单:prompt 和模型输出属于敏感数据,不希望经过第三方服务。
3. 最小改造:把 request_id 变成 trace 的入口
3.1 SDK 初始化与三种关联方式的取舍
接入 Langfuse 的第一步是安装 SDK 并初始化。Python 环境用 pip 安装,初始化时填入服务端地址和密钥,也支持从环境变量读取。这一步没什么难度,很多人栽在后面的关联方式选择上。
"如何把现有业务里的 request_id 和历史 trace 关联起来",是接入时最核心的设计决策。我实验过三种方式:
- 直接使用自定义 trace id,把 request_id 作为 trace 的 id 传入。这是最直接的方式,UI 上能看到 trace id 就是请求号,排查时可以无缝衔接。
- 把 request_id 放在 trace 的 metadata 里。优点是 metadata 可以附带更多信息,比如用户 id、环境、版本号;缺点是无法直接通过 trace id 定位,需要在 UI 按 metadata 字段过滤。
- 使用 session_id 关联。适合多轮对话场景,但并不适合一对一映射请求。
我最终采用的是组合方案:trace id 用 request_id,同时在 metadata 里冗余存一份 request_id,并附上 user_id、环境、服务版本等信息。这样既能快速用 id 查 trace,又能用 metadata 做统计分析。这里有个细节值得注意:自定义 trace id 必须保证唯一。相同 id 的 trace 会被服务端视为同一条记录,如果重复创建,后到的数据会覆盖先到的,导致信息丢失。所以 request_id 本身必须确保在一个 project 内全局唯一。
3.2 在 Web 入口统一创建 trace,而不是散落到业务代码里
接入时最容易犯的错,是每个业务函数各自创建 trace,结果一个请求产生七八条零散的 trace。正确的做法是在网关或 Web 框架的中间件层统一创建 trace,再把业务逻辑挂到这条 trace 之下。
以 FastAPI 为例,我在中间件里做三件事:从请求头读取 X-Request-Id,取不到就现场生成;按上述规则创建 trace;在 finally 中补充 trace 输出并确保数据刷盘。代码大致如下:
import uuid from fastapi import Request from langfuse import Langfuse langfuse = Langfuse( public_key="pk-...", secret_key="sk-...", host="http://your-langfuse-host:3000", ) @app.middleware("http") async def langfuse_trace_middleware(request: Request, call_next): request_id = request.headers.get("X-Request-Id", uuid.uuid4().hex) trace = langfuse.trace( id=request_id, name=f"{request.method} {request.url.path}", input={"path": request.url.path, "query": str(request.query_params)}, metadata={ "request_id": request_id, "user_id": request.headers.get("X-User-Id"), "env": "prod", }, ) try: response = await call_next(request) trace.output = {"status_code": response.status_code} return response except Exception as e: trace.output = {"error": str(e)} raise这样改造完成后,我们线上的日志系统和 Langfuse 之间就有了一个稳定映射:任何一条业务日志里的 request_id,都能在 Langfuse 里找到对应的可视化 trace。部门里其他后端同事不需要了解 Langfuse 的细节,只要会复制 request_id 就能自己排查问题,这是我觉得收益最高的一个改动。
3.3 一个被低估的环节:确保数据刷盘
接入初期我发现 trace 偶尔会丢失,尤其是在本地压测时。排除掉配置问题后,定位到原因是进程退出时 SDK 的异步发送队列还没来得及提交数据。Langfuse SDK 默认异步批处理上传,主流程不阻塞,但这也意味着在脚本运行、压测工具强制结束时,队列里的数据可能还躺在内存里。解决办法是在进程退出或请求处理完毕后调用 flush 机制,Web 服务里可以在 shutdown 钩子中统一处理:
@app.on_event("shutdown") async def shutdown_event(): await langfuse.flush_async()需要提醒的是,不要在每次请求结束时都调用 flush,那会把异步批处理变成同步请求,吞吐量直接下降。你只需要在确认"请求生命周期彻底结束、需要立刻看到数据"的场景下手动刷盘,比如定时任务跑完后。
4. RAG 场景埋点:检索、重排与生成的完整追踪
4.1 先画出链路图,再决定埋点层级
我们的核心业务是一个多租户知识库问答系统,请求处理链路大致是:用户问题 → 语义向量化 → 向量检索 → 可选重排 → 上下文拼装 → 模型生成 → 流式返回。在接入 Langfuse 之前,这段链路在 request_id 日志里只有几行耗时记录,根本看不到中间结果。接入时我坚持先画链路图,再决定埋点布在哪,而不是边写代码边随手加 span。
最终确定的 trace 结构是:trace 代表一次问答请求;检索阶段包一个 span;如果调外部向量化模型,则在检索 span 内部嵌一个 generation 记录 embedding 调用;重排单独包一个 span;模型生成单独用 generation;最终回答写在 trace 的 output 里。这个结构从根节点展开后层次清晰,UI 瀑布图一眼就能看出时间花在哪个阶段。
4.2 检索阶段的埋点:分数比文档 id 更重要
检索阶段我踩过一个比较深的坑。最初我只把召回文档的 id 列表放进 span 的 output,觉得这样够了。后来一次线上问题排查时,我看到了完整的检索结果列表,却发现虽然 id 是对的,但相似度分数普遍很低,排在最前面的文档也只有 0.3 分。这说明问题不是"没召回到正确文档",而是"向量检索本身质量已经下降"。如果只记录 id,我根本得不到这个判断依据。
现在我的检索 span output 固定保留 triples:召回文档 id、对应的相似度分数、以及本次检索命中的 top_k 配置。这些信息在后续效果对比时极有价值,你可以直接在界面上比较不同向量化模型版本之间的召回分布差异。代码示意如下:
with trace.span(name="retrieval", input={"query": query}) as span: query_vector = embed(query) docs = vector_store.search(query_vector, top_k=5) span.output = { "doc_ids": [d.id for d in docs], "scores": [d.score for d in docs], "hit_count": len(docs), }重排阶段也用 span 记录,input 是重排前的文档 id 顺序,output 是重排后的顺序。这样一旦确认检索正常但最终答案错误,还能进一步判断是不是重排把正确答案排到了后面。
4.3 模型生成阶段:让 token 用量和成本自动可算
模型生成环节我统一封装成一个独立的 generation 创建函数,强制要求调用方提供模型名、模型参数、输入消息。token 用量的来源根据不同接入方式而不同:如果使用 Langfuse SDK 内置的模型调用集成,它能自动截获请求并填充用量;但我们是自研的模型网关,只能手动在 generation.end 时补充 usage。
gen = trace.generation( name="answer_generation", model="MODEL-x", model_parameters={"temperature": 0.3, "max_tokens": 1024}, input={"messages": [{"role": "user", "content": prompt}]}, ) result = model_gateway.chat(prompt) gen.output = result.text gen.usage = { "input": result.usage.prompt_tokens, "output": result.usage.completion_tokens, "total": result.usage.total_tokens, }这一步做完,Langfuse 看板就能按模型维度统计 token 消耗趋势,按 trace 维度看到单次问答的成本。对我们这种多租户产品来说,按租户统计成本是一件很实用的事,我会在 metadata 里带上 tenant_id,之后在界面上按 metadata 分组过滤,就能直接算出每个租户每天消耗了多少 token 和费用。
5. 流式输出与 Agent 工具调用:最容易断链的两个环节
5.1 流式输出:先创建 generation,再边流边攒,最后统一 end
RAG 应用上线没多久,我们就上了流式输出。结果发现 Langfuse 里生成阶段的记录几乎全部是空壳,没有输出内容,token 用量全是零。原因很简单:流式接口返回的是一个迭代器,模型回答不是在生成完成时一次性返回的,而是在多个 chunk 里陆续吐出来的。如果只在调用完才去记 generation,拿到的是"尚未开始消费的迭代器"而不是"完整的模型输出"。
正确做法是:在拿到流式迭代器之前先创建 generation,然后边接收 chunk 边把内容拼起来,等流完全结束后再把完整文本和用量补到 generation 上。核心思路是"先创建对象、后补完成态",而不是"等结果全了再创建一个瞬间完成的 generation"。
gen = trace.generation( name="stream_answer", model="MODEL-y", input={"messages": [{"role": "user", "content": prompt}]}, ) collected_chunks = [] for event in model_gateway.stream(prompt): collected_chunks.append(event.delta) yield event.delta full_output = "".join(collected_chunks) gen.end( output=full_output, usage={"input": prompt_tokens, "output": len(full_output)}, )另外需要注意,如果流式过程中途被客户端断开,生成逻辑可能会抛异常。异常处理里同样需要调用 gen.end,把错误信息写入 output,否则这条 trace 会一直停留在未完成状态,影响耗时统计。我的习惯是放在 try/finally 中调用 end,确保无论正常结束还是异常退出,generation 都有终点。
5.2 Agent 工具调用:每个工具都是一棵子树的根
Agent 场景比 RAG 复杂在工具调用的随机性和多轮性。一个 Agent 可能要在一次任务里调用两三个工具,每个工具的内部逻辑又可能触发模型调用。如果只把"工具名 + 返回结果"用日志打出来,排查时根本不知道哪些调用属于同一轮思考循环。
我在 Agent 场景的埋点规则是:整个 Agent 执行用一个 trace 表达;每一次 ReAct 循环包一个 span,span 的 name 就是"思考轮次编号 + 意图摘要";工具调用再在循环 span 内部继续包 span,记录工具名、入参、返回结果;如果工具内部还要调用模型生成,那就在这个 span 内嵌 generation。这里最关键的心得是:一定要记录每一轮循环的中间思考内容,因为很多 Agent 最终答错,实际上是中间某一步就选错了工具或选错了参数,只看最终结果根本无从判断。
5.3 异步任务和回调环境里的上下文传递
流式和 Agent 场景里都容易踩同一个坑:Langfuse 的 span/generation 需要在同一个执行上下文里创建和结束。一旦代码切到了新线程、线程池、或者异步回调,trace 对象可能已经丢失。我们有个定时任务是放在后台线程池里跑的,最开始通用上下文没有传透,结果 trace 里只有根节点,子 span 全部散落成独立的孤儿记录。
解决方法是:在线程任务创建时,把需要追踪的 trace id 显式传进去,然后在子线程里通过 langfuse 的上下文接口重新绑定。不要依赖全局隐式上下文,显式传递成本很低,但能避免大量"数据不完整"的问题。
6. 拿着 request_id 反查 trace:一次线上质量事故的定位全记录
6.1 从"回答质量下降"到"定位到 embedding 超时"的完整过程
回到开头的案例。在接入 Langfuse 之后,类似的用户反馈变成了完全不同的排查体验。用户报了一个具体问题,我们拿到 request_id 后,直接在 Langfuse 界面的搜索框里输入这条 id,立刻看到对应的 trace。瀑布图显示整条链路耗时 4.2 秒,其中检索阶段占了 3.2 秒,模型生成只占 0.8 秒。
点开检索 span,看到的 output 让我心里一沉:只召回了 2 条文档,相似度分数分别是 0.31 和 0.27,而健康数据里 top5 的分数普遍在 0.75 以上。再展开嵌套的 embedding generation,发现这次向量化调用耗时 2.8 秒,最终抛出了一个超时错误。也就是说,向量化失败了,代码走得是降级分支,用关键词匹配兜底。关键词匹配在这个场景下没匹配到有效文档,最终模型基于一个残缺上下文生成了错误回答。
这个过程如果用传统日志排查,需要同时看四五个服务的日志,还要手动把每个阶段的耗时和结果拼在一起。而在 trace 里,整条链路的每一个步骤、每一次调用的输入输出、耗时、异常全部摆在同一棵树上,排查时间从小时级缩短到了分钟级。
6.2 如何用 score 体系把"偶发问题"变成"可统计问题"
这类偶发问题最头疼的点在于,它不是每次都发生。用户反馈一次,我们修完,可能过一周又出现。所以除了单点定位,我更建议把"搜索结果质量差"这类信号沉淀成指标。我们的做法是:当检索 span 的分数分布明显低于阈值时,在 span 上自动打一个 score("retrieval_quality", 分数, 0-1 之间)。这样 Langfuse 看板里可以按时间维度直接统计"低质量检索事件"的曲线,能清楚地看到是偶发还是趋势性劣化。
用户反馈层面,我们也在关键业务节点上做主动评分:当用户对回答点了"无帮助",服务会调用 Langfuse 的 score 接口,把反馈绑定到对应 trace。这个绑定动作一定要在请求仍可拿到 trace 对象时做,否则就需要靠 trace id 再次引用。我们选择在请求生命周期内完成评分,避免后续异步任务去猜 trace id。
6.3 一个反向的排查技巧:从 trace 反查 request_id
大多数时候我们是"有 request_id 找 trace",但有些场景反过来的需求也很常见。Langfuse 的列表页支持按 metadata 筛选,我会直接输入 metadata.request_id 相关的条件,把所有请求都拉出来;但 trace id 就没办法这样模糊搜。所以如果你也采用"trace id = request_id"的方案,记得在 metadata 里冗余存一份 request_id,这样两种方向的检索路径都是通畅的。
7. 上线前不得不做的四个决定:采样、脱敏、刷盘与数据生命
7.1 采样策略:全量不是默认选项
很多人接入可观测性系统的第一反应是"我要全部记录",但 LLM 应用的 trace 数据量和传统日志不在一个数量级。一次带完整 prompt 和模型输出的 trace,动辄几十 KB;如果有人恶意刷接口,存储成本会迅速失控。我们的方案是分级采样:正常请求按 20% 比例采样,4xx/5xx、慢请求(超过 3 秒)、特定白名单租户则全量采样。这样既保证了对"坏事"的完整记录,又控制住了数据总量。
实现上可以在中间件里对 request_id 取哈希判断是否采样:
import hashlib def should_sample(request_id: str, rate: int = 20) -> bool: digest = hashlib.md5(request_id.encode()).hexdigest() sample = int(digest[:8], 16) % 100 return sample < rate or request_id in force_trace_ids需要强调,判断逻辑必须在创建 trace 之前执行,否则采样率等于零。不采样时就不创建 trace 对象,业务代码里的 span/generation 调用可以放在一个空实现的可选上下文里,或者干脆用条件分支跳过。
7.2 脱敏:prompt 不是日志的附属品
prompt 和模型输出是数据隐私的高发区。我们曾经在测试环境全量记录之后发现,一些用户把个人身份证号、手机号直接写进了问题里,这些内容原样进入了 Langfuse。虽然部署在自托管环境,但数据安全意识必须前置。
Langfuse SDK 初始化时支持自定义 masking 函数,用来在数据上传前对字段做转换。我们接入了一个简单的 PII 检测函数,将身份证、手机号、邮箱模式替换成脱敏占位符。我个人的建议是:宁可过度脱敏,也不要事后后悔。模型调用的完整内容在排障时确实有用,但绝大多数问题通过"prompt 中的非敏感部分 + 结构化元数据"就能定位。
7.3 异步刷盘与队列阻塞:高并发下最容易忽视的调优点
前面提过程序退出时要 flush,但高并发下还有一个隐蔽问题:如果业务量远大于上传速度,SDK 内部的异步队列会持续积压,内存增长。我们压测时达到每秒几百次请求时,观察到队列积压和上传延迟增大。Langfuse SDK 允许配置批处理参数,包括批次大小和 flush 间隔。建议在压测阶段就调好这些参数,不要用默认值就直接上生产。
另外,如果 Langfuse 服务端和业务服务部署在不同机房,网络延迟会导致上传队列更容易积压。我们的对策是在同机房部署采集端,而不是让业务直连生产环境的 Langfuse 服务。这个细节看着小,但对稳定性影响很大。
7.4 数据生命周期:ClickHouse 再能扛也要设边界
自托管模式下,数据存储在 ClickHouse 中,写入性能很高,但成本也会随业务增长线性增加。我们的保留策略是:原始 trace 数据保留 30 天,聚合指标和 score 数据长期保留。30 天后定期清理原始数据,防止存储无限膨胀。
如果你用的是云托管版本,服务商通常提供数据保留策略配置,但要注意配置后的计费变化。其实多数团队真正需要长期留存的不是每一帧的完整输入输出,而是那些被标记为异常、低分、慢请求的 trace。我们会在业务层面做一次二次筛选,把值得留档的 trace 批量导出到对象存储或数据仓库,Langfuse 里只留最近一个月的数据。这样一个"按需归档"的思路,既保住了核心证据,又没有让存储成本失控。
写在最后的体会
整套体系从 request_id 日志演进到 Langfuse 可视化 trace,大概花了两周。最明显的改变并不是"UI 变好看了",而是团队排查问题的语言发生了变化:以前是"我去翻一下日志找找有没有报错",现在是"把 request_id 发我,我看看 trace 瀑布图"。前者依赖运气,后者依赖完整证据链。如果你也正在被"日志正常但结果不对"折磨,建议先不追求把每个 span 都建得完美,而是先从中间件入口统一建好 trace,再把检索、生成这两个关键节点加上埋点,你立刻就能体会到"看得见链路"和"瞎猜原因"之间的差距。