单个大语言模型的调用,追踪起来轻车熟路:发送提示词,拿到 token,记下延迟,翻篇。可一旦四五个智能体同时开跑,有些还并行,每个调着不同的模型,事情就全乱了。有一个超时了,不声不响地切到备用廉价模型;批判型智能体读完输出,又打回去重做一轮;整个流程跑成40秒而不是预计的12秒,或者成本烧掉预算的三倍——你根本不知道是哪个智能体造的孽。
日志在这里几乎帮不上忙。并发任务把日志行交错排成一条扁平的流,没有父子结构,没法问“到底是哪个角色把 token 烧光了”。核心痛点其实很明确:你缺的是一棵看得清责任归属的调用树,而不是一堆平铺的事件日志。
这正是分布式追踪要解决的问题。用 Trace 把每一次跨智能体、跨模型的调用串成有根有叶的树,谁耗了多久、谁走了降级、谁触发了复审,一目了然。可问题又来了:要给每个角色、每次模型调用、每条重试路径都手工接上 OpenTelemetry 仪表,又繁琐又容易漏,漏掉一层,追踪树就开始撒谎。这种苦力活没人想干两遍。
所以,我在一次黑客马拉松里,从自己开发的多智能体代码生成器 DevSwarm 中,把仪表化部分抽了出来,做成了一个独立的、MIT 协议的开源库:otel‑swarm。思路很简单:一次 createSwarm(),三个动词,就把追踪树稳稳当当地搭起来。
otel‑swarm 的 API 干净得有点不真实。引入 createSwarm,传入服务名和 OTLP 端点,就可以得到一个 swarm 对象。用它开启一个 task,叫作“generation”,顺便塞进自定义属性。task 的回调里,根追踪节点已经打开,接下来就是 agent,agent 里面再 llm,层层嵌套,恰好对应真实的调用拓扑。
每个 agent() 调用都会在根节点下开一个子节点,角色名称一目了然:planner、frontend、backend、critic,全看你怎么起名。
而 llm() 更进一步,开的是符合 GenAI 语义约定的追踪节点,里面记录模型名称、输入输出 token 量,甚至把真正执行调用的逻辑封装在一个 call 函数里。这个 call 拿到模型 ID,你负责对接你的模型提供方,返回内容和 token 用量就行。
如果一次模型调用炸了怎么办?otel‑swarm 把容错与可观测性绑到了一起。llm() 接受一个 fallbackModel,call(model) 抛出的异常不会只记在日志里,而是触发一次切换,用备用模型再调一遍 call,并且把这次切换记录为一个名叫 fallback_promotion 的事件。
该事件清清楚楚记录着 from 模型、to 模型,连原始 provider 返回的错误原文都原样保留。谁降级了、从哪降的、因为什么错,都不用去翻别的系统,全在同一个 trace 里。
另一个精妙的设计藏在 swarm.events 里。它是一个 EventEmitter,把追踪节点的生命周期镜像成事件流:llm_start、llm_end、fallback、critic_catch 等等,每个 LLM 事件都带着 traceId。前端实时 UI 监听这些事件,而追踪后端通过 OTLP 导出器接收同一套代码路径生成的 trace;UI 面板上看到的,永远跟 SigNoz 里的 trace 数据一致,不会出现各说各话的情况。更实用的,是 traceId 本身变成了一条深层链接:UI 表格里随便哪一行,点进去就能直接跳到 SigNoz 里那一条精确的 trace。
我们把 npm run example 往本地的 SigNoz 上一挂,效果立刻显现。在服务名 otel‑swarm‑demo 下,整整齐齐生成了 9 个追踪段:一个 1325 毫秒的根节点 generation,下面是 agent.planner 挂着 llm.planner,agent.frontend 挂着 llm.frontend,agent.backend 挂着 llm.backend,最后是 agent.critic 挂着 llm.critic。其中前端 LLM 的节点上挂着一个 fallback_promotion 事件,评审智能体的节点上则有一个 critic_catch 事件。所有东西都在一棵树上,一眼就能看穿整个 swarm 的每一次呼吸。
这套方案并不是 toy demo。在 DevSwarm 的生产环境中,同样的库已经追踪了 29 次 generation、跨 8 个模型
热门跟贴