LLM智能体可观测性实战:基于OpenTelemetry的AgentTrace追踪与脱敏方案
1. 为什么LLM智能体需要一台行车记录仪1.1 从一次线上事故说起去年年底我帮一个团队排查他们客服智能体的胡言乱语问题。现象很典型用户问退款政策智能体先查了订单库又调了知识库检索最后给出的答案里混进了一条三个月前的旧政策还附带了一个根本不存在的退款单号。团队五个人围着日志看了两天最后发现是工具调用返回的JSON里有个字段被上游接口改了类型模型拿到null之后自己脑补了一个值。问题本身不复杂复杂的是定位过程。他们的日志散在三个地方应用层的print日志、模型API的请求响应日志、工具函数的执行日志。三份日志时间戳精度不一样请求ID对不上中间还隔着异步任务。等他们把链路拼出来故障已经复现了十几次。这件事让我彻底意识到一个事实LLM智能体的可观测性和传统后端服务的可观测性根本不是一回事。传统服务你打个日志、埋个trace调用链是确定的、代码路径是可枚举的。但智能体不一样——它的执行路径是模型在运行时决定的同一个输入两次运行可能走完全不同的工具组合中间还夹着自然语言的推理过程。你没法靠读代码预判它会干什么。这就是AgentTrace这类工具存在的意义。说白了它就是给智能体装的一台行车记录仪记录它每一步想了什么、调了什么、拿到了什么、最后输出了什么出事的时候能倒带回放平时还能用来分析它的行为模式。1.2 AgentTrace到底解决什么问题先把概念理清楚。AgentTrace不是一个具体的开源库而是一类面向LLM智能体的追踪与可观测性方案的统称核心思路是把智能体的一次完整执行拆解成结构化的Span跨度树每个Span对应一个语义单元——一次模型调用、一次工具执行、一次检索、一次规划决策。它要解决的核心痛点有这么几个黑盒问题模型为什么这么答中间用了哪些信息传统日志只能看到输入输出看不到思考过程。链路断裂多轮对话、多工具、多智能体协作时一次用户请求可能触发几十次内部调用没有统一Trace ID根本串不起来。成本归因Token花了多少、花在哪个环节、哪个工具最烧钱没有细粒度数据就没法优化。评测困难想对比两个Prompt版本的效果没有标准化的执行记录只能靠人肉看case。合规审计金融、医疗这类场景要求可追溯智能体说了什么、依据什么说的必须留痕。热词里提到的OpenTelemetry是关键。AgentTrace这类方案基本都建立在OTel的语义约定之上把智能体的Span映射成标准的OTel Trace这样就能直接复用现有的Jaeger、Tempo、Langfuse等后端不用自己造一套观测体系。这个选择非常务实——可观测性这件事生态比功能重要。1.3 谁该认真看这篇内容如果你属于下面任何一类这篇值得花时间正在做智能体开发被线上出问题查不到原因折磨过的工程师用LangChain、LangGraph、Dify这类框架搭过智能体但只会看框架自带日志的开发者需要给智能体做效果评测、成本分析的技术负责人对LLM应用的可观测性感兴趣想搞清楚Trace、Span、OTel这套东西怎么落到智能体场景的人。不需要你精通分布式追踪但至少得写过能调用工具的智能体知道什么是Function Calling。下面我会从设计思路讲到实操落地尽量把每个为什么都讲透。2. 智能体追踪的核心设计思路拆解2.1 为什么不能直接套用传统APM很多人第一反应是我有APM啊SkyWalking、Pinpoint不都能看调用链吗为什么还要专门搞智能体追踪我拿实际数据说话。一个典型的智能体处理一次用户请求内部可能发生这些事1次规划调用模型决定下一步、3次工具调用每次工具内部可能还有HTTP请求、2次检索、1次最终生成。传统APM能看到的是这些HTTP请求的耗时和状态码。但你看不到的是模型在规划那一步到底输出了什么、它为什么选了工具A而不是工具B、工具返回的内容有没有被截断、最终答案是基于哪几条检索结果生成的。更麻烦的是语义层面的东西。传统APM的Span是技术单元——一个函数、一次RPC。智能体的Span是语义单元——一次推理、一次决策。这两者的粒度和含义完全不同。你硬套APM最后得到的就是一堆没有语义的HTTP调用排查问题时还是抓瞎。所以AgentTrace的设计第一原则就是Span的划分必须贴合智能体的语义结构而不是技术调用结构。2.2 Span树应该怎么设计这是整个方案里最需要想清楚的部分。我踩过的坑是一开始把Span切得太细每次模型调用、每次字符串处理都建一个Span结果一次请求产生几百个Span看都看不过来。后来又把Span切得太粗整个智能体执行就一个Span等于没追踪。经过几轮调整我总结出一个比较合理的Span层级结构你可以直接参考层级Span类型对应语义关键属性L0agent.run一次完整的智能体执行用户输入、最终输出、总耗时、总TokenL1agent.step一轮思考-行动循环步骤序号、当前状态L2llm.call一次模型调用模型名、Prompt、Completion、Token数L2tool.call一次工具执行工具名、入参、出参、耗时、是否报错L2retrieval一次检索查询语句、命中文档ID、相似度分数L3http.request工具内部的网络请求URL、状态码、耗时这个结构的核心逻辑是L0到L2是语义层L3是技术层。排查业务问题时看L0-L2就够了需要深挖性能瓶颈时再下钻到L3。父子关系用Trace ID和Parent Span ID串起来和OTel的标准完全兼容。提示L2的llm.call一定要记录完整的Prompt和Completion但要注意脱敏。热词里使用llm时如何防止密钥等鉴权信息泄露是个高频问题追踪系统本身就是泄露重灾区——Prompt里经常夹着API Key、用户隐私数据。后面第4节我会专门讲脱敏方案。2.3 为什么选OpenTelemetry作为底座自己造一套追踪协议不是不行但代价太大。选OTel的理由很实在生态现成Jaeger、Tempo、Grafana、Langfuse、Phoenix全都支持OTel协议数据采集完直接能可视化不用自己写UI。语义约定成熟OTel有GenAI语义约定Semantic Conventions for GenAI虽然还在演进但基本的gen_ai.system、gen_ai.request.model、gen_ai.usage.input_tokens这些字段已经标准化了社区工具都认。采样和导出机制完善批量导出、采样策略、背压处理这些工程细节OTel的SDK都帮你搞定了自己写容易出问题。跨语言Python、Java、Go、JS都有成熟SDK多语言智能体系统也能统一。我实测下来用OTel做底座从零到能看Trace一个熟练工程师半天就能跑通。自己造轮子光是把数据可视化做出来就得一周。2.4 采样策略全量还是抽样这是实操中必须做决策的点。全量记录所有Trace存储成本会爆炸——一次智能体请求动辄几十KB的Trace数据日活一万的系统一天就是几百GB。我的建议是分层采样错误全采任何Span报错的Trace100%保留。这是排查问题的核心数据。慢请求全采超过P95耗时的Trace100%保留。正常请求采样按1%-10%的比例采样用于统计分析和效果评测。关键用户全采VIP用户或特定测试账号的请求全量保留。OTel支持基于Trace ID的采样决策可以在Span创建时就决定采不采避免采了一半的尴尬。具体配置后面实操部分会给代码。3. 核心细节解析与实操要点3.1 一次智能体执行的完整追踪流程我把一次典型的智能体执行拆成下面这些追踪点你可以对照自己的代码看哪些地方还没埋点入口埋点用户请求进来创建根Spanagent.run生成Trace ID注入到上下文。规划阶段模型决定下一步做什么创建llm.call Span记录Prompt和输出。工具选择如果模型决定调工具创建tool.call Span记录工具名和参数。工具执行工具内部如果有网络请求创建http.request Span作为子Span。结果回填工具返回结果更新tool.call Span的属性和状态。循环重复2-5直到模型决定输出最终答案。收尾记录最终输出、总Token、总耗时结束根Span。关键点在于上下文的传递。Python里用contextvars异步场景下要确保Span的父子关系正确。我见过太多人因为异步任务里丢了上下文导致Span全变成平级的Trace树直接废掉。3.2 Prompt和Completion的记录与脱敏这是最敏感的部分。追踪系统要记录Prompt才能排查问题但Prompt里可能包含API Key、Token等鉴权信息用户手机号、身份证、地址等PII内部业务数据、价格策略我的做法是双层处理第一层在埋点时就做正则脱敏。常见的Key格式sk-开头、Bearer后面、长随机字符串直接替换成[REDACTED]。手机号、身份证用正则匹配替换。第二层存储时加密。敏感字段用AES加密后再落库只有有权限的人能解密查看。import re SENSITIVE_PATTERNS [ (rsk-[a-zA-Z0-9]{20,}, [REDACTED_API_KEY]), (rBearer\s[a-zA-Z0-9\-._~/]*, Bearer [REDACTED]), (r1[3-9]\d{9}, [REDACTED_PHONE]), (r\d{17}[\dXx], [REDACTED_ID]), ] def sanitize(text: str) - str: for pattern, replacement in SENSITIVE_PATTERNS: text re.sub(pattern, replacement, text) return text注意脱敏一定要在Span创建时就做不要等到导出时再做。因为Span对象在内存里可能被其他代码读到导出前脱敏等于没脱敏。3.3 Token计数的准确性Token计数直接影响成本归因但很多人这里会踩坑。模型API返回的usage字段有时候不准尤其是流式响应。我的经验是优先用API返回的usage这是最权威的。流式响应要自己累加很多SDK在流式模式下不返回usage得用tiktoken之类的库自己算。区分输入输出输入Token和输出Token价格不一样混在一起算成本会错。记录模型版本不同版本的计费规则可能不同。在Span属性里我一般记这几个字段gen_ai.usage.input_tokens、gen_ai.usage.output_tokens、gen_ai.usage.total_tokens、gen_ai.response.model。3.4 工具调用的错误捕获工具调用是智能体最容易出问题的环节。追踪系统必须能区分几种错误工具本身报错函数抛异常Span状态设为ERROR记录异常信息。工具返回错误码函数正常返回但内容是错误比如{error: not found}这种情况Span状态是OK但要在属性里标记tool.result.statuserror。工具超时单独标记方便统计超时率。模型解析工具结果失败这是最隐蔽的工具返回正常但模型理解错了。这种情况只能在llm.call的Span里通过对比输入输出发现。我一般会在tool.call Span上加一个tool.result.valid属性标记返回结果是否符合预期schema。这个属性对排查模型胡言乱语特别有用。4. 实操过程与核心环节实现4.1 环境准备与依赖安装先明确技术栈。我用Python举例因为智能体生态Python最成熟。需要装这些pip install opentelemetry-api \ opentelemetry-sdk \ opentelemetry-exporter-otlp \ opentelemetry-instrumentation \ tiktoken后端我选Jaeger做演示因为它部署简单、UI直观。生产环境可以用Tempo或Langfuse看团队习惯。docker run -d --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/all-in-one:latest16686是UI端口4317是OTLP gRPC端口4318是OTLP HTTP端口。跑起来后访问http://localhost:16686就能看到Jaeger界面。4.2 初始化TracerProvider这是所有追踪的基础配置错了后面全白搭。我给出一个生产可用的配置from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.resources import Resource from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio resource Resource.create({ service.name: my-agent-service, service.version: 1.0.0, deployment.environment: production, }) # 采样策略基于Trace ID的比例采样父Span决定子Span sampler ParentBasedTraceIdRatio(0.1) # 10%采样率 provider TracerProvider(resourceresource, samplersampler) exporter OTLPSpanExporter(endpointhttp://localhost:4317, insecureTrue) provider.add_span_processor(BatchSpanProcessor( exporter, max_queue_size2048, schedule_delay_millis5000, max_export_batch_size512, )) trace.set_tracer_provider(provider) tracer trace.get_tracer(__name__)几个关键参数解释一下ParentBasedTraceIdRatio(0.1)根Span按10%概率采样子Span跟随父Span的决策。这样保证一个Trace要么全采要么全不采不会出现半截Trace。BatchSpanProcessor批量导出减少网络开销。schedule_delay_millis5000表示最多攒5秒就发一次。insecureTrue本地开发用生产环境要配TLS。4.3 给智能体主流程埋点下面是一个简化但完整的智能体执行函数带完整埋点import time from opentelemetry.trace import Status, StatusCode def run_agent(user_input: 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.input, sanitize(user_input)) total_input_tokens 0 total_output_tokens 0 step 0 messages [{role: user, content: user_input}] try: while step 10: # 防止死循环 step 1 with tracer.start_as_current_span(fagent.step) as step_span: step_span.set_attribute(agent.step.index, step) # 模型调用 llm_result call_llm(messages) total_input_tokens llm_result[input_tokens] total_output_tokens llm_result[output_tokens] # 判断是否要调工具 if llm_result.get(tool_calls): for tool_call in llm_result[tool_calls]: tool_result execute_tool(tool_call) messages.append({ role: tool, content: tool_result, tool_call_id: tool_call[id] }) else: # 没有工具调用输出最终答案 final_answer llm_result[content] root_span.set_attribute(agent.output, sanitize(final_answer)) break root_span.set_attribute(agent.total_input_tokens, total_input_tokens) root_span.set_attribute(agent.total_output_tokens, total_output_tokens) root_span.set_attribute(agent.step_count, step) root_span.set_status(Status(StatusCode.OK)) return final_answer except Exception as e: root_span.set_status(Status(StatusCode.ERROR, str(e))) root_span.record_exception(e) raise这段代码的核心是用with语句管理Span生命周期确保异常时Span也能正确关闭。start_as_current_span会自动处理上下文传递子Span自动挂到当前Span下。4.4 模型调用Span的详细实现模型调用是最需要详细记录的部分import tiktoken def call_llm(messages: list) - dict: with tracer.start_as_current_span(llm.call) as span: model_name gpt-4o-mini span.set_attribute(gen_ai.system, openai) span.set_attribute(gen_ai.request.model, model_name) span.set_attribute(gen_ai.request.temperature, 0.7) # 记录脱敏后的Prompt prompt_text \n.join([m[content] for m in messages]) span.set_attribute(gen_ai.prompt, sanitize(prompt_text)) start time.time() try: response openai_client.chat.completions.create( modelmodel_name, messagesmessages, temperature0.7, ) latency time.time() - start content response.choices[0].message.content or tool_calls response.choices[0].message.tool_calls span.set_attribute(gen_ai.completion, sanitize(content)) span.set_attribute(gen_ai.response.finish_reason, response.choices[0].finish_reason) span.set_attribute(llm.latency_ms, int(latency * 1000)) # Token统计 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.usage.total_tokens, usage.total_tokens) # 成本估算以gpt-4o-mini为例实际价格按官方调整 cost usage.prompt_tokens * 0.15 / 1_000_000 \ usage.completion_tokens * 0.6 / 1_000_000 span.set_attribute(llm.cost_usd, cost) span.set_status(Status(StatusCode.OK)) return { content: content, tool_calls: [ {id: tc.id, name: tc.function.name, arguments: tc.function.arguments} for tc in (tool_calls or []) ], input_tokens: usage.prompt_tokens, output_tokens: usage.completion_tokens, } except Exception as e: span.set_status(Status(StatusCode.ERROR, str(e))) span.record_exception(e) raise这里有几个细节值得说成本估算要单独记不要等到事后算。因为模型价格会变事后算容易用错价格。finish_reason要记length表示被截断了tool_calls表示要调工具stop表示正常结束。这个字段对排查问题很有用。latency单独记虽然Span本身有duration但显式记录方便做聚合分析。4.5 工具调用Span的实现工具调用的埋点重点是记录入参出参和错误import json def execute_tool(tool_call: dict) - str: tool_name tool_call[name] try: arguments json.loads(tool_call[arguments]) except json.JSONDecodeError: arguments {} with tracer.start_as_current_span(tool.call) as span: span.set_attribute(tool.name, tool_name) span.set_attribute(tool.arguments, sanitize(json.dumps(arguments))) start time.time() try: result TOOL_REGISTRY[tool_name](**arguments) latency time.time() - start result_str json.dumps(result, ensure_asciiFalse) \ if not isinstance(result, str) else result span.set_attribute(tool.result, sanitize(result_str[:2000])) span.set_attribute(tool.latency_ms, int(latency * 1000)) span.set_attribute(tool.result.valid, True) span.set_status(Status(StatusCode.OK)) return result_str except Exception as e: latency time.time() - start span.set_attribute(tool.latency_ms, int(latency * 1000)) span.set_attribute(tool.result.valid, False) span.set_status(Status(StatusCode.ERROR, str(e))) span.record_exception(e) return json.dumps({error: str(e)})注意tool.result我截断到2000字符。工具返回大JSON时全量记录会让Trace体积爆炸。截断后保留头部信息一般够排查问题了。如果确实需要完整结果可以单独存到对象存储Span里只放引用ID。4.6 多智能体协作的追踪热词里多智能体如何配置是高频问题。多智能体场景下追踪的复杂度会上升一个量级。核心思路是用Span Link而不是父子关系。比如一个主管智能体Supervisor分派任务给三个工人智能体Worker这三个Worker是并行执行的它们之间没有父子关系但都属于同一次用户请求。这时候Supervisor的Span是根Span。每个Worker的Span是根Span的子Span。Worker之间如果需要通信用Span Link关联。from opentelemetry.trace import Link def dispatch_to_worker(task: str, parent_context): # 创建Link关联到父上下文 link Link(parent_context.get_span_context()) with tracer.start_as_current_span( agent.worker, links[link] ) as span: span.set_attribute(worker.task, task) # ... 执行任务Span Link的好处是即使Worker在完全独立的进程或服务里执行也能通过Link关联回原始请求。这在分布式多智能体系统里是必须的。5. 常见问题与排查技巧实录5.1 Trace断链为什么我的Span全是平级的这是最高频的问题。现象是Jaeger里看到一堆没有父子关系的SpanTrace树完全散架。根因上下文丢失。常见于三种场景异步任务asyncio.create_task创建的任务不会自动继承上下文需要手动传递。线程池ThreadPoolExecutor提交的任务同样不继承上下文。跨服务调用HTTP请求没带traceparent头。解决方案import asyncio from opentelemetry import context as otel_context # 异步任务手动传递上下文 async def main(): ctx otel_context.get_current() task asyncio.create_task(worker(), contextctx) await task # 线程池用contextvars.copy_context import contextvars from concurrent.futures import ThreadPoolExecutor ctx contextvars.copy_context() with ThreadPoolExecutor() as executor: future executor.submit(ctx.run, worker_func, arg)跨服务调用的话用OTel的自动instrumentation它会自动注入和提取traceparent头。手动实现的话就是在HTTP header里加traceparent字段。5.2 数据量太大Trace存储成本失控我见过一个团队上线追踪系统一周存储费用涨了十倍。原因是全量记录了所有Prompt和Completion而且没做采样。优化方案按优先级优化手段效果实施难度开启采样降低50%-90%数据量低Prompt/Completion截断降低30%-60%低敏感字段脱敏后不存原文降低10%-20%中冷热分离老数据归档降低长期成本中只记录关键Span降低20%-40%高我的建议是采样截断组合拳一般能降80%以上。截断策略Prompt保留前2000字符后500字符Completion保留前1000字符。中间用...[truncated N chars]...标记。5.3 性能影响追踪拖慢了智能体追踪本身有开销主要是Span创建、属性设置、数据导出。我实测下来合理的配置下开销在5%以内但如果配置不当可能到30%。性能优化清单用BatchSpanProcessor而不是SimpleSpanProcessor后者每个Span都同步导出性能灾难。属性值不要太大单个属性超过1KB就要考虑截断。避免在热路径上做复杂计算比如Token计数用缓存。采样率不要设太高10%对大多数场景够用。导出用gRPC而不是HTTP前者性能更好。提示如果发现追踪开销异常先用SimpleSpanProcessor换成BatchSpanProcessor试试这是最常见的性能问题来源。5.4 排查速查表我把常见问题和排查思路整理成表方便你对照现象可能原因排查方法Trace树散架上下文丢失检查异步/线程池的上下文传递Span缺失采样决策不一致检查采样器配置确保父子一致数据延迟大BatchSpanProcessor队列满调大max_queue_size或减小schedule_delayToken数不准流式响应未累加检查流式模式的usage处理成本归因错误模型价格配置过时定期更新价格表敏感信息泄露脱敏未覆盖审查脱敏正则补充新格式Jaeger看不到数据端口或协议不匹配检查OTLP endpoint和协议5.5 几个我踩过的坑坑一Span属性类型不一致。OTel的属性值类型必须一致同一个key不能一会儿是string一会儿是int。我一开始把tool.latency_ms有时写成int有时写成float导致后端解析报错。统一用int毫秒级精度够了。坑二忘了关闭Span。用start_span而不是start_as_current_span时必须手动end()。我有个同事忘了endSpan一直不导出排查了半天。坑三采样率改了但没生效。采样器是在TracerProvider初始化时设置的运行中改不了。要改采样率得重启服务或者用动态采样器。坑四脱敏正则太激进。我一开始用[a-zA-Z0-9]{32,}匹配长字符串结果把正常的业务ID也脱敏了排查问题时看不到关键信息。脱敏规则要精准宁可漏脱也别误脱。坑五多进程环境下Trace ID冲突。用gunicorn多worker部署时每个worker的TracerProvider是独立的Trace ID可能重复。解决方案是用OTel的TraceIdRatioBased采样器配合全局唯一的service instance id。6. 追踪数据的进阶用法6.1 用Trace数据做效果评测追踪系统积累的Trace数据是评测智能体效果的天然数据集。我一般这么用构建回归测试集从生产Trace里挑出典型case标注期望输出形成回归测试集。每次改Prompt或换模型跑一遍看有没有退化。对比分析同一个case用不同Prompt版本跑对比Trace里的Token数、工具调用次数、最终输出质量。失败案例挖掘筛选出Span状态为ERROR或用户反馈差的Trace人工分析根因。热词里evaluation智能体添加方法论说的就是这个方向。追踪是评测的基础设施没有Trace数据评测就是空中楼阁。6.2 成本优化从Trace里找省钱机会我帮一个团队做过成本优化从Trace数据里发现三个问题Prompt冗余系统Prompt有3000 Token但其中一半是重复的示例可以精简。工具调用过多平均每次请求调5次工具但分析发现其中2次是冗余的模型在重复查同样的信息。模型选型不当简单任务用了大模型换成小模型效果差不多成本降了80%。这三个优化加起来月成本从2万降到6千。没有Trace数据这些问题根本发现不了。6.3 告警让追踪系统主动发现问题追踪数据不光能事后排查还能实时告警。我配置的告警规则错误率5分钟内tool.call错误率超过5%告警。P99延迟agent.run的P99超过30秒告警。Token异常单次请求Token超过10万告警可能是死循环。成本异常小时成本超过阈值告警。这些告警用Prometheus从Trace数据里聚合出来配合Alertmanager发通知。实测下来能在用户投诉之前发现问题。7. 我个人的一些实践体会追踪系统这东西上线容易用好难。我见过太多团队装了个Jaeger埋了点然后就没然后了——数据在那儿躺着没人看。真正让追踪产生价值的是把它嵌入到日常工作流里。我的做法是每次线上问题先看Trace再改代码。养成习惯后排查效率提升非常明显。每周review一次Trace数据看有没有异常模式比如某个工具突然变慢、某个Prompt突然变贵。把Trace链接放进工单系统处理问题时直接点开看不用来回找日志。还有一点不要追求大而全。一开始就埋几十个属性最后没人看得过来。先把核心的埋好——模型调用、工具调用、错误信息这三个覆盖了80%的排查场景。等有需求了再逐步加。最后分享一个我觉得特别有用的小技巧在Span里加一个agent.version属性标记当前智能体的版本号。这样对比不同版本的Trace时一眼就能看出差异。我们团队靠这个属性定位过一次Prompt改版导致的性能退化——新版本平均多调了2次工具Token成本涨了40%但输出质量没提升。没有版本标记这种问题很难发现。追踪系统的价值不在于技术多先进而在于它能不能让你在出问题时少熬几个夜。从这个角度看AgentTrace这类方案值得每个认真做智能体的团队投入。