ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

读开源Agent源码,搞懂扩展机制设计与能力接入

读开源Agent源码,搞懂扩展机制设计与能力接入

最近花了小一周时间,把某个开源 Agent 项目的扩展机制源码从头到尾读了一遍。起因特别朴素:我想给它加一个「查询内部工单状态」的能力。文档里写得很轻松,"注册一个工具,声明好 schema,就完事了",结果真正动手的时候,光是"注册成功但模型就是不调用"这一个问题,就卡了我大半天。后来我干脆不看文档,直接把代码翻出来,从能力注册表入口开始,沿着一次真实调用路径追到底。读完之后我才发现,以前对「给 Agent 加能力」这件事的理解,确实太浅了。

这篇文章不是源码逐行注释,而是想把我沿着这个问题读完源码后,对扩展机制设计的理解、实际加能力的过程,以及踩过的坑,都梳理出来。如果你也在给 Agent 项目接入新能力,或者准备读一套类似的扩展机制源码,这几个角度应该能帮你省点时间。

1. 为什么一个看似简单的功能会让我去翻源码

1.1 起因:来自一个朴素的业务需求

需求本身不复杂。我们内部有一个工单系统,每天有大量同事在处理客户问题时需要反复确认工单进展。希望让 Agent 直接回答这类问题:用户发一句"我的工单 TK-20241215-001 现在处理到哪一步了",Agent 自动调用内部接口,把状态返回给用户。

第一反应自然就是"加个工具函数"。在我原本的认知里,给 Agent 加能力不就是写一个普通函数,然后在某处注册一下吗?结果实际操作下来,两个问题把我拦住了。

第一个问题是,能力注册好之后,模型在对话里从来不主动调用它。无论我怎么问,模型都回答"暂时无法访问内部系统"。第二个问题更诡异,偶尔能触发调用,但传参总是错的,比如把完整的工单号截掉一段再传进去。我去翻项目的使用文档,文档里只讲了"定义输入输出格式、注册工具"两步,完全没有提到底层是怎么把能力暴露给模型的。

我开始意识到,用户文档描述的是"怎么用",而真正决定"为什么能用、为什么不能用"的细节,全在源码里。

1.2 使用者直觉与实现者现实的差距

没读源码之前,我对「加能力」的理解有三个层次,其实只有一个:

  • 以为只需要写一个能完成任务的函数,业务逻辑对就行
  • 以为注册之后能力自然会被模型发现和使用
  • 完全不关心能力在 Agent 主循环里是怎么被加载、调度、回传结果的

读完源码之后,"加能力"这件事在我心里被拆成了三个完全不同的环节:

第一,业务实现层。你写一个函数或类,真正去调用某个 API、查某个数据库,这部分和普通后端开发没有区别。

第二,能力暴露层。模型不是人,它读不懂你的代码,它只能通过一段文本描述和一份参数规范来"理解"这个能力是干什么的、什么时候该用、参数怎么填。这一层做不好,能力写得再好也等于不存在。

第三,生命周期与兜底层。能力不是裸函数,它在框架里要经历加载、初始化、校验、调用、异常处理、卸载这些阶段。每一个阶段都有约定,违反约定可能不会立刻报错,但会在特定场景下给你挖坑。

源码的力量就在于,它把这三个层次的约定全部具象化了。

2. 打开源码:扩展机制的主干到底是怎么设计的

2.1 能力不是函数,是一个带生命周期的对象

我读的这个开源 Agent 项目,扩展机制的核心抽象不是"函数",而是"能力对象"。每个能力被定义成一个类,类上除了业务方法之外,还带有一组元数据和生命周期钩子。

简化之后大概是这种感觉:

class AgentCapability: name: str description: str input_schema: dict async def on_load(self, ctx: CapabilityContext) -> None: """能力被装载时调用,一般用来初始化连接、读取配置。""" async def run(self, params: dict, ctx: CapabilityContext) -> CapabilityResult: """能力被模型调用时执行的核心方法。""" async def on_unload(self) -> None: """能力被卸载时调用,用于释放资源。"""

为什么框架要把能力设计成一个类,而不是一个函数?我读源码时候的体会是:函数是一次性的调用单元,但一个能力在 Agent 进程里是要长期存活的。它需要在启动时建立数据库连接池、读取配置、预加载数据,运行时要被反复调用,停止时要释放资源。这些状态和生命周期,函数表达不了,类更合适。

另外,把元数据直接定义在类上还有一个好处:框架可以在启动阶段扫描所有能力对象,自动收集它们的 name、description、input_schema 列表。这个列表最终会作为"工具清单"喂给模型,是模型决定"要不要调用、什么时候调用"的唯一依据。后面我会详细说这个清单的重要性。

2.2 注册表:能力集合的管理中枢

这个项目扩展机制的中枢,是一个注册表对象。所谓注册表,本质上就是一个以能力名称为 key 的字典,只不过在这个字典之上加了几层约束和查询能力。

框架里对应的代码逻辑,简化下来大致如下:

class CapabilityRegistry: def __init__(self): self._capabilities = {} def register(self, capability: AgentCapability) -> None: if capability.name in self._capabilities: raise DuplicateCapabilityError(capability.name) self._capabilities[capability.name] = capability def resolve(self, name: str) -> AgentCapability: return self._capabilities.get(name) def all_schemas(self) -> list[dict]: """返回所有能力的工具声明,最终会拼到模型上下文里。""" return [cap.schema() for cap in self._capabilities.values()]

这段代码看起来简单,但它解决了几个关键问题:

第一,能力注册是显式的、白名单式的。不是你在文件夹里放一个文件它就会被加载,需要主动调用 register,这样运行时到底有哪些能力可用,是确定、可审计的。

第二,注册表提供了"按名字查找"的机制。模型在推理结束时会返回一个 tool_call 结构,里面包含能力名称和参数字典,框架就是靠这个名称在注册表中找到对应能力对象的。

第三,all_schemas 是连接大模型和理解能力的桥梁。它会把所有能力的描述、参数声明汇总成一个数组,在每次对话推理时注入上下文。这正是模型"知道有哪些工具可以用"的信息来源。

我在这里卡了很久才转过弯来:Agent 的扩展机制不是简单地调用一个函数,而是要先把能力的"说明书"同步给模型,模型再决定调用哪个能力、用什么参数调用。注册表就是这份说明书的管理器。

2.3 加载顺序与依赖:框架如何优雅地处理初始化

一开始我以为能力加载就是循环执行 register,读完才发现没那么简单。真实项目里,能力之间会有依赖关系。

举个例子:我那个工单查询能力,内部依赖一个统一的 HTTP 客户端对象。这个客户端在其他能力里也要用,所以框架在设计时把它作为一个公共服务放进了 CapabilityContext。能力通过 ctx.get_service("http_client") 拿到它,而不是自己去 new 一个。

框架在启动时,会先收集所有能力声明的依赖关系,再做一个拓扑排序,保证被依赖的能力或服务先初始化。这样就不会出现某个能力运行时发现依赖还没准备好的问题。

更讲究的是加载失败的处理策略。如果某个能力的 on_load 抛异常,框架不会让整个 Agent 启动失败,而是把这个能力标记为 disabled,然后继续启动其他能力。启动完成后,注册表里会有一个专门的接口可以查询哪些能力被禁用了、原因是什么。这个设计很实用,因为 Agent 的能力来源可能很多,某个能力加载失败不该拖垮整个系统。

3. 亲自实现一个能力:完整过程与代码级拆解

3.1 第一步:把「查询工单」翻译成 schema

看完源码架构后,我开始动手实现。第一件事不是写代码,而是设计这个能力的 schema。

所谓 schema,就是描述能力输入参数的 JSON 规范。大模型在决定调用这个工具时,会严格按照 schema 的说明来填参数。我最后定下来的 schema 长这样:

{ "name": "query_ticket_status", "description": "根据工单编号查询内部工单系统中工单的当前处理状态。当用户询问工单处理进展、审批进度、当前节点时使用。", "parameters": { "type": "object", "properties": { "ticket_id": { "type": "string", "description": "工单号,格式通常以 TK 开头,例如 TK-20241215-001。用户可能只给数字编号,需要补全为完整工单号。" } }, "required": ["ticket_id"] } }

这段 schema 最有价值的部分不是参数类型,而是 description 字段。我一开始写得极其简单:"查询工单状态"。结果模型经常把这个能力和其它能力混淆,甚至面对"帮我查一下审批进度"这种问题时完全不触发调用。

后来我总结出一个规律:模型判断用哪个工具,依据主要是 description 里的语义匹配度。description 要写清楚三件事:这个工具是干什么的、在什么场景下用、参数里有没有需要特殊处理的格式。比如我在 description 里补充了"用户可能只给数字编号,需要补全为完整工单号",模型在实际调用时就会先做格式归一化再传参,这个细节直接解决了我之前遇到的"参数被截断"问题。

3.2 第二步:实现能力类并挂进注册表

schema 定好后,开始写能力类的主体:

class QueryTicketStatusCapability(AgentCapability): name = "query_ticket_status" description = "查询工单状态" input_schema = {...} # 上面那份 schema def on_load(self, ctx: CapabilityContext): self.client = ctx.get_service("ticket_api_client") self.timeout = ctx.config.get("request_timeout", 5) ctx.logger.info("query_ticket_status capability loaded") async def run(self, params: dict, ctx: CapabilityContext) -> CapabilityResult: ticket_id = params["ticket_id"] try: remote = await self.client.query_status(ticket_id, timeout=self.timeout) if remote.code != 0: raise CapabilityError(f"工单接口返回错误: {remote.msg}") text = self._format_status(remote.data) return CapabilityResult.ok(text=text) except CapabilityError: raise except Exception as exc: ctx.logger.exception("unexpected error in query_ticket_status") raise CapabilityError(f"查询工单状态失败: {exc}") def _format_status(self, data: dict) -> str: steps = data.get("steps", []) current = data.get("current_step", "未知") return f"当前进度:{current}。详细节点:" + " > ".join(steps) if steps else f"当前进度:{current}"

这段代码里有几个地方是读了源码之后才学会的:

注意错误类型的转换。能力里拋出来的异常,不能是原生 Exception。框架在调用 run 的时候,只会捕获有限几种异常类型。如果抛了一个框架不认识的异常,整个 Agent 对话会直接崩溃。正确做法是包装成 CapabilityError,框架捕获之后会转成一个失败结果回填给模型。

注意不要在 run 里直接 print。上下文里的 ctx.logger 自带会话 ID 和链路追踪信息,出问题的时候顺着日志能定位到具体是哪一次用户请求触发的。直接 print 到 stdout 等于丢弃了所有上下文信息。

注意超时。Agent 是一个实时交互系统,用户等不了太久。所有外部调用都要有超时控制,这是我这个能力最早就加好的参数。

写完之后,把这个能力类注册进注册表。注册的方式很简单,就是实例化后调用 registry.register,或者如果框架支持装饰器,就在类上打一个 @capability.register 标记。我用的这个框架是显式注册:

registry.register(QueryTicketStatusCapability())

注册之后还有一个验证步骤:遍历注册表,调用 all_schemas,确认 query_ticket_status 出现在工具清单里。这一步别看简单,它能排除一大半"注册失败"的问题。

3.3 第三步:跟踪一次真实调用,看它怎么被模型使用

能力接入完成后,我打开框架的调试日志,完整跟踪了一次用户提问的链路。整个流程大概是这样的:

用户输入"我的工单 TK-20241215-001 怎么样了"。

系统组装消息上下文,把所有已注册能力的 schema 列表作为工具声明喂给模型。此时模型"看到"了 query_ticket_status 这个工具的存在。

模型推理结束,返回一个 tool_call,内容是调用 query_ticket_status,参数是 {"ticket_id": "TK-20241215-001"}。

框架解析 tool_call,拿到能力名称,在注册表里 resolve 找到对应的能力实例。

框架先用 schema 校验参数。如果参数不符合规范,会直接在这里拦截,不会调用业务方法。比如 ticket_id 缺了就不会往下走。

校验通过后,框架执行 run 方法,拿到 CapabilityResult,也就是那段格式化好的状态文本。

框架把工具调用记录和返回结果以工具消息的形式回填给模型。注意,这里的结果不是直接返回给用户,而是先交给模型再做一轮总结。

模型基于工具返回结果,生成最终回复:"您的工单 TK-20241215-001 当前处理进展为:已完成需求确认,下一步进入开发排期,最新更新于今天上午 10:24。"

最后把模型总结后的内容展示给用户。

我花了不少时间才真正理解第 7 步。以前做普通接口开发,函数返回什么就直接给调用方,但 Agent 不是这样。Agent 里工具调用的返回结果是一个"中间产物",它要被模型二次消费。这也就意味着,能力返回的内容要尽量结构化、信息完整,不能省略关键细节,因为你不知道模型会怎么总结这段内容。返回"查询成功"四个字,等于没返回。

3.4 实践中的几个隐性约定

做完整个流程后,我也总结出几个源码里没有明说、但实际运行中特别重要的约定。

能力的 description 不能写废话。有些框架会尝试对 description 做语义压缩,如果一句话里大段都是无关修饰,很可能影响模型的判断。我见过一个极端例子,有人把"该工具非常强大、非常稳定、强烈推荐"都写进 description,结果模型反而忽略它。

能力要遵守"只做声明的事"。如果你在 schema 里写的是"查询订单",就不要在实现里顺手把订单状态给改了。模型对能力的信任建立在其行为一致性上,一旦模型认为这个能力"不可预测",它就可能选择不调用。

输出内容要有层级。一次工单查询可能返回十来个字段,直接全部平铺返回,模型提炼信息时容易漏。最好先组织成"当前进度 + 关键信息 + 明细列表",让模型有重点可抓。

4. 读完源码才看懂的设计权衡

4.1 注册表模式的价值:把「变更」变成「增量」

读完整套扩展机制,我最大的感受是,注册表模式的核心价值不在于"灵活",而在于把"变更"变成了"增量"。

如果不用注册表,最常见的做法是在 Agent 核心代码里用一大串 if-else 去判断模型想调用哪个工具。能力少的时候,这种写法确实又快又直观。但能力一旦超过十个,麻烦就来了:每加一个能力,就要改一次核心代码;每改一次核心文件,就要重新走一遍全量回归。

注册表模式下,Agent 核心代码完全不感知具体能力的实现细节。它只面向 Capability 抽象编程:注册表给我什么能力,我就依赖什么能力。新增一个能力时,你只增加一个新文件,然后注册一下,核心代码一行都不用改。这符合工程里"对扩展开放、对修改关闭"的原则。

用餐厅做类比的话,注册表模式就像自助餐的档口:新增一道菜,只需要在后厨增加一个档口,前台客人拿着盘子去取就行,完全不用改收银系统。

4.2 模型能力与外部能力解耦的本质

这个项目的第二个设计思路,是把"模型本身的推理能力"和"外部工具的能力"彻底解耦。

模型只负责"决策":根据用户问题和工具清单,决定要不要调用工具、调用哪个工具、参数是什么。外部能力只负责"执行":老老实实按照参数去查数据、调接口,把结果结构化返回。两者之间只通过 schema 和 CapabilityResult 通信。

这种解耦最直接的好处是,你可以随意替换底层实现。今天用内部工单接口,明天换成第三方工单平台,只要能力类的行为和 schema 保持一致,模型那边完全不需要重新适配。另一个好处是测试变得非常容易,你不需要启动一个真的 Agent,直接实例化能力类,传参数调用 run,验证结果格式就可以了。

不过解耦也有代价。模型只能通过 description 来理解你的能力,所以"能力写得好不好"变成了一个更微妙的问题。代码层面的解耦做得再好,如果 description 写得模棱两可,模型照样不会正确使用它。

4.3 错误处理、安全边界与可观测性,拉开差距的地方

源码读到最后,我注意到真正能让一个 Agent 项目在真实环境里跑稳的地方,不是能力逻辑本身,而是错误处理、安全边界和可观测性这三层兜底。

错误处理上,框架规定能力只能抛特定类型的异常,所有异常都要能转换成"可回填的失败结果"。为什么?因为 Agent 是一个连续对话系统,一次工具调用的失败不应该终结整个会话。失败结果回填给模型后,模型还能继续处理:它可以提示用户参数有误,可以换个说法再试,可以主动承认暂时无法完成。把失败变成一种"可继续协商的中间状态",这个思路非常关键。

安全边界的处理也很隐蔽。我读源码时发现框架会对模型传入的参数做二次校验,不是直接信任模型。原因很直接,大模型偶尔会凭空捏造参数,比如它可能会自己编一个不存在的工单号,或者把用户名字填进工单号字段。做一次严格校验,能挡掉大部分这种低级错误。更严格一点的框架还会对能力做权限分级,某些能力只允许在特定场景下调用。

可观测性更是容易被忽视。好的框架会在调用链路上埋点,记录能力加载耗时、调用耗时、参数大小、返回大小。出问题的时候,顺着这些指标能快速定位是模型决策慢了,还是能力执行慢了。我这次排查问题,一大半时间都花在看日志和指标上,如果没有这些埋点,效率至少要低一半。

5. 一周时间踩掉的坑:问题与排查实录

5.1 问题一:能力注册了,模型始终不调用

这是我最开始遇到的头号问题。注册表里明明能看到 query_ticket_status,但不管怎么问,模型就是不用。

排查思路是沿着"模型如何感知能力"这条链一路走下来:

首先确认能力真的注册进注册表了。我在启动日志里加了打印,确认注册成功。

然后检查 all_schemas 的输出。这里我发现了第一个坑:schema 里的 name 字段如果我写成了 queryTicketStatus 之类的大小写不一致,模型拿到的工具名就和注册表的 key 对不上,自然找不到。统一之后问题解决了一半。

再检查 description 是否足够明确。我之前写的是"查询工单状态",这个描述太泛了,模型面对"我的单子到哪了""审批到哪步了"这类说法时,匹配度不够。把描述改成前面那种声明式的详细写法之后,触发率明显提升。

最后,我还发现一个更隐蔽的问题:工具清单里能力太多时,模型偶尔会遗漏某些工具。这个属于模型上下文窗口的注意力问题,解决方法是精简 description,让真正重要的信息更突出。

5.2 问题二:结果回填后又执行了一遍

这个坑是我无意中踩到的。有一天我在测试环境里连续调用同一个工单查询,发现查询接口的调用次数是预期次数的两倍。

查日志后发现,框架内部如果对"工具调用结果"的去重机制不完善,当模型在多轮对话里重读历史消息时,可能会认为这个工具调用还没有被执行,于是再次触发。这个问题的本质是执行状态没有被正确标记。

我自己的项目里没法改框架核心,所以绕行方案是:在能力实现里做一层幂等保护。同一个 ticket_id 在短时间内重复查询时,直接返回上一次缓存的结果。这样即使框架重复调用,也不会对业务造成影响。

5.3 问题三:并发场景下上下文串了

部署上线后有一次压测,同事反馈两个测试账号同时提问时,A 用户查到的工单状态和 B 用户串了。

定位到问题后发现,问题不在能力本身,而在我复用的那个 HTTP 客户端对象上。它的内部实现里有个很隐蔽的全局变量,第一次请求设置的某些状态没有清理,第二次请求就被复用了。在异步并发环境下,这个全局变量被多个请求共享,数据自然就乱了。

解决方法是把所有请求相关的状态从全局变量挪到请求级别的对象里,并且利用上下文隔离机制把 request_id、会话 ID 传递到底层。如果框架支持原生上下文变量,在能力入口把会话信息注入到上下文变量里,是最干净的做法。

5.4 排查工具箱

根据自己的排查经历,我做了一张速查表,专门用来定位"加能力后不对劲"的常见情况:

症状大概率原因优先排查点
能力注册成功但模型不调用description 不准确、schema 名称不一致、工具清单没传给模型打印启动时的注册列表与 schema 列表,逐项核对名称
模型一直传错误参数schema 参数约束太宽、示例不足在 properties 的 description 里补全格式说明和示例
调用后返回空内容接口返回异常、数据被格式化丢掉打开能力日志,确认原始响应内容
工具反复执行多次框架对执行结果去重不足在能力内做幂等控制,增加缓存
多用户之间数据串线全局变量、单例对象在异步环境中共享可变状态检查能力代码中所有全局变量,改用请求级上下文
对话崩溃,无错误输出能力中抛出了框架不识别的原生异常统一转换为框架规定的能力异常类型

这张表不是万能的,但覆盖了我在真实现场遇到的大部分情况,可以作为大家排查时的起步参考。

6. 重新理解「加能力」这件事

6.1 能力是约束下的自由

这套源码读下来,我最大的认知转变是:以前觉得"扩展机制"的使命是给开发者更大的自由,怎么方便怎么来。看完源码才发现,真正让一个 Agent 项目能持续扩展的,恰恰是严格的约束。

注册表约束了能力被发现的方式,schema 约束了能力和模型的沟通语言,生命周期钩子约束了资源从生到死的轨迹。正因为有这些约束,能力模块之间才能做到互不干扰、即插即用。插头和插座是约束,但正是这种约束让任何电器都能即插即用。没有约束的扩展,短期看着自由,长期就是一团乱麻。

6.2 从源码里学到的阅读方法

最后分享一点阅读源码的经验。面对一套不熟悉的扩展机制,最有效的切入路径不是从头读到尾,而是带着一个实际问题,沿最小链路去读。

我会先找注册表入口,搞清能力从声明到注册需要经过哪些步骤;然后沿一次真实调用链路走通,理解能力从模型决策到执行回填的完整流程;最后去读错误处理路径,看看框架在各种异常下怎么兜底。把这三条线读完,整套扩展机制的骨架就清楚了。

这次读源码的收获,不是记住了某个具体实现,而是建立了一个判断框架。以后我再给 Agent 加能力,会先问自己三个问题:模型如何发现这个能力?模型如何正确填参数?失败后如何优雅地反馈给用户?这三个问题想清楚了,"加能力"就真的只是填空的活了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进