做AI Agent工程这两年,我有个越来越强烈的体会:Agent跑成功的时候,你根本不需要看日志;但Agent一旦跑失败,你大概率什么都查不到。上周我就经历了一次典型事故——一个数据聚合Agent跑了40分钟,调用了十几个外部工具,最终只吐出一句“任务失败”,没有堆栈、没有中间状态、没有上下文快照。更麻烦的是,同样的任务重新跑一遍,它可能又成功了。这种不确定性让“复现Bug”变成了一厢情愿,也让传统的log+堆栈排障方式彻底失效。
我们团队最后是靠一套Agent Trace机制救回来的:把一次完整执行的所有环节——模型思考、工具调用、工具返回、状态变更、异常重试——全部以结构化的方式记录下来,然后像翻监控录像一样,把失败的那次执行一帧一帧还原出来。这篇文章我会用一次真实的失败执行作为案例,完整拆解Trace的数据模型、还原过程、根因定位方法,以及我们在落地过程中踩过的那些坑。适合正在做AI Agent开发、调试和测试的同学参考,尤其是那些已经开始被“随机性失败”折磨的团队。
1. Agent的失败为什么是“悬案”:传统日志在非确定性系统面前失效了
1.1 三次本质差异决定了排障思路必须变
传统后端服务是一个确定性系统:相同的入参一定会走相同的代码路径,只要日志打够、堆栈捞全,问题基本都能复现。但Agent不是这样。一个Agent执行任务的路径,是由LLM在运行时动态决定的,它可能这次先调用工具A再调用工具B,下次觉得没必要调用A直接跳过去。这种路径的不确定性,让传统“复现Bug—打日志—定位”的闭环直接断裂。
我概括过传统系统和Agent系统的三个本质差异,这也是为什么必须引入Trace而不是继续依赖日志:
- 失败层级的多样性:Agent的失败可能发生在LLM推理层(规划错了)、工具调用层(参数传错)、状态流转层(上下文丢了)、外部系统层(下游接口超时)。传统日志通常只记录了“哪一层报错”,但Agent需要知道的是“这一层为什么这么决策”。
- 非确定性执行路径:同一任务两次执行,LLM采样结果不会完全一致。没有执行轨迹记录,你无法回答“上一次它到底做了什么”这个基本问题。
- 副作用无法回滚:Agent执行过程中可能已经写入了数据库、发送了邮件、创建了订单。出了问题不能像单测一样重放一遍,必须靠执行轨迹确认“它动过哪些外部资源”。
1.2 传统日志为什么帮不上忙
我做排障时最常见的场景是:Agent返回了一个错误,日志里只有一行Tool execution failed: get_weekly_orders。这个信息相当于告诉你“车坏了”,但没告诉你是发动机、轮胎还是油路的问题。
更麻烦的是,Agent的很多错误是“复合型”的。比如最终失败原因是“生成报告失败”,但往前追溯,可能是第二步工具返回了一个格式异常的数据,LLM基于这段异常数据做了错误决策,然后一路错到底。传统日志以“发生了什么结论”为中心,而Agent排障必须以“决策过程”为中心——这正是Trace的定位。
提示:判断一个系统是否需要上Trace,最简单的标准就是——出了故障之后,你是否能准确说出“上一次执行中,模型在第三步到底看到了什么”。如果答不上来,现有的日志体系撑不起Agent排障。
2. Trace的底层模型:一次执行、一串Span、无数Event
2.1 最核心的三个概念
我做Trace设计时的第一原则是:不要凭空发明一套新概念,沿用业界已经验证的OpenTelemetry链路模型,把Agent的一次执行映射成标准的Trace-Span-Event三级结构。
- Trace(Run ID):一次完整的Agent执行从开始到结束,生成一个全局唯一的
trace_id。这是所有排障的入口主键。 - Span:一次执行内部的一个有明确边界的环节。一次Agent执行通常可以拆成规划、模型生成、工具调用、工具返回、状态更新等若干个Span。
- Event:Span内部的细粒度事件记录,比如模型生成了哪些中间token、工具调用的入参是什么、抛出了什么异常、触发了几次重试。
用一个生活化的类比:Trace是整个航班的飞行记录仪,Span是航段(起飞、巡航、降落),Event是每一秒的仪表读数。排查事故时,先看哪个航段出了问题,再放大到对应的仪表读数。
2.2 为什么必须用三级模型,不能只有一层
我在第一版Agent Trace设计时曾经偷懒过——只记录“每步做了什么”,结果排障时依然抓瞎。原因很简单:面向的问题粒度完全不同。
- Trace回答的是“这次执行整体是否成功、耗时多久、调了多少次工具”;
- Span回答的是“哪一个环节出了问题、这个环节的上下游是谁”;
- Event回答的是“这个环节内部到底发生了什么、为什么失败”。
三层各有用途,缺一层都会让排障效率大打折扣。举个例子,只看Trace层你能发现“调用工具阶段耗时过长”,但只有进入Span层才能看到是“第二个工具重试了三次”,再进到Event层才能看到第三次重试是因为“返回结果里的金额字段变成了字符串类型,导致LLM解析失败”。
2.3 构建执行树:parent_span_id 是关键
Span之间不能是平铺的列表,必须通过parent_span_id构建成树状结构。因为Agent执行天然是分层的——一个规划Span下面挂了多个工具调用Span,每个工具调用Span下面又挂了事件明细。
这里分享一个实践中的小细节:时间片(时序)在Agent Trace中比传统链路中更重要。传统链路每个Span的耗时相对固定,而Agent的模型生成Span可能耗时20秒,工具调用Span可能只有200毫秒。两个Span之间的时间间隔往往暴露问题——比如模型生成结束后到下一次工具调用之间隔了十几秒,通常意味着它在悄悄重试或者卡在了哪里。
3. 解剖一次失败执行:从Trace完整还原崩溃现场
3.1 一个真实的失败案例背景
我拿我们上周的那个排障案例展开。任务本身不复杂:Agent需要从订单系统查询本周订单数据,聚合出每日趋势,并生成一段业务分析文字。整个执行过程调用了工具OrderTool.get_weekly_orders,然后基于返回结果做汇总。最终Agent返回了一个让人摸不着头脑的错误:“报告生成失败,订单数据不可用”。
如果只看最终错误信息,你会以为数据源出了问题。但当我用Trace ID拉出完整执行轨迹后,真相完全不一样。下面是一段简化后的Trace数据(JSON Lines格式,为了阅读清晰做了缩进):
{ "trace_id": "trc_9f31a2b8e7", "session_id": "sess_02c4d1", "user_input": "查询本周订单数据,生成每日趋势和业务分析", "status": "failed", "start_time": "2025-03-04T10:00:01.128Z", "end_time": "2025-03-04T10:11:47.392Z", "spans": [ { "span_id": "span_001", "parent_span_id": null, "name": "plan", "span_type": "llm_plan", "start_time": "2025-03-04T10:00:01.200Z", "end_time": "2025-03-04T10:00:09.804Z", "events": [ {"type": "llm_input", "data": {"messages": ["system: 你是一个数据分析助手...", "user: 查询本周订单..."]}}, {"type": "llm_output", "data": {"plan": ["调用OrderTool查询订单", "按日聚合订单金额", "生成趋势分析"]}} ] }, { "span_id": "span_002", "parent_span_id": "span_001", "name": "call_tool:OrderTool.get_weekly_orders", "span_type": "tool_call", "start_time": "2025-03-04T10:00:10.010Z", "end_time": "2025-03-04T10:00:10.892Z", "events": [ {"type": "tool_input", "data": {"date_format": "MM/DD/YYYY", "value": "02/05/2025"}}, {"type": "tool_error", "data": {"message": "date format error, expected YYYY-MM-DD"}}, {"type": "retry_count", "value": 1} ] }, { "span_id": "span_003", "parent_span_id": "span_001", "name": "call_tool:OrderTool.get_weekly_orders(retry)", "span_type": "tool_call", "start_time": "2025-03-04T10:00:12.310Z", "end_time": "2025-03-04T10:00:13.055Z", "events": [ {"type": "tool_input", "data": {"date_format": "YYYY-MM-DD", "value": "02/05/2025T00:00:00"}}, {"type": "tool_error", "data": {"message": "date format error, expected YYYY-MM-DD"}}, {"type": "failure_reason", "data": "agent stopped retrying"} ] }, { "span_id": "span_004", "parent_span_id": "span_001", "name": "generate_report", "span_type": "llm_generate", "start_time": "2025-03-04T10:00:13.800Z", "end_time": "2025-03-04T10:11:40.221Z", "events": [ {"type": "llm_error", "data": {"message": "required data missing: order list is empty", "raw": "报告生成失败,订单数据不可用"}} ] } ] }3.2 逐帧还原执行过程
把这段Trace按时间线展开后,整个执行过程其实非常清晰:
| 时间 | Span | 事件 | 说明 |
|---|---|---|---|
| 10:00:01 | plan | LLM规划 | 模型输出了三步计划,看起来正常 |
| 10:00:10 | call_tool | 第一次调用 | 传入了date_format=MM/DD/YYYY,被工具拒绝 |
| 10:00:12 | call_tool | 重试调用 | 转换了date_format字段,但value仍然是02/05/2025T00:00:00,依然被拒 |
| 10:00:13 | generate_report | 模型生成 | 因拿不到订单数据,最终输出一个误导性错误 |
从Trace里能明显看出三个关键信息:
第一,真正的失败点发生在10:00:10的工具调用,而非最后那句“报告生成失败”。最终错误信息是LLM在数据缺失情况下编造出来的“合理解释”,具有很强欺骗性。
第二,Agent进行了一次重试,但重试时只修正了格式字段,没有修正value字段。这说明模型并没有真正理解错误信息里的“expected YYYY-MM-DD”意味着什么,只是机械地调整了它认为可疑的参数。
第三,两个工具调用Span之间的间隔只有2秒左右,不存在超时或卡顿问题,排除了下游系统故障的可能。
3.3 外层错误的“欺骗性”是Agent排障最大的陷阱
这个案例完美地展示了一个我之前反复踩的坑:Agent的最终错误输出,往往是它基于不完整信息做的“合理化解释”,而不是真实的失败原因。
在传统系统里,最外层的报错信息通常是最靠近根因的;但在Agent系统里恰恰相反,最外层错误信息往往经过了LLM的重构和想象,距离真实原因最远。这也是为什么我一直强调:排障时必须从Trace中最深的工具错误Event开始看,而不是从Agent最终的message开始看。
4. 顺着Trace找根因:我常用的五步定位法
4.1 从最深处的Error Event反查
拿到一个失败执行的Trace后,我的习惯是先过滤出所有type为tool_error或llm_error的Event,然后选择时间戳最早的那一个,作为根因候选。
这个方法看起来简单,但非常有效。因为Agent执行有很强的“链式放大”特征:早期一个小错误,经过后续几步LLM的推理、编造、扩展,到最后可能完全面目全非。后端排障是“由顶向下逐层下钻”,Agent排障一定要“由底向上逆向回溯”。
4.2 还原Agent当时看到的完整上下文
定位到最早的Error Event后,下一步不是急着改代码,而是要完整还原Agent在那一刻的上下文。我最常查的三样东西:
- 系统Prompt里有关于日期格式的明确说明吗?还是说只是工具描述里的一句话带过?
- 工具返回的错误信息原文是什么?它是否给出了正确的格式示例?
- 模型在这个错误发生之前,已经看到了哪些中间结果?有没有可能上下文被截断导致它遗漏了关键信息?
在刚才那个案例里,还原完上下文后我们发现:工具返回的错误信息虽然写了expected YYYY-MM-DD,但整个错误文本很短,没有给出正确的调用示例。模型第一次传错后,第二次只是盲目调整了参数名,并没有真正理解value字段也需要同步转换格式。这说明问题并不单纯是“模型笨”,工具层的错误信息设计也有优化空间。
4.3 区分“模型不懂”和“代码没接好”
这是Agent根因分析里最需要经验的一步。我一般用下面这个标准来判断责任归属:
| 现象 | 可能原因 | 责任方 |
|---|---|---|
| Prompt/工具描述里明确给了示例,模型依然传错 | 模型对指令遵循能力不足 | 模型/提示词层 |
| 工具描述里没有格式说明,模型凭感觉传参 | 工具定义不完整 | 工程层 |
| 工具返回了结构化错误,LLM错误解析 | 错误信息设计不佳 | 工程层 |
| 工具本身逻辑对部分输入抛异常 | 工具健壮性不足 | 工程层 |
| 上下文过长被截断,模型看不到关键约束 | Prompt设计/上下文管理 | 工程层 |
在落地Trace后我发现一个很反直觉的规律:大部分Agent“看起来像模型问题”的失败,最后查下来都是工程层的问题。工具描述写得不清楚、错误信息不友好、重试逻辑不带上下文修正、上下文截断策略太粗暴——这些才是高频根因。
4.4 用Diff Trace对比成功执行
有一种特殊场景:失败Trace本身看不出明显异常,参数没传错,工具也正常返回,但Agent最终结果就是不对。这种情况我建议把同一任务的成功Trace拉出来,和失败Trace做逐Span对比。
具体做法是:把两个Trace按Span名对齐,逐个对比输入、输出、耗时、token数。我之前遇到过一个问题,最后一次排查出失败Trace里某个工具返回的结果被截断成了之前的70%,导致LLM汇总时缺了一部分数据。单纯看失败Trace根本发现不了,一对比就真相大白了。
4.5 留意重试机制掩盖真实原因的情况
当Span列表里出现多次retry_count,就要特别小心。重试在Agent里是把双刃剑——它能提高任务完成率,但也经常把根因藏起来。
我在实践中的一个原则是:重试时必须记录每一次重试的完整输入和输出,并标记触发重试的原因。如果只是静默地重新调用一次工具,Trace看起来就多了一个慢Span,里面没有诊断价值。只有记录了“上一次因为什么失败、这次调整了什么参数”,重试链路才能真正辅助排障。
5. 把Trace体系真正落地:埋点、存储、检索与可视化
5.1 埋点:在Agent主循环的六个关键位置打点
Agent的工程实现千差万别,但核心执行循环基本一致。我建议在下面六个位置打Trace埋点,覆盖绝大多数排障需求:
- LLM生成开始/结束:记录完整messages输入和生成的response,这是还原“Agent看到什么”的关键
- 工具调用开始:记录函数名和完整入参
- 工具返回结束:记录返回结果,并对大体积结果做截断
- 状态更新:记录Agent内部状态(比如任务清单、已完成步骤)的变更
- 异常捕获:记录所有exception信息及其上下文
- 重试触发:记录重试原因、重试次数、重试前的修正决策
下面是我在用的一种极简埋点实现,核心概念清晰,接入成本也低:
import uuid import json from datetime import datetime, timezone class TraceEmitter: def __init__(self, trace_id: str): self.trace_id = trace_id self.spans = [] self.current_span_stack = [] def start_span(self, name: str, span_type: str) -> str: span_id = uuid.uuid4().hex[:12] parent_id = self.current_span_stack[-1] if self.current_span_stack else None self.current_span_stack.append(span_id) self.spans.append({ "trace_id": self.trace_id, "span_id": span_id, "parent_span_id": parent_id, "name": name, "span_type": span_type, "start_time": datetime.now(timezone.utc).isoformat(), "end_time": None, "events": [] }) return span_id def end_span(self, span_id: str): span = next(s for s in self.spans if s["span_id"] == span_id) span["end_time"] = datetime.now(timezone.utc).isoformat() if self.current_span_stack: self.current_span_stack.pop() def emit_event(self, span_id: str, event_type: str, data: dict): span = next(s for s in self.spans if s["span_id"] == span_id) span["events"].append({ "type": event_type, "data": data, "time": datetime.now(timezone.utc).isoformat() }) def save(self, writer): writer.write(json.dumps({ "trace_id": self.trace_id, "status": "failed" if any(e["type"].endswith("_error") for s in self.spans for e in s["events"]) else "success", "spans": self.spans }, ensure_ascii=False) + "\n")在Agent主循环里的使用方式也很简单,以工具调用为例:
trace = TraceEmitter(trace_id="trc_9f31a2b8e7") span_id = trace.start_span("call_tool:OrderTool.get_weekly_orders", "tool_call") try: trace.emit_event(span_id, "tool_input", {"date_format": "MM/DD/YYYY", "value": "02/05/2025"}) result = order_tool.invoke(date_format="MM/DD/YYYY", value="02/05/2025") trace.emit_event(span_id, "tool_result", {"summary": result.summary()}) except Exception as e: trace.emit_event(span_id, "tool_error", {"message": str(e)}) finally: trace.end_span(span_id)5.2 存储:从JSONL起步,别一开始就上重型系统
在存储方案上,我见过不少团队一上来就搭ClickHouse,结果数据量没起来,运维成本先压垮了团队。我建议分阶段演进:
- 阶段一(验证期):直接往本地JSONL文件追加Trace记录。排查时用
jq按trace_id过滤就行,零运维成本。 - 阶段二(小规模生产):接一个轻量的PostgreSQL或者SQLite,按trace_id建索引,查询维度做到session_id和error_type即可。
- 阶段三(规模化):当每日Trace量达到数十万条时,再迁移到ClickHouse或者兼容OpenTelemetry的链路系统,同时考虑采样策略。
存储时有一个建议:Trace原始数据尽量以JSON全文保存,不要为了省空间只存结构化字段。Agent排障的灵异问题常常需要回看原始输入输出,字段拆分越细,还原能力越弱。
5.3 检索与可视化:最小可用闭环长什么样
可视化这块我踩过“过度设计”的坑。第一版我花了两周做了一个花哨的甘特图界面,结果团队用起来发现最缺的其实就是个检索框。收敛之后,我们的最小可视化闭环就三个能力:
- 按trace_id或者session_id拉出一条完整的时间线,清晰展示各Span的先后顺序、父子关系、耗时,错误Span用高亮标红。
- 点击某个Span能展开它的全部Events,尤其是tool_input和tool_error的原始内容。
- 支持按error_type、模型名称、工具名称做聚合统计,能快速看出哪类失败占比最高。
提示:如果你的Agent系统已经接入了OpenTelemetry规范的链路追踪,可以考虑直接把Agent的Span映射为标准Span,复用已有的Jaeger或者Grafana Tempo。好处是不用重复造可视化轮子,坏处是Agent的Event语义和标准RPC调用不太一样,需要做一些字段映射。
6. 实践半年的踩坑清单:给Trace系统补上的七块短板
6.1 只记结论不记快照,等于没记
这是我犯过的最严重的错误。早期我为了节省存储,把每个Span只记录“发生了什么”的摘要,比如tool_result: ok。等到排障时才发现,我需要的是“工具完整返回了什么数据”“模型那一刻看到了什么文本”,而不是一个布尔值。
现在的原则是:凡是模型可见的内容,必须尽可能完整快照。LLM和工具的输入输出是排障的“现场物证”,不能只留摘要。
6.2 大体积工具结果要截断,但不能只截断
工具返回几MB的JSON很常见,全量存储不现实。我的做法是双轨:存储在线的截断结果(前面N个字符+长度统计),同时把完整结果写入对象存储或者本地磁盘,Trace里带上文件路径。这样既控制了体积,又保留了全量还原的能力。
6.3 敏感信息脱敏必须在写入前完成
Agent的工具调用参数里经常夹带密钥、Token、用户手机号之类的敏感字段。如果Trace系统不处理脱敏,它就成了一个泄密数据库。我建议在埋点层就配置敏感字段正则,对疑似敏感内容做掩码处理,而不是在存储层后补。否则一旦数据进了日志系统,想要彻底清理就非常被动。
6.4 异步任务必须穿透trace_id
很多Agent系统会异步执行子任务或者多Agent协作。这个时候如果子任务的Trace不从父任务透传trace_id,你会在排障时看到一堆孤立的Trace片段,根本无法还原全貌。
我的做法是在Agent的上下文对象里挂一个TraceContext,所有的子任务、回调、异步分支都从里面取trace_id,并且把父Span ID带下去。
6.5 保留策略:成功短留,失败长留
Agent执行的成功率通常不是100%,失败Trace的诊断价值远高于成功Trace。我目前的保留策略是:成功Trace保留7天,失败Trace保留30天,带工具级错误的Trace保留90天。这个策略在存储成本和排障能力之间平衡得还不错。
6.6 从第一天就做,别等出事故再补
这是我的真心话。Trace体系如果等项目跑飞了再补,最大的困难不是技术,而是“没有历史的Trace数据可供回溯”。事故发生后再想还原现场,只能靠用户描述和残缺日志,那种无力感非常难受。哪怕是最简单的JSONL方案,也应该在Agent系统的第一个Demo阶段就接入。
6.7 用Trace数据反向驱动Agent质量改进
Trace不只是用来排障的,它还是Agent质量分析的一手数据源。我每周会做一次Trace数据汇总:统计哪类工具报错最多、哪个环节平均耗时最长、哪种任务的LLM重试率最高。这些指标能直接指导Prompt优化和工具设计。在我看来,Trace的价值一半在事故排障,另一半在日常迭代——甚至后者的长期价值更大。
回到开头那个案例。我们通过Trace定位到根因后,只做了两个改动:工具的错误信息里增加了正确的日期格式示例,同时在工具层放开了解析逻辑,接受常见日期格式。从那以后,这类失败基本消失了。整个过程没有AI的“玄学修复”,就是靠Trace把模糊的失败变成明确的工程问题。这也是我坚持写完这篇实践记录的原因——Agent的不可解释性,恰恰需要通过工程手段来消解。