1. 先别急着选模型Agent 开发真正难的是“看清它在干什么”去年我接手一个智能体项目具体业务是让 Agent 根据用户的历史操作记录自动生成下一步执行方案。功能 Demo 动起来的那天全组都很兴奋它能把长文本需求拆成步骤还能自己决定要不要调外部接口、要不要查数据库。但真正开始联调所有人都笑不出来了。用户问“为什么给我推荐这个方案”我们不能回答一个工具调用传入的参数错了不知道是哪一步把变量赋歪的明明用户意图没变两次执行结果却完全不一样。过去写传统后端服务出问题靠日志定位基本够用可 Agent 的决策路径天生是“概率生成 多步循环”日志打出来是一串没有因果关联的碎纸片你根本没法回答“它到底基于什么前提做了这个决定”。这也是我想聊 OpenTelemetry 在 Agent 开发中作用的起因。OpenTelemetry 不是模型不能帮你的 Agent 变得更聪明但它能把整个执行过程变成一条条带因果关系、可回放、可量化的追踪记录。你不再靠猜而是能回答“哪一步出了问题”“这个响应为什么长这样”。如果你正在做 Agent 应用、RAG 服务、或者任何带自主决策循环的 AI 功能这篇内容应该能帮你少走不少弯路。2. Agent 为什么比普通服务更难观测状态和路径都在爆炸2.1 Agent 的本质是一棵“因果树”而不是一张“日志表”传统后端服务的执行路径是确定的收到请求、查表、再查表、返回。你在日志里看到“userId1234, 查询订单成功”已经能还原完整行为。Agent 不同。典型的执行循环是接收用户输入 → 决定意图 → 检索记忆或知识库 → 调用工具 → 观察结果 → 再次推理 → 继续下一步。每一步的结果都依赖模型输出而模型输出是概率性的。同一个问题可能走 3 步就结束也可能中途绕了 5 个工具调用才收敛。如果用户允许 Agent 多轮自我修正分支还会更多。这种结构用日志表达非常吃力。日志是一条线性记录可 Agent 的执行路径是树状的一个根目标下面挂着多个推理节点每个推理节点又可能挂上工具调用、知识检索、甚至另一个子 Agent。要回答“为什么这样选”你需要的不是一条日志而是一整棵树的回放。OpenTelemetry 里的 Trace 和 Span 恰好就是为这个设计的。一个 Trace 对应一次完整的 Agent 执行一个 Span 对应执行中的某个阶段一次大模型调用、一次工具调用、一次检索、一次内部决策。父子关系天然形成一个棵决策树跟 Agent 的思维结构严丝合缝。2.2 日志也能看为什么我还要上可观测性如果你问我“现在日志 时间戳能不能凑合看”答案是小 Demo 可以生产环境不行。我总结了三层差异第一日志缺乏“因果关系”。日志里面的 output 是不会告诉你“这个 output 是哪个输入产生的”。Trace 里的 Span 天生挂在父 Span 下面因果链是结构化的。第二日志缺乏“成本画像”。Agent 项目的 token 成本是硬成本每次工具调用失败带来的重试成本、每轮长思考带来的模型开销日志里看不到。但 Trace 属性可以记录每一步的 token 数、耗时、调用次数导出之后能直接算出一次回答花了多少钱。第三日志缺乏“可对比性”。生产环境 Agent 出问题了你常需要对比成功样本和失败样本之间的差异。日志只能一条一条翻Trace 则可以把两次执行并排对比看分支在哪分岔、哪一步属性不同。说白了Agent 开发已经从开发模式进入运营模式。你不仅要让它“能跑”还要让它“可解释、可复盘、可控”。这一步OpenTelemetry 是目前最顺手的工具。3. 把一次 Agent 执行建模成 TraceSpan 到底该怎么挂3.1 一次对话的执行结构我自己习惯把一次完整的 Agent 执行定义成一个根 Span名字叫agent.run它覆盖从收到用户请求到最终响应的全过程。根 Span 下面按执行阶段挂子 Spanagent.reasoning一次大模型推理调用包含 prompt 摘要、模型名、推理轮次。agent.tool_call一次具体工具调用包含工具名、入参摘要、返回码、耗时、重试次数。agent.memory_lookup向量库检索或历史对话查询包含检索语句、命中数量。agent.task_delegate调用子 Agent 时的跨进程追踪后面会专门展开。为什么推荐这种粒度因为我发现把 Span 拆到“一整个 Agent 任务”这一层没有意义你得不到任何执行中间信息拆到“模型内部每一个 token”这种粒度也不现实OpenTelemetry 属性没法承载那么大信息量搞不好先把自己性能拖垮。上面这四个层级刚好卡在“能看清逻辑分叉”和“不会数据爆炸”的平衡点。3.2 Span 属性是给“人”看的不是给“日志”看的埋点的时候有个很关键的习惯属性要填“人能直接用它做判断”的信息而不是填一堆重复日志。我给每种 Span 定义了固定字段。agent.reasoning上我会放agent.model_name用的什么模型agent.reasoning_step这是第几轮推理agent.reasoning_input_token和agent.reasoning_output_tokentoken 成本agent.reasoning_request_id模型服务的请求 ID方便回查模型侧日志agent.tool_call上我会放agent.tool_name工具名agent.tool_arg_summary入参的摘要文本注意是摘要不是把整个大 JSON 塞进去agent.tool_statussuccess / error / timeoutagent.tool_error_message失败时的错误信息摘要agent.tool_retry_count重试次数agent.tool_duration_ms工具本身耗时agent.memory_lookup上我会放agent.memory_query检索语句agent.memory_hit_count命中数量agent.memory_top_score最高相关度分数agent.memory_source用的是哪个知识库名称字段不要贪多。有一个原则“你希望监控大盘上能筛选出什么就放什么字段”。凡是只想排障时打开看的尽量放进日志或者事件。3.3 用 Span Event 记录“思维片段”比存完整 prompt 更划算模型内部思考过程是什么样很多人第一反应是把完整输入和输出都塞进 Trace。这样做有两个问题一是 OpenTelemetry 的属性最好不要超过 4096 字节超长文本会被截断二是整个 prompt 带隐私和敏感信息存进集中式后端有合规风险。我现在的做法是用span.add_event()记录关键事件节点例如思考开始、检索返回、工具结果返回。事件名称我会写成agent.thought.snapshot属性里放模型本轮思考的摘要通常是取前 200 到 500 字。这样既不丢核心信息又不会因为长文本把后端存储打爆。有的人可能担心“只存摘要会不会丢关键信息”。我的经验是真正要排查决策对错时重要的是结构化的属性比如检索命中数、工具返回码、重试次数而不是模型的完整原话。模型原话可以通过agent.reasoning_request_id去模型服务侧单独取没必要在 Trace 里复制一份。4. 给 Agent 加埋点的完整接入过程从 SDK 到第一个 Span4.1 环境搭建一次配置三支柱全通我先说下环境。现在做 Agent大部分人在 Python 生态里所以以 Python 为例。第一件是装 SDK。pip install opentelemetry-sdk opentelemetry-api opentelemetry-exporter-otlp初始化这部分我是放在 Agent 服务的入口做的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 provider TracerProvider() provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter(endpointhttp://localhost:4317, insecureTrue) ) ) trace.set_tracer_provider(provider)这里我解释了为什么用 BatchSpanProcessor导出操作是异步批量的不会阻塞 Agent 的主推理链路。Agent 本身的单次响应可能就要几秒如果埋点本身再同步导出用户体感会明显变差。BatchSpanProcessor 默认会在后台攒一批再发对延迟几乎无感。接着定义全局 tracer 和 meter主要是 tracer 用得最多tracer trace.get_tracer(agent-observability)这一步做完你的 Agent 服务已经具备上报能力了但还看不到任何 Span因为还没有埋点。4.2 用装饰器包住所有工具调用一次埋点全局生效Agent 开发里工具调用是最容易出问题的地方也是我最优先埋点的对象。我给所有工具函数加了一个统一装饰器逻辑很简单但非常省事。import functools import time from opentelemetry import trace def traced_tool(tool_nameNone): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): tracer trace.get_tracer(agent-tools) start time.time() with tracer.start_as_current_span(fagent.tool_call.{tool_name or func.__name__}) as span: span.set_attribute(agent.tool_name, tool_name or func.__name__) span.set_attribute(agent.tool.arg_summary, str(kwargs)[:200]) try: result func(*args, **kwargs) span.set_attribute(agent.tool.status, success) span.set_attribute(agent.tool.duration_ms, (time.time() - start) * 1000) return result except Exception as e: span.record_exception(e) span.set_attribute(agent.tool.status, error) span.set_attribute(agent.tool.error_message, str(e)[:200]) span.set_attribute(agent.tool.duration_ms, (time.time() - start) * 1000) raise return wrapper return decorator用法就是在工具函数上直接加装饰器traced_tool(search_stock_price) def search_stock_price(stock_code): # 原有逻辑 pass这个方案的好处是“侵入性低”。团队里已经有十几个工具函数我不需要去改每个函数的内部逻辑只需要在定义处加一行装饰器。后续新加的工具如果想默认不走埋点也可以加参数控制开关比如traced_tool(xxx, enableFalse)。4.3 推理循环的根 Span把多轮子 Span 串起来光有工具 Span 还不够它们之间没有父子关系就成了一条条孤立记录。我需要在 Agent 主执行函数的入口处创建根 Span这样所有内部子 Span 会被自动挂到根下面。from opentelemetry import trace tracer trace.get_tracer(agent-orchestrator) def run_agent(user_message: str, session_id: str): with tracer.start_as_current_span(agent.run) as root_span: root_span.set_attribute(agent.session_id, session_id) root_span.set_attribute(agent.user_message_summary, user_message[:100]) # Agent 的主循环思考 - 执行工具 - 再思考 - 输出 for step in agent_loop(user_message): # 子 Span 在这里会自动挂在 agent.run 下 pass这个根 Span 是整棵树的地基也是后续做大盘分析的核心维度。你会发现有了根 Span 之后再去查“某次失败的 Agent 运行到底在哪个工具上卡住”只需要打开那次运行的总览页面从根 Span 往下点两三个层级就能找到不用再去日志里 grep。另外提醒一点agent.run的命名最好全局统一。后端时序数据库里所有 Agent 执行的根 Span 都叫同一个名字看板聚合才方便。你要是这边叫agent.run那边叫task.executer统计成本和质量时得做两遍映射很痛。5. 模型调用和向量检索的埋点把“推理”和“记忆”也纳入追踪5.1 包装模型 SDK捕获 token 与请求耗时Agent 里最贵的资源是大模型调用这一步的埋点直接关联成本。我不会去改模型服务端的代码而是在调用层包一层封装。def llm_call(messages, model_name): tracer trace.get_tracer(agent-llm) with tracer.start_as_current_span(fagent.reasoning) as span: span.set_attribute(agent.model_name, model_name) span.set_attribute(agent.reasoning.input_msg_count, len(messages)) # 调用真实模型 SDK这里是伪代码 response model_client.chat(messagesmessages, modelmodel_name) span.set_attribute(agent.reasoning.input_token, response.usage.prompt_tokens) span.set_attribute(agent.reasoning.output_token, response.usage.completion_tokens) span.set_attribute(agent.reasoning.latency_ms, response.latency_ms) span.set_attribute(agent.reasoning.request_id, response.request_id) return responsetoken 数据是硬通货后面算单个会话平均成本、看模型是否退化全靠它。如果模型 SDK 不直接返回 token 数也可以用字符串长度估算一个近似值但精度可能差不少建议优先读 SDK 自带的 usage 字段。5.2 向量检索跨度记录查询和命中质量Agent 调用向量库的频率通常不低每次检索的结果是否相关直接影响后续工具选择和回答质量。我建议给检索层也建 Span。def vector_search(query, top_k): tracer trace.get_tracer(agent-memory) with tracer.start_as_current_span(agent.memory_lookup) as span: span.set_attribute(agent.memory.query, query) span.set_attribute(agent.memory.top_k, top_k) results vector_db.search(query, top_ktop_k) span.set_attribute(agent.memory.hit_count, len(results)) if results: span.set_attribute(agent.memory.top_score, results[0].score) return results这里有一个容易被忽略的细节agent.memory.top_score是判断“检索质量”的快速指标。一个执行失败的 Agent如果根分支上检索的 top_score 全程低于 0.5说明它一开始就抓到了错误的知识背景后续回答跑偏就不奇怪了。这个属性比去逐字看回答内容要快得多。5.3 记录工具结果但别把大型返回体塞进属性工具返回体经常很大比如一个搜索接口返回了几百条结构化记录。直接存到 Span 属性会让后端存储快速膨胀查询慢、成本高。我的经验是属性里只存“返回体大小”和“前几个关键字段摘要”完整返回体只进日志。with tracer.start_as_current_span(agent.tool_call.search_api) as span: result search_api(query) span.set_attribute(agent.tool.result_count, len(result)) span.set_attribute(agent.tool.result_summary, str(result[:2])[:200])真正需要完整返回体做深排障时日志里能检索到就行。Trace 负责“快速定位上半场”日志负责“细节回放下半场”两者配合不要试图让 Trace 承担所有信息。6. 多 Agent 协作和人机循环场景跨进程追踪的落地6.1 主 Agent 调用子 Agent靠 Context 传播串起完整链路做 Agent 的项目很少只会有一个 Agent。常见的是主 Agent 负责任务规划遇到复杂子任务再动态拉起独立的子 Agent子 Agent 自身还有独立的工具循环。如果不做处理主 Agent 执行是一个 Trace子 Agent 执行是另一个 Trace你无法把“主 Agent 的哪一条指令”和“子 Agent 的哪一次执行结果”串起来。解决方案是 OpenTelemetry 的 context propagation。主 Agent 调用子 Agent 服务时把当前 Span 的上下文写进请求头子 Agent 收到后把它作为父上下文继续建 Span。from opentelemetry import trace, baggage from opentelemetry.propagate import inject def call_child_agent(payload): tracer trace.get_tracer(agent-orchestrator) with tracer.start_as_current_span(agent.task_delegate) as span: headers {} inject(headers) # 把当前 trace id / span id 塞进 headers # 发起 HTTP 调用把 headers 带过去 response http_client.post(http://child-agent-service/run, jsonpayload, headersheaders) span.set_attribute(agent.child.task_id, payload[task_id]) span.set_attribute(agent.child.result_status, response.status_code) return response关键在于使用公开的inject函数它会自动把当前的 traceparent 等上下文信息写入 header。子 Agent 服务端不需要额外传参在入口处拿到 header 里的上下文会自动续接为父 Span 的子 Span。我实际用 OpenTelemetry 的标准上下文传播安排主 Agent、子 Agent、知识库检索服务三者间的调用关系。服务启动后在两段 Trace 里直接能看到从用户问题到最终答案的完整路径排查协作异常非常直观。6.2 多轮对话一个 Session 对应一个维度而不是一个根 Span这里有个容易想歪的设计。有人以为“多轮对话就是一个大根 Span”于是把一次会话的十几轮对话全部挂在一个根 Span 下。我试验过这个方案发现效果很差根 Span 跨度过长打开一次一定会拉半天而且中间某个子任务失败时整条链路会显得格外臃肿。更合理的做法是每轮对话是一个独立的 Trace用统一的属性agent.session_id把它们关联起来。with tracer.start_as_current_span(agent.run) as span: span.set_attribute(agent.session_id, session_id) span.set_attribute(agent.turn_index, turn_index)这样既保留单轮执行的完整因果结构又能在后端按agent.session_id快速拉出整个会话的时间线。查“用户第 3 轮为什么推荐方案 A”直接筛 session_id 出来按 turn_index 排序即可。6.3 异步和后台执行上下文不能丢Agent 有时不是同步回答比如把长任务放进消息队列后台 worker 去执行。这种情况最大的坑是主调用方已经返回了子任务在另一个线程里跑上下文如果没带过去后半段 Span 就变成孤儿节点。解决办法是在提交异步任务时把当前上下文序列化保存worker 启动时再恢复。from opentelemetry.context import get_current from opentelemetry.propagate import inject, extract ctx get_current() # 任务入队时把上下文编码进消息 headers {} inject(headers, contextctx) task_payload {task_id: ..., trace_ctx: headers} # worker 里 from opentelemetry.propagate import extract from opentelemetry import context as otel_context ctx extract(task_payload[trace_ctx]) otel_context.attach(ctx) # 后面创建的 Span 会挂到原来的链路上这套机制我实测在 Celery 和自定义线程池里都能用。执行完以后最好调用detach把上下文恢复回去防止上下文泄露导致后续 Span 串链。7. 从“能看到”到“能决策”基于 Trace 数据反推问题与评估 Agent 质量7.1 利用 Trace 数据复盘一连串决策路径Trace 不只是排障工具更是 Agent 行为审计工具。我一个很典型的例子某一次线上 Agent 拒绝执行一个任务表面上的原因是“权限不足”。调出那次agent.tool_call的 Trace 后发现它在决策前检索到的知识库内容是过期版本的权限说明导致判断失误。这就是 Trace 提供“决策路径可追问”的价值。过去只能看到“它拒绝了”现在能看到“它基于什么记忆、在哪个步骤、做出了什么判断”。对 Agent 的产品运营来说这两者有本质区别。7.2 提炼几个关键 Metrics健康度不必复杂Span 数据持久化一段时间后我建议同步接入 OpenTelemetry Metrics把高频问题做成大盘指标。这里我提供一份我在项目里实际用过的口径表指标名口径用途agent.run.total完成的 Agent 执行次数判断调用量趋势agent.run.success_rate无工具报错、正常返回回调的占比整体健康度agent.run.latency_p95单次 Agent 执行的 p95 耗时体验与水位的敏感性agent.tool.error_rate优先观察关键工具的失败占比工具稳定性的监视器agent.reasoning.token_per_run每次执行消耗 token 数成本增长趋势agent.memory.top_score检索结果最高分的均值知识库质量是否下滑这些指标我建议用 OpenTelemetry 的 meter 来暴露from opentelemetry import metrics meter metrics.get_meter(agent-metrics) success_counter meter.create_counter(agent.run.success_total, unit次) latency_histogram meter.create_histogram(agent.run.latency, unitms)把 Metrics 和 Trace 一起采之后无论是告警还是容量评估都不用临时改代码去打点直接看报表就行。7.3 把 Trace 和离线评估绑定建立可回归的 Agent 质量基线另一个容易忽略的用法是离线评估。我会把生产环境的有问题 Trace 根据根 Span 的分类定期抽一部分存进评估集例如某个问题类型的回答效果、某次工具调用改正前后的差异。下次优化模型提示词或升级工具逻辑时拿这批 Trace 重构出的运行数据和新的执行结果做对比才能验证“改动真的产生了收益”。这个思路本质上把 Agent 开发从“调一个提示词的玄学”变成了“可量化的实验流程”。我在项目里执行了这个流程后后续每次调优都能拿出前后对比数据团队其他成员也终于从“感觉很厉害”进入到“这么改是有依据的”。8. 避坑清单我踩过的五个坑和相应的处理建议8.1 属性爆炸比缺数据更快拖垮分析体验刚开始做 Agent 可观测性时我因为想覆盖所有场景在 Span 里塞了几十种属性。埋点一时爽排障时面对大量非标准属性分析效率反而降低了。建议每一类 Span 只定义固定的一组属性并且写清楚命名规范给每个字段一句中文注释。原则是“能留着用来判断问题的字段才留其余优先进日志”。8.2 敏感数据别进 TraceAgent 处理的大多是用户原始问题直接把它们整段写进 Span 属性会有数据安全风险。我给自己的项目立了一条规矩Span 里只放摘要或者脱敏后的信息比如只存“包含个人联系方式”“包含地址”之类的布尔标志完整原文只放低级别日志例如 Debug 级并在日志里配置脱敏策略。8.3 采样策略要围绕 Agent 业务特点传统服务可以用默认的采样策略来平衡成本和性能但 Agent 的运行你往往不希望真的按比例丢掉。因为出问题的时候恰恰是最需要数据的时候。我个人的经验是对 Agent 场景优先采用“请求路径内保持采样一致性”的策略错误 Span 强制上报正常 Span 再考虑按百分比采样。如果后端压力大可以在导出端开尾部采样保证失败路径的完整保留。8.4 把 trace id 写进应用日志Trace 和日志是两套系统但排查时经常需要互相跳转。我没有把这两套数据硬合一而是统一约定Agent 服务写日志时把当前 context 里的 trace id 一道写进去。排障时从 Trace 页面打开某一步再去日志里按 trace id 过滤两边一配就能还原整条现场。我最开始单纯靠 Span 上的摘要排查后来又被迫苦哈哈地满日志找原始报文加了 trace id 之后省了大量时间。8.5 留足“后续迭代”的余量Agent 的框架、Model、工具列表更新都很快。如果你在第 4 章那种嵌入工具装饰器的方式里把所有逻辑写死下次升级会很痛苦。我给自己的项目预留了一个“埋点开关和属性配置入口”让配置只集中在独立模块中。这不是提前过度设计而是在实际迭代里不得不用的妥协。对我个人来说引入 OpenTelemetry 是我在 Agent 项目里做过最划算的一笔投资。它没有改变模型的推断能力却扭转了团队对 Agent 的沟通方式从“这东西有时候不听话”变成“咱们看一下那一次执行在第几步跑偏的”。如果你手头也在做类似的自主决策智能体我建议尽早接哪怕一开始只埋最简单的一层 Span自己把 Trace 跑通等踩到真正的疑难问题时手上已经有了一套可以用来追问完整链路的干净工作。