没有比线上排查了半天最后发现日志一切正常更让人崩溃的时刻了。上个月我们一个问答类服务的用户连续反馈三次回答明显跑偏我拿着日志系统里搜出来的 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_keypk-..., secret_keysk-..., hosthttp://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( idrequest_id, namef{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(nameretrieval, input{query: query}) as span: query_vector embed(query) docs vector_store.search(query_vector, top_k5) 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( nameanswer_generation, modelMODEL-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再边流边攒最后统一 endRAG 应用上线没多久我们就上了流式输出。结果发现 Langfuse 里生成阶段的记录几乎全部是空壳没有输出内容token 用量全是零。原因很简单流式接口返回的是一个迭代器模型回答不是在生成完成时一次性返回的而是在多个 chunk 里陆续吐出来的。如果只在调用完才去记 generation拿到的是尚未开始消费的迭代器而不是完整的模型输出。正确做法是在拿到流式迭代器之前先创建 generation然后边接收 chunk 边把内容拼起来等流完全结束后再把完整文本和用量补到 generation 上。核心思路是先创建对象、后补完成态而不是等结果全了再创建一个瞬间完成的 generation。gen trace.generation( namestream_answer, modelMODEL-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( outputfull_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 循环包一个 spanspan 的 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再把检索、生成这两个关键节点加上埋点你立刻就能体会到看得见链路和瞎猜原因之间的差距。