☰
AI Agent 可观测性实战:OpenTelemetry GenAI 链路追踪与成本归因
2026/10/12 9:24:09 网站建设 项目流程

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、local
gen_ai.operation.name操作类型chat、embeddings、execute_tool
gen_ai.request.model请求的模型名gpt-4o、claude-3-5-sonnet
gen_ai.response.model实际响应的模型可能与请求不同
gen_ai.usage.input_tokens输入 token 数整数
gen_ai.usage.output_tokens输出 token 数整数
gen_ai.request.temperature温度参数0.0 ~ 2.0
gen_ai.request.max_tokens最大输出 token整数
gen_ai.response.finish_reasons结束原因stop、length、tool_calls
gen_ai.tool.name工具名search、calculator
gen_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(resource=resource) exporter = OTLPSpanExporter(endpoint="http://otel-collector:4317", insecure=True) provider.add_span_processor(BatchSpanProcessor(exporter)) trace.set_tracer_provider(provider) tracer = trace.get_tracer(__name__)

BatchSpanProcessor是异步批量导出的,比SimpleSpanProcessor性能好很多,生产环境必选。insecure=True只在内网环境用,跨网络记得配 TLS。

4.2 模型调用埋点:把 token 和成本算清楚

模型调用是最核心的埋点位置。下面这段代码是我项目里实际用的简化版。

def call_model(messages, model="gpt-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( model=model, messages=messages, **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(f"gen_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 propagation
token 统计不准流式响应未统计流式结束时的 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,结果一次请求产生了几千个 span,trace 视图完全没法看。后来改成只在模型调用级别打 span,流式响应的 chunk 不单独记录,只在结束时记录汇总。span 数量降了两个数量级。

第二个坑是属性值类型不一致。OTel 的属性值有类型要求,字符串、整数、浮点数、布尔值不能混。我一开始把 token 数写成字符串,结果后端聚合时全乱了。后来统一用整数,成本用浮点数,布尔值用真正的 bool,问题解决。

第三个坑是异步任务的上下文丢失。Agent 里有很多异步操作,比如并发调用多个工具。Python 的 asyncio 里,context 不会自动传播到新任务。需要用contextvars手动传递,或者用 OTel 提供的trace.use_span上下文管理器。这个坑很隐蔽,表现是异步任务的 span 变成了独立的 trace,链路断裂。

第四个坑是评估器的性能开销。我一开始用大模型做实时评估,结果评估本身的成本比主任务还高。后来改成规则评估实时做、模型评估离线做,成本降下来了,质量监控也没丢。

第五个坑是价格表过期。模型厂商调价后我没及时更新,导致成本统计偏差了 40%,差点做出错误的优化决策。后来加了价格表的版本管理和变更告警,每次调价都能第一时间感知。

8. 后续可以扩展的方向

这套体系搭起来之后,能做的事情还有很多。比如基于 trace 数据做自动化的提示词优化——分析哪些提示词导致了高幻觉率,自动生成改进建议。再比如做异常检测——用历史 trace 训练一个模型,实时识别偏离正常模式的执行链路。还有成本预测——根据当前对话的进展,预测最终成本,超过预算就提前干预。

我个人最看好的方向是评估驱动的自动修复。当检测到幻觉或死循环时,不只是告警,而是自动触发修复动作,比如回滚到上一个正常状态、切换备用模型、注入纠正提示。这需要可观测性体系和编排器深度集成,但技术上完全可行。

这套东西的价值不在于技术多复杂,而在于它把 Agent 从"能跑"变成了"可控"。没有可观测性的 Agent,你只能祈祷它别出问题;有了可观测性,你能在问题发生前就发现苗头,在问题发生后快速定位根因。这个差别,在 demo 阶段看不出来,在生产环境就是生死线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询