☰
Multi-Agent系统可观测性实践:从日志碎片到完整执行链Tracing
2026/10/14 5:39:36 网站建设 项目流程

Multi-Agent系统上线后的第三周,我就被一次"看不见的故障"折磨得够呛:任务执行到一半失败,每个Agent的日志都显示"我这边没问题",可整体结果就是不对。五个Agent协作、十几个工具调用,日志散落在不同的进程和目录里,想还原一次完整执行过程,几乎等于做侦探。那几天我干得最多的一件事,就是对着时间戳和残缺日志,闭眼脑补执行链路。直到我下定决心给整个系统装上Tracing,把每一次Multi-Agent执行记录成一条完整的执行链,才彻底告别这种"日志碎片拼图"式的排查方式。这篇文章就把我在这套体系上的设计思路、代码实现和踩过的坑,一次性完整分享出来。不管你是在跑AI Agent应用,还是自己搭了一套多角色协作系统,这套方案都能直接拿来参考。

1. 为什么Multi-Agent系统比单一Agent更需要Tracing

1.1 单一Agent时代:查日志基本够用

我最早做的那版智能体应用,结构非常简单:用户发一句需求,Agent判断要不要调工具,模型生成结果返回。调用路径基本是线性的——入口、模型推理、工具、模型再推理、出口。出了问题,拿到一条请求日志从头看到尾就能定位。那时候我对"可观测性"的理解就是print、logger和异常堆栈。

这种线性结构下,"查日志"之所以够用,是因为链路短、节点少、状态传递清晰。输入输出就那几个,出错无非是模型返回异常、工具调用失败或者提示词写错了,肉眼扫一遍就八九不离十。就算偶尔需要排查性能,也就看两三个节点各自的耗时,很快能算出来瓶颈在哪。

1.2 多Agent协作:日志不是分散,是粉碎

Multi-Agent一上,线性结构就彻底碎掉了。任务的执行不再是一条直线,而是一棵动态生长的树:主控Agent拆解任务,子Agent各自处理一块,Agent之间还会互相委派,碰到复杂流程还会并行执行。调用的工具、外部API、内部微服务、数据库查询全混在一起。

这时候"查日志"的体验会变成:你用请求ID在系统里搜,搜出来几十条记录,时间上有前有后,但没法判断哪条是父节点、哪条是子节点;两个Agent可能往同一个文件里写日志,你分不清谁先执行、谁在等谁。更致命的是,模型调用本身有状态依赖——前一步的输出决定后一步的输入,一旦某个环节"理解错了",后面所有Agent都会被带偏,而日志只记录最终结果,你根本看不到过程里的决策依据。

所以Multi-Agent场景真正需要的,不是更多日志,而是"一次请求一棵树"的数据结构。Tracing就是做这件事的。

1.3 Tracing解决的三类核心问题

归纳下来,我在自己的系统里依靠Tracing解决的是三类问题,你也可以拿这个清单自查:

  • 定位故障:某个任务失败,能从根节点开始逐层排查,找到第一个产生异常的节点;
  • 还原决策过程:每个Agent的输入输出、关键步骤摘要、工具调用记录都在,能复盘"它为什么会这么选";
  • 分析性能与经济性:每个节点的耗时拆解透了,瓶颈在哪一目了然;token用量清清楚楚,知道钱花在哪个环节。

我常跟同事打一个比方:单一Agent的日志像一张购物小票,Multi-Agent的Tracing像快递物流的分拣中心大屏——每个包裹(请求)、每个站点(Agent)、每个动作(工具调用)都在图上,出了问题点一下就看到了。

2. 从一个生产级需求倒推Tracing设计

2.1 先想清楚:你到底要记录什么

开始写代码前,我建议先列一份"必须记录清单"。我的第一版设计只记录了名字和时间戳,结果排查时依然一头雾水,后来才被迫补了十几类字段。这里给出我的最小落地方案:

  • 请求边界信息:Trace ID、会话ID、入口时间;
  • 节点标识:Agent名称/版本、工具调用的名称和参数(跑完记得脱敏);
  • 模型调用记录:模型名、输入token数、输出token数、温度等关键采样参数、首token延迟;
  • 输入输出快照:重要Agent的输入输出全文或摘要,用于事后判断"这一步做的事是否符合预期";
  • 错误信息:异常类型、堆栈片段、重试次数;
  • 时间戳:开始/结束时间,用于计算每个环节的纯耗时。

这份清单的另一个用途,是帮你约束存储成本。别什么字段都塞进去,否则半个月后存储就是个大问题。我见过不止一个团队栽在这里:Tracing是好事,但全量无脑采集很快就会让存储账单一飞冲天。

2.2 链路模型:Span、Trace与执行链的关系

先花一点篇幅把模型讲清楚,因为很多讨论最终说不清,都是栽在概念层面:

概念含义
Trace一次完整的用户请求链路,从入口到出口的全部过程
Span链路中的一个工作单元,可以是一次Agent决策、一次LLM调用或一次工具调用
Parent Span ID当前Span的上一级ID,用于构造树形结构
Execution Chain由多个Span组成的树,根Span代表入口任务

用一个生活类比:Trace像你在餐厅下了一单,从下单到上菜的完整过程;每个Span像后厨的各个环节——点单员登记、厨师备菜、总厨检查。它们之间是递进和协作关系,而不是简单的先后关系。日志是一条时间线,Tracing是一棵依赖树,区别就在这里。

这个模型为什么重要?因为Multi-Agent场景里,并行是常态。如果只按时间顺序写日志,你根本看不清哪些环节可以并行、哪些环节必须等待上一个Agent的结果。树形结构能告诉你的信息包括:关键路径在哪里、哪个分支拖慢了全链路、哪些Agent其实可以并行但被设计成了串行。

2.3 技术选型:不是一开始就要自建

我给出的选择建议是这样:

第一,如果只是快速验证,直接接入行业里成熟的LLM应用追踪平台。它们往往自带Agent、工具、LLM调用的自动埋点,甚至能可视化整个Agent决策过程。最大优势是省事,缺点是数据不一定能完全留在本地,落库结构也未必匹配你的业务抽象。

第二,如果你有跨系统的追踪需求——比如Agent会调用内部微服务——优先基于开放分布式追踪标准来做扩展。在这套标准里,Span结构对Agent场景天然友好:服务对应一个Agent进程,操作对应一次Agent决策或工具调用。你可以把一次Agent调用建模为一个Span,把它的工具调用建模为子Span,把LLM调用建模为另一个子Span,完整表达出一棵执行树。

第三,如果团队有存储和运维能力,完全可以自建一套轻量方案,本质上就是"标准数据模型 + 上报接口 + 存储查询"。自建的重点不在代码多复杂,而在协议统一和语义标准化。我的最终选择就是自建了一个迷你采集服务,通过异步批量接口上报,写入列式存储,再用可视化工具渲染执行链。

但我得说一句:新团队别一上来就自建。先用现成的,把业务跑通,再逐步沉淀成内部通用组件。磨刀不误砍柴工的前提是你得知道砍哪根柴。

3. 手把手实现一套执行链Tracing

3.1 第一步:定义核心数据结构

我用Python举例。SpanRecord是整套体系的基石,字段不建议随意砍,因为后面所有查询、聚合、可视化都依赖它。数据结构如下:

from datetime import datetime, timezone from typing import Optional, Any from pydantic import BaseModel, Field class SpanRecord(BaseModel): trace_id: str span_id: str parent_span_id: Optional[str] = None name: str span_type: str # agent / tool / llm status: str = "ok" # ok / error started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) finished_at: Optional[datetime] = None input: Optional[Any] = None output: Optional[Any] = None metadata: dict = Field(default_factory=dict)

用数据校验库来管理字段,能省掉很多脏数据问题。特别提醒,input和output尽量存"快照"而非"引用"——如果你存的是某个对象的引用,事后查的时候对象可能已经被改了。我自己就吃过这个亏:存了一个list的引用,排查时发现list已经在后序流程里被原地修改了,现场完全被污染。

3.2 第二步:用上下文传播打通执行链

进程内协作的场景里,最省事的做法是用上下文变量(contextvars)来传播当前Span ID。它比全局变量安全,因为每个异步任务的上下文是独立的,并发调用不会互相串号。

import contextvars current_span_ctx: contextvars.ContextVar[str] = contextvars.ContextVar("current_span_id", default="") def get_current_span_id() -> str: return current_span_ctx.get() def set_current_span_id(span_id: str): current_span_ctx.set(span_id)

用这个方式,同步调用链和同一个异步任务内部的await链都能自动传下去:进入一个Agent时set当前span,退出时恢复父span。切到新任务时,系统会自动复制一份上下文给子任务,子任务内部的修改不会影响外层,正好符合我们的需求。

但如果你的Agent是独立进程,比如通过HTTP或者消息队列通信,就不能靠上下文变量了。这时候要在网络协议头里带上标准链路头(格式和业界通用的traceparent一致),对端解析出父Span ID再继续向下挂。记住这条经验:跨进程传递不是可选项,是必须项,否则整条执行链到进程边界就断了。

3.3 第三步:埋点包装器,让改造成本降到最低

我不希望每个Agent代码里手动去写"开始span、结束span、上报span",那样太啰嗦,而且很容易漏。更好的方式是用包装器把埋点逻辑收敛到一处。下面是一个同时支持同步和异步函数的装饰器:

import asyncio import functools import time import uuid def _start_span(name: str, span_type: str) -> SpanRecord: parent_id = get_current_span_id() span = SpanRecord( trace_id=..., # 从入口透传或按需生成 span_id=uuid.uuid4().hex, parent_span_id=parent_id or None, name=name, span_type=span_type, ) set_current_span_id(span.span_id) return span def _end_span(span: SpanRecord, output=None, error=None, status="ok"): span.finished_at = datetime.now(timezone.utc) span.output = output if error: span.status = "error" span.metadata["error"] = error set_current_span_id(span.parent_span_id or "") trace_collector.submit(span) def traced(name: str, span_type: str = "agent"): def decorator(func): if asyncio.iscoroutinefunction(func): @functools.wraps(func) async def async_wrapper(*args, **kwargs): span = _start_span(name, span_type) try: result = await func(*args, **kwargs) _end_span(span, output=result) return result except Exception as exc: _end_span(span, error=str(exc), status="error") raise return async_wrapper else: @functools.wraps(func) def sync_wrapper(*args, **kwargs): span = _start_span(name, span_type) try: result = func(*args, **kwargs) _end_span(span, output=result) return result except Exception as exc: _end_span(span, error=str(exc), status="error") raise return sync_wrapper return decorator

用的时候,在每个Agent的执行函数上直接加一行装饰器:

@traced("research_agent", span_type="agent") async def run_research_agent(query: str): ...

这样改造成本几乎为零。原来已经写好的函数不需要动内部逻辑,只需要在定义处加上一行。LLM调用也可以单独做一个带模型元数据记录的子Span,工具调用类似。

3.4 第四步:采样、存储与可视化

低流量阶段全量采集没问题,但流量一大,就得讲策略了。我的做法是:基于Trace ID做一致性采样,同一个Trace要么全采,要么全不采,保证执行链完整;错误Span强制全采,不能漏。

import hashlib def should_sample(trace_id: str, rate: float = 0.1) -> bool: digest = hashlib.sha256(trace_id.encode("utf-8")).hexdigest() return int(digest[:8], 16) / 0xFFFFFFFF < rate def decide(trace_id: str, status: str, rate: float = 0.1) -> bool: if status == "error": return True return should_sample(trace_id, rate)

存储环节建议用列式分析型数据库,这种存储对"按Trace ID筛出整棵执行链"的查询非常友好。写入一定要走批量异步通道,不要每个Span一条insert,否则系统稍微一起来,数据库连接和写入吞吐都会成为瓶颈。我见过最简单的批量模型是这样:

class TraceCollector: def __init__(self, buffer_size=1000): self.buffer = [] self.buffer_size = buffer_size def submit(self, span: SpanRecord): self.buffer.append(span.model_dump()) if len(self.buffer) >= self.buffer_size: self.flush() def flush(self): payloads = self.buffer[:] self.buffer.clear() # 异步批量发送,失败进入重试队列 upload_batch(payloads)

可视化方面,最理想的是渲染出树形时间轴:每行是一个Span,横轴是时间,缩进体现父子关系。不用自己从零画,成熟的分布式追踪UI都能直接喂数据。如果你暂时不想引入太重的前端组件,哪怕先导出一张表格按缩进列出来,也比看原始日志强十倍。

4. 实战排查案例与踩坑心得

4.1 异步并发下父子关系错乱

我踩的第一个大坑,出现在用异步任务并发调用多个工具时。现象是:多个工具调用的Span全部挂到了同一个父节点下,执行链看起来像一个扇子,而不是一棵树。排查半天发现,问题出在asyncio.create_task。

创建新任务时,系统会复制当前上下文,但这个复制发生在create_task那一刻。如果我在子任务内部延迟创建Span,或者在子任务里误读了外层已经被重置的上下文,父子关系就会乱掉。更隐蔽的是,一旦借助线程池处理阻塞操作,contextvars默认不会自动跨线程传播,父Span ID直接丢失。

我的教训是:跨任务边界的时候,不要依赖上下文隐式传递,显式把父Span ID传给子任务的初始化参数。也就是说,靠装饰器解决同一任务内的执行链,在任务并发边界要手动维护。实操时我还额外规定了一条纪律:任何"生成新任务"的代码,必须承载父Span ID,不能靠运气。

4.2 数据膨胀:吞吐一高存储就爆炸

第二个坑更疼。有一个Agent会在内部做多轮自我反思,每轮自我反思都产生几千字的思维过程,加上工具返回的全文,一个请求累积下来七八KB。开始时我全量存储,高并发下运行一周就吃掉了几十个GB。

后来我做了三层保留策略:

场景保留内容说明
正常Span输入摘要 + 输出前约500字够判断关键决策
模型调用Spantoken用量 + 采样参数 + 异常标记不存完整提示词
错误Span完整输入输出快照 + 堆栈排障优先级最高

特别注意,要给超长文本做截断时别切在奇怪的位置。我通常先存一个字符级别摘要,再加一个"全文截断"字段,两者配合。摘要用于快速理解,截断字段用于需要看细节时兜底。

存储的TTL策略也要想清楚:热数据保留7天,冷数据归档到对象存储,压缩保存。线上排查只需要最近几天,历史数据留着是为了做回归集和成本分析,不追求秒级查询。

4.3 一个耗时异常增高的真实排查案例

有一次用户反馈,某个任务的平均耗时从4秒涨到了30秒。表面上看像是模型变慢了,但我打开执行链后发现,任务被拆成了主控Agent加三个子Agent,三个子Agent并行执行,其中"审查Agent"那条链路明显最长,占了28秒。

展开审查Agent的Span树,才看到它在内部连续调了三次"内容安全模型",前三次都返回空内容,第四次才成功。应用层日志里只有"审查Agent耗时28秒"这一行,根本看不见它为什么卡了那么久。通过Span里保存的输入快照,我发现提示词已经超过了模型上下文窗口的某个隐藏限制,被自动截断,导致模型返回空内容。更麻烦的是应用层没有捕获"HTTP 200但内容为空"这种异常情况,于是傻乎乎地重试了三轮。

修复方案两件事:一是压缩提示词,把冗余指令精简掉;二是重试前必须检查输出是否为空,空则走降级逻辑而不是盲目重试。这个案例如果没有Tracing,我可能得靠猜和反复复现才能定位,至少多花一两天。

4.4 快速排查速查表

症状先看什么常见根因
整体耗时突增根Span下各分支耗时对比某子Agent串行阻塞
任务中途失败但无异常日志错误Span所在分支上游输入异常导致下游决策失败
Token消耗异常高LLM调用Span的usage聚合长上下文重试或低效多轮调用
执行链断掉只剩根节点跨进程Header传递网络边界未透传链路ID
并发执行时父子混乱子任务的Parent ID异步任务创建时机或线程池执行

5. 从执行链到全链路可观测的扩展

5.1 让执行链变成质量评估的素材库

执行链数据除了排查,还有更大的用途:它是天然的质量评估样本集。每个Span记录的输入输出,就是一个业务场景下的真实案例。我会定期把错误样本和低质量输出自动收集起来,归入回归测试集。以后做Agent升级、换模型、调提示词的时候,先把历史执行链跑一遍,看有没有引入回归。

这件事的价值比你想的大。我在实际使用中发现,很多"感觉变好了"的改动,用历史链路一测就原形毕露——某个场景得分上去了,其他场景却悄悄变差。没有执行链做基线,这种回归风险根本发现不了。

更进一步,还可以把Trace和用户反馈关联起来:用户点"不满意"时,自动拉出这条请求的整棵执行链,辅助人工复盘。做多了之后,你甚至可以让另一个Agent自动阅读执行链来给出复盘意见。这就是把排查工具升级成质量闭环了。

5.2 从成本追踪到预算"红绿灯"

Token用量如果能按执行链聚合,成本分析会变得非常精细。比如你可以跑一下SQL,看花费最高的前十个场景分别消费在哪个环节:是某个Agent反复调用长上下文的模型,还是某个工具把超大返回结果塞给了模型。

基于这些数据,我给系统的预算控制加了一层"红绿灯":某类执行链的平均成本超过阈值就触发告警,连续超限就自动切换更便宜的模型或收紧重试策略。没有Tracing之前,成本是月底看账单;有了之后,成本可以做到实时监控、按链路归因。

5.3 从被动排障到主动预警

当Tracing跑了一段时间后,你会积累出"正常基线"。比如主控Agent平均耗时1.2秒,审查Agent平均耗时3.5秒,工具调用失败率0.5%。当某个指标连续偏离基线时,就可以自动告警。这比盯着CPU和内存告警有意义得多,因为它告警的是业务行为本身出了问题。

你甚至可以给Span类型建指标:LLM调用延迟分布、工具失败率、Agent重试次数、上下文命中率。把这些指标和基础设施指标放在同一个仪表盘上,整个系统的健康度一目了然。

写这套Tracing体系的过程中,我最深的体会是:它不只是一个排查工具,更像一套系统行为的体检报告。搭建本身不复杂,难在执行标准能不能坚持、数据质量能不能维护。我现在的做法是,埋点统一走内部包,任何Agent代码都必须通过那个装饰器接入;每周固定用trace数据做一次系统体检。

给正准备上Multi-Agent的朋友一个建议:第一天就把Tracing接上,别等出事故再补。补的时候你会发现,丢失的上下文恰恰是最难追回的东西——那段时间里到底发生了什么,没人知道,也永远不会知道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询