ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent可观测性实战:从分布式追踪到Token成本精细化治理

Agent可观测性实战:从分布式追踪到Token成本精细化治理 做Agent开发最难受的一个瞬间不是模型返回报错而是它“正常”地完成了一次对话但结果彻底不能用。用户问“为什么这次回答错了”你看了一眼日志只能看到一段Prompt和一段Completion中间的规划、检索、工具调用、上下文截断、重试全是一片黑盒。模块没崩进程没挂业务链路表面完整但你就是说不出问题出在哪。所以我才越来越觉得Agent可观测性不是锦上添花它是Agent应用上线的底线能力。这篇内容我想从实操角度聊聊三件事用分布式追踪把Agent的多步决策链串起来、通过链路诊断定位“看不见的断点”、以及把Token成本从“月末一张账单”变成“每条链路可分摊的明细账”。这些经验的适用对象是正在搭建Agent平台的技术负责人、独立开发者和做AI应用成本治理的同学不涉及复杂的框架原理更多是我在真实项目里反复踩坑之后沉淀下来的做法。1. 先说清楚Agent可观测性到底在解决什么问题1.1 传统监控为什么管不住Agent传统后端监控的模型是“请求-响应”客户端发一个HTTP请求服务端处理逻辑返回结果。我们能监控QPS、时延、错误率、依赖的Redis和MySQL状态这套体系对常规业务服务非常成熟。但Agent应用完全不同。一次用户请求进来背后可能是8到15次LLM调用、3到5次工具调用、一次RAG检索、两轮自我反思中间还穿插着并行执行和异常重试。更关键的是这些步骤不是“写死的代码分支”而是模型根据上下文动态决策出来的同一个问题换一种表达方式链路可能完全不同。拿商场来类比。传统监控管的是商场的总客流和营业额你能看到今天进来了多少人、卖了多少东西但不知道每个顾客是怎么逛的为什么去了A店没去B店为什么拿着东西走到收银台又放下了。而Agent可观测性要做的事是给每个顾客每次请求装一个追踪器把他走过的每个柜台、停留的时间、最终有没有买单全部记录下来。体感上最直接的差异是传统系统出问题根因通常落在某个具体的代码区间或依赖服务上Agent系统出问题根因往往是一次“错误但又不算崩溃”的决策——比如模型错误地跳过了一个必要步骤或者工具返回了异常数据但被异常捕获机制吞掉了模型在缺数据的情况下继续硬编。没有链路级追踪这类问题几乎无法定位。1.2 可观测性的三个核心目标我把Agent可观测性的目标收敛成三条后续所有技术方案都在为这三条服务。第一链路可见。对每一次Agent交互还原完整的决策链路用户输入进来之后Planner做了哪些拆解调了哪几个LLM模型每个模型的输入输出Token是多少先后调用了哪些工具工具的返回状态如何最终结果是怎么汇总出来的。链路可见是排查一切问题的基础没有这个前提后面的诊断和成本分析都是空谈。第二因果可查。当一个回答出现幻觉、超时、中断或质量异常时能把失败因果定位到具体环节。不是只看“哪个环节报错”而是同时能看到“为什么这个环节会走到这里”——它的前置输出是什么、模型当时看到了什么上下文、基于什么信息做出了这个决策。第三成本可算。Token费用是Agent应用最大的可变成本一个没有成本观测的系统本质上是在盲开一台不断消耗资金的机器。我们需要知道每一分钱花在哪个Agent、哪个环节、哪类用户、哪次调用上这样才能有针对性地做模型降级、缓存优化和链路瘦身。1.3 和传统后端可观测性的关键差异如果你之前做过微服务追踪直接用老思路套Agent大概率会水土不服。核心差异有四个。维度传统后端Agent应用调用主体确定性代码逻辑不确定性模型决策状态粒度函数/接口级别决策步骤Token级别成本因子资源消耗CPU/内存Token消耗随输入长度波动失败模式异常/超时/熔断异常默认降级幻觉部分成功重试代价较低幂等即可高一次重试意味着重新计费最麻烦的是“部分成功”的失败模式。传统系统的API要么成功要么失败Agent不一样——工具返回了一个畸形响应模型解析失败但它不会停下来它会“猜一个合理的结果”继续往下跑最后给你一个语气笃定的错误答案。这个过程没有异常抛出没有错误日志用户在端侧看到的是一次完美的回复只有通过链路里的工具调用状态和模型推理上下文才能发现真相。理解了这些差异你就能明白为什么Agent可观测性不能简单复用一个APM工具而需要在传统Trace模型之上叠加LLM语义层。2. 整体方案设计先搭好“可观测的骨架”2.1 核心方案事件流与链路结构的三层拆分选型前我花了很长时间想一个问题如果用一句话概括Agent运行时的本质它是什么我的答案是Agent是一次输入驱动下由模型决策编排的外部资源调用序列。这里有两个关键词“决策”和“资源调用”。决策是Agent内部发生的、不可直接观察的思维过程资源调用是Agent外部发生的、可记录的系统交互。可观测性要做的就是把这两类行为统一纳管。我的落地思路是把Trace结构拆成三个层次。第一层是业务交互层代表用户与Agent的一次完整会话或者一次用户请求触发的Agent执行。它是最顶层的Trace根节点通常用一个session_id或request_id标识记录用户输入摘要、Agent名称、最终响应状态。第二层是决策编排层代表Agent内部的规划和推理步骤。包括任务解析、子任务拆解、模型调用、反思、路由决策等。这一层是我们自定义的核心传统APM没有对应的语义节点需要自己设计Span类型。第三层是资源动作层代表Agent调用的外部工具、检索器、API和数据库。这一层可以用传统Span表达记录工具名、调用参数摘要、返回状态和错误码。三层之间通过嵌套Span组织成一个完整Trace既能回答“用户在干什么”也能回答“Agent在想什么”还能回答“系统做了什么”。2.2 为什么选OpenTelemetry作为底座不少人问我可观测性要不要自研一套上报协议。我的建议是除非你已经有一个非常成熟的内部平台否则直接用OpenTelemetry不要自研。原因有三。第一OTel已经定义了完善的Trace数据模型、采样策略和上下文传播协议尤其是W3C tracecontext和baggage解决的是跨服务、跨异步边界传递TraceID这个最基础也最棘手的问题。自己做这套传播机制反复调试的成本远超预期。第二生态成熟。OTel Collector可以对接市面上几乎所有后端ClickHouse、Elasticsearch、Jaeger、Tempo、Prometheus甚至直接导出到云厂商的托管服务。你只需要埋点一次后续换存储、接告警、做可视化都只是配置变更。第三社区在持续演进LLM可观测性相关的语义约定。虽然当前的GenAI语义约定还不算完整但按OTel的命名风格自定义Span属性未来可以平滑向标准靠拢。在选择接入方式上我是SDK手动埋点加少量自动埋点混合。LLM调用、工具调用这种关键路径用SDK手动创建Span确保语义可控对HTTP框架和数据库访问依赖自动埋点省功夫又不漏关键外部调用。一个小建议如果团队之前没有OTel的沉淀先不要追求全量接入选一条完整链路用户输入到最终输出从这条链路跑通所有环节再逐步推广。2.3 必备的数据模型与Span设计可观测性系统的地基是数据模型。你采集什么决定了你能分析什么。我最开始只采集了请求和响应结果发现什么都查不了。后面重新设计了一套Span类型才算真正打开局面。以下是我在项目中实际使用的一套Span类型定义你不需要照搬但可以参照它的分类逻辑。Span类型语义关键属性示例agent.session一次用户会话session_id、user_group、agent_name、app_versionagent.planner任务拆解/规划步骤plan_steps、model_name、temperaturellm.completion单次模型调用model_name、input_tokens、output_tokens、max_tokens、prompt_hashtool.invocation工具调用tool_name、tool_args_summary、status_code、latency_mstool.result_processing工具结果解析parse_status、error_code、truncated_flagrag.retrieval检索步骤retrieval_source、chunk_count、total_chars、re_rankedagent.reflection自我反思/校验reflection_type、detected_issue、correction_madeagent.memory_op记忆读写memory_type、key_preview、op_type每一类Span都统一携带一组公共属性trace_id、session_id、agent_name、environment、region、user_group。这样后续无论在链路视图还是成本聚合视图都能灵活下钻。这里特别提醒一个容易踩的坑不要在Span里记录原始Prompt和原始工具返回内容。一是成本高单条回答里的大段检索文本会让存储和传输膨胀数倍二是有数据安全风险用户的敏感信息会落库。我是用“摘要加哈希”策略——记录Prompt模板的哈希值、工具返回内容的前128个字符摘要、关键字段的去标识化结果既保留诊断线索又控制体积和风险。3. 链路诊断实操让Agent的每次“思考”都留下现场3.1 埋点落地的关键一步先定义“语义Span”很多人埋点的误区是一上来就写代码结果埋出来的链路长什么样完全不可控。我的经验是先把Agent代码里的“决策阶跃”复盘一遍把这些阶跃定义为语义Span然后再动手。什么叫“决策阶跃”就是Agent代码中一个独立逻辑单元的执行边界。比如一个ReActAgent它的决策阶跃包括接收用户消息、思考下一步行动、选择一个工具、调用工具、解析工具结果、决定是否继续循环、生成最终回答。每一个阶跃都是一个Span。用代码示例说明。假设你有一个简单的Agent主循环from opentelemetry import trace from opentelemetry.trace import SpanKind tracer trace.get_tracer(agent.observability) def run_agent(user_input: str, session_id: str): with tracer.start_as_current_span(agent.session, kindSpanKind.SERVER) as session_span: session_span.set_attribute(session_id, session_id) session_span.set_attribute(user_input_preview, user_input[:100]) plan planner.plan(user_input) with tracer.start_as_current_span(agent.planner) as plan_span: plan_span.set_attribute(plan_steps, len(plan.steps)) plan_span.set_attribute(model_name, plan.model_name) result None for step in plan.steps: with tracer.start_as_current_span(agent.step_execution) as step_span: step_span.set_attribute(step_name, step.name) result execute_step(step, session_id) step_span.set_attribute(step_status, result.status) with tracer.start_as_current_span(llm.completion, kindSpanKind.CLIENT) as llm_span: response llm_client.complete(user_input, result.context) llm_span.set_attribute(model_name, response.model) llm_span.set_attribute(input_tokens, response.input_tokens) llm_span.set_attribute(output_tokens, response.output_tokens) llm_span.set_attribute(prompt_hash, sha256(user_input[:50])) return response.text这里最关键的设计点不是代码本身而是这个认知语义Span的价值在于“事后你能模拟重放Agent当时的状态”。所以每个Span至少要包含三样东西——它看到了什么输入、做出了什么决策、产生了什么输出。一个Span如果只记录“执行成功”和“耗时”那它对Agent诊断几乎没有价值。3.2 分布式上下文如何跨异步边界传递Agent系统的链路之所以难追踪很大程度是因为它充满异步跳转。你可能在一个Planner线程里发起了多个并行工具调用也可能把子任务交给了消息队列消费还可能跨进程调用一个Reranker服务。每一次跨边界Trace上下文都可能断掉。OTel给出的标准解法是W3C traceparent头传递进程间的HTTP调用通过Header透传这个大多数框架都支持。真正容易断的是进程内的异步任务尤其是线程池和协程切换。以Python为例核心工具是contextvarsimport contextvars from opentelemetry import context, trace # 在异步函数中保持Trace上下文不断 async def execute_step_async(step): # OTel SDK已内置ContextVars的自动传播 with trace.get_tracer(agent.observability).start_as_current_span( tool.invocation, kindSpanKind.CLIENT ) as span: span.set_attribute(tool_name, step.tool_name) result await some_async_tool_call(step.args) span.set_attribute(tool_status, result.status) return result只要SDK初始化时启用了ContextVars的集成协程内新建的Span会自动成为上层Span的子Span。但如果你用了线程池或celery任务还记得要手动把OTel的Context对象作为任务参数带过去。我自己在这里栽过很深的跟头有一次用asyncio.gather并发调三个工具Trace串得一塌糊涂三个工具调用全挂在了同一个Span下面。原因是旧版SDK没有正确关联异步任务的顺序后面通过显式传入父子上下文解决。具体做法是在调度任务时用当前context封装上下文快照在异步任务启动时手动激活。import asyncio from opentelemetry import context, trace async def run_tool_with_context(step, ctx): token context.attach(ctx) # 在子协程中激活父上下文 try: return await call_tool(step) finally: context.detach(token) async def run_parallel_tools(steps): ctx context.get_current() # 捕获当前上下文 tasks [asyncio.create_task(run_tool_with_context(step, ctx)) for step in steps] return await asyncio.gather(*tasks)这个模式保证了并发的每个工具调用都拥有独立的父级关系链路视图上不会出现“一个父亲三个陌生儿子”的诡异结构。3.3 链路诊断速查表三类典型的“隐形断点”链路数据有了怎么用才是核心。我总结了Agent系统里最常见的三类“隐形断点”——所谓隐形就是它不抛异常、不报错、系统也不会自动告警但结果就是不对劲。断点类型典型现象诊断方法处置方向工具异常被吞回答语气笃定但内容与事实不符查tool.result_processing的parse_status看是否有异常捕获后继续执行异常时强制Agent返回“无法完成”而非“猜测完成”上下文关键信息被截断回答缺失重要细节但首尾逻辑通顺查llm.completion的context_window_utilization看输入是否接近模型上限检索结果压缩、分段摘要、关键内容前置重试风暴用户侧表现为超时Token消耗异常高查同一trace下的工具调用重试次数以及每次重试的输入Token设置重试次数上限对可缓存工具启用结果缓存举一个真实场景。有次用户投诉某个问答Agent在面对“帮我确认明天会议室是否可用”这类问题时回答“已为您确认可预订”但实际会议室已经满了。查Trace发现会议室系统的API返回了一个JSON解析异常工具结果处理的Span里parse_status为failed但代码的try-except把异常吞掉后返回了一个空对象模型把这个空对象“理解”为“查询成功”。这就是典型的异常吞掉导致的幻觉型失败。修复方案不是改模型而是在工具调用失败时显式给模型传递一段错误信息当前工具返回异常请告知用户查询失败并建议稍后重试而不是让模型自行脑补结果。第二个常见断点来自长文档处理。某Agent在总结一份30页文档时RAG检索返回了8万字符的内容模型输入窗口只有128K系统执行了截断策略把中段内容掐掉了。结果总结遗漏了关键的财务数据但Agent完全没感知。通过在llm.completion中加入context_window_ratio属性并设置截断告警阈值这类问题就能被及时发现。3.4 从Trace还原“决策因果”链路诊断的最高目标不是“看到链路”而是“能复现当时的决策条件”。我现在的做法是把三样东西放入每个关键Span的属性中决策前置状态摘要、当前可用的关键上下文版本号、决策参数模型、温度、top_p。这里有一个很重要的细节可观测性不只是给自己看也是给模型“留底”。当一次Agent执行被投诉时你能把当时的输入、工具返回、中间推理完整地打包出来用同样的配置重新跑一遍或做离线分析。很多看似“随机”的质量问题实际上是由上下文差异或工具返回差异决定的没有决策条件的完整记录根本无从对比。4. Token成本精细化核算从“账单总数”到“可分摊明细”4.1 为什么只看总量没有意义很多团队核算Token成本的方式很简单月底拉一下模型的账单看总共花了多少钱再除一下总请求数得到一个“单次请求平均成本”。然后呢然后就没有然后了。这个做法的核心问题在于Token消耗的方差极大。同样一个问题用户直接提问可能消耗2000TokenAgent先做规划、再检索、再反思、再总结可能消耗3万Token——差了15倍。总账单把这个差异抹平了你无从判断成本热点到底在哪也无从优化。成本精细化核算的目的是把模糊的总额拆解到可以决策的粒度哪个Agent吃钱、哪类任务吃钱、哪个环节吃钱、哪类用户吃钱、哪个模型吃钱。拆到这一步优化动作才能跟上。4.2 成本核算的四个维度在设计中我建议把Token成本设置为四个可聚合的维度。会话维度一次用户会话消耗多少Token。这是最粗的粒度用于判断产品层面的用户价值——一个用户每天产生多少次会话、消耗多少成本、带来多少业务收益。Agent维度在多Agent系统中每个Agent的Token消耗占比。不同Agent可能复用不同的模型和服务成本差异非常大。工具与检索维度哪些工具调用间接导致高Token消耗。你可能发现某个检索工具返回的内容过长直接把模型输入撑到极限成本暴增的来源往往在这里。阶段维度Agent生命周期中规划、执行、反思、总结各消耗多少Token。这个维度藏着最大的优化空间。这四个维度可以叠加使用比如“某Agent的反思阶段在长会话场景下消耗了62%的Token”——这种结论能直接转化成优化动作。4.3 实操在Span上打Token成本点Token成本核算的前提是在每一次LLM调用发生时就记录用量数据。强烈建议在每个llm.completion Span上记录以下属性with tracer.start_as_current_span(llm.completion) as llm_span: llm_span.set_attribute(model_name, response.model) llm_span.set_attribute(llm.request_model, request_model) llm_span.set_attribute(llm.usage.input_tokens, response.input_tokens) llm_span.set_attribute(llm.usage.output_tokens, response.output_tokens) llm_span.set_attribute(llm.usage.total_tokens, response.total_tokens) llm_span.set_attribute(llm.prompt_cache_hit_tokens, response.cache_hit_tokens) llm_span.set_attribute(llm.prompt_template_hash, prompt_template_hash)为什么连prompt_template_hash都要记因为“提示词工程”的本质是在改成本。某个prompt模板如果写得太啰嗦每个用户每次请求都要多付几百Token日积月累就是一笔大开销。有了模板哈希你就能按模板聚合出成本排名定位“哪段提示词最费钱”。之后在离线管道中把Span数据写入ClickHouse或者Elasticsearch然后做成本聚合。逻辑上不复杂——用模型名去匹配单价表再把输入输出Token折算成金额。比如一个接口设定的输入单价为每百万Token 15元输出单价为每百万Token 75元不同模型价格差异很大用实际配置就行。那么一次输入3000Token、输出1200Token、其中输入缓存命中1500Token的调用成本大约是输入费用3000 / 1000000 × 15 0.045元缓存命中部分按折扣价计费输出费用1200 / 1000000 × 75 0.09元合计约0.135元单看一次调用没有感觉但如果把它乘以每天10万次就是13.5万元一天的消耗。所以成本因子必须折叠进每一次调用里通过离线聚合得到总成本才能找出头号成本热点。4.4 一个成本热点案例反思阶段的隐性开销我在项目里做过一次成本剖析发现一件反直觉的事。原本以为最大成本应该在任务规划和工具调用结果数据分析出来后反思阶段占了总Token消耗的41%其中输出Token占比高达34%。原因是代码里设置了每一轮用户会话都做一次“答案质量反思”而且反思时会把整个对话历史重新包装进Prompt让模型再次输出全量修订结果。这个设计初衷是提升质量但在长会话场景下历史越长反射成本越呈线性增长。一次用户只是问个简单问题背后却花了一杯咖啡的钱。优化动作很明确把反思从“每轮必做”改成“仅当置信度低于阈值时触发”同时把反思时的输入换成“关键信息摘要”而非全量历史。优化后反思阶段的Token占比从41%降到19%整体成本下降了约30%而人工评估的回答质量没有明显波动。还有一个很常见的重试风暴。某个Agent工具调用失败后设置了最多5次重试每次重试都会重新调用一次模型并重新构造上下文。一次失败的工具调用带来5次模型调用和5次新增Token支出。我们在Tool调用成功后工具结果的缓存以及降低重试上限这类成本一下就控住了。链路里的错误码和Token成本Span拼接在一起才能看到这类因果关系。5. 常见问题与排查技巧实录5.1 高频问题速查表做了一轮Agent可观测性建设之后遇到的很多问题都是共性的整理成速查表给你参考。问题现象根因方向解决方案部分请求没有Trace异步边界上下文丢失检查线程池/消息队列任务是否传递了Context快照Trace串线多个会话连在一个根上错误的上下文手动attach启用contextvar自动传播删除手动token残留Span数量爆炸存储压力大单次Agent执行生成过多细粒度Span合并低价值步骤Span开启属性采样Token成本统计重复同一个LLM调用被二次计费用唯一request_id去重在管道层做幂等生产环境全量Trace性能下降采样策略不恰当尾采样加关键路径全保压力大时降采样率敏感信息出现在链路详情里Span属性记录了原始上下文日志脱敏插件默认只记录摘要和哈希5.2 高并发场景下的采样策略Agent系统的并发上来之后全量Trace会有两个问题一个是性能损耗一个是存储成本。性能方面OTel的SDK本身做了很多优化但如果你在每个工具调用的参数里塞大文本序列化开销会很可观。处理器会占用额外CPU查询链路会变慢。存储方面全量Trace的写入量会非常大。我们的策略是组合式采样。对常规请求按比例采样比如10%到20%但对错误链路、慢链路和Token异常高的链路全量保留。这样既控制了Trace的总量又保证了“有价值的问题”一条都不会丢。实现上可以直接用OTel的TailSampler配置维度包括span的status是否为error、某个时间窗口内的请求耗时是否超过阈值、llm.usage.total_tokens是否超限。这套策略上线后链路存储量减少了接近五成而关键问题的定位率没有下降。5.3 数据安全与隐私开关前面提过不要记录原始Prompt这里再展开说。Agent会把用户的自然语言输送给模型其中极可能包含姓名、电话、地址、公司内部信息等敏感内容。如果这些内容直接进入可观测性后端等于说每个有后端查询权限的人都能看到用户隐私。我的默认策略是三层脱敏。第一层是在Span属性输出前拦截对符合手机号、邮箱、身份证等正则的内容做掩码替换。第二层是在日志后端配置清理策略超过保留周期的Trace数据定期删除。第三层是权限分级生产环境的关键Span详情只有特定角色能查看普通开发者只能看到摘要级别信息。比如工具返回的原文中包含订单号、客户手机号等字段Span里只记录“订单号尾部6位”和“客户名称的首尾各一个字”。排查时这些摘要足够区分是哪个订单出了问题但不会暴露完整的隐私信息。如果你在阿里云或其他云厂商的托管服务上做服务商都有对应的数据加密和访问审计功能配合使用安全要求能达到合规级别。在链路设计上还要考虑一个容易被忽略的点当你需要使用“原文”去做问题复现时应该通过运行时日志系统按需拉取而不是把原文直接接入可观测性主链路。运行时日志有独立权限、独立存储、独立生命周期两者隔离既兼顾排查能力又避免数据面过宽。最后再分享一个实际测试中很有用的技巧。我把trace_id直接放进API响应体的自定义Header里客户端在用户反馈页面加了一个“一键提交诊断信息”的按钮用户上报问题时自动带上这个Header。后端收到投诉后直接通过traceId把当时的全链路状态捞出来——包括当时各环节的耗时、Token消耗、工具调用结果、模型决策参数。排查时间从过去的一个小时降到几分钟。整个可观测性体系的价值在这一刻体现得最充分它不是给技术团队看的一堆图表而是让每次用户投诉都能变成一条可回放的决策录像。如果你正在做Agent应用我建议把这个能力当作基础设施来建设它比多调几个模型参数带来的价值大得多。
返回列表