资讯详情 AI Agent 可观测性实战:OpenTelemetry GenAI 链路追踪与成本归因
📅 2026/10/12 9:24:14
1. 从黑盒到白盒AI Agent 可观测性为什么成了刚需做 AI Agent 的人都有一个共同的痛上线前跑 demo 一切正常上线后用户随便问几句就开始胡言乱语要么陷入无限循环反复调用同一个工具要么一次对话烧掉几十万 token 的账单。更让人崩溃的是你根本不知道它为什么这么做。传统的日志方案在这里几乎失效。普通后端服务出问题你打一条日志、加一个断点链路是确定的输入输出是可控的。但 AI Agent 不一样它的执行路径是动态生成的每一次推理都可能走不同的分支每一次工具调用都可能返回意料之外的结果。你面对的是一个真正的黑盒模型在想什么、为什么选这个工具、为什么重复调用、为什么生成了不存在的事实这些信息全部藏在模型的内部状态里。这就是 AI Agent 可观测性要解决的核心问题。简单说它要回答三个问题Agent 每一步到底做了什么、每一步花了多少钱、每一步的结果对不对。这三个问题对应三个可观测性维度——链路追踪Trace、成本度量Metrics、质量评估Evaluation。OpenTelemetry 的 GenAI 语义约定就是把这三点标准化的一套规范。我最初接触这套东西的时候觉得不过是又一套日志规范。但真正在项目里踩过坑之后才发现没有可观测性的 Agent 就像没有仪表盘的飞机能飞但你不知道油还剩多少、高度是多少、发动机有没有异常。这篇文章我会把整套方案拆开讲清楚为什么传统方案不够用、OpenTelemetry GenAI 的三根柱子具体怎么落地、实操中怎么配置、遇到幻觉和死循环怎么排查、成本怎么归因到具体环节。适合正在做 Agent 开发、被线上问题折磨过的工程师也适合刚开始接触可观测性、想系统了解这套体系的朋友。2. 传统可观测方案为什么在 Agent 场景下失灵2.1 普通日志和 APM 的三大盲区先说清楚为什么不能直接用现有的日志和 APM 工具。我试过最朴素的做法在 Agent 的每个函数入口打日志记录输入输出。跑了两天就放弃了原因有三个。第一个盲区是上下文丢失。Agent 的一次用户请求内部可能触发十几次模型调用、五六次工具调用这些调用之间有严格的因果关系——第 3 次模型调用是因为第 2 次工具返回了某个结果。普通日志是扁平的你只能看到时间线上散落的一堆记录无法还原出完整的决策链路。当 Agent 在第 8 步开始胡说八道时你根本不知道是哪一步的输入污染了后续的推理。第二个盲区是成本不可归因。一次对话烧了 50 万 token钱花在哪了是系统提示词太长、是工具返回结果太大、还是模型陷入了循环反复读同一个文件普通日志记不了 token 消耗更记不了每个 span 的归属。你只能看到一个总数无法定位到具体的环节。第三个盲区是质量无法量化。Agent 说了一句话这句话是对是错传统监控只能告诉你请求成功返回了但返回的内容是不是幻觉、是不是答非所问它一无所知。你需要一套评估机制把质量这个模糊的概念变成可观测的指标。2.2 Agent 执行模型的特殊性要理解为什么需要专门的方案得先理解 Agent 的执行模型和普通服务的区别。普通服务的调用链是静态的、确定的。一个 HTTP 请求进来经过固定的几个中间件、几个服务、几个数据库查询路径在代码写死的时候就确定了。你可以画出一张精确的调用图。Agent 的调用链是动态的、生成的。用户问一个问题模型自己决定要不要调用工具、调用哪个工具、调用几次、什么时候停止。这个决策过程是运行时生成的每次都可能不同。更麻烦的是Agent 有循环结构——它可能反复执行思考-行动-观察这个循环直到自己认为任务完成。这个循环次数是不确定的可能 3 次结束也可能 30 次还在转。这种动态性带来两个直接后果。一是链路深度不可预测你不能像普通服务那样预设 trace 的层级结构。二是失败模式更隐蔽死循环不会报错它只是安静地烧钱幻觉不会抛异常它只是返回了一个看起来很正常但完全错误的答案。2.3 三根柱子的必要性所以我们需要三根柱子来分别解决这三类问题。Trace链路追踪解决做了什么的问题。它把一次用户请求拆解成一棵调用树每个节点是一个 span记录这一步的类型模型调用/工具调用/检索、输入、输出、耗时、状态。有了这棵树你就能还原 Agent 的完整决策路径。Metrics度量解决花了多少的问题。它把 token 消耗、调用次数、延迟、成本这些数值型指标聚合起来支持按模型、按工具、按用户维度切片分析。有了它你就能回答哪个环节最烧钱。Evaluation评估解决做得对不对的问题。它把质量检查的结果作为 span 的属性记录下来比如这一步是否产生了幻觉工具调用参数是否合法最终答案是否满足用户意图。有了它你就能把质量问题变成可查询、可告警的指标。这三根柱子不是孤立的它们共享同一套上下文。一个 trace 里的每个 span 都携带 token 数和成本评估结果也挂在对应的 span 上。这样你排查问题时可以沿着 trace 找到出问题的那一步看到它花了多少钱、质量评分是多少形成完整的证据链。3. OpenTelemetry GenAI 语义约定拆解3.1 为什么是 OpenTelemetry 而不是自研有人会问为什么不自己写一套埋点方案我一开始也这么想后来发现自研的坑太深。第一标准化。OpenTelemetry 的 GenAI 语义约定定义了一套通用的属性名比如gen_ai.system、gen_ai.request.model、gen_ai.usage.input_tokens。这意味着你的埋点数据可以被任何兼容 OTel 的后端消费不会被某一家厂商锁定。今天用 A 平台明天换 B 平台数据格式不用改。第二生态成熟。OTel 的 SDK 覆盖了主流语言采集、导出、存储、可视化整条链路都有现成工具。你不需要自己造轮子只需要按规范埋点。第三上下文传播。OTel 的 context propagation 机制天然支持跨服务、跨进程的链路串联。Agent 往往涉及多个服务网关、编排器、工具服务、模型代理自研方案很难优雅地处理这种分布式上下文。当然GenAI 语义约定本身还在演进有些字段还在讨论中。但核心的模型调用、token 统计、工具调用这几块已经相对稳定足够支撑生产使用。3.2 核心属性字段速查下面这张表是我实际项目里最常用的字段建议收藏。属性名含义典型值gen_ai.system模型提供方openai、anthropic、localgen_ai.operation.name操作类型chat、embeddings、execute_toolgen_ai.request.model请求的模型名gpt-4o、claude-3-5-sonnetgen_ai.response.model实际响应的模型可能与请求不同gen_ai.usage.input_tokens输入 token 数整数gen_ai.usage.output_tokens输出 token 数整数gen_ai.request.temperature温度参数0.0 ~ 2.0gen_ai.request.max_tokens最大输出 token整数gen_ai.response.finish_reasons结束原因stop、length、tool_callsgen_ai.tool.name工具名search、calculatorgen_ai.tool.call.id工具调用 ID用于关联请求和结果这些字段看起来简单但每一个都有讲究。比如finish_reasons如果是length说明输出被 max_tokens 截断了这往往是幻觉的诱因之一——模型话没说完就被强行打断后续推理基于残缺的上下文很容易跑偏。再比如gen_ai.response.model和gen_ai.request.model不一致说明发生了模型路由或降级这会影响成本和质量的归因。3.3 Span 层级设计怎么把 Agent 循环装进树里这是实操中最关键的设计决策。Agent 的执行是循环的但 trace 是树状的怎么映射我的做法是三层结构。根 span代表一次完整的用户请求属性里记录用户 ID、会话 ID、总 token、总成本。中间 span代表 Agent 的一次循环迭代iteration每次思考-行动-观察是一个 iteration span。叶子 span代表具体的操作模型调用是一个 span工具调用是另一个 span。这样设计的好处是循环次数直接体现为 iteration span 的数量。如果一次请求产生了 50 个 iteration span那基本可以判定是死循环了。同时每个 iteration 内部的模型调用和工具调用有明确的父子关系你能看到这一步模型为什么决定调这个工具、工具返回了什么、下一步模型又基于这个返回做了什么。注意iteration span 的命名建议带上序号比如agent.iteration.3这样在 trace 视图里能一眼看出执行顺序。不要用时间戳命名可读性太差。还有一个细节工具调用的请求和结果往往不在同一个 span 里。模型先返回一个tool_calls你的编排器执行工具再把结果喂回模型。这两个动作应该用gen_ai.tool.call.id关联起来。我见过有人把工具执行单独打一个 trace结果排查时根本对不上号这是典型的埋点设计失误。4. 实操落地从埋点到可视化4.1 环境准备与 SDK 初始化先讲环境。我用的是 Python 生态核心依赖三个包opentelemetry-api、opentelemetry-sdk、opentelemetry-exporter-otlp。如果你用的是其他语言对应的 SDK 也都有思路一致。pip install opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp初始化 TracerProvider 的时候有个坑一定要在应用启动的最早期完成初始化否则早期产生的 span 会丢失。我习惯在入口文件的第一行就做这件事。from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource resource Resource.create({ service.name: ai-agent-orchestrator, service.version: 1.2.0, deployment.environment: production, }) provider TracerProvider(resourceresource) exporter OTLPSpanExporter(endpointhttp://otel-collector:4317, insecureTrue) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer trace.get_tracer(__name__)BatchSpanProcessor是异步批量导出的比SimpleSpanProcessor性能好很多生产环境必选。insecureTrue只在内网环境用跨网络记得配 TLS。4.2 模型调用埋点把 token 和成本算清楚模型调用是最核心的埋点位置。下面这段代码是我项目里实际用的简化版。def call_model(messages, modelgpt-4o, **kwargs): with tracer.start_as_current_span(gen_ai.chat) as span: span.set_attribute(gen_ai.system, openai) span.set_attribute(gen_ai.operation.name, chat) span.set_attribute(gen_ai.request.model, model) span.set_attribute(gen_ai.request.temperature, kwargs.get(temperature, 1.0)) span.set_attribute(gen_ai.request.max_tokens, kwargs.get(max_tokens, 4096)) response client.chat.completions.create( modelmodel, messagesmessages, **kwargs ) usage response.usage span.set_attribute(gen_ai.usage.input_tokens, usage.prompt_tokens) span.set_attribute(gen_ai.usage.output_tokens, usage.completion_tokens) span.set_attribute(gen_ai.response.finish_reasons, [response.choices[0].finish_reason]) span.set_attribute(gen_ai.response.model, response.model) cost calculate_cost(response.model, usage.prompt_tokens, usage.completion_tokens) span.set_attribute(gen_ai.usage.cost_usd, cost) return response成本计算函数需要维护一张价格表。这里有个经验价格表要按模型版本细分并且支持热更新。模型厂商调价很频繁硬编码在代码里迟早出问题。我一般放在配置中心定期同步。PRICING { gpt-4o: {input: 2.5 / 1_000_000, output: 10.0 / 1_000_000}, gpt-4o-mini: {input: 0.15 / 1_000_000, output: 0.6 / 1_000_000}, } def calculate_cost(model, input_tokens, output_tokens): price PRICING.get(model) if not price: return 0.0 return input_tokens * price[input] output_tokens * price[output]提示成本字段建议用gen_ai.usage.cost_usd这个自定义属性名虽然不在官方约定里但社区已经形成事实标准。用美元计价方便跨模型对比。4.3 工具调用埋点关联请求与结果工具调用的埋点要解决一个核心问题把模型的请求调用和实际的执行结果关联起来。def execute_tool(tool_name, tool_args, call_id): with tracer.start_as_current_span(fgen_ai.tool.{tool_name}) as span: span.set_attribute(gen_ai.operation.name, execute_tool) span.set_attribute(gen_ai.tool.name, tool_name) span.set_attribute(gen_ai.tool.call.id, call_id) span.set_attribute(gen_ai.tool.arguments, json.dumps(tool_args)) try: result TOOL_REGISTRY[tool_name](**tool_args) span.set_attribute(gen_ai.tool.result, json.dumps(result)[:2000]) span.set_status(trace.Status(trace.StatusCode.OK)) return result except Exception as e: span.set_status(trace.Status(trace.StatusCode.ERROR, str(e))) span.record_exception(e) raise这里有个细节值得说gen_ai.tool.result我做了截断只保留前 2000 字符。原因是工具返回结果可能非常大比如读了一个大文件全量记录会让 trace 存储爆炸。截断的同时记录原始长度排查时如果发现结果被截断可以再去查原始数据。call_id的关联逻辑是这样的模型返回的tool_calls里每个调用都有一个id你执行工具时把这个id传进来作为 span 属性。这样在 trace 视图里模型调用 span 和工具执行 span 就能通过这个 id 对应上。4.4 评估埋点把质量变成可查询的指标评估埋点是三根柱子里最容易被忽略、但价值最高的一环。我的做法是在关键节点挂评估结果。def evaluate_response(span, question, answer, context): score run_evaluator(question, answer, context) span.set_attribute(gen_ai.evaluation.hallucination, score[hallucination]) span.set_attribute(gen_ai.evaluation.relevance, score[relevance]) span.set_attribute(gen_ai.evaluation.score, score[overall]) if score[hallucination] 0.7: span.add_event(hallucination_detected, { severity: high, detail: score[detail] })评估器可以是基于规则的比如检查答案里有没有引用不存在的工具返回内容也可以是基于模型的用另一个模型做裁判。生产环境我建议两者结合规则评估做实时拦截模型评估做离线分析。add_event是个好东西它能在 span 上打时间点标记。当幻觉被检测到时打一个 event这样在 trace 时间线上能直观看到问题发生的位置。5. 幻觉、死循环、烧钱的排查实战5.1 幻觉排查沿着 trace 找污染源幻觉的典型表现是模型生成了看似合理但实际错误的内容。排查思路是回溯上下文找到是哪一步的输入导致了幻觉。我遇到过一个案例Agent 在回答用户问题时引用了一个根本不存在的文档编号。沿着 trace 往回看发现第 4 步工具调用返回了一个空结果但编排器没有正确处理空结果直接把空字符串喂给了模型。模型面对空上下文为了完成任务自己编了一个文档编号。排查步骤是这样的。先在 trace 里定位到产生幻觉的那个模型调用 span看它的finish_reasons是不是stop正常结束。然后看这个 span 的父 span也就是对应的 iteration检查这一步的输入消息。重点看工具返回结果是否为空、是否被截断、格式是否异常。如果发现工具返回为空再往上看工具执行 span 的gen_ai.tool.result和状态码。经验幻觉的高发场景有三个——工具返回空结果、工具返回格式错误、上下文被 max_tokens 截断。埋点时在这三个位置加告警能提前拦截大部分幻觉。5.2 死循环识别用 iteration 数量做阈值告警死循环的识别相对简单因为它是可量化的。核心指标就是单次请求的 iteration span 数量。我的做法是在根 span 结束时统计子 span 数量超过阈值就告警。MAX_ITERATIONS 15 def check_loop(span, iteration_count): span.set_attribute(agent.iteration.count, iteration_count) if iteration_count MAX_ITERATIONS: span.add_event(possible_infinite_loop, { iteration_count: iteration_count, threshold: MAX_ITERATIONS })阈值怎么定我的经验是看 P99。正常任务的 iteration 数量一般在 3 到 8 之间P99 大概在 12 左右。所以阈值设在 15 比较合理既能捕获异常又不会误报。当然不同任务类型差异很大代码生成类任务 iteration 会多一些问答类任务会少一些建议按任务类型分别设阈值。死循环的根因通常是工具返回结果无法让模型判断任务已完成。比如搜索工具每次都返回相似但不完全相同的结果模型觉得还没找到就一直搜。解决办法是在系统提示词里明确告诉模型如果连续两次搜索结果相似就停止搜索并给出当前最佳答案或者在编排器层面做硬性限制。5.3 成本归因定位最烧钱的环节成本排查的核心是按维度聚合。我常用的维度有四个按模型、按工具、按 iteration 序号、按用户。按模型聚合能看出是不是某个模型特别贵。按工具聚合能看出哪个工具返回的 token 最多。按 iteration 序号聚合很有意思——如果成本集中在后面的 iteration说明 Agent 在纠结前面的推理没解决问题。按用户聚合则是为了发现异常用户比如某个用户触发了大量长对话。下面这张表是我某次排查的实际数据能直观看出问题。维度值总成本占比模型 gpt-4o主推理78%模型 gpt-4o-mini评估4%工具 search返回结果12%工具 read_file返回结果6%iteration 1-3前期推理22%iteration 4-8中期推理35%iteration 9后期纠结43%看到这张表优化方向就很明确了后期 iteration 占了 43% 的成本说明 Agent 在任务后期效率低下。进一步看 trace 发现后期模型反复调用 search 工具每次返回的结果都差不多。优化方案是给 search 工具加结果去重同时在提示词里加入避免重复搜索的指令。改完之后总成本降了将近 30%。5.4 常见问题速查表现象可能原因排查位置解决方向幻觉工具返回空/格式错误工具 span 的 result加空值校验、格式规范化幻觉上下文被截断模型 span 的 finish_reasons提高 max_tokens 或压缩上下文死循环工具结果无法判定完成iteration span 序列提示词加停止条件、编排器加硬限制成本飙升工具返回结果过大工具 span 的 result 长度结果截断、摘要压缩成本飙升模型选型不当模型 span 的 model 属性简单任务降级到小模型链路断裂上下文未传播检查 trace_id 是否连续补全 context propagationtoken 统计不准流式响应未统计流式结束时的 usage用最终 chunk 的 usage 字段6. 生产环境的几个关键决策6.1 采样策略全量还是抽样生产环境不可能全量记录所有 trace存储成本扛不住。我的策略是分层采样。正常请求按 1% 采样保证有足够的统计样本。错误请求 100% 记录因为出问题的链路最有价值。高成本请求超过某个 token 阈值100% 记录因为要分析成本异常。慢请求超过延迟阈值100% 记录因为要分析性能瓶颈。def should_sample(trace_id, is_error, cost, latency): if is_error or cost 0.5 or latency 30: return True return hash(trace_id) % 100 0这个策略的好处是日常统计用 1% 样本足够出问题时又有完整的现场。实测下来存储成本能降低 90% 以上但排查能力几乎不受影响。6.2 敏感信息脱敏Agent 的输入输出里可能包含用户隐私、业务敏感数据。埋点时必须脱敏。我的做法是在 span 属性写入前过一层脱敏函数。手机号、邮箱、身份证号用正则替换。工具参数里的敏感字段比如密码、密钥直接替换成[REDACTED]。用户消息内容如果涉及隐私只记录长度和哈希不记录原文。注意脱敏要在埋点层做不要指望后端存储层做。数据一旦写进去泄露风险就存在了。我见过有人把原始 prompt 全量记录结果 trace 系统被拖库用户对话全泄露这是重大事故。6.3 与现有监控体系的融合OpenTelemetry 的好处是能和现有监控打通。trace 的 trace_id 可以注入到日志里这样日志和 trace 能互相跳转。metrics 可以导出到 Prometheus复用现有的告警规则。评估结果可以作为自定义指标接入 Grafana 看板。我一般会建三个看板。实时看板看当前 QPS、错误率、P99 延迟、每分钟成本。质量看板看幻觉率、工具调用成功率、评估分数分布。成本看板看按模型、按工具、按用户的成本趋势。三个看板配合 trace 查询基本能覆盖所有排查场景。7. 我在实际项目里踩过的坑第一个坑是span 嵌套过深。一开始我把每次模型调用的每个 chunk 都打一个 span结果一次请求产生了几千个 spantrace 视图完全没法看。后来改成只在模型调用级别打 span流式响应的 chunk 不单独记录只在结束时记录汇总。span 数量降了两个数量级。第二个坑是属性值类型不一致。OTel 的属性值有类型要求字符串、整数、浮点数、布尔值不能混。我一开始把 token 数写成字符串结果后端聚合时全乱了。后来统一用整数成本用浮点数布尔值用真正的 bool问题解决。第三个坑是异步任务的上下文丢失。Agent 里有很多异步操作比如并发调用多个工具。Python 的 asyncio 里context 不会自动传播到新任务。需要用contextvars手动传递或者用 OTel 提供的trace.use_span上下文管理器。这个坑很隐蔽表现是异步任务的 span 变成了独立的 trace链路断裂。第四个坑是评估器的性能开销。我一开始用大模型做实时评估结果评估本身的成本比主任务还高。后来改成规则评估实时做、模型评估离线做成本降下来了质量监控也没丢。第五个坑是价格表过期。模型厂商调价后我没及时更新导致成本统计偏差了 40%差点做出错误的优化决策。后来加了价格表的版本管理和变更告警每次调价都能第一时间感知。8. 后续可以扩展的方向这套体系搭起来之后能做的事情还有很多。比如基于 trace 数据做自动化的提示词优化——分析哪些提示词导致了高幻觉率自动生成改进建议。再比如做异常检测——用历史 trace 训练一个模型实时识别偏离正常模式的执行链路。还有成本预测——根据当前对话的进展预测最终成本超过预算就提前干预。我个人最看好的方向是评估驱动的自动修复。当检测到幻觉或死循环时不只是告警而是自动触发修复动作比如回滚到上一个正常状态、切换备用模型、注入纠正提示。这需要可观测性体系和编排器深度集成但技术上完全可行。这套东西的价值不在于技术多复杂而在于它把 Agent 从能跑变成了可控。没有可观测性的 Agent你只能祈祷它别出问题有了可观测性你能在问题发生前就发现苗头在问题发生后快速定位根因。这个差别在 demo 阶段看不出来在生产环境就是生死线。