ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Agent 生产环境排障实战:用 Tracing 还原每一次决策现场

2026/9/8 12:49:38 拓冰建站 浏览量
Agent 生产环境排障实战:用 Tracing 还原每一次决策现场 把 Agent 接进生产环境后最难的不是让它跑通一次漂亮的 demo而是它在线上出了问题时你根本无从下手。它可能调了三次工具、读了两轮记忆、中间还被重试机制悄悄重放了一遍最终给你一个看似合理其实错误的答案。这个时候光靠猜没用你需要的是能完整回放它每一步决策现场的数据。给 Agent 加 Tracing本质上就是在它的“大脑”里装一个黑匣子把一次任务从输入到输出的完整调用链、耗时、参数、中间状态全部记录下来。这篇文章我会用实战方式聊聊为什么要用 Tracing 做 Agent 可观测性以及从 0 到 1 具体怎么落地。适合正在做 Agent 应用、AI 中间件和平台工程的开发者也适合被线上 Agent 故障搞得焦头烂额的朋友。1. 先搞清楚 Agent 为什么会这么难调试1.1 Agent 不是一个函数而是一条决策链路传统后端服务一次请求通常是确定的一条链路API 入口、鉴权、业务逻辑、数据库、响应。出问题的时候你把日志按 request_id 一拉基本就能定位到是哪个环节挂了。但 Agent 不一样它不是一个函数调用而是一个带有循环和分支的决策过程。以最常见的 ReAct 模式为例Agent 会反复执行“思考-行动-观察”的循环大模型根据当前上下文决定下一步要调用哪个工具拿到工具的返回结果之后再把这个结果拼进上下文继续推理直到它认为可以输出最终答案。这个过程里还经常叠加其他因素为了完成一个复杂任务主 Agent 可能会派生出子 Agent为了提升效率多个工具调用可能并行执行某些工具超时了框架会自动重试一次模型输出有时候会不稳定同一个问题可能走出完全不同的两条路径。你可以简单类比一下普通 API 调用像是打一辆出租车从 A 点到 B 点路线基本确定出问题好查Agent 更像一个物流分拣中心包裹进来后被扫描、分拣、装车、转运、再扫描任何环节都可能延迟、丢失或者错投。只看最终的派送结果你根本不知道是哪个环节出了问题。这也是很多从传统后端转过来做 Agent 的开发者最不适应的一点。以前你可以通过“入参-出参-异常”三段式日志快速判断逻辑对不对但在 Agent 场景下出参正确不代表过程正确出参错误也不一定是最后一步导致的。如果你没有一条完整的调用链排查 Agent 故障基本就是在猜。而猜测在线上环境里是最昂贵的行为。1.2 为什么传统日志和指标在 Agent 面前失效了很多团队刚开始给 Agent 加监控时第一反应是把日志打全再加上几个指标比如请求量、成功率、平均延迟。这套组合在普通服务里够用但在 Agent 场景下缺陷很明显。首先是日志难以串联。Agent 一次任务可能跨多个进程、多个异步任务甚至调用外部 API。如果你没有在每个日志行里带上同一个 task_id散落各处的日志根本无法按时间线和调用层级组织起来。很多 Agent 框架内部会自动调用大模型如果你不手动打点日志里只会看到输入和输出中间为什么选了这个工具、哪一步开始跑偏全都没有。其次指标能告诉你“成功率下降了”但告诉不了你“为什么下降”。比如成功率从 99% 掉到 95%指标看得到趋势但一个 Agent 任务里可能包含十几次工具调用到底是哪一类工具失败拖垮了整体成功率是模型生成了非法参数还是工具服务本身超时或者记忆检索命中率太低导致模型反复走错分支这些细节必须在一条完整链路里才能看清楚。还有一个容易被忽略的问题Agent 的中间状态往往带有“决策语义”。传统日志记录的是程序执行的事实但 Agent 日志里如果缺少“当时模型认为应该做什么、基于什么判断这么做”的信息事后默认看日志的人只能靠脑补。所以 Agent 可观测性的核心难点不是采集数据而是把每一次决策、每一次副作用操作按照关联关系组织起来。这个场景恰好是 Tracing 最擅长的事。2. 用 Tracing 还原 Agent 的决策链2.1 Tracing 的本质一张带时间线的调用树Tracing 不是什么新东西最早在微服务领域被大规模使用。核心概念只有两个Trace 和 Span。一次完整的业务请求从入口到所有依赖服务整体构成一个 Trace。Trace 里的每一个有意义的操作单元比如一次 HTTP 请求、一次数据库查询、一次消息发送就是一个 Span。Span 是带时间片段的对象记录操作的开始时间、结束时间、名称、状态、自定义属性以及事件同时通过 trace_id 和 parent_span_id 串成父子关系。所有 Span 串起来就是一棵调用树直观展示了“哪个操作是谁发起的、耗时多久、在什么时间发生的”。你可以把 Span 想象成快递的每个站点扫描记录Trace 就是完整的物流轨迹。扫描记录越多越能还原包裹从哪来、经过哪、最后去了哪。对于 Agent 来说这个逻辑一模一样一次用户提问到 Agent 最终回答就是一条 Trace中间的每一次模型推理、工具调用、记忆读取、子 Agent 任务都是这条 Trace 下的子 Span。唯一不同的是Agent 的链路不是单纯的线状而是会频繁出现分支、循环和并行。2.2 给 Agent 做 Tracing到底要看什么给 Agent 接 Tracing 不是把链路图画出来就完事了关键是设计要记录的信息。我在实战中会把问题归成四类所有埋点都围绕这四类问题展开。第一类它做了什么。这是最基础的。Agent 调用了哪个工具、用了哪个模型、读了哪段记忆、是不是所有步骤都在预期路径内。没有这类信息后面所有分析都无从谈起。第二类它为什么这么做。这类信息最容易被忽略但对排查 Agent 异常决策特别重要。当时的 prompt 是什么模型输出里的推理片段是什么为什么在条件 A 和条件 B 都满足时它选择了工具 C 而不是工具 D如果你不记录模型中间的推理摘要事后只能看到一个“它做了”看不到“它想过什么”。第三类它做得怎么样。每一步操作耗时多久、消耗了多少 token、是否失败、失败之后有没有重试。这类数据可以直接用来做性能优化和成本控制。比如一个 Agent 任务平均要消耗 8000 个 token如果通过 Trace 发现其中有 3000 个 token 浪费在重复读取同一段记忆上你就能精准优化记忆检索策略。第四类从用户视角看全貌。所有链路最终要能按用户、会话、任务维度聚合。一个用户在一次会话里问了三轮问题每一轮 Agent 分别走了哪些路径每一步的反馈如何这些数据对做产品评估和回归测试非常关键。我在线上见过最典型的例子是Agent 偶尔多扣了用户的钱开发团队查了两天没头绪。后来把 Trace 完整拉出来才发现某个订单查询工具在超时后触发了一次重试同一个工具被调用了两次其中一次拿到了旧状态。没有调用父子关系和状态属性这种问题从普通日志里几乎不可能翻出来。3. 从 0 到 1 落地给 Agent 装上黑匣子3.1 工具选型OpenTelemetry、Langfuse、LangSmith 怎么选给 Agent 加 Tracing首先要解决的就是“用什么存、用什么看”。市面上现在可选的方案不少我按照自己的使用经验给你做个对比。先说 OpenTelemetry。它是一套标准化的可观测性协议和 API最适合用来做全链路统一。Agent 应用不光有大模型调用还有大量普通函数、HTTP API、数据库操作用 OpenTelemetry 可以把这些全部纳入同一个数据模型后续和公司已有的监控系统打通也很方便。缺点是需要自己搭 Collector 和存储后端工作量大一些界面也比较通用不会自动帮你把 prompt、token 这些 LLM 专用字段展示得特别美观。然后是 Langfuse、LangSmith 这类 LLM 专用可观测平台。LangSmith 如果用的是 LangChain 或 LangGraph集成最顺滑几乎改几行代码就能把每次 LLM 调用的 prompt、response、token 使用情况都存下来界面非常懂 LLM 场景。但它偏 SaaS 化数据存在云端对于数据敏感的业务要慎重评估。Langfuse 可以开源自托管也能和 LangChain 配合追踪 LLM 调用、工具调用、打分评估都内置了比较适合中小团队。如果让我给一个决策建议业务里主要用 LangChain/LangGraph 且不介意上云先用 LangSmith 快速跑通理解和定义清楚你的 Trace 结构如果要做生产级全链路统一或者 Agent 里大量自定义代码我建议以 OpenTelemetry 为底座把 LLM 专用字段作为 Span 的属性自己管理再搭配一个适合的 UI。这样既能保留 LLM 场景的展示能力又不至于把可观测体系割裂成两套。3.2 Span 设计把 Agent 的每一步切成可复盘的节点工具定了之后最重要的一步是设计 Span 结构。这一步最容易被新手忽略上来就埋点最后 Trace 是有数据了但所有信息都塞进一个巨大的 Span复盘时跟看一篇流水账没区别。Agent 场景下我建议把 Span 至少切成这么几个层级具体名称可以根据框架调整但核心语义要保持一致。Span 名称作用建议记录的关键属性agent.runAgent 任务的根 Span代表一次完整任务agent_id、session_id、user_id、input_preview、trace_modeagent.plan规划阶段记录模型产出的步骤拆解plan_steps、reasoning_preview、model_nameagent.memory.read记忆检索或读取memory_source、query_preview、hit_countagent.tool.callAgent 决定调用工具的边界tool_name、args_preview、parent_step_idtool.execute工具实际执行建议由工具侧埋点tool_name、status、latency_ms、output_previewagent.llm.call每一次 LLM 调用包括最终生成答案model_name、prompt_preview、token_input、token_output、latency_ms、temperature设计原则其实很简单凡是会产生“决策”或者“外部副作用”的地方都值得一个 Span。比如写文件的工具、发邮件的工具一定要记录参数摘要和返回值摘要凡是可能改变后续决策的数据比如记忆检索到了几条、命中了什么内容也要记下来。因为事后复盘时你不仅想知道“它调了搜索”更想知道“它带着什么搜索词、搜到了什么结果、然后做了什么选择”。3.3 最小埋点实现一个通用装饰器就够如果不想依赖特定的 Agent 框架可以直接基于 OpenTelemetry API 写一层通用的装饰器让所有工具函数和 LLM 调用统一走这个入口。下面我用 Python 写一个最简版核心逻辑放在公共库里实际项目里只需要调用装饰器就行。第一步是初始化 TracerProvider并配置导出器。我这里以 OTLP HTTP 导出为例把 Span 发送到本地或云端的 OpenTelemetry Collectorfrom opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.resources import Resource from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace.export import BatchSpanProcessor resource Resource.create(attributes{ service.name: agent-service, service.version: 1.4.2, }) provider TracerProvider(resourceresource) provider.add_span_processor(BatchSpanProcessor( OTLPSpanExporter(endpointhttp://otel-collector:4318/v1/traces) )) trace.set_tracer_provider(provider)这里有一个特别容易踩的坑BatchSpanProcessor 的队列参数。生产环境下 Agent 任务可能会瞬间批量制造大量 Span如果队列默认太小高并发下会丢 Span表现出来就是 Trace 缺环。建议根据任务量把 max_queue_size 和 max_export_batch_size 调大同时在本地观察是否有 dropping 日志。第二步是封装一个公共装饰器给任意函数自动包上 Spanimport functools from opentelemetry import trace from opentelemetry.trace import Status, StatusCode tracer trace.get_tracer(agent.tracer) def traced_step(span_name, step_typeNone, **default_attrs): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): attrs dict(default_attrs) attrs[agent.step.type] step_type or func.__name__ with tracer.start_as_current_span(span_name, attributesattrs) as span: try: result func(*args, **kwargs) preview str(result)[:300] span.set_attribute(agent.step.output, preview) span.set_status(Status(StatusCode.OK)) return result except Exception as exc: span.set_attribute(agent.step.error, str(exc)) span.set_status(Status(StatusCode.ERROR, str(exc))) span.record_exception(exc) raise return wrapper return decorator traced_step(agent.tool.search, step_typetool) def search_knowledge_base(query: str): return retriever.invoke(query)注意这里的start_as_current_span不能改成start_span。start_span只是创建一个独立的 Span不会把它设置为当前上下文子 Span 就不会自动挂到它下面最后得到的是一堆平铺的孤点根本形不成调用树。这也是新手最容易犯的错误。如果你的 Agent 跑在异步环境还要额外处理 Context 传递的问题。asyncio.create_task()默认会拷贝当前 ContextAgent 主流程里启动子任务前当前 Span 的上下文会被带进去这没问题但如果你用了线程池或者跨 Task 手动 await就必须把当前 Context 显式传进去否则 Trace 会从某个子任务开始断掉。这个问题在后面的排查章节里再细说。3.4 LLM 调用要埋点但不要乱存 promptLLM 调用是 Agent 里信息量最大、也最容易“过度记录”的部分。很多人一上来就把全量 prompt 和 response 塞进 Span 属性结果存储成本飙升还带来隐私问题。我的建议是LLM 的 Span 记录摘要属性就够了需要全量数据时再按需获取。具体来说记录这几类信息最实用模型名称、temperature、max_tokens、prompt 前 300 到 500 个字符、response 前 300 个字符、stop_reason、prompt_tokens、completion_tokens。如果模型调用了工具还要记录模型要求调用的工具名列表和前几个参数。如果你需要支持完整复盘更合理的做法不是把大段文本塞进 Span而是把完整消息存到外部存储把消息 ID 挂到 Span attribute 上。这样 Trace 保持轻量真到了要逐字回放的时候通过 ID 再捞全量数据也不迟。还要注意很多 Agent 框架自带的追踪只能覆盖框架内部的标准环节比如普通 LLM 调用和内置工具调用。你自己写的检索逻辑、带状态的记忆更新、多 Agent 之间的消息传递框架通常不会替你打点。所以最稳妥的方式是“自动 手动”结合框架已有的保留缺的关键节点自己补 Span。4. 常见问题与排查技巧实录4.1 Trace 串不起来八成是 Context 丢了接入 Tracing 之后反馈最多的一个问题就是能看到 Span但 Span 都是独立的trace_id 对不上或者所有 Span 的 parent_span_id 都是空的。出现这个现象九成是上下文传播出了问题。最常见的两个原因一是异步环境没有传播上下文二是自己封装装饰器时用了start_span()而不是start_as_current_span()。前者在 Python 的线程池场景里尤其典型主协程里创建了一个当前 Span然后把任务丢给线程池线程里拿不到主协程的 Context自然创建不出子 Span。排查方法也很直接先看一条 Trace 的 trace_id 是否一致不一致说明根 Span 之后的所有 Span 都没有继承上下文再找根 Span 是不是 agent.run如果不是说明入口处没有正确初始化最后抽查几个中间步骤的 parent_span_id缺了就按调用链往回找看是哪个调用点丢的。如果你是在异步框架里手动管理任务可以用contextvars.copy_context()把当前 Context 包住整个协程或者把父 Span 信息作为参数传入目标函数在线程任务里显式start_span(name, contextparent_ctx)。这个麻烦是一开始能避免的在架构设计时约定所有异步任务统一的执行入口比事后打补丁轻松得多。4.2 Token 统计不准流式生成要“最后回填”Agent 应用里Token 消耗统计错了直接影响成本核算。最常见的原因是流式生成。如果你在收到第一个 chunk 时就关闭 Spanusage 字段通常是 0因为很多模型接口的 usage 信息都放在最后一个 chunk 里。正确做法是把 Span 的生命周期拉长到整个流式生成结束在收完所有 chunk 后把 usage 回填到 Span 属性里。OpenAI 和 Anthropic 的 Python SDK 都会在最终 chunk 里携带 usage 字段你要做的就是别提前关闭 Span。还有一个隐蔽问题重试导致 token 统计翻倍。Agent 在 LLM 调用失败后自动重试如果每次重试都新建 Span最终汇总的时候一次任务可能被记录成多个任务。我的建议是给每个重试 Span 加一个agent.retry.attempt属性记录这是第几次尝试在汇总层按 Trace 聚合时把重试 Span 合并到同一任务下。否则你月底看成本报表会莫名发现 token 消耗比预期高出一大截。4.3 看不到推理过程怎么办很多 Agent 框架默认不会把模型的“思考链”写进日志尤其是不走 ReAct 的链式编排场景你只能看到一步步的函数调用看不到模型为什么要这么编排。这种情况下有两种补救方案。如果模型接口返回了思考过程比如输出里带 reasoning 或 thought 字段你可以在拿到响应后把这些内容的截断摘要写到 Span 属性里。如果模型接口不返回思考过程那就把触发决策的关键上下文记下来比如当前系统提示词里关于“什么条件下调用什么工具”的规则、当前可用的工具列表、上一步工具的返回摘要。有了这些上下文事后复盘时仍然能推断出模型当时大概率的决策逻辑。但这里要强调一下隐私安全不要无脑把全量推理内容落库。医疗、金融等敏感场景尤其要注意。我自己的做法是先做脱敏只记录不包含个人信息的摘要再决定是否把完整内容写入独立的高权限存储。另外内部推理的 Span 和对外回复的 Span 要分开标记避免模型内部思考内容意外通过日志系统间接暴露。4.4 数据太多、存储爆了怎么定采样策略Tracing 好用的代价是数据量增长很快。一次带搜索和多轮会话的 Agent 任务如果把每轮 prompt 和工具输出全量存下来一天几万个任务就能把对象存储打爆。所以必须设计采样策略。先算一笔账。假设每天有 1 万次 Agent 任务平均每个任务产生 10 个 Span每个 Span 只存摘要属性大约 1KB 左右单日原始数据量约 100MB这个规模普通数据库完全可以承受。但如果每个 Span 里塞进 2KB 全量 prompt 和 10KB 工具响应单日直接冲到 1GB 以上存储和查询成本都会明显上升。我的经验是分层采样所有错误和慢请求 100% 采样因为排障主要靠这批数据正常请求按照业务量设 10% 到 50% 的采样率量小的服务可以全采如果并发很高再按 trace_id 的 hash 做统一样本确保同一条 Trace 要么整条落库、要么整条不落库。这一点非常关键如果你按 Span 维度随机采样同一个 Trace 只留一半节点复盘时看到的就是一棵残缺的树。在 OpenTelemetry 里可以通过 Sampler 配置核心思路是根 Span 决定整棵树是否采样子 Span 跟随父 Spanfrom opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased provider TracerProvider( samplerParentBased(rootTraceIdRatioBased(0.2)) )当然生产环境的采样率要根据业务容忍度动态调整。如果你处在排查重大故障的阶段可以临时把采样率调到 100%问题解决后再调回来。4.5 工具重复执行和超时重试Tracing 能发现什么Agent 最危险的 Bug 不是模型答错题而是它认为工具失败了实际上工具已经执行完并产生了副作用。比如一个写操作工具超时框架自动重试两个请求同时落到同一个业务接口上再比如一个慢查询工具触发了重试第二次调用拿到了第一条请求还没来得及更新的旧状态最终导致数据不一致。这类问题 Tracing 能帮你发现但没法自动避免。我的实践建议是对工具做幂等性分类。查询类工具允许重试写操作类工具禁止框架自动重试只能在 Agent 层面判断“是否确认执行成功”后再决定下一步。同时在每次工具调用的 Span 属性里标记agent.tool.idempotent是 true 还是 false。这样以后排查“为什么这个操作执行了两次”时直接按属性过滤效率高很多。我还养成了一个习惯所有对外部系统有副作用的操作都要单独打点并且把操作的请求标识、目标系统、参数摘要放进 Span 属性。Agent 的可观测性越接近“每一次外部副作用都留痕”线上问题的定位就越接近“秒杀”。5. 从 Tracing 走向全链路可观测5.1 把指标和日志也串进来Tracing 解决的是“一条链路内部发生了什么”但要回答“系统整体健不健康”还需要叠加指标。比如 Agent 任务成功率、平均完成轮数、P95 token 消耗、工具调用失败率、记忆检索命中率这些都可以从 Trace 数据聚合出来也可以用 OpenTelemetry Metrics 接口单独记录。日志在 Agent 可观测体系里同样不能丢但它的角色要重新定位。日志更适合记录“不可作为 Span 展示的细节”比如某个工具返回的完整 JSON、某个模型报错时的完整堆栈。关键是要在打日志时把当前 Context 里的 trace_id 和 span_id 带进去。这样你看到一条报错日志可以一键跳转到对应的 Trace整个过程就像在海上航行时同时拿着雷达和望远镜。我自己的方案是自定义一个 LogFormatter从 OpenTelemetry 的 Context 中读取当前 trace_id 并拼进日志行。这样既有结构化日志又不破坏原有日志系统的使用习惯排查问题时两个系统互相印证效率非常高。5.2 用历史 Trace 反哺 Agent 回归测试Tracing 除了排障还有一个常被忽略的价值它是一个天然的测试样本库。Agent 版本发布前与其拍脑袋造测试用例不如从线上捞一批真实 Trace把当时的用户输入回放到新版本里对比两版 Agent 的决策路径和最终输出快速发现行为回归。实际操作时把历史 Trace 里的输入部分导出成 JSONL 数据集用脚本驱动新版本 Agent 执行再把新 Trace 的关键属性比如工具调用序列、模型选择、最终答案和旧 Trace 对比。不需要要求文本完全一致重点看“决策结构有没有退化”。比如旧版本是先查询用户信息再查询订单列表新版本却直接查询了订单列表然后报错这个顺序变化往往就是回归信号。我在项目里就是用这种方式从几千条真实 Trace 里筛出“曾经触发过错误工具选择”的样本做成回归集。每次改 prompt 或者升级模型之前先跑一遍比写多少单元测试都管用因为它是真实用户拿真实数据催出来的结果。这也算是 Tracing 带来的长期红利投资可观测性本质上是在为后续的模型迭代铺路。最后分享一点个人心得。给 Agent 上 Tracing别想着一步到位接一堆平台。先安静想十分钟明确你最需要回答的是哪三类问题再动手埋点。我一开始只给工具调用加了一个最简单的 span第二天就靠它查出了一个线上订单重复执行的根因。等你确认这套最小埋点能解决实际问题了再往指标、日志、自动回归的方向扩展投入产出比最高。如果一开始就把所有流量全量存下来数据多到根本不知道看哪里。Tracing 不是把链路画出来就完了它是帮你恢复现场、逼你规范操作的工具。愿大家的 Agent 不再是个黑盒。