Agent可观测性实战:分布式追踪、链路诊断与Token成本核算
最近在复盘一个Agent项目的线上问题发现一件特别现实的事模型在“思考”代码在“执行”但我们的排障方式还停留在“看日志猜原因”的阶段。用户反馈Agent答非所问你追了半天最后发现是某个工具接口悄悄返回了空结果财务拿着账单来问为什么Token费用翻了三倍你只能给出“可能是任务变多了”这种模糊答案。做Agent工程化时间越长我越觉得可观测性不是锦上添花而是刚需中的刚需。Agent和传统后端服务的排障逻辑完全不同。传统服务出错会抛异常、会返回5xx、会有明确的调用栈Agent出错往往是隐式的不报错只是结果不对、流程卡住、或者Token烧得特别快。要解决这些问题需要同时具备三把尺子分布式追踪知道每一步发生了什么、链路诊断定位到底哪一步出了问题、Token成本核算每一笔账单都能追溯到具体决策步骤。这篇文章就围绕这三把尺子展开。我把它定位成一份面向工程化实践的经验总结适合三类人正在搭建Agent平台或Agent框架的工程师、负责Agent服务稳定性与性能调优的SRE以及需要对大模型调用做成本核算的团队负责人。内容尽量贴近实际踩坑经历不讲空泛理论基本都是我实测下来有效的做法。1. Agent的观测视角和传统后端差别在哪里1.1 链路层一次任务背后是一棵调用树传统Web请求不管多复杂归根到底是一条相对固定的调用链网关进微服务A处理查数据库返回。Agent请求完全不是这个形态。一个最简单的Agent任务——比如“帮我查一下本周的销售数据并总结异常”——本身的执行过程可能是这样的先把用户意图交给LLM做意图识别LLM决定需要调用数据分析工具Agent根据工具的schema生成参数并发起调用工具返回数据后Agent把结果作为上下文重新交给LLM让模型生成分析结论如果模型发现数据不足它可能还会再触发一次工具调用。这个过程里LLM调用到底发生了多少次是不确定的取决于模型的自主决策。我把这种结构称为“目标驱动的动态调用树”——每一次任务都是一棵树树的分支数量不是预先定义好的而是模型根据中间结果动态决定的。传统观测体系假设链路是固定的用“入口路径耗时分布”就能覆盖大部分问题Agent链路则必须支持“每一轮循环”的多级嵌套否则你根本看不出模型在哪个分支上做了错误决策。实际落地时我建议把一次Agent任务当作一个“根trace”把每个Agent循环阶段计划、工具调用、结果解析、再计划当作子span不要按“函数调用”去切span否则日志会碎成一地。这个点在2.2会展开细讲。1.2 运行时层除了慢还有“不正常但没报错”传统可观测性三件套——指标、日志、追踪——在Agent场景依然有效但需要做语义扩展。指标层面除了常规的QPS、P99耗时还要加一组Agent特有指标平均每任务LLM调用次数、工具调用失败率、上下文token水位、重试次数、记忆读写延迟。日志层面除了业务日志必须记录每次LLM调用的request/response摘要包括模型名、温度参数、token使用量、停止原因是正常结束还是达到max_tokens。这些字段是链路诊断和成本核算共同的基础。最容易出问题的是“不正常但没报错”的状态。比如工具调用失败后Agent可能因为提示词设计不当选择静默跳过这个结果继续往下走最后给出一个看似完整但缺了关键信息的答案。又比如某次RAG检索返回了大量低相关度片段模型硬是把所有片段读了一遍导致上下文水位暴涨单次调用的token消耗直接翻倍。这种问题传统错误率指标是发现不了的必须靠“链路轨迹回放token水位监控”双管齐下。1.3 成本层Token才是最诚实的账单模型厂商的计费口径基本统一按输入token加输出token计费缓存命中会有折扣。这意味着一次任务到底花多少钱不取决于你调用了多少次API而取决于每次调用塞了多少token进去。可现实中大部分团队的成本归因还停留在“统计API调用次数”这个粗糙粒度。举个例子。我在一个项目里见过这样的账单同一个会话周期内“用户提问10次”对应的成本是A“内部工具自动检索代码库10次”对应的成本是B。A和B的差距可以达到两个数量级因为代码库检索会把几十个文件的完整内容拼进上下文。如果报表里只显示“本周期共调用API 20次”财务根本看不出钱花在哪里。要做到精细化核算就必须把Token消耗按“Agent步骤”归因每一步调用都要记录输入token中多少来自系统提示词、多少来自会话历史、多少来自工具返回、多少来自检索增强内容输出token又分为正常回复和模型自我修正产生的中间文本。这些内容我在第3章展开。2. 分布式追踪与链路诊断的落地实现2.1 用OpenTelemetry搭基座别重复造轮子我见过一些团队为了“轻量”自己设计了一套trace结构——一张表存span、一张表存log最后越写越复杂连父子关系都理不清。我的建议很简单直接用OpenTelemetry简称OTel作为基座Agent框架层做语义约定扩展即可。OTel已经帮你解决了三件最难的事跨进程上下文传播W3C Trace Context协议、标准化的Span生命周期管理、以及与Jaeger、Zipkin、Grafana Tempo等后端存储的对接问题。具体操作上我会在Agent入口处获取或创建trace上下文然后确保所有内部调用都继承同一个trace_id。对于LLM调用这类OTel没有标准插桩的环节需要手动创建span并设置属性。核心属性我通常会记录这几组{ agent.trace_id: task-8f3a2c9e, agent.task_type: data_analysis, agent.session_id: session-001, llm.model: deepseek-chat, llm.prompt_tokens: 18432, llm.completion_tokens: 512, llm.max_tokens: 2048, llm.stop_reason: stop, llm.temperature: 0.1, llm.cache_hit_tokens: 0, tool.name: codebase_search, tool.input_size_chars: 48391, tool.status: ok }把这些属性落到span上链路诊断和成本核算就都有了数据基础。注意手动创建的span要设置好父span的引用避免出现“看起来是散的”的问题。跨进程传递时如果Agent框架内部用了消息队列也要把trace_id写进消息头否则下一跳会变成孤儿span。2.2 Span设计要贴合Agent的循环结构很多人在做Agent追踪时沿用RPC调用的模式一个函数一个span结果就是图上一堆散点根本看不出决策过程。我后来总结出的经验是以“循环轮次”为粒度而不是以“函数”为粒度。一条链路长这样root(task) — 整个Agent任务session_init — 加载会话历史、构建系统提示词plan(round1) — 模型生成计划action(round1, toolcodebase_search) — 工具执行tool_rpc — 真正的远程调用observation(round1) — 工具结果回流、上下文更新plan(round2) — 模型基于新观察再次决策action(round2, toolread_file)final_reply — 生成最终回复这样设计最大的价值是你可以按“round”对链路做异常检测——比如某个任务到了第7轮还在反复调用同一个工具那大概率是死循环或工具返回的结果根本不满足模型预期。这种判断是“按函数切span”做不到的。强调一点plan和action这两个span的耗时可能包含了一次或多次LLM调用。如果一次plan里因为上下文过长被API限流重试了三次你也要把这些重试事件作为span事件记录在plan span下这样诊断时才能区分“模型思考慢”和“重试导致慢”。同样工具返回内容很大时需要在observation span里记录内容大小和截断情况否则你很难判断是不是工具结果撑爆了上下文。2.3 链路诊断的四个经典场景第一个场景是慢链路归因。用户说“Agent好慢”你先看root span的耗时分布再按子span排序区分是LLM响应慢、工具RPC慢、还是检索慢。通常LLM响应慢还可以进一步拆解是服务端排队了还是输入token太多导致首字延迟变大。如果是输入token太多重点查上下文是否膨胀必要时开启检索压缩或做历史摘要。第二个场景是死循环识别。如果链路里出现“同一个tool连续调用多次且参数几乎不变”基本可以判断模型陷入了工具调用循环。根源八成是工具返回的错误信息不够结构化模型看不懂为什么失败于是不断重试相同参数。我处理过一个案例工具返回“permission denied”模型反复重试直到token额度耗尽。后来把工具错误改成带错误码和解决建议的结构化消息循环立刻消失。第三个场景是上下文过载。上下文窗口是Agent最贵的资源之一。我习惯在span里记录每次写入上下文的token增量设一个水位数比如接近模型上限的80%一旦超过就触发摘要压缩或消息裁剪。这个水位线观测往往比成本监控更早暴露问题——上下文快满的时候单次调用的token消耗会异常攀升。第四个场景是重试风暴。工具调用失败后Agent自动重试是常见设计。但重试要注意指数退避和最大次数限制否则一个下游服务抖动就能让整个Agent集群的token消耗瞬间冲高。在链路上对“重试事件”打标是事后复盘的重要依据。我习惯在告警规则里加一条同一trace下重试次数大于5直接通知值班群。3. Token成本精细化核算把每一笔Token追到Agent步骤3.1 按次数核算是最大的错觉先算一笔账按当前主流模型价格大致估算具体以各家平台为准假设模型A的输入token单价是0.014元/千Token输出token单价是0.028元/千Token模型B输入是0.001元/千Token。同样是100次调用模型A一次调用输入5000 token输出500 token总成本大约是0.084元模型B一次调用输入同样量级成本只有0.0055元。同一业务场景下100次调用的成本差距可能超过15倍。更极端的差异来自输入token的“膨胀倍数”同一个用户提问如果每次都把完整的历史记录、工具定义、检索片段塞进去输入token可能是用户问题本身长度的30倍以上。所以要给财务看的报表绝对不是“调用次数”而是“每千Token成本×各类Token消耗量”。我建议至少按以下三个口径做统计按调用次数看热度、按输入输出token数看用量、按真实计费金额看成本。3.2 流式计费与usage归一对账LLM返回结果现在基本都是流式的但Token计费不以流里逐次返回的文本块为准而是以API最终响应里的usage字段为准。不同平台的usage字段名还不一样有的用prompt_tokens/completion_tokens有的带cache_read_token、cache_creation_token还有的只给total_tokens。这导致一个麻烦如果观测系统不统一归一成本报表根本无法对账。我的做法是写一层“usage归一化适配器”把各家返回的字段统一转成标准JSON再落库{ standard_version: 1.0, model: deepseek-chat, input: { prompt_tokens: 18432, cache_read_tokens: 0, cache_creation_tokens: 0, input_cost_cny: 0.2580 }, output: { completion_tokens: 512, output_cost_cny: 0.0143 }, total_cost_cny: 0.2723, currency: CNY }注意如果模型支持上下文缓存账单里会出现“缓存写入”和“缓存命中”两笔不同费率的用量。缓存命中部分通常便宜很多但写入缓存本身也会消耗一定成本。观测时一定要区分这两类token否则你会在成本报表里看到“输入token涨了但费用没涨”的怪象——那多数是缓存命中的功劳做预算时可以利用这个特性优化成本。对流式响应的观测还有一个细节不要在流式过程中累加文本字数估算token误差有时高达30%以上。特别是中文内容按字符数估token基本不靠谱。一切以API返回的usage为准如果个别平台不返回usage就用官方tokenizer离线预计算。我遇到过供应商的流式接口在响应结束后不返回usage只能自己写tokenizer补齐这种case一定要在归因日志里打标否则对账时永远差一截。3.3 归因标签体系与多维下钻有了标准usage还不够没有归因标签的usage只是一堆数字。我的做法是给每一条LLM调用记录补上三层标签任务层trace_id、task_type、session_id、组件层agent_step、prompt_template_id、tool_name、retrieval_source、资源层model、deployment、region。这样归因时就能回答三类问题这个任务花了多少钱这个提示词模板花了多少钱这个工具引发的token消耗占比多少一条归因日志的落库schema大致是这样trace_id, session_id, round_no, span_id model_name, prompt_template_id, tool_name input_token_breakdown: { system: 1024, history: 8320, tool_result: 3480, retrieval: 3616, user_input: 256 } completion_tokens, stop_reason, error_code cost: 0.2723, currency: CNY created_atinput_token_breakdown这一层非常关键。我在实际项目里靠它发现过一个“吞金兽”某个数据查询工具的schema定义长达2000多token被拼进每次调用一个月下来光这个schema定义就消耗了上千元的输入token成本。解决方案是精简工具描述、按需加载详细schema成本立刻降下来。这种优化如果不做token归因根本不知道从哪里下手。多维下钻的场景也很实用。运营问“本周成本为什么涨了”你先按session维度看是不是某个大会话在持续循环再按prompt模板维度看是不是某个新上线的系统提示词模板太啰嗦再按工具维度看是不是某个工具的返回内容不受控。三步下来基本就能锁定成本异常源。3.4 安全阈值、预算控制与并发场景的成本联动很多团队做预算控制只设一个“日费用告警线”等告警出来已经晚了。更好的做法是在链路层做三重预算拦截请求级预算、会话级预算、租户级预算。请求级预算是指单次Agent任务的token上限超过就直接拒绝继续执行或降级会话级预算是对单个用户或单个会话的累计消耗限制防止异常循环拖垮成本租户级预算是面向部门或多租户环境的整体额度。代码示例pythondef check_budget(task_ctx): if task_ctx.estimated_cost request_budget: raise BudgetExceeded(ftask cost {task_ctx.estimated_cost} over {request_budget}) session_used cost_store.session_total(task_ctx.session_id) if session_used task_ctx.estimated_cost session_budget: degrade_to_cheaper_model(task_ctx.session_id)这里的“estimated_cost”要尽量用真实用量来计算。如果任务还没执行完可以用历史同类型任务的平均成本做滚动预测如果已经拿到usage就直接把usage写入成本存储。还有一点和并发直接相关Agent扛并发不能只看QPS因为每个请求的token消耗方差很大。假设100并发里混入几个超大上下文请求瞬时成本可能是平时的20倍。所以线上扩容时要同时做“并发数限制”和“token消耗速率限制”后者更像成本层面的“防洪堤”。4. 认证与令牌异常的链路诊断实录4.1 login/token failed 到底卡在哪一环做Agent平台经常碰到一类问题客户端调第三方服务登录或换token时报sign-in could not be completed token exchange failed或者token endpoint returned 403 forbidden。每次看到这类报错第一反应别急着怀疑“平台是不是封了我”先按链路拆解。token exchange失败的可能原因有很多种按我的排查顺序大概是第一refresh_token或client_assertion过期这是最常见的第二服务端时钟偏差过大导致签名验证失败尤其出现在容器环境时间漂移时第三网络出口IP或接入方式变化触发了目标服务的策略校验返回403第四token请求参数格式错误比如content-type没设对、scope写了不存在的值第五访问的endpoint本身不允许当前来源调用。我先把“token exchange failed”后面的URL、状态码、响应体抓出来放回链路里看是哪一跳。绝大多数时候这类故障不需要看模型日志链路侧的状态码和时间戳已经把问题范围缩小了。我之前遇到一个报错响应体里提示“error sending request”链路日志显示目标endpoint的TLS握手阶段就失败了结果发现是SDK版本太老用的加密套件不被对方接受。升级SDK后问题消失这种case靠猜是猜不出来的。4.2 长时任务里的token续签与刷新竞争Agent任务经常要执行几分钟甚至更久而access token的寿命通常只有几十分钟到几小时。问题在于任务开始时token是有效的任务中间的某次工具调用突然带了个过期token整个流程直接中断。传统方案是“调用失败后重新登录再重试”但Agent场景里重试间隔可能很长用户体验很差。我建议在Agent框架里加一个“token健康度预检”模块在发起敏感调用前先检查access token的剩余有效期小于阈值就先走刷新流程而不是等失败后补救。刷新流程要注意两个坑。第一个坑是并发刷新。如果Agent内部同时有多个后台任务在跑它们在同一时刻发现token即将过期一起发起refresh请求结果旧refresh_token已经被第一个请求轮换掉其他请求全部报错甚至会把会话状态搞坏。解决办法是给刷新动作做全局互斥同一session只允许一个刷新请求在飞其余线程等待结果复用。代码示例pythonimport threading refresh_lock threading.Lock() refreshing {} def refresh_token_once(session_id): if session_id in refreshing: return refreshing[session_id] with refresh_lock: if session_id not in refreshing: refreshing[session_id] do_refresh(session_id) return refreshing[session_id]第二个坑是refresh_token本身被判失效。常见原因包括刷新返回的响应里refresh_token字段为空客户端却继续使用旧值或者refresh_token是单次使用制rotation客户端没实现轮换还拿同样的字符串去刷新第二次。我会把每一次refresh请求的request和response完整记录到链路日志里这样“refresh_token is empty”这类错误一眼就能定位。还有一种隐蔽情况是响应体里的refresh_token是新字段名SDK还在读旧字段拿到空值后反馈成“invalid refresh_token: empty string”这类兼容性问题在日志里会留下很典型的特征。4.3 从观测数据反推认证故障的四步流程第一步在认证链路的入口和出口都挂上span记录URL、状态码、响应体摘要和耗时。第二步给access token和refresh token建立生命周期监测表记录签发时间、过期时间、最后使用时间方便判断是否提前失效。第三步对刷新事件做计数器统计如果某个session的刷新频率异常高多半是token有效期配置过短或时钟不稳。第四步把认证失败事件与Agent任务链路关联看是“某个业务动作触发认证失败”还是“认证失败导致业务动作中断”。很多认证问题的根源不在认证服务本身而在调用方的使用方式。有一次我们排查一个“Agent任务中途token失效”的问题最后发现是多个worker进程各自维护了一份token缓存彼此不知道对方已刷新导致旧的token被反复使用。改成统一缓存后问题彻底消失。这种问题只有把“token生命周期”纳入可观测体系才能发现。5. 常见问题与排查技巧速查表5.1 高频问题速查表下面是我在实际Agent项目里遇到过的问题整理带排查思路和解决方向遇到相似问题可以直接照做。现象可能原因诊断方法解决方向sign-in could not be completed token exchange failedrefresh_token过期、断言过期或参数格式错误看认证端点状态码与响应体、看token生命周期表修正刷新参数、重新签发token403 forbidden来源网络或访问策略校验不通过在认证链路span里对比不同来源的请求结果检查接入点配置与来源白名单refresh_token报empty string客户端将空字符串作为refresh_token提交抓取请求体检查token存储取值逻辑修复序列化或取值守卫多实例并发刷新把token作废刷新竞争或rotation未实现统计刷新事件次数与并发时间戳加互斥锁统一token缓存Agent任务中途触发认证失败access token在任务执行期间过期看任务耗时与token过期时间戳增加预检刷新按需缩短任务周期agent execution terminated due to error工具返回异常、模型输出解析失败或循环超限链路里查终止前的最后一个span和停止原因增加结构化错误返回设置循环上限上下文相关内容超长工具返回或检索内容被全量塞入上下文看输入token构成并按来源拆分设置内容长度截断、摘要化、按需读取表格里每一行都是我处理过的真实案例的抽象版本。你可能会发现真正让你头疼的问题往往不是“模型笨”而是工程链路里那些不起眼的细节——token过期、空字符串、缓存不一致、循环没上限。5.2 排查时的三个心态防线第一看到token类报错先分清楚是哪种tokenaccess token、refresh token还是模型API密钥。很多人把这三类混为一谈排查半天对不上号。第二报错信息里的URL比错误正文更值得先看它直接指明是哪个endpoint出了问题。第三链路数据里“没有数据”也是一种数据。如果某一步完全没span那基本可以断定在这一步之前已经失败重点排查前序环节。还有一个容易被忽略的点Agent的日志要保留原始响应体特别是认证和工具调用场景。很多报错只在响应体的细节字段里体现了真实原因摘要日志会把关键信息丢掉。我一般会设置“原文保留策略”认证类响应体保留完整工具调用响应体按长度截断但保留错误码字段。个人体会是Agent可观测性这套东西越早搭越省心。我见过不少团队等成本账单爆了才开始补观测结果历史数据全丢了复盘无从谈起。如果你现在正在做一个Agent项目哪怕还没上生产我也建议先把三类数据字段定义好链路span属性、usage归一化字段、认证token生命周期表。这三个基础打好了后面无论是诊断慢链路、识别死循环、还是给财务讲清楚每一分钱花在哪都会顺利很多。最后再分享一个小技巧每次给Agent升级提示词模板或者调整工具返回结构时顺手把链路上的“每任务平均token消耗”和“工具调用重试次数”两个指标记录下来隔几天对比一下。这两个指标是最灵敏的“退化探测器”很多模型行为劣化都是先从这两个数字上露出苗头的。