资讯详情

资讯详情

读开源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, timeoutself.timeout) if remote.code ! 0: raise CapabilityError(f工单接口返回错误: {remote.msg}) text self._format_status(remote.data) return CapabilityResult.ok(texttext) 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 加能力会先问自己三个问题模型如何发现这个能力模型如何正确填参数失败后如何优雅地反馈给用户这三个问题想清楚了加能力就真的只是填空的活了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →