十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

一树到底:Agent可观测性设计与全链路Trace实践

一树到底:Agent可观测性设计与全链路Trace实践 见过太多这样的排查现场了用户抛来一句这个任务跑了十分钟还没结果你打开日志平台按 request_id 一筛哗啦出来几十条日志有 LLM 调用的、有工具返回的、有子任务派发的时间戳也对得上但你就是说不清楚这中间到底发生了什么。更气人的是明明整体结果失败了日志里却找不到一个像样的 error——因为 Agent 自己把错误吞了换个思路又试了一次最后还是不行留给你的只有一句任务失败。ooderAgent 是我们团队自研的一套多智能体运行时接入了数据分析、文档处理、代码生成好几条业务线。规模上来之后这类问题几乎每周都要上演一轮。后来我们下定决心做了一件事把可观测体系从日志检索升级成全链路 Trace Tree让每一次 Agent 执行的耗时、Token 消耗、失败原因全部挂在一棵树上做到真正的一树到底。这篇文章就是把这套方案的完整实录从数据模型设计、耗时下钻、Token 成本治理到失败回溯再到落地时的采样与存储选型把我踩过的坑和最终沉淀下来的做法一次性讲清楚。1. Agent 排障为什么这么难日志有了关联没了1.1 从一次怎么也查不到根因的线上问题说起去年我们上线了一个文档智能处理 Agent用户上传一批 PDFAgent 自动完成解析、摘要、归档。上线第三天就有用户反馈某个文件处理失败而且不是必现同一份文件过一会儿重试又能成功。我们按常规思路查日志找到了对应请求的所有输出看到中间调了三次 LLM、两次文件解析工具、一次归档 API其中第二次文件解析工具返回了一个sign-in could not be completed token exchange failed的鉴权错误。看时间戳Agent 在这个错误之后又继续执行了最终也给了用户一个处理失败的答复。但问题是这个鉴权错误到底发生在哪个子任务里为什么没有导致整个链路立刻终止重试之后为什么又能成功日志本身回答不了这些。因为我们看到的是一条被压平的时间线而 Agent 的真实执行结构是一棵多分支的树——主 Agent 规划派生子任务子任务再调工具工具失败后主 Agent 捕获、重新规划、再执行。把树压平成日志父子关系、重试关系、依赖关系全部丢失了。1.2 传统监控三件套在 Agent 场景下的失效原因Metrics、Log、Trace 是后端可观测的经典三件套但在 Agent 场景下它们各自都有明显的短板。Metrics 适合回答量的问题QPS 多少、平均耗时多少、错误率多少。但 Agent 的每一次执行过程差异极大同一个用户请求可能走完全不同的分支平均值掩盖了太多信息。p99 很高到底是哪一类 Agent、哪一个环节拖慢的Metrics 给不出答案。Log 适合回答点的问题某个时刻发生了什么。但 Agent 场景下一次执行会产生大量日志分布在不同的服务、不同的进程里甚至多次 LLM 调用之间的上下文关联非常隐晦。没有结构化关联日志就是一盘散沙。传统 Trace 其实是最接近正确答案的但它是为单次请求经过多个微服务设计的而 Agent 的执行模型是一个请求经过多次循环决策、多次工具调用、多个分支子任务。如果只用简单的一条 Trace 链根本无法表达子 Agent 又派生了子任务某个错误节点之后 Agent 重新规划继续执行这类拓扑关系。所以我们的结论很明确Agent 可观测性的核心单元不应该是一条链路而应该是一棵有向树。树的根是用户请求节点是 Agent 生命周期里的每一个关键动作树枝的生长方向就是任务的拆分与派发方向。2. 一树到底的前置设计Trace、Span、Event 如何映射 Agent 生命周期2.1 先画清楚一棵树上有哪几类节点设计这套体系之前我首先干了一件事把 ooderAgent 执行一个任务的完整生命周期拆了一遍。拆完之后发现无论 Agent 的逻辑多复杂落到执行层面其实就四种动作整体会话、规划推理、调用模型、调用工具。再加上子任务和重试这两类特殊节点总共六种 Span 类型就够用了。Span 类型含义典型子节点关键属性session_span用户的一次完整请求plan_span, sub_agent_spanuser_id, biz_type, trace_idplan_spanAgent 的一次规划/决策循环llm_span, tool_span, sub_agent_spanagent_name, model_name, temperaturellm_span一次大模型调用无model_name, prompt_tokens, completion_tokens, total_tokens, ttft_ms, duration_mstool_span一次内部工具/外部 API 调用无tool_name, input_summary, status_code, error_codesub_agent_span派发给子 Agent 的任务plan_span, tool_spanagent_name, task_summaryretry_span对某个失败节点的重试llm_span, tool_spanretry_reason, attempt_number这套分类的好处是每一种节点都有明确的职责边界下游做耗时统计、Token 聚合、错误聚类时可以按 span_type 精确过滤不需要在业务代码里打各种语义模糊的 tag。2.2 节点上挂什么从 token 用量到错误码的 attribute 设计光有节点类型还不够每个节点上要挂足够的属性。设计 attribute 时我坚持一条原则所有可能在排障时想知道的信息都要在 Span 创建时一次性打全事后补丁式的加字段是最痛苦的。我们最终确定的 Span attribute 分为四组。第一组是资源标识trace_id、span_id、parent_span_id、service_name、agent_name。第二组是与 LLM 相关的计量数据model_name、prompt_tokens、completion_tokens、total_tokens、estimated_cost_usd、queue_ms、ttft_ms、generation_ms。第三组是工具调用信息tool_name、tool_input_summary截断到 200 字符、tool_status、error_code、error_message。第四组是业务上下文user_id、biz_type、file_id、task_type方便后续按业务维度聚合。这里特别想提一下 ttft_ms 和 generation_ms 的拆分。最开始我们只记录了 LLM 调用的总时长导致很多慢请求根本定位不到瓶颈到底是网络慢模型排队还是生成了太多 token拆开之后一目了然。后面会有专门章节详细讲这块。2.3 上下文传递让每一层调用自动找到自己的父节点一树到底最核心的机制是上下文传递。每个 Span 创建时都要拿到当前上下文里的 trace_id 和 parent_span_id然后把自己注册为父节点下的一个子节点。ooderAgent 是 Python 写的异步框架我们直接用了contextvars来传递上下文在 Agent 执行入口创建一个根 Span把 trace 上下文写入 contextvar之后无论业务代码里怎么 await、怎么开协程只要是同一个上下文里发起的子调用都能自动挂到正确的父节点下。这里有一个关键坑如果业务代码里用了asyncio.gather并发执行多个工具调用contextvar 虽然会自动传播到子任务里但如果你显式创建了Task必须确保在这之前已经拿到了上下文。我们最初有一版代码在创建 Task 之后才读取 contextvar结果那批子任务的 parent_span_id 全变成了空整棵树直接脱节。排查这个问题的过程也很痛苦——树上大量节点变成孤儿节点所有 Span 都挂在根节点下面树形结构名存实亡。后来我们加了一个启动时校验每次创建 Task 前打印当前 trace 上下文是否存在不到半天就定位了问题。3. 耗时是棵树不是一条线逐层下钻定位慢点3.1 把 LLM 调用的耗时拆成四段Agent 任务的耗时大头基本都在 LLM 调用上但如果只记录总耗时排查慢请求时依然无从下手。我们最后把一段 LLM 调用 Span 的耗时拆成了四个阶段分别打点。第一个是网络与排队耗时queue_ms从请求发出到模型真正开始处理的时间包含网络 RTT 和模型服务端的排队时间。第二个是首 token 耗时ttft_ms模型开始处理后到返回第一个 token 的时间这个值直接反映模型服务端的负载和输入上下文长度。第三个是生成耗时generation_ms第一个 token 到最后一个 token 的间隔。第四个是后处理耗时主要包含输出校验、解析 JSON、内容过滤等。这里要补充一个计算过程方便理解为什么这么拆。一次 LLM 调用的总耗时可以用一个近似公式表达total_ms ≈ queue_ms ttft_ms output_tokens × per_token_ms postprocess_ms其中 per_token_ms 是生成阶段每个 token 的平均耗时。有了这四个分段你看到一次 LLM 调用耗时 80 秒立刻能判断慢在哪个环节如果 ttft_ms 占了 60 秒那基本可以断定是输入上下文太长、模型要预填充大量历史 token如果 generation_ms 占了 60 秒那多半是 output_tokens 数量太大或者模型推理速度本身慢如果 queue_ms 占了 50 秒那是模型服务端在排队和你的代码逻辑没关系。3.2 一个慢任务的下钻实例从 120 秒定位到 25 秒 TTFT举一个我们实际遇到的例子。用户反馈数据分析任务跑得很慢从 tree 视图看根节点总耗时 120 秒直觉上这已经不正常了。我们从根开始逐层下钻session_span 下只有一个 plan_spanplan_span 下挂了 3 个 llm_span 和 2 个 tool_span。第一个 llm_span 耗时 80 秒第二个 10 秒第三个 5 秒两个 tool_span 分别耗时 1 秒和 2 秒。瓶颈一眼锁定在第一个 llm_span。展开这个 Span 的 attribute结果如下指标数值total_ms80000queue_ms300ttft_ms25000generation_ms54000output_tokens1800per_token_ms30ttft_ms 高达 25 秒说明模型在生成第一个 token 之前就花了很长时间做预填充。再结合这是我们自己的推理服务我们立刻去查了当次请求的输入 token 数—— prompt_tokens 显示 9 万。问题清楚了Agent 在规划阶段把历史对话、文档全文、前面几轮的工具输出全塞进了上下文导致模型不得不预填充海量 token。顺着这棵树我们定位到一个之前完全没察觉的逻辑缺陷代码里在每次规划时都会把文档全文重新加入上下文一次Agent 循环到第五轮的时候同样的文档内容已经重复带了四遍prompt_tokens 从最初的 1.2 万膨胀到 9 万。这就是为什么单看日志永远发现不了问题——散落的日志里每次 LLM 调用返回的 usage 数据都有记录但没人会逐条去加。修复方案也很直接将文档内容在首轮放进上下文后后续轮次用摘要替代同时给 Agent 的执行循环加了一个上下文预算机制prompt_tokens 超过阈值时强制执行一次 token 压缩。上线后同类任务的耗时直接从 120 秒降到了 35 秒左右。3.3 树的形状告诉你的另一件事并行度与调度瓶颈除了单节点耗时树的拓扑结构本身就有信息量。传统 Log 和单条 Trace 都是线性的你只能看到每一步花了多久但在树上你可以清楚地看到哪些子节点是串行执行的哪些是并行执行的各个分支的耗时分布如何。我们看树的规则是如果要优化整体耗时先看树宽而不看树深。根节点同时派发了 5 个子 Agent总耗时却等于 4 个子 Agent 耗时之和——这说明并行度设计有问题某个共享资源比如 LLM 限流、数据库连接池把并行请求全部排队串行化了。这种问题在线性链路中几乎无法发现因为你只能看到请求发出去到返回的总耗时根本看不到其实它们在等待同一个锁。还有一个实用技巧给 plan_span 增加一个 fan_out 属性记录本轮规划派发了几个子任务。当 fan_out 大于 3 时重点检查并行度是否生效。我们在某次优化中就是通过这个字段发现某个 Agent 在遍历文件列表时用了同步循环而不是asyncio.gather导致本应并行的子任务全部串行执行整体耗时被文件数量线性放大。4. Token 成本挂在树的哪个节点计量粒度决定治理深度4.1 单次 LLM 调用记账容易Agent 全流程记账很难Token 成本治理是 Agent 上线后最容易被忽视但又最紧迫的问题。单次 LLM 调用的 usage 数据在 API 返回值里就有这谁都知道但一个 Agent 任务跑下来可能调了十几次模型Token 成本必须聚合到树的哪个层级这个问题我们调整了三次才想清楚。先说结论Token 计量要分三层。第一层是单次调用级对应 llm_span 上的 prompt_tokens、completion_tokens、total_tokens这个用于回答哪一次调用最贵。第二层是分支级比如同一个子任务下的所有 LLM 调用之和对应 sub_agent_span 上的累计字段。第三层是全链路级从 trace_id 维度把所有 llm_span 的 token 加总得到完成这个用户请求总共花了多少 token。以一次典型的数据分析任务为例节点调用次数prompt_tokenscompletion_tokens估算成本(USD)主 Agent 规划32400032000.06子 Agent A数据清洗45200041000.11子 Agent B图表生成2890021000.02重试节点21300016000.03全链路合计1197900110000.22如果没有树形结构这 11 次调用的 Token 数据就是日志里 11 条孤立记录你不可能快速得出这个任务类型单均成本 0.22 美元其中数据清洗子任务占比 50%这种结论。4.2 重试、上下文膨胀最容易忽略的两大隐形消耗Token 成本治理里有两个隐形消耗是我见过绝大多数团队都会踩的坑。第一个是重试成本。Agent 的容错机制会导致失败后重新规划、重新调用模型。每一次重试不是免费重跑而是真金白银的 Token 消耗。我们在树上专门设计了 retry_span每次重试都会挂一个新的重试节点其下所有的 LLM 调用和工具调用都归属到这个重试节点。这样你一眼就能看出某次任务的重试开销如果 retry_span 子树上的 token 总成本超过了正常节点那这个 Agent 的失败率已经高到值得专项治理了。第二个是上下文膨胀。Agent 多轮循环中每轮都会把之前的历史消息全量发给模型。在树上看这种问题特别清晰同一个 plan_span 下挂着一串 llm_span每个 llm_span 的 prompt_tokens 都在单调递增。比如第一轮 3000第二轮 6200第三轮 9800到第十轮已经 28000。这说明 Agent 的上下文窗口在持续膨胀大量 Token 花在了重复发送历史内容上。我们在成本日报里加了上下文膨胀率指标单条 trace 的最终轮 prompt_tokens 除以首轮 prompt_tokens超过 3 就视为需要引入摘要压缩策略。4.3 从成本树到预算治理按节点类型聚合与告警有了树结构之后成本治理就不只是事后看账单了。我们做了三件事。第一件事是按 span_type 聚合每日 Token 成本和占比输出一份类似报表的数据让团队清楚每天的钱花在了哪里多少花在主 Agent 规划、多少花在子 Agent、多少花在重试、多少花在工具调用前的上下文拼接。这里的重点是占比——绝对数值会随着业务量波动但占比的异常变动往往意味着业务逻辑出现了问题。第二件事是设置节点级成本告警。每个 llm_span 创建后如果估算成本超过预设阈值比如单次调用超过 0.1 美元立即在 Span 上打一个 cost_anomaly 标签并告警。这能帮我们发现某次 prompt 意外膨胀导致单次调用成本翻了三倍之类的问题而不是等到月底账单出来才追悔莫及。第三件事是关于token 失效类的场景。Agent 在执行过程中经常需要调用外部 API而外部 API 的鉴权 token 往往有时效性。如果 Agent 执行时间较长就可能出现执行到一半时 token 过期、工具调用返回 401 或 token exchange failed 的情况。从树上看你会看到一个 tool_span 状态为 error紧接着挂着一个 retry_span重试时如果 token 已经刷新则成功、否则继续失败。这种问题的根因在树的路径上非常清晰但如果没有树只会在日志里看到一堆莫名其妙的鉴权错误。我们最终的解法是在工具调用层封装了发现鉴权失败后自动刷新本地 token 并重试一次的逻辑并把这类事件单独标记为 token_refresh_retry在成本报表中单列统计。这里还有一点值得单独说很多团队的 Token 计量只是把每个请求的 usage 打印到日志里这远不够。因为 Agent 场景下同一个用户请求的多轮 LLM 调用、多个子 Agent 之间的 Token 消耗不是独立的它们共享同一个上下文和历史状态。只有把 Token 数据挂到树节点上才能回答这个用户请求真正花了多少钱以及花在哪里这两个基本问题。5. 失败回溯把事故现场从一棵树里完整捞出来5.1 隐性失败结果是成功过程里全是错误节点Agent 场景下最折磨人的一类问题是隐性失败——用户最终得到了一个结果看起来是成功的但过程里其实埋着大量错误节点和重试。这类问题如果只看结果永远不会被发现但只要看一眼树问题就暴露无遗。我们在 elas 里定位过一次案例某个 Agent 处理文件的成功率看指标是 98%但从树干结构看超过 30% 的请求都至少有一个 tool_span 处于 error 状态。也就是说将近三分之一的请求在过程中遇到过至少一次错误只是被 Agent 的容错机制兜住了最终依然完成了任务。这些错误没有直接导致用户失败但它们拖慢了整体耗时、增加了 Token 消耗而且随时可能在某个边界条件下变成真正的失败。从这件事之后我们的 Dashboard 上多了一个指标error 节点覆盖率即包含至少一个 error 状态节点的 trace 数量占总 trace 数量的比例。这个指标监控的不是任务是否成功而是过程是否健康。5.2 从 error 节点反查根因的完整排查链路当真正的问题发生时树的回溯价值就体现出来了。这里讲一个完整的排查链路是真实的失败案例。某个 Agent 在处理用户上传的 Excel 文件时间歇性失败。用户侧看到的错误是任务处理失败请稍后重试。我们从树的根节点往下看session_span 状态是 error下面挂了 plan_span 和 sub_agent_span。sub_agent_span 状态 error展开后看到它的子节点里有两次 tool_span 调用第一次返回了 token endpoint returned status 403 forbidden 的错误紧接着产生了一个 retry_span重试后再次失败。再往 error_message 看错误细节是 sign-in could not be completed token exchange failed: token endpoint returned status 403 forbidden: country。到这里根因已经很明确了不是 Agent 的逻辑问题而是子 Agent 调用的某个外部服务在鉴权时被拒绝。过去的排查方式会卡在这一步日志里全是类似 tool call failed 的记录然后你要去代码里找到底是哪个工具调用、这个工具调用的鉴权逻辑是什么、为什么失败。现在树结构直接告诉你了这是 spreadsheet_read 工具失败原因是外部服务 OAuth token 交换被拒而它在整个 Agent 任务中的位置、它的父节点、它的重试过程全部挂在树路径上不需要猜测。修复方案是检查外部服务的授权配置发现该服务对所在区域有限制导致 token 交换请求被拒绝。调整授权策略后问题解决。整条链路的排查耗时从过去的大半天缩短到了十分钟以内。5.3 错误聚类让偶发问题变成可量化指标失败回溯不能只靠单个 trace 的人工排查更要靠批量聚类找出规律。我们做的错误聚类很简单但很有效以 error 节点的 error_code span_type parent_span_type 三个字段作为组合 key统计每天的分布情况。错误签名出现次数占比典型 trace_idtoken_exchange_failed / tool_span / plan_span21342%trace_…a1f3timeout / tool_span / sub_agent_span8717%trace_…b2e8invalid_token / tool_span / plan_span6413%trace_…c9a4json_parse_error / llm_span / plan_span418%trace_…d5f1这张表让偶发问题变成了可量化指标。当你看到某个错误签名连续几天都在增长就可以主动去查不用等用户来反馈。有一段时间invalid_token / tool_span / plan_span 的占比突然从 5% 涨到 13%查下去发现是某个外部 API 的鉴权 token 有效期缩短而 Agent 侧没有及时刷新导致的。这个发现让我们赶在用户批量抱怨之前就把问题修复了。错误聚类还有一个附带价值可以反推哪些错误是Agent 应该自己兜住的、哪些是必须触发告警让工程师介入的。我们的规则是error 节点的错误如果是可重试类鉴权过期、网络超时Agent 自动重试是合理的如果是不可重试类参数错误、数据格式错误Agent 重试多少次都没意义这时候树上的 retry_span 白白消耗 Token应该从源头阻止 Agent 做无意义的重试。6. 落地一树到底真正难啃的骨头采集、采样与展示6.1 埋点位置框架层统一打点而不是业务代码手工埋很多团队做可观测失败就是把埋点逻辑散落在业务代码里。今天你在 Agent A 的某个工具调用后面打一行日志明天他在 Agent B 的某次 LLM 调用里记个耗时最后数据五花八门连字段名都对不上。我们的做法是直接在 ooderAgent 运行时框架层统一埋点。Agent 的核心执行循环只有那么几个入口模型调用、工具执行、子任务派发、规划循环。在每个入口都嵌入 Span 创建逻辑业务开发者根本不需要关心埋点。他们调框架提供的方法框架自动完成 Span 的创建、属性填充、上下文传递和上报。这个设计的收益在后来的维护中非常明显。我们后来要给 LLM 调用增加 ttft 数据只改了框架层一个装饰器所有存量业务代码自动生效。如果当初把埋点写在业务代码里光是全局改一遍至少要大半天。6.2 采样策略全量存不起错误必须一条不落Agent 请求量大之后全量存储每棵树的成本相当可观。一棵比较复杂的树可能有几十个 Span、几百个 attribute一天几百万请求全量存到分布式 Trace 系统里存储成本直接失控。我们最终采用的策略是三段式。第一段是头部采样对正常请求按 10% 比例采样保证能看到整体形态。第二段是错误必采只要树上出现任何一个 error 状态节点这条 trace 必须 100% 保留因为错误是最有排查价值的。第三段是耗时尾部采样p99 以上的慢请求必须保留即使它们是成功状态因为慢本身就是一种类型的故障。这个策略有一个坑需要注意判断是否有 error 节点必须等整棵树执行结束才能确定而流式上报的时候树还没有闭合。我们的解法是让每个子节点在完成时把自己的状态上报给链路聚合服务由聚合服务判断该 trace 是否需要全量保留。如果确实需要再回捞所有 Span 数据。6.3 存储与展示的最终选型存储和展示我们对比过几条路最终选型可能对读者有参考价值。第一条路是完全自建。用 ClickHouse 存 Span 表用自研 API 层做 tree 组装。这个方案最灵活但工作量很大而且要自己处理 trace_id 到 tree 的递归查询查询层容易成为性能瓶颈。第二条路是接入开源链路系统比如 Jaeger 或 Tempo。它们天然支持 Trace ID 查询和树形展示Jaeger 的Trace Timeline视图就非常符合一树到底的诉求。我们最终选择了这个方案数据上报走 OpenTelemetry 协议存储用兼容 OTLP 的后端展示直接用自带 UI。第三条路是商业化 APM 产品。功能全、省心但 Token 成本自定义监控这类 Agent 特有指标往往需要额外开发而且数据出域问题在一些公司会有合规压力。关于展示层面我必须说实话Jaeger 自带的树形视图满足排障需求已经够了但团队日常运维还需要一个汇总视角的 Dashboard。我们额外做了两个维度按 Agent 类型看平均耗时、Token 成本、error 节点覆盖率的趋势按业务线看成本占比和失败率排行。这两个 Dashboard 现在是我们团队每天早上的第一件事。6.4 我看过最值的一笔投入如果要给准备做 Agent 可观测的团队一个优先级建议我的排序是先建树、再挂指标、最后才做存储和采样。树结构是地基没有地基后面所有的耗时分析、成本治理、失败回溯都是空中楼阁。回到开头那个一树到底的概念。它不是什么高深的理论就是一个朴素但坚决的原则任何一个 Agent 执行过程从根到叶子、从耗时到成本、从成功到失败都应该能在同一棵树上完整讲清楚。做到这一点之后你会发现很多之前只能靠猜的问题变成了看一眼树就知道怎么回事的确定性工程问题。最后分享一个我个人在实际操作中的体会不要在 Agent 还没有规模化的阶段就追求完美的可观测体系但一定要在最开始就把树的骨架搭好。哪怕第一版只记录 span 类型和耗时也比没有强得多。因为 Agent 一旦跑起来迭代速度会非常快等到你想补可观测的时候业务逻辑已经复杂到很难追溯了。先有一棵树再让树慢慢长满叶子这是我做 ooderAgent 可观测一年下来最值的一条建议。
返回列表