可观测性在 Agent 框架里一直是"二等公民"——大多数实现都是先把功能跑通,再把追踪逻辑硬塞进主链路。AgentScope 1.0 时代的 Tracing 就是这么一个典型:OpenTelemetry 的集成直接焊死在 Agent 类内部,流式场景几乎没法追踪,会话 ID 在嵌套 span 之间传不出去。
想象一个最常见的排查场景:用户反馈"这次对话答非所问",你打开追踪面板想看清楚"这一轮里 Agent 调了几次工具、每次耗时多少、中间有没有 thinking 块"。结果你发现,这段对话的 span 全是孤立的碎片,没有共同的会话标识把它们串起来;流式回复那段干脆没有 span;而好不容易拿到的属性,字段名还停留在半年前老模型的结构上。你不是没有追踪,而是追踪"看得见但拼不全、拼全了又对不上"。PR #1579 把这一摊乱麻整个拆了重做,核心动作只有一句话:把 Tracing 从 Agent 的"内脏"里剥离出来,变成一个独立的、声明式接入的TracingMiddleware。这一刀切下去,耦合、流式、会话传播、模型脱节四个老问题同时被解开。我从源码层面拆开讲。
一、旧实现的四个痛点
先说清楚 1.0 时代到底卡在哪。Tracing 当时是 OpenTelemetry 直接嵌在 Agent 类内部的集成,四个结构性问题绕不开。
第一,耦合度极高。tracing 的初始化、span 的创建、属性的填充,全部散落在Agent类的方法体里。你想给框架换一套追踪后端、或者改个 span 命名规则,得去改 Agent 类本身——可观测性逻辑和编排逻辑搅在一起,谁也动不了谁。
第二,流式场景几乎瘫痪。reply_stream这种逐 token 吐数据的接口,在旧实现里没有被纳入追踪范围。流式是 Agent 最常用的交互形态,结果恰恰是最该被观测的部分没有 span 兜底。
第三,缺跨 span 的会话 ID 传播。一个 Agent 调用子 Agent、子 Agent 再调工具,会形成一层层嵌套的 span。但会话标识符(session id)在 span 之间没法自动透传——你看到的每一段 span 都是孤立的,串不成一条完整的会话链路。
第四,提取/转换逻辑和消息、工具块模型脱节。当时的属性提取器还在按老的Message/ToolBlock结构填字段,而模型早就演进到ContentBlock变体(text/thinking/tool/data)了。追踪数据和真实跑起来的数据结构对不上,导出的信息要么缺字段、要么结构错。
这四个问题归纳成一句:追踪逻辑和"它要观测的对象"成了强耦合关系,对象一动,追踪就断。
二、架构升级:剥离成独立的 TracingMiddleware
PR 最大的架构动作,是把 OpenTelemetry 集成从 Agent 类内部整个抽出来,变成中间件系统的一部分。
重构后,TracingMiddleware成了 OpenTelemetry 追踪的唯一入口点。它不再寄生在Agent里,而是作为标准中间件,挂在 AgentScope 的中间件链上。这意味着:追踪能力变成了一种"可插拔"的切面能力——你要就挂上,不要就不挂,Agent 类本身对 OTel 一无所知。
这种剥离带来的好处是结构性的:Agent 类终于只关心编排,tracing 只关心观测,两边通过中间件约定的生命周期钩子通信。后面要加新的追踪语义、换追踪后端,都只在中间件内部改,绝不回头污染 Agent。更深一层看,这也是"依赖倒置"的一次实践——原本是 Agent 依赖 tracing(紧耦合),重构后变成 Agent 依赖一套抽象的中间件接口,具体追踪实现反而是被注入的。框架的编排核心不再知道"怎么追踪",只知道"在某个生命周期节点通知一下钩子",追踪细节完全下沉到中间件。这种关系反转,才是解耦真正的含金量。
值得补充的是,中间件化之后,追踪就不再只服务于 Agent 单一对象了。任何接入中间件链的组件——子 Agent、工具执行器、记忆中间件——都能在统一的生命周期钩子上被观测,因为观测点已经从"Agent 的方法体"升级到了"平台级的生命周期"。换句话说,tracing 的覆盖面是中间件链决定的,而不是某个类的边界决定的。
三、ContextVar 会话传播:嵌套 span 之间的隐形信使
这是整篇里最值得玩味的一块。会话 ID 要在嵌套 span 之间透传,难点在于 span 常常横跨函数调用甚至跨协程,普通的参数传递会因为调用层级深、或异步切换而断掉。
PR 用 Python 的ContextVar解决了这个问题。
# 简化自 PR #1579 的会话传播逻辑importcontextvars# 会话 ID 作为上下文变量,随执行流自动传播session_id_var:contextvars.ContextVar[str]=contextvars.ContextVar("agentscope_session_id",default=None)def_get_or_create_session_id()->str:sid=session_id_var.get()ifsidisNone:sid=str(uuid4())# 尚无会话则新建session_id_var.set(sid)returnsid# 在嵌套 span 创建时,从 ContextVar 取出并填充到 span 属性def_start_span(name:str,...):sid=session_id_var.get()span=tracer.start_span(name,...)ifsid:span.set_attribute("gen_ai.session.id",sid)# 通用属性自动填充returnspanContextVar的关键特性是:它的值跟着"执行上下文"走,而不是跟着函数调用栈走。contextvars模块专门为此设计——当代码通过asyncio创建新任务、或在同步/异步边界切换时,上下文会被自动复制携带,变量值不会丢失。于是最外层Agent.reply设下的session_id,到了它内部调用的子 Agent、再到子 Agent 调用的工具 span 里,都能用session_id_var.get()原样取回。
这正是"跨 span 会话传播"的底层机制:不靠每个函数显式传参,而是靠上下文变量在协程切换时自动透传,把原本孤立的 span 串成一条有共同gen_ai.session.id的会话链路。对排查"这次对话里到底串起了哪几条调用"来说,这是决定性的能力。
为什么说"显式传参"在异步嵌套里会断?假设你把session_id作为参数,从reply一路往下传进子 Agent、再传进call_tool。表面上可行,但只要中间出现一次asyncio.create_task()或loop.create_task(),新任务会拿到一份当前上下文的拷贝——如果拷贝发生在设值之前,子任务就取不到;更糟的是await跨协程时,普通局部变量不会跟随切换。而ContextVar的设计恰恰是为这个场景服务的:它在copy_context()、create_task时自动随上下文携带,值在每个协程边界都保持一致。所以"会话 ID 传播"这件事,本质上不是传参问题,而是执行上下文一致性问题,这正是contextvars的本职。
四、trace_reply_stream 装饰器:三个入口统一织入
为了把追踪能力织进 Agent 的关键动作,PR 新增了trace_reply_stream装饰器,并把它应用到三个入口上。
# 简化自 PR #1579:用一个装饰器统一织入追踪trace_reply_stream(Agent.reply_stream)# 流式回复 —— 旧实现漏掉的重灾区trace_reply_stream(Agent.reply)# 非流式回复trace_reply_stream(Toolkit.call_tool)# 工具调用# 装饰器内部大致逻辑deftrace_reply_stream(fn):@wraps(fn)asyncdefwrapper(self,*args,**kwargs):sid=_get_or_create_session_id()with_start_span(_span_name(fn))asspan:span.set_attribute("gen_ai.session.id",sid)result=awaitfn(self,*args,**kwargs)_extract_and_set(span,result)# 结果回写 span 属性returnresultreturnwrapper注意这个装饰器同时覆盖了reply_stream和reply—— 这意味着流式和非流式两条回复路径终于被同一套追踪逻辑统一接管,旧实现里"流式不追踪"的漏洞被直接堵上。而把它也挂在Toolkit.call_tool上,工具调用的耗时、入参、结果也一并纳入 span,让"回复 → 调工具 → 再回复"的完整链路有迹可循。
装饰器模式在这里的意义是:追踪逻辑对业务方法是透明的。被装饰的方法体不需要任何改动,织入发生在方法之外,正好呼应了"追踪是可插拔切面"的整体设计。
五、Extractor / Converter 重构:对齐 ContentBlock 模型
最后一块硬骨头,是让追踪数据真正对齐当前的块模型。PR 重构了两层:提取器(Extractor)和转换器(Converter)。
Extractor 负责把会话 ID 等通用属性填进 span,并且更新了对 Agent 属性、Tool 属性的提取逻辑,使其匹配当前的结构。之前脱节的根源,就是提取逻辑还在读老的字段名,模型演进后这些字段早改了。
Converter 负责把 Pydantic 的ContentBlock变体转换成 OTel 的 GenAI “parts” 语义。ContentBlock在 AgentScope 里是一族变体:文本块(text)、思考块(thinking)、工具块(tool)、数据块(data)。Converter 把这些变体逐个映射到 OTel GenAI 约定里的parts——每个 part 带类型、内容、角色等信息,使得导出的追踪数据能被任何兼容 GenAI 语义约定的后端(如各类 LLM 可观测平台)直接消费,而不是 AgentScope 自创的一套私有结构。
# 简化自 PR #1579 的 Converter:ContentBlock 变体 → GenAI partsdefcontent_block_to_genai_part(block:ContentBlock)->dict:ifisinstance(block,TextBlock):return{"type":"text","text":block.text}ifisinstance(block,ThinkingBlock):return{"type":"thinking","text":block.thinking}ifisinstance(block,ToolBlock):return{"type":"tool","name":block.name,"args":block.args}ifisinstance(block,DataBlock):return{"type":"data","data":block.data}return{"type":"unknown"}这套转换的价值在于语义对齐:追踪系统不再只记录"一段文本",而是记录"这是 thinking 块还是 tool 块",下游分析平台才能据此做差异化展示(比如把 thinking 和正式回复区分呈现)。把内部模型结构和行业标准语义约定对齐,是让 Agent 可观测性"出圈"到通用生态的关键一步。
六、on_reply 生命周期接入:完整链路兜底
重构后的TracingMiddleware在on_reply生命周期钩子里为 Agent 的全生命周期接入追踪。也就是说,中间件不是在单个方法上打补丁,而是在 Agent 的回复生命周期这一更高层级的锚点上统一织入——无论这次回复内部走了多少层嵌套调用、是否流式、是否调了工具,只要它发生在on_reply的边界内,追踪就完整覆盖。
把三、四、五节串起来看:ContextVar 保证会话 ID 在嵌套 span 间透传,装饰器保证三个入口都被织入,Converter 保证块模型对齐 GenAI 语义,而on_reply是这一切的总开关。四者合力,才把"可观测性"从 1.0 时代的补丁式缝合,升级成中间件式的声明式切面。
七、给框架开发者的几点启发
第一,可观测性应该是切面,不是内脏。任何直接焊死在主类里的追踪逻辑,最终都会因为主类演进而脱节。用中间件/装饰器把它隔离出来,主类清爽、追踪也能独立演进。
第二,跨调用的上下文传播优先选 ContextVar。尤其是异步、嵌套场景,显式传参会断,而contextvars跟着执行上下文自动携带,是会话/租户/追踪链传播的天然载体。
第三,流式和非流式要同一套追踪逻辑。流式是最常用的交互形态,却最容易在追踪里被漏掉。统一用一套装饰器覆盖两个入口,能避免"大部分能追、流式追不到"的尴尬。
第四,追踪数据要对齐行业标准语义约定。把内部块模型转换成 OTel GenAI 的parts,而不是自创私有结构,能让你的追踪数据被通用可观测生态直接消费——这决定了你的可观测性是"闭环自嗨"还是"开放互联"。当你哪天想把追踪接进 Jaeger、Grafana 或任意兼容 GenAI 语义的后端时,前期对齐约定的成本,会十倍地返还成接入的便利。
第五,把"会话传播"当成一等公民来设计,而非事后补丁。1.0 时代的遗漏恰恰说明,会话标识如果在设计之初没被当成贯穿链路的基础设施,后面想补会非常痛苦——你已经到处都在创建 span 了,再去 retro 地往每个 span 里塞 ID,成本高且容易漏。PR #1579 用ContextVar在一处设值、处处可读,正是"基础设施前置"的范式:先有传播通道,再让所有 span 自然接入。
如果你正在给自己的 Agent 框架加可观测性,PR #1579 给出的范式很清晰:别把追踪塞进 Agent 的肚子,把它做成一件能随时挂上、随时摘下的中间件外套。当追踪从紧耦合的装饰器进化成声明式中间件,框架的可观测性才真正有了长大的空间。