最近在复盘一个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(round=1) — 模型生成计划
- action(round=1, tool=codebase_search) — 工具执行
- tool_rpc — 真正的远程调用
- observation(round=1) — 工具结果回流、上下文更新
- plan(round=2) — 模型基于新观察再次决策
- action(round=2, tool=read_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上限,超过就直接拒绝继续执行或降级;会话级预算是对单个用户或单个会话的累计消耗限制,防止异常循环拖垮成本;租户级预算是面向部门或多租户环境的整体额度。
代码示例(python):
def check_budget(task_ctx): if task_ctx.estimated_cost > request_budget: raise BudgetExceeded(f"task 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只允许一个刷新请求在飞,其余线程等待结果复用。
代码示例(python):
import 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 failed | refresh_token过期、断言过期或参数格式错误 | 看认证端点状态码与响应体、看token生命周期表 | 修正刷新参数、重新签发token |
| 403 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类报错,先分清楚是哪种token:access token、refresh token,还是模型API密钥。很多人把这三类混为一谈,排查半天对不上号。第二,报错信息里的URL比错误正文更值得先看,它直接指明是哪个endpoint出了问题。第三,链路数据里“没有数据”也是一种数据。如果某一步完全没span,那基本可以断定在这一步之前已经失败,重点排查前序环节。
还有一个容易被忽略的点:Agent的日志要保留原始响应体,特别是认证和工具调用场景。很多报错只在响应体的细节字段里体现了真实原因,摘要日志会把关键信息丢掉。我一般会设置“原文保留策略”:认证类响应体保留完整,工具调用响应体按长度截断但保留错误码字段。
个人体会是,Agent可观测性这套东西,越早搭越省心。我见过不少团队等成本账单爆了才开始补观测,结果历史数据全丢了,复盘无从谈起。如果你现在正在做一个Agent项目,哪怕还没上生产,我也建议先把三类数据字段定义好:链路span属性、usage归一化字段、认证token生命周期表。这三个基础打好了,后面无论是诊断慢链路、识别死循环、还是给财务讲清楚每一分钱花在哪,都会顺利很多。
最后再分享一个小技巧:每次给Agent升级提示词模板或者调整工具返回结构时,顺手把链路上的“每任务平均token消耗”和“工具调用重试次数”两个指标记录下来,隔几天对比一下。这两个指标是最灵敏的“退化探测器”,很多模型行为劣化,都是先从这两个数字上露出苗头的。