1. 从一次线上事故说起:为什么Multi-Agent系统必须要有Tracing
去年冬天,我参与的一个多智能体协作项目在凌晨两点突然开始返回大量错误结果。用户反馈说,原本应该由三个Agent接力完成的报告生成任务,最终输出的内容前言不搭后语,像是把三个不同任务的中间产物拼在了一起。当时我们团队四个人盯着日志系统看了整整一个小时,愣是没定位到问题出在哪——因为每个Agent的日志是独立打的,时间戳精度只到秒级,三个Agent的日志混在一起,根本分不清谁先谁后、谁调用了谁、哪个环节传错了参数。
那次事故之后,我们花了三周时间给整个Multi-Agent系统加上了完整的Tracing记录执行链。上线之后,同样的故障再次出现时,我只用了不到三分钟就定位到了根因:第二个Agent在调用工具时超时重试了两次,但重试逻辑没有正确清理上一次的中间状态,导致第三次调用时携带了脏数据。
这就是我今天想聊的话题。如果你正在构建或者维护一个Multi-Agent系统,不管你是用LangChain、AutoGen、CrewAI还是自己手搓的框架,Tracing记录执行链这件事,越早做越好。它解决的核心问题是:当多个Agent互相调用、传递消息、执行工具时,你需要一条完整的、可追溯的、带时间戳和层级关系的执行链路,让你能在出问题时快速定位,在优化时找到瓶颈,在复盘时看清全局。
这篇文章适合所有正在做Multi-Agent系统开发的同学,不管你是刚入门还是已经踩过一些坑,我都会从设计思路、核心细节、实操落地、问题排查四个维度,把Tracing这件事讲透。我会尽量用大白话解释原理,给出可以直接抄的代码结构,也会分享一些只有真正在生产环境跑过才会知道的避坑经验。
2. 整体设计思路:Tracing在Multi-Agent架构中到底该记什么
2.1 先搞清楚Multi-Agent系统的执行模型
在聊Tracing之前,得先把Multi-Agent系统的执行模型说清楚。因为不同的执行模型,Tracing的设计思路完全不一样。
目前主流的Multi-Agent执行模型大概分三类:
第一类是串行接力型。Agent A执行完,把结果传给Agent B,B执行完传给C,像流水线一样。这种模型最简单,Tracing也最好做,你只需要记录每个Agent的输入输出和时间戳就行。
第二类是层级调度型。有一个Orchestrator Agent负责拆解任务、分配子任务给Worker Agent,Worker执行完把结果汇报给Orchestrator,Orchestrator再决定下一步。这种模型下,Tracing需要记录树状的调用关系,因为一个Orchestrator可能同时或顺序调用多个Worker。
第三类是自由协作型。多个Agent之间可以互相发消息、互相调用,没有固定的层级关系。这种模型最复杂,Tracing需要记录的是一个有向图,每个节点是一个Agent的执行实例,每条边是一次消息传递或函数调用。
我参与的那个项目用的是第二种层级调度型,Orchestrator下面挂了四个Worker Agent,分别负责检索、分析、写作和校对。这种模型下,Tracing的核心是记录清楚“谁调用了谁、传了什么、返回了什么、花了多久”。
2.2 Tracing记录的核心字段设计
不管你的系统是哪种执行模型,Tracing记录的核心字段都差不多。我整理了一个最小可用字段集,你可以直接参考:
| 字段名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| trace_id | string | 全局唯一标识,一次完整任务链路的ID | 是 |
| span_id | string | 当前执行单元的ID,每个Agent执行或工具调用生成一个 | 是 |
| parent_span_id | string | 父执行单元的ID,用于构建层级关系 | 是 |
| agent_name | string | 当前执行的Agent名称 | 是 |
| event_type | string | 事件类型:agent_start、agent_end、tool_call、tool_result、message_send、message_receive等 | 是 |
| timestamp | int64 | 毫秒级时间戳 | 是 |
| input_payload | json | 输入数据,比如传给Agent的消息或工具参数 | 是 |
| output_payload | json | 输出数据,比如Agent的返回或工具结果 | 是 |
| duration_ms | int64 | 执行耗时,毫秒 | 否 |
| status | string | 执行状态:success、error、timeout | 是 |
| error_message | string | 错误信息,status为error时填写 | 否 |
| metadata | json | 扩展字段,比如模型名称、token消耗、重试次数等 | 否 |
这个字段集是我在实际项目中反复调整后定下来的。最开始我们只记了trace_id、agent_name和timestamp,结果发现根本不够用。后来加了span_id和parent_span_id,才能把调用关系串起来。再后来加了input_payload和output_payload,才能在出问题时复现现场。最后加了duration_ms和status,才能做性能分析和错误率统计。
注意:input_payload和output_payload建议做脱敏和截断处理。我们最开始把完整的用户输入和模型输出都存进去了,结果日志体积爆炸,一天就写了20GB。后来改成只存前500个字符,超出部分截断并标记,体积直接降了90%。
2.3 为什么不用现成的日志系统而要专门做Tracing
你可能会问:我用ELK或者Loki打日志不行吗?为什么非要专门做Tracing?
这个问题我当初也想过。答案是:普通日志系统能记录“发生了什么”,但很难记录“谁触发了谁、谁在等谁、整条链路长什么样”。普通日志是扁平的,而Multi-Agent系统的执行链路是树状或有向图状的。你需要的是一个能表达层级关系和时序关系的结构化记录,而不是一堆按时间排序的文本行。
举个例子。当Orchestrator调用Worker A,Worker A又调用了工具T,工具T返回结果给Worker A,Worker A再返回给Orchestrator。这一串事件在普通日志里是四行独立的记录,你只能靠时间戳和关键词去猜它们之间的关系。但在Tracing系统里,这四行记录通过span_id和parent_span_id形成了一个明确的树状结构,你一眼就能看出调用链。
另外,Tracing系统通常需要支持链路查询和可视化。比如你输入一个trace_id,系统能直接渲染出整条执行链的时序图,每个节点的耗时、状态、输入输出都能展开查看。这种能力是普通日志系统很难提供的。
2.4 技术选型:自建还是用开源方案
Tracing这件事,你可以自建,也可以用开源方案。我两种都试过,说说我的体会。
自建的话,最简单的做法是用一个结构化的日志表,每次Agent执行前后各写一条记录,然后用trace_id和span_id做关联查询。这种方案的好处是灵活、可控、没有额外依赖。坏处是你得自己处理异步写入、自己写查询界面、自己做可视化。
开源方案的话,目前比较主流的是OpenTelemetry这套体系。它本来是为微服务设计的分布式追踪标准,但完全可以套用到Multi-Agent系统上。你只需要把每个Agent执行当成一个span,把Agent之间的调用当成span之间的父子关系,就能直接复用OpenTelemetry的采集、存储和可视化能力。我们后来就是迁移到了OpenTelemetry,用Jaeger做可视化,效果很好。
如果你刚开始做,我建议先用自建方案快速跑通,等链路复杂了再迁移到OpenTelemetry。因为OpenTelemetry的接入有一定学习成本,一开始就上可能会拖慢开发进度。
3. 核心细节解析:Span的创建、传递与关联
3.1 Span的生命周期管理
Span是Tracing的基本单元。在Multi-Agent系统里,一个Span可以是一次Agent执行、一次工具调用、一次消息传递,甚至是一次模型推理。Span的生命周期很简单:创建、记录、结束。
创建Span的时机很关键。我的经验是:在Agent开始执行的那一刻创建Span,在Agent执行结束的那一刻关闭Span。中间发生的所有工具调用、消息传递,都作为子Span挂在当前Span下面。
这里有一个容易踩的坑:异步执行下的Span传递。如果你的Agent是异步执行的,比如用asyncio或者线程池,Span的上下文不会自动传递到子任务里。你需要手动把当前Span的上下文传给子任务,否则子任务创建的Span会变成孤儿Span,parent_span_id为空,链路就断了。
我们当时的做法是封装了一个SpanContext类,里面存了trace_id和当前span_id。每次创建新Span时,从上下文里取parent_span_id。异步调用时,把SpanContext作为参数显式传进去。虽然麻烦一点,但能保证链路完整。
class SpanContext: def __init__(self, trace_id, span_id): self.trace_id = trace_id self.span_id = span_id class Span: def __init__(self, context, agent_name, event_type): self.trace_id = context.trace_id self.span_id = generate_span_id() self.parent_span_id = context.span_id self.agent_name = agent_name self.event_type = event_type self.start_time = current_time_ms() self.status = "running" def end(self, output_payload, status="success", error_message=None): self.end_time = current_time_ms() self.duration_ms = self.end_time - self.start_time self.output_payload = output_payload self.status = status self.error_message = error_message self.persist()上面这段代码是我简化后的核心逻辑。实际使用的时候,你还需要考虑异常处理、超时自动关闭、批量写入等问题。
3.2 跨Agent的Trace上下文传递
在Multi-Agent系统里,Agent之间的调用是跨进程、跨网络的。这时候Trace上下文怎么传递?
最常见的做法是在消息头里带上trace_id和parent_span_id。比如Agent A调用Agent B时,在请求的metadata里塞入当前的trace_id和span_id。Agent B收到请求后,从metadata里取出这两个值,创建自己的Span时把parent_span_id设为传过来的span_id。
这种做法的好处是简单、通用,不管你是用HTTP、gRPC还是消息队列,都能在协议头里加字段。坏处是如果某个环节忘了传,链路就断了。
我们当时的做法是在框架层面统一处理。所有Agent之间的通信都走一个统一的MessageBus,MessageBus在发送消息时自动注入Trace上下文,在接收消息时自动提取Trace上下文。这样业务代码完全不用关心Tracing的事,链路也不会断。
提示:如果你的系统里有些Agent是外部服务,你没法改它们的代码,那就在调用外部服务的那一层做手动埋点。记录调用前后的时间戳和输入输出,把这次调用作为一个独立的Span挂到当前链路上。
3.3 工具调用与模型推理的细粒度追踪
Agent执行过程中,最耗时的往往是工具调用和模型推理。这两块如果只记录在Agent级别的Span里,粒度太粗,出问题时不好定位。我的建议是给每次工具调用和每次模型推理都创建独立的子Span。
工具调用的Span需要记录:工具名称、入参、出参、耗时、是否成功。模型推理的Span需要记录:模型名称、prompt长度、completion长度、token消耗、耗时、是否成功。
这些细粒度数据在优化性能时特别有用。比如你发现某个Agent整体耗时很长,展开它的子Span一看,发现80%的时间花在了一次数据库查询工具上,那优化方向就很明确了。
我们当时还做了一个统计:把所有工具调用的平均耗时、P95耗时、错误率都算出来,做成看板。这样哪个工具不稳定、哪个工具慢,一目了然。
3.4 采样策略:不是所有链路都需要完整记录
生产环境里,如果每条链路都完整记录,存储成本会很高。这时候需要采样策略。
常见的采样策略有三种:
第一种是全量采样。所有链路都记录。适合开发环境和流量很小的生产环境。
第二种是概率采样。按一定比例随机记录,比如10%。适合流量大但对链路完整性要求不高的场景。
第三种是尾部采样。先缓存链路数据,等链路结束后根据条件决定是否保留。比如只保留出错的链路、只保留耗时超过阈值的链路。这种策略最实用,但实现也最复杂。
我们用的是尾部采样。具体做法是:链路执行过程中,数据先写到本地缓冲区。链路结束后,检查这条链路是否有错误、是否超时、是否被标记为重要。如果有,就完整写入存储;如果没有,就按10%的概率写入。这样既能保证问题链路不丢失,又能控制存储成本。
4. 实操落地:从零搭建一套可用的Tracing系统
4.1 存储选型与表结构设计
Tracing数据的存储,我推荐用列式数据库或者时序数据库。因为Tracing数据的特点是写入量大、查询模式固定(按trace_id查、按时间范围查、按agent_name聚合),列式存储在这种场景下性能优势明显。
我们当时用的是ClickHouse。一张表存所有Span,按天分区,按trace_id和timestamp建索引。表结构大概长这样:
CREATE TABLE spans ( trace_id String, span_id String, parent_span_id String, agent_name String, event_type String, timestamp DateTime64(3), duration_ms Int64, status String, error_message String, input_payload String, output_payload String, metadata String ) ENGINE = MergeTree() PARTITION BY toDate(timestamp) ORDER BY (trace_id, timestamp) TTL timestamp + INTERVAL 30 DAY;这个表结构我们跑了半年,支撑了每天千万级的Span写入,查询性能也很好。TTL设了30天,自动清理旧数据。
如果你不想自己维护数据库,也可以用OpenTelemetry Collector + Jaeger的方案。Jaeger自带存储和查询界面,接入成本更低。但定制化能力不如自建。
4.2 埋点接入:在Agent框架层统一处理
埋点这件事,最忌讳的是让业务开发人员手动在每个Agent里写Tracing代码。一来容易漏,二来代码侵入性强,三来不同人写法不一致。
我们的做法是在Agent框架层做统一拦截。具体来说,我们定义了一个BaseAgent类,所有Agent都继承它。BaseAgent的execute方法里自动做了三件事:创建Span、执行子类的业务逻辑、关闭Span。子类只需要实现do_execute方法,完全不用关心Tracing。
class BaseAgent: def execute(self, input_payload, context): span = Span(context, self.name, "agent_start") try: result = self.do_execute(input_payload, span.child_context()) span.end(result, "success") return result except Exception as e: span.end(None, "error", str(e)) raise def do_execute(self, input_payload, context): raise NotImplementedError这种设计的好处是:Tracing逻辑和业务逻辑完全解耦,新增Agent时自动获得Tracing能力,不需要额外写代码。工具调用和模型推理也可以用类似的方式封装。
4.3 链路查询与可视化
Tracing数据存下来之后,最重要的是能查、能看。我们做了两个东西:一个是链路查询API,一个是链路可视化页面。
链路查询API支持按trace_id查完整链路、按agent_name和时间范围查执行记录、按status查错误链路。查询结果返回一个树状结构,前端拿到后直接渲染。
链路可视化页面是我们自己写的一个简单前端,输入trace_id后,页面会展示一条时序图。每个Span是一个横条,横条的长度代表耗时,横条的颜色代表状态(绿色成功、红色失败、黄色超时)。点击横条可以展开查看输入输出详情。
这个页面看起来简单,但实际用起来非常高效。出问题时,我只需要拿到用户反馈的trace_id,输入进去,三秒钟就能看出哪个环节出了问题。
提示:如果你的团队没有前端资源,可以直接用Jaeger的UI。Jaeger的UI功能很全,支持链路搜索、时序图展示、Span详情查看,而且开源免费。我们后来迁移到OpenTelemetry之后,就是直接用Jaeger的UI,省了不少开发成本。
4.4 与现有监控体系的联动
Tracing系统不应该是一个孤岛。它应该和现有的监控体系联动起来。
我们做了两件事:
第一,把Tracing数据里的关键指标抽出来,打到Prometheus里。比如每个Agent的调用次数、平均耗时、错误率,每个工具调用的P95耗时。然后在Grafana里做看板,实时监控。
第二,把Tracing系统和告警系统打通。当某个Agent的错误率超过阈值,或者某条链路的耗时超过阈值时,自动触发告警,并把对应的trace_id附在告警信息里。这样收到告警的人可以直接点开trace_id查看完整链路,不用再去翻日志。
这种联动带来的效率提升非常明显。以前收到告警后,要先去日志系统搜关键词,再去数据库查数据,来回切换好几个系统才能定位问题。现在收到告警后,直接点trace_id,所有信息都在一个页面里。
5. 常见问题与排查技巧实录
5.1 链路断裂:Span找不到父节点
这是最常见的问题。表现是查询链路时,发现有些Span的parent_span_id为空,或者指向一个不存在的span_id。
原因通常有三种:一是异步调用时忘了传递Trace上下文;二是跨服务调用时消息头里没带Trace字段;三是Span写入顺序乱了,子Span先于父Span写入存储。
排查方法:先检查异步调用的代码,确认SpanContext有没有正确传递。再检查跨服务调用的协议头,确认Trace字段有没有被正确注入和提取。如果是写入顺序问题,可以在查询时做二次排序,按timestamp重新构建树。
我们的解决方案是在Span写入时做缓冲,等整条链路结束后再批量写入。这样能保证父Span先于子Span落库。但缺点是如果链路执行时间很长,数据会延迟写入。折中方案是设置一个缓冲超时,比如5秒,超时后强制写入。
5.2 性能开销:Tracing拖慢了主流程
Tracing本身是有开销的。每次创建Span、序列化数据、写入存储,都要花时间。如果埋点太密,或者写入是同步的,确实会拖慢主流程。
我们的优化经验是:第一,Span数据先写本地内存缓冲区,由后台线程异步批量写入存储,主流程不等待写入完成。第二,input_payload和output_payload做截断,只存前500字符。第三,非关键路径的Span可以降低采样率。
实测下来,优化之后Tracing带来的额外耗时在整体耗时的1%以内,基本可以忽略。
5.3 数据膨胀:存储成本失控
Tracing数据量很容易失控。一个中等规模的Multi-Agent系统,每天产生几百万到几千万个Span是很正常的。如果不做控制,存储成本会很高。
控制手段主要有四个:一是设置TTL,比如只保留30天。二是做采样,非问题链路只保留10%。三是截断大字段,input和output只存前500字符。四是压缩存储,ClickHouse本身有很好的压缩率,我们实测压缩比在10:1左右。
我们当时算过一笔账:每天1000万个Span,每个Span平均1KB,原始数据10GB。压缩后1GB,保留30天就是30GB。这个成本完全可以接受。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 链路断裂,Span找不到父节点 | 异步调用未传递上下文 | 检查异步代码的SpanContext传递 | 封装上下文传递工具,强制传递 |
| 链路断裂,Span找不到父节点 | 跨服务调用未带Trace字段 | 检查协议头 | 在MessageBus层统一注入和提取 |
| 链路断裂,Span找不到父节点 | 写入顺序乱 | 检查存储中的timestamp顺序 | 缓冲后批量写入,或查询时二次排序 |
| Tracing拖慢主流程 | 同步写入存储 | 压测对比开启和关闭Tracing的耗时 | 异步批量写入,截断大字段 |
| 存储成本失控 | 全量采样+无TTL | 统计每日数据量和存储费用 | 设置TTL,尾部采样,截断字段 |
| 查询链路慢 | 索引设计不合理 | 查看查询计划 | 按trace_id和时间建索引,分区存储 |
| Span数据不完整 | 异常时未关闭Span | 检查异常处理逻辑 | 用try-finally保证Span一定关闭 |
5.5 几个只有踩过坑才知道的细节
第一个细节:Span的timestamp精度一定要到毫秒。我们最开始用秒级时间戳,结果同一秒内发生的多个Span无法排序,链路图渲染出来是乱的。改成毫秒后问题解决。
第二个细节:trace_id的生成要用全局唯一ID,不要用自增ID。因为Multi-Agent系统可能是分布式的,自增ID会冲突。我们用的是一个带时间前缀的UUID,既唯一又大致有序。
第三个细节:input_payload和output_payload的序列化要用JSON,不要用pickle。JSON可读性好,排查问题时可以直接看。pickle虽然快,但跨语言不兼容,而且有安全风险。
第四个细节:Span的status字段要区分error和timeout。最开始我们只记了success和error,后来发现超时和报错的处理方式不一样,需要区分开。超时通常是性能问题,报错通常是逻辑问题。
第五个细节:metadata字段非常有用,可以塞各种扩展信息。比如模型名称、token消耗、重试次数、用户ID等。我们后来把很多临时想记但没地方放的数据都塞进了metadata,查询时用JSON函数提取,非常灵活。
6. 从Tracing数据中挖掘更多价值
6.1 性能瓶颈分析
Tracing数据不仅能用来排查问题,还能用来做性能分析。我们把所有Span的duration_ms按agent_name和event_type做聚合,算出每个Agent、每种事件类型的平均耗时、P50、P95、P99。然后做成看板,每周review一次。
这个看板帮我们发现了不少性能问题。比如有一次发现某个检索Agent的P95耗时突然从2秒涨到了8秒,展开一看,是它调用的向量数据库查询变慢了。后来查出来是索引没建好,重建索引后恢复正常。
还有一次发现模型推理的耗时占比从60%涨到了85%,排查后发现是prompt变长了,导致推理时间增加。后来优化了prompt模板,把不必要的上下文删掉,耗时降回了正常水平。
6.2 错误模式识别
Tracing数据里的错误信息,如果做聚合分析,能发现一些规律性的问题。
我们把所有status为error的Span按error_message做聚类,发现前三大错误类型是:工具调用超时、模型返回格式错误、参数校验失败。针对这三类错误,我们分别做了优化:工具调用加了重试和熔断,模型返回加了格式校验和自动修复,参数校验加了更友好的错误提示。
优化之后,整体错误率从5%降到了1.2%。这个收益是非常可观的。
6.3 成本归因与优化
如果你的Multi-Agent系统调用了付费的模型API,Tracing数据还能帮你做成本归因。我们在metadata里记录了每次模型推理的token消耗,然后按agent_name和任务类型做聚合,算出每个Agent、每类任务的成本。
这个数据帮我们发现了几个成本大头。比如某个Agent的prompt里塞了大量重复的上下文,导致token消耗是必要值的3倍。优化prompt后,这个Agent的成本直接降了60%。
6.4 链路回放与测试用例生成
Tracing数据还有一个妙用:链路回放。我们把生产环境里记录的一些典型链路(包括正常的和异常的)导出成测试用例,在每次发版前跑一遍,验证新版本的行为是否和旧版本一致。
这种做法比手写测试用例高效得多,因为它是基于真实数据的。而且覆盖率高,各种边界情况都能覆盖到。
我们当时写了一个小工具,输入trace_id,自动把这条链路的所有输入输出提取出来,生成一个可执行的测试脚本。每次发版前跑一遍,几分钟就能完成回归测试。
7. 一些个人体会
这套Tracing系统我们跑了大半年,从最开始的简单日志表,到后来的ClickHouse + 自研可视化,再到迁移到OpenTelemetry + Jaeger,中间踩了不少坑,也积累了一些经验。
最大的体会是:Tracing这件事,越早做越好。不要等到系统复杂了、出问题了才想起来做。因为等到那时候,你不仅要补Tracing,还要改大量已有代码来适配,成本高很多。
第二个体会是:Tracing的设计要和系统的执行模型匹配。串行系统用简单的日志表就够了,层级调度系统需要树状结构,自由协作系统需要图结构。不要一开始就追求大而全,按需设计,逐步演进。
第三个体会是:Tracing数据要能用起来才有价值。存下来不看,等于没存。一定要配套做查询界面、做看板、做告警联动,让数据真正参与到日常开发和运维中。
最后分享一个小技巧:如果你不确定Tracing该记哪些字段,就先按最小字段集记,然后在实际排查问题的过程中,发现缺什么就补什么。我们现在的字段集就是这么一步步迭代出来的,比一开始就设计一个大而全的schema实用得多。