上一轮改造里我把所有接口日志统一成了 request_id 开头的格式。当时觉得这套体系已经够用直到被一个 RAG 类接口的高延迟问题折腾了整整一周我才发现光有 request_id 远远不够。那次的矛盾在于日志确实都在几十条散落的记录也能靠 request_id 串起来但真正想看清“哪一段调用吃了时间”“为什么 Llm 返回前等了 4 秒”时我还是得翻十几个文件手动拼时间线。所以这篇 O02 的内容很明确把 request_id 从日志里的一个字符串升级成 Langfuse 里的可视化 trace。这篇文章会覆盖 Langfuse 的核心模型、本地部署、FastAPI 接入、生产避坑适合已经会用 request_id 做日志定位、但想往全链路追踪迈一步的团队。1. request_id 的日志体系真正卡在了哪个环节1.1 request_id 能定位问题但定位不到“关系”request_id 的价值没有争议。无论服务怎么拆分只要日志里带上它我们就能把一个请求经过的所有日志捞出来。但“捞出来”和“看明白”之间有一条很宽的沟。举一个真实遇到过的场景。某个接口内部会依次调用解析模块、向量化模块、检索模块和大模型模块其中检索模块还会并发发起多个子请求。使用 request_id 排查时日志大致长这样10:00:00.001 INFO request_idabc123 stepenter10:00:00.030 INFO request_idabc123 stepcall_parse10:00:00.035 INFO request_idabc123 stepcall_embedding10:00:00.612 INFO request_idabc123 stepparse_done10:00:02.401 INFO request_idabc123 stepcall_llm10:00:05.123 INFO request_idabc123 stepllm_done10:00:05.124 INFO request_idabc123 stepexit单独看这些日志信息是完整的但问题在于它们是一张“平铺的表”不是一棵“有结构的树”。当出现并发子任务、重试、异步回调时日志的时间顺序并不等于调用顺序更不可能回答“call_llm 之所以晚是因为它在等 retrieval 里哪个子调用”这类问题。还有一层更难受迁移或重构后日志格式一变之前的聚合脚本就废了。本地 grep 尚可但一旦多个服务、多个环境都接入靠 request_id 手动拼链路的时间成本会指数级上升。1.2 可视化 trace 要回答的三个具体问题我决定引入 Langfuse 之前先给自己列了三个短期目标。这三个目标直接决定后面所有设计调用结构能看见一次请求内部哪个模块调了哪个模块嵌套关系清清楚楚。耗时分布能看见总耗时是 5 秒但这 5 秒到底花在模型调用、数据库等待还是进程排队上一眼能看出来。数据上下文能看见每个关键节点传了什么输入、拿到了什么输出、模型用了多少 token、prompt 版本是哪个。只要这三个问题能解决request_id 体系就不需要推翻它只需要降级成 trace 里的一个关联字段。这也是 Langfuse 最适合的场景它不是来替代你的日志系统而是把你的日志事件组织成一棵可交互、可检索、可统计的树。注意很多人一上来就纠结“该用 OpenTelemetry 还是 Langfuse”。我的观点是如果服务里有大量的 LLM 调用、embedding 调用和 prompt 版本管理诉求Langfuse 的语义更贴近业务如果只是普通的 HTTP/RPC 调用OpenTelemetry 更通用。这篇针对的是前者做法可以两套并行没必要一开始就二选一。2. 先把 Langfuse 的 trace 模型看明白再动手2.1 trace 是一棵树observation 是树上的节点Langfuse 里的核心概念不多但要理解透一次请求对应一条trace一条 trace 内部由若干个observation组成。observation 又有几种常见类型其中我们日常用到最多的是span和generation。可以这样理解trace 是整棵树的根span 是树上可以继续挂子节点的枝干generation 是叶子上的模型调用记录。一次完整的请求树在 Langfuse 界面里大概长这样Trace (id request_id) ├─ Span: 接收 HTTP 请求 │ ├─ Span: 解析用户输入 │ └─ Span: 鉴权与参数校验 ├─ Span: 召回与检索 │ ├─ Generation: embedding 模型调用 │ └─ Span: 向量库查询 ├─ Generation: 主模型调用 └─ Span: 写回数据库并返回响应每个节点都有自己的耗时、输入、输出、metadata、开始时间和结束时间。Langfuse 的可视化时间线其实就是把这棵树按照发生时间铺开。树的好处是无论并发多乱父子关系不会丢。这意味着写代码时要想清楚哪些操作适合用 span 包起来哪些调用适合用 generation 记录。可以粗暴一点凡是需要观察耗时和入参出参的非模型逻辑都用 span凡是要记录模型名、token 用量、prompt 版本的都用 generation。2.2 一个最小可用的 Python 示例安装和初始化都很轻。Python SDK 本身是纯 Python 实现的不依赖额外服务数据上报是异步的。pip install langfuse环境变量按下面的方式配置SDK 会自动读取export LANGFUSE_PUBLIC_KEYpk-xxx export LANGFUSE_SECRET_KEYsk-xxx export LANGFUSE_HOSThttp://localhost:3000最小代码长这样from langfuse.decorators import observe, langfuse_context observe(nameclean_user_input) def clean_user_input(raw: str) - str: return raw.strip()[:100] observe(namehandle_request) def handle_request(raw: str) - str: text clean_user_input(raw) langfuse_context.update_current_trace( namehandle_request, metadata{source: demo}, ) return text handle_request( 你好世界 )核心逻辑只有两步observe()让函数变成 trace 树上的节点langfuse_context.update_current_trace()修改当前 trace 的元信息。运行时clean_user_input会自动挂在handle_request下面因为装饰器会自动识别当前函数是否在一个已有的 trace 上下文里。这里有个容易被忽略的地方observe()装饰一个普通函数它创建的节点默认不是 trace 根节点而是跟随 Python 当前的 trace 上下文。所以入口函数和子函数用同一个装饰器就够了不需要手动传 trace id。2.3 SDK 的默认行为和上下文传播用装饰器时语言层面能自动传播的上下文会让代码显得“像魔法一样”但它的机制并不复杂SDK 内部维护了一个 contextvarobserve()在被调用时读取当前 contextvar把当前节点挂到父节点下然后把自己设为新的子上下文函数退出后恢复。这个机制在同步代码里表现很好在 FastAPI 这类异步框架里也兼容但有一个硬性要求异步协程内部的子任务必须仍然处于同一个事件循环上下文。如果你用asyncio.create_task或ThreadPoolExecutor把子调用扔到别的线程默认情况下 contextvar 不会自动跟着传递树结构就会断开。我在接入时就踩过这个坑。某个模型调用并发逻辑里用了线程池Langfuse 界面上主 span下面孤零零的子 span 全部变成了独立 trace。排查后才知道是上下文没传过去。后来统一改成了asyncio协程序列才恢复正常。这也是建 trace 时最值得先跑一遍的小实验把入口、子调用、并发子任务都打出来确认它们是不是同一条树。3. 从零把 Langfuse 跑起来本地部署避坑记录3.1 本地部署 vs 托管服务怎么选Langfuse 是开源项目可以自己部署也提供了托管的版本。我的建议是如果是开发环境直接本地 docker compose 拉起来如果生产环境没有专门的运维人力再考虑托管版本因为自建之后还要维护数据库迁移和对象存储成本并不低。本地部署最常见的方式是参考官方仓库里的 compose 文件。核心依赖包括一个 PostgreSQL 数据库和一个 RedisLangfuse 应用本身是一个 Web 服务。这里给一个最小化的示例文件实际使用时直接按官方仓库里的版本复用不要自己手写version: 3.8 services: db: image: postgres:15 environment: POSTGRES_DB: langfuse POSTGRES_USER: langfuse POSTGRES_PASSWORD: langfuse volumes: - db_data:/var/lib/postgresql/data redis: image: redis:7 langfuse: image: langfuse/langfuse:2 depends_on: - db - redis ports: - 3000:3000 environment: DATABASE_URL: postgresql://langfuse:langfusedb:5432/langfuse REDIS_URL: redis://redis:6379 NEXTAUTH_URL: http://localhost:3000 NEXTAUTH_SECRET: my-secret-change-me SALT: my-salt-change-me ENCRYPTION_KEY: my-encryption-key-change-me volumes: db_data:启动命令docker compose up -d等待几十秒后访问http://localhost:3000注册第一个账号就能进入控制台。控制台里可以创建项目拿到 public key 和 secret key。3.2 依赖、key 和日志落库的检查点本地部署最容易翻车的不是应用启动失败而是数据写不进去。我整理几个检查点检查DATABASE_URL里的数据库密码是否与 PostgreSQL 容器一致否则应用能起来但迁移会失败。检查ENCRYPTION_KEY长度是否满足要求缺了它保存敏感配置时会报错。创建项目后在 SDK 里配置的 host 必须是应用可访问的地址不能写localhost指向容器内部。最靠谱的验证方法是写一个只包含一次函数调用的脚本跑完后立刻看控制台里有没有对应 trace。如果控制台下发 select 到了数据再去接 FastAPI避免把部署问题和业务问题混在一起。注意本地部署跑通后public key 和 secret key 要放进服务端的环境变量不要提交到代码仓库。这个很多人觉得无所谓但 trace 里带的是真实业务输入输出key 一旦泄露等于把模型调用成本和安全风险一起交出去。4. FastAPI 服务从 request_id 升级到完整 trace 的接入过程4.1 在中间件里定 request_id在入口 handler 里建根 traceFastAPI 接入 Langfuse 的常见误区是先到处加observe()然后发现 trace 根本不在一条线上。正确顺序应该是先把根确定再让业务代码往根上挂。我的做法是在中间件统一处理 request_id。第三方网关可能会传X-Request-ID没有就生成一个 UUID。这个 id 放进request.state再写进响应 header方便前端和日志系统对账from uuid import uuid4 from fastapi import FastAPI, Request from langfuse.decorators import observe, langfuse_context app FastAPI() app.middleware(http) async def set_request_id(request: Request, call_next): rid request.headers.get(X-Request-ID, uuid4().hex) request.state.request_id rid response await call_next(request) response.headers[X-Request-ID] rid return response然后入口路由函数是 trace 根节点observe() async def chat_endpoint(request: Request): rid request.state.request_id langfuse_context.update_current_trace( namechat_endpoint, idrid, # 外部 trace id 直接用 request_id metadata{request_id: rid, version: v2}, ) body await request.json() result await run_business_logic(body) return {result: result, request_id: rid}这里把update_current_trace的id设置成 request_id是为了后续在 Langfuse 界面里能直接用 request_id 反查 trace。如果你担心 trace id 和业务 id 混在一个命名空间里也可以把 request_id 只放在 metadata 里界面用 metadata 过滤。两条路我都试过最终选了直接作为 id因为检索速度更快URL 拼接也更直接。4.2 把业务子步骤标记成嵌套 span根 trace 建立后业务逻辑里的内部函数只要继续用observe()就会自动成为子节点。这是整套方案最舒服的地方。observe(namebuild_prompt) def build_prompt(user_query: str, hist: list[dict]) - str: # 处理上下文和 prompt 拼接 return prompt observe(namerun_retrieval) async def run_retrieval(user_query: str): # 内部再细分 embeddings await generate_embeddings(user_query) return await search_similar(embeddings) observe(namerun_business_logic) async def run_business_logic(body: dict): prompt build_prompt(body[query], body.get(history, [])) docs await run_retrieval(body[query]) return await call_main_model(prompt, docs)最终树形会自动变成chat_endpointrun_business_logicbuild_promptrun_retrievalcall_main_model你的代码里不需要出现一个全局变量去记录当前 trace idSDK 的 context 机制会替你维护。需要维护的唯一约束是这些函数不能脱离调用链比如你自己发一个异步任务去执行build_prompt树自然就断了。4.3 关键节点记录 generation 与 token 用量模型调用节点我建议单独用 generation 语义。装饰器写法是from langfuse.decorators import observe observe(namemain_model_call, as_typegeneration) def call_main_model(prompt: str, docs: list[str]): # 调用某个大模型供应商的接口 resp model_client.complete(promptprompt, documentsdocs) # 手动挂上 token 用量 langfuse_context.update_current_observation( inputprompt, outputresp.text, modelllm-model-a, usage{input: resp.input_tokens, output: resp.output_tokens}, model_parameters{temperature: 0.2}, ) return resp.text这里最需要注意的坑是序列化。input和output如果直接塞 Pydantic 对象、numpy 数组或 bytesSDK 上传时会报序列化失败。我的习惯是在入口统一转成可 JSON 序列化的结构太长的内容截断保留前若干字符这样既不影响排查也不会因为一个大对象把整个 trace 上传拖慢。如果 SDK 版本不支持as_type参数你可以先按 span 记录再在 observation 的 metadata 里标记节点类型效果接近。关键信息是模型名、输入、输出、耗时、token这四个字段才是排查线上问题最常用的。5. 可视化之后怎么读 trace 和用 trace 反查线上问题5.1 用 request_id 在 Langfuse 里一键反查接入之后日志系统里仍然保留 request_id只是在出现线上问题时多了一条更快的路径拿 request_id 去 Langfuse 控制台搜索。如果在update_current_trace里把 request_id 放进了 trace 的 id 字段直接搜索这个 id 就能进到 trace 详情页。如果只是放在 metadata 里也可以在 trace 列表的过滤条件里增加 metadata 搜索条件。两种方式在界面上的操作都不复杂但直接作为 id 会方便很多尤其在对接内部告警平台时可以把告警链接直接拼接成http://langfuse.local/trace/{request_id}值班同学点开就是完整链路。这里要克服一个心魔我们已经习惯看日志文本第一次点开 trace 详情页会觉得信息太密其实阅读顺序是有固定套路的——先看时间线上的最大空隙在哪里再点开对应的 span 看成对输入输出最后看 metadata。5.2 从时间线里找到“假耗时”可视化 trace 最大的红利是定位“假耗时”。什么叫假耗时就是接口总耗时有 5 秒但模型调用本身只花了 900 毫秒剩下 4 秒全花在某个看起来不太起眼的子 span 上。没有 trace 之前你只能猜有了 trace直接点开 4 秒的空隙就能看到它在等什么。我印象很深的一次定位某个搜索接口偶发变慢日志里模型调用耗时正常数据库查询耗时也正常但整体就是慢。通过 Langfuse 展开 trace发现慢在检索模块的并发实现上——多个子请求虽然用协程并发发起但其中一个子函数内部偷偷用了线程池等待结果导致所有协程阻塞。这类问题靠 request_id 一条条对日志可能要花半天在 trace 时间线上它就是明晃晃的一段空白。读 trace 时间线的建议先看根 span 的总耗时和子 span 耗时之和是否匹配如果差距大说明有未记录的隐藏阻塞。再找耗时占比最大的那个节点点进去看它的输入输出指标。最后对比正常请求与异常请求的 trace 结构差别往往在节点数量或耗时分布上。5.3 用 score 把业务反馈挂到 trace 上Langfuse 还有一个很实用的能力给 trace 打 score。这个能力可以玩出很多花活。比如线上反馈系统判断某个回答质量差就可以调用 API 给对应 trace 打一个分数并附带原因标签。这样后续做质量分析时可以直接筛出低分 trace逐一复盘是 prompt 问题、检索问题还是模型问题。from langfuse import Langfuse lf Langfuse() lf.score( trace_idxxx-request-id, nameuser_feedback, value0.3, comment回答内容不够准确缺少依据, )score 不一定要来自用户反馈也可以是自动化的规则。比如“响应超过了 10 秒就打 0 分”这样在 trace 列表里就能快速圈出所有慢请求。把 score 和 request_id 体系结合起来等于给日志系统加了一层业务视角这是普通日志平台很难做到的。6. 上生产前必须处理的三个问题6.1 数据量暴增后的采样策略全量 trace 在开发环境没有问题生产环境一旦流量上来存储和费用都会失控。Langfuse 的 SDK 支持采样率参数但我更建议按业务重要性来控制。我的策略是三层默认接口只保留 10% 的 trace用于趋势分析。慢请求、报错请求强制全量保留。核心交易链路全量保留。实现时可以在入口处做判断observe() async def chat_endpoint(request: Request): trace_allowed should_trace(request) if trace_allowed: langfuse_context.update_current_trace( namechat_endpoint, idrequest.state.request_id, metadata{trace_level: full}, ) # rest of the handlershould_trace内部可以读取请求 header、路径、耗时预判等条件。核心原则是成本可控的前提下保留“值得复盘”的请求而不是所有请求。6.2 上报链路别反过来拖垮业务Langfuse 的 SDK 上报默认是异步的理论上不会阻塞业务。但有几个边界情况仍然要小心进程退出时异常可能导致队列里的 trace 没有 flush 完。建议在应用关闭钩子里调用langfuse.flush()确保数据不丢。如果服务本身部署在严格隔离的网络环境访问不了 Langfuse 的地址SDK 会反复重试。需要设置合理的超时和重试策略或者干脆在本地做代理。队列积压时不要让 memory 无限增长。生产环境最好设置一个上限超过后直接丢弃新 trace优先保证业务稳定。我见过一个生产事故某团队把所有中间件都加了 trace某个 Spark 任务瞬间产生几十万条 trace上报队列积压到内存吃满最后任务 OOM。所以采样不是可选项是必选项。6.3 输入输出可能带着敏感数据这是最容易被忽略、影响却也最大的一点。trace 里默认会记录函数输入输出而业务上下文里很可能包含手机号、账号、密钥、鉴权 header。一旦 Langfuse 的可视化页面权限控制不够严等于把这些数据摊开给所有人看。我的处理方式是在中间件层面统一删掉敏感 header不要传进 trace 的 metadata。对业务函数的输入输出做脱敏处理比如手机号打码、token 只保留前几位。核心链路使用独立的 project项目权限单独控制避免所有环境混在一起。Langfuse 本身提供了关闭输入输出捕获的选项开发环境可以全量采集生产环境应保守一些。宁可少看一点上下文也不要让安全风险成为引用 trace 的阻碍。7. 一个小习惯给 trace 起个能检索的名字最后分享一个我后来才总结出来的经验trace 的命名直接决定检索效率。一开始我图省事所有 trace 都叫chat_endpoint结果一个月后想找某个版本的上线问题列表里全是同名 trace只能靠时间一个个点开。后来我把命名规范改成“业务模块-接口语义-版本”例如chat_endpoint_v2或retrieval_search_v3配合 metadata 里的版本号筛选效率一下子上来了。另一个习惯是把 trace 的 metadata 里固定塞几个标准字段服务名、环境、代码版本、请求来源、是否命中缓存。不需要多但一定要稳定。因为这些字段会成为你未来做统计分析的维度比如“v2 版本上线后P99 耗时为什么涨了”直接在 trace 列表里按版本过滤就能看到全貌。从 request_id 到可视化 trace本质上不是把日志系统换掉而是给散装事件补上结构。接入过程没有想象中复杂真正花时间的是理解树的语义以及管好数据量。如果你现在还在为一条 request_id 反复拼日志不妨先拿一个非核心接口跑通试试积累两周数据后再回来看大概率会觉得原来的排查方式像在旧地图里找路。