我一直有个习惯:拿到一个开源项目,第一件事不是看 README 怎么吹,而是直接翻源码找它的“骨架”。因为所谓框架,本质就是作者对一类问题的抽象答案。这次我拆的是 DeepSeek 开源的一个 Agent 框架,拆到一半就忍不住感叹:原来“插件化”不是一个技术名词,而是一整套设计哲学。不夸张地说,这个框架把插件化做到了让我重新审视自己代码习惯的程度。
这篇内容不打算讲 API 怎么调,重点是我从源码里扒出来的几条核心设计思路,以及我把自定义工具挂进框架时踩过的真坑。如果你也在写 Agent、做 LLM 应用,或者单纯想看看“好的插件系统该长什么样”,这篇应该能给你一些不一样的参考。
1. 为什么我会对一个 Agent 框架的“插件化”这么上头
1.1 我之前的 Agent 代码长什么样:直面痛点
在拆这个框架之前,我自己也写过几个 Agent 应用,代码风格可以用四个字形容:惨不忍睹。
核心逻辑就是一个巨长的run()函数,里面塞了一条又一条if-elif-else。用户输入“帮我查天气”,我就判断关键词“天气”,然后调天气 API;用户输入“搜索一下”,我就判断关键词“搜索”,然后调搜索接口。每加一个新能力,我就要往这个函数里再塞一个分支。结果就是:函数越来越长,逻辑越来越绕,改一个分支可能影响另外三个功能。
这种“面向 if-else 编程”的方式,在只有两三个工具的时候还能忍。一旦工具数量到十个、二十个,代码就成了一团浆糊。而且最难受的是:每加一个工具,我都要重新读一遍主函数的所有分支,生怕漏掉某个边界条件。
我拆开源框架时带着一个疑问:一个“正经”的 Agent 框架是怎么管理这么多工具的?结果“插件化”这三个字给了我一个非常出乎意料的答案。
1.2 插件化到底在解决什么问题:从“改代码”到“插零件”
插件化的表面定义谁都会说:把功能封装成独立模块,按需加载,互不影响。但真正让我“上头”的,是这个框架对插件化的理解角度。
它不把插件当成“功能模块”,而是把插件当成“能力描述”。什么意思?在传统写法里,我加一个天气工具,关心的是“if 用户提到天气就执行”。在这个框架里,我加一个天气工具,关心的是“我这个工具能做什么、输入什么、输出什么、什么时候适合用”,然后把选择权交给了 Agent 本身去编排。
这就是思路上的一个巨大翻转:传统方式是“代码主动匹配用户意图”,插件化方式是“模型根据能力描述主动匹配工具”。前者是人写的规则,后者是模型驱动的决策。
为了讲清楚这件事,我决定把它的源码从头到尾过一遍,重点盯住三个问题:Agent 主循环长什么样?插件是怎么被注册进去的?插件之间是怎么配合而不打架的?
1.3 这次拆解的目标与标准
给自己定个拆解标准,避免走马观花:
- 第一,读懂它主循环的骨架:Agent 是怎么一步步从接收到输出完整走完的。
- 第二,搞懂插件注册机制:它用什么数据结构存插件、插件怎么暴露给模型、模型怎么知道该调用哪个插件。
- 第三,实际跑一个 Demo:自己写一个插件挂进去,验证整个流程。
- 第四,记录坑和意外情况:不经历几个 bug 的拆解,等于没拆。
带着这四个目标,我正式开工了。
2. 框架骨架拆解:Agent 循环到底是怎么跑起来的
2.1 核心抽象:Agent、Tool、Memory 各自分工
一个 Agent 框架跑起来的前提,是它得先把概念分清楚。这个框架的核心抽象非常干净,我数了一下,真正称得上“核心类”的只有三个:
- Agent:负责主循环调度。它拿着模型、工具列表、记忆存储,一步一步推进对话。
- Tool:负责具体执行。现在大家更习惯叫 Function Calling 的工具,但在这里它被抽象得更宽泛,不仅可以调 API,还可以执行一段本地逻辑。
- Memory:负责上下文管理。它决定模型能看到多少历史信息。
按照框架源码里的设计,Agent 把一个复杂任务拆成四个阶段:感知系统输入、决策调用哪个工具、执行工具动作、观察执行结果然后决定下一步。
明白了这几个概念的分工,再看主循环就非常顺了。它就像一个工厂流水线:Agent 是车间主任,Tool 是一台台机器,Memory 是仓库管理员。车间主任拿到订单,决定先开哪台机器,机器反馈结果之后仓库管理员把新的物料放回线边,车间主任再决定下一步,直到订单完成为止。
2.2 主循环的执行流程:感知、决策、行动、观察
我把源码里的执行流程画成一条逻辑链(不画图,用文字描述):
- Agent 接收到用户消息。
- 把用户消息、历史记录、工具描述拼进 Prompt。
- 调用模型,拿到返回结果。
- 判断返回结果里有没有“工具调用指令”。
- 如果有,解析出工具名和参数,执行对应的 Tool。
- 把工具执行结果作为“观察”追加到消息列表。
- 再次调用模型,如此循环,直到模型返回最终答案。
可以看出这个循环本身很简单,真正有讲究的是每一步里塞进去的扩展点。
最关键的一点是:这个框架里工具调用指令的格式不是硬编码在 Agent 类里的,而是通过 Tool 的 schema 声明生成的。每个插件只需要暴露自己的 name、description、parameters,框架会自动生成传给模型的工具描述列表。你要是新增一个插件,只要在注册时把这三样东西给全,模型自然而然就会认识新工具。
2.3 关键源码节点走读:它是怎么把我的输入交给大模型的
源码里头最核心的一段,我把它简化成伪代码(实际代码我做了脱敏处理,结构保持一致):
async def run(self, user_input: str): messages = self.memory.build(user_input) # 组装上下文 for step in range(self.max_steps): response = await self.llm.chat(messages) # 调用模型 if response.tool_call: tool = self.registry.get(response.tool_call.name) result = await tool.execute(response.tool_call.arguments) messages.append(self._as_observation(result)) continue # 循环继续,让模型看结果后决定下一步 else: return response.content # 模型没要求调用工具,直接返回答案就这几行代码,配合 registry(注册器)、tool schema、memory 三个辅助模块,组成了整个 Agent 的地基。
我再细说一下 messages 的作用。它是整个循环的“黑匣子记录仪”:用户说过的、模型说过的、工具返回过的,所有信息都会追加到列表里。这样模型每一次做出新决策的时候,都能看到前几步发生了什么,不会“失忆”。
我看完这段代码的第一反应是:我之前把 Agent 想复杂了。它不需要一个复杂的“规划器”,它只需要一个循环,加上一个能“看见所有工具”的模型。规划这件事本身,被交给了模型。而框架要做的,只是提供高质量的工具描述和上下文结构。
3. 插件化的底层机制:注册器才是灵魂
3.1 一个注册器如何把杂乱工具变成整齐工具表
聊插件化,必聊注册器。这个框架里的 registry 并不复杂,但设计得很闭环。核心数据结构就是一个字典:Key 是工具名,Value 是工具对象。就这么简单?对,就这么简单。复杂的是它往里塞东西的方式。
我简化一下注册器核心逻辑:
class ToolRegistry: def __init__(self): self._tools = {} def register(self, tool: BaseTool): # 工具必须提供 schema,否则注册时直接报错 schema = tool.get_schema() self._tools[tool.name] = { "schema": schema, "handler": tool } def get_schema_list(self): # 把全部工具 schema 拉成列表,供模型读取 return [t["schema"] for t in self._tools.values()] def execute(self, name: str, arguments: dict): handler = self._tools[name]["handler"] return handler.execute(**arguments)什么是 schema?说白了就是工具的使用说明书。里面写着工具叫什么、能干什么、需要哪些参数、每个参数是什么格式。这个说明书有两份用途:一份给模型看,让模型知道“有这个工具可用,输入长这样”;另一份给框架看,用来做参数校验。
我在拆源码时特别注意了 schema 结构,发现它遵循一套约定俗成的格式:工具名是简短清晰的短语,描述是“在什么情况下适合用这个工具”的完整句子,参数列表说明类型和是否必填。这比我自己以前起的“func1”“func2”这种名字不知道高到哪里去了。
3.2 钩子机制与上下文传递:插件之间如何不打架
如果一个框架只有注册器,那它还不能算“优雅”。真正让我眼前一亮的是插件之间的数据传递方式——它没有设计复杂的插件间通信,而是规定了一个非常朴素的约定:每个插件只接收框架传进来的上下文对象,只返回自己的执行结果。
这个上下文对象就是 memory 里的消息列表和会话状态。插件不直接改上下文,只返回结果,由框架统一把结果写入 memory。
举个例子:A 工具是搜索商品,B 工具是下单。A 执行完返回了一个商品列表,B 并不需要直接拿到 A 的内部状态,它只需要从 memory 里读“商品列表”这个消息,然后提取商品 ID 就可以执行。插件之间没有耦合,全靠一条“消息总线”串联。
这个设计比我之前见过的一些“插件之间互相调用 API”的方案干净太多。插件像一个个独立工位,每个工位只负责自己那一道工序,工件通过传送带(memory)从一个工位流向下一个工位。
3.3 它和我之前见过的插件系统的本质差别
差在哪?我认为有三点:
第一,它的插件描述是面向模型的,不是面向代码的。传统插件系统的入口往往是一门编程语言里的接口定义;它的入口是一段写给人(模型)看的自然语言描述。模型读懂了描述,就能自主调用。
第二,它把“插件执行”和“插件决策”分开了。框架只负责执行插件,模型负责决定要不要执行以及执行哪一个。传统系统往往是代码里写死“什么条件触发什么插件”,它是模型来做这个判断。
第三,它对插件做了“能力视角”的抽象。传统插件通常对应一个具体的 API,而这里的插件被定义成一个“能力单元”:可能是调用 API,可能是组合多个 API,甚至可能是一个固定的业务规则。
我边拆边想:这套设计是不是也适合普通后端项目?比如我把一个订单系统的“支付”“库存扣减”“通知”都做成插件,每个插件提供能力描述,由一个决策模块根据业务场景来编排——这不就是 AI 时代的服务编排吗?
4. 实战测试:把自定义工具挂进框架的全过程
4.1 我写的第一个插件:一个简单的文件搜索工具
光读源码不过瘾,我实际写了一个插件挂进去跑。我选了一个非常实用的功能:在指定目录里递归搜索包含关键字的文件。这个功能用 Python 写大概就是 os.walk + 字符串匹配,但把它封装成框架插件,还是有不少讲究。
我的插件定义(代码做了精简和脱敏):
from typing import Dict, Any class FileSearchTool(BaseTool): name = "file_search" description = "在指定目录中递归搜索包含关键字的文件路径列表,适合用户需要查找某个文件时使用。" parameters = { "type": "object", "properties": { "directory": {"type": "string", "description": "要搜索的起始目录绝对路径"}, "keyword": {"type": "string", "description": "文件名中包含的关键字"} }, "required": ["directory", "keyword"] } async def execute(self, directory: str, keyword: str) -> Dict[str, Any]: import os matches = [] for root, dirs, files in os.walk(directory): for f in files: if keyword in f: matches.append(os.path.join(root, f)) return {"matches": matches[:50]}写完这个插件,我只需要在启动时做一次注册:
framework = AgentFramework() framework.register_tool(FileSearchTool())然后就可以直接和 Agent 对话:“帮我在 Downloads 目录里找一下和 budget 相关的表格文件。”它会自己调用 file_search 工具,传入 directory 和 keyword,拿到结果之后组织成自然语言回复给我。
4.2 从注册到调用的完整链路,每一步都验证给我看
我把完整链路走了一遍,每个环节都仔细验证了数据流:
注册阶段:FileSearchTool 的 schema 被拉出来,存进 registry。我用 framework 的控制台命令打印了一下工具列表,确认 file_search 已经在里面。
对话阶段:我发了一条消息,框架把整个工具列表的描述塞进了系统提示词。这一步非常关键,因为模型根本不会调用一个它不知道的工具。
模型决策阶段:模型看到“file_search”的描述后,返回了一个标准调用结构,包括工具名和参数 JSON。结构长这样:
{ "name": "file_search", "arguments": { "directory": "/Users/test/Downloads", "keyword": "budget" } }框架执行阶段:registry 根据 name 找到 FileSearchTool 的实例,把 arguments 解包成参数传进 execute 方法。execute 返回结果后,框架把结果转换成观察消息,追加到会话里。
最终回复阶段:模型读到观察结果,整理成自然语言告诉我:“在 Downloads 目录下找到了 3 个包含 budget 的文件,路径如下……”
全链路顺滑得让我有点不真实感。我特意在 execute 方法里加了一行日志,确认它确实被调用了,而且参数完全正确。
4.3 对比自己手写 if-else 的版本:差距一目了然
我翻出自己以前那个“关键词匹配”版本,做了一个残酷的对比:
| 维度 | 我的 if-else 版本 | 框架插件化版本 |
|---|---|---|
| 加一个新工具 | 改主函数,加一个 elif | 新建一个类,注册一行代码 |
| 模型理解工具 | 靠人工硬编码关键词 | 靠 schema 描述自动读取 |
| 多工具组合 | 完全没戏 | 模型自主编排 |
| 参数提取 | 自己写正则 | 框架按 schema 自动提取 |
| 出错排查 | 满屏找日志 | 插件内单点排查 |
看完这个表,我只能说:以前那种“面向关键词编程”的 Agent 开发方式真的可以退休了。工具多了以后,用插件化这套不仅代码量更少,正确率还更高——因为参数提取是框架做的,不是靠我自己写的粗暴正则。
5. 插件化的代价:我在实际使用中踩过的一些坑
5.1 明明注册成功却调不到:一个排查了半小时的蠢错误
第一个坑来得比想象中快。我写好了插件,信心满满地运行,结果 Agent 对我的请求完全不理,直接用自己的话回答,根本不调用工具。
我第一反应是模型不行,换了一个更强的模型,还是不行。然后我开始怀疑描述写得不清楚。反复改了三四版描述,Agent 依然我行我素。
最后我静下心来看日志,发现一个关键细节:我确实注册了 FileSearchTool,但框架从我定义的类里读取 name 和 description 时,用的是类属性而不是实例属性。问题出在我定义 name 时没考虑到它会被当成 classmethod 处理,我的字段定义方式让框架拿到了一个空值,导致 schema 列表里根本没有 file_search。
这个坑的关键教训是:注册器自身有一套读取约定,它约定插件暴露哪些属性、什么类型的属性,你没有按约定写,它不会报错,只会让你变成一个“隐形工具”。所以遇到这种“工具存在但模型不调用”的问题,第一件事是查注册后的 schema 列表,而不是反复改描述。
5.2 并发场景下插件状态隔离的坑:实例别被污染
第二个坑出现在我尝试“插件持有状态”的时候。我自己的业务里,有一个插件需要记录“上一次查询的时间”,我用实例变量存了下来。单线程场景没问题,但并发场景直接翻车:多个会话共享同一个插件实例,A 会话改了状态,B 会话读取时就拿到了脏数据。
框架作者显然预料到了这一点,官方推荐做法是:插件保持无状态,需要记录信息就写到消息列表里,由框架管理生命周期。也就是说,插件不要有“记忆”,记忆属于 memory 模块。
我后来把所有状态全部改到 memory 里,问题立刻消失。这个经历让我意识到:插件化不是简单地“一个对象包一层”,它对状态管理有自己的约束。遵守框架的架构哲学,比在框架上硬加自己的想法重要得多。
5.3 过度插件化:把简单问题复杂化的反面案例
拆完这个框架以后我有一种冲动:把所有功能都做成插件。结果做了两天,发现过度插件化同样让人崩溃。
举一个尴尬的例子:我有一个内部工具,功能是“把字符串转大写”。就这种一句话的需求,我也为它建了一个类、配了一套 schema、写了一份 description。结果模型经常在不需要的时候也调用它,因为描述写得太泛了。我更想追求“任何功能都能扩展”,反而让模型在决策时多了一些不必要的选项,降低了回答质量。
后来我删掉了一批低价值的工具,只保留真正有“决策价值”的能力,比如搜索、查数据库、发邮件。而那些纯计算类操作,直接在代码里写死就好,不进插件系统。
这个反例让我明白一个道理:插件化框架解决的核心问题是“不确定该调用谁”,如果你的功能是确定性逻辑,插件化反而是一种负担。
6. 对我自己的启发:写业务代码时能直接搬走的三个设计招式
6.1 用“声明式描述”替代“过程式判断”
这个框架最大的设计智慧,是把“什么时候该做什么”的判断从代码里抽出来,交给了模型。回到普通后端业务,这个思路照样适用:与其在代码里写一堆 if-else 判断“走哪个逻辑”,不如把每个逻辑单元的边界描述清楚,让上层的调度模块来做决策。
我现在的项目里,凡是我预期“未来会加很多同类实现”的地方,都先定义一个描述结构,把行为的关键信息(适用条件、输入输出格式)集中管理。这样新增实现时,我只需要提供一份描述,调度逻辑完全不用改。
6.2 把“变化”和“稳定”分开存放
拆完这个框架,我重新审视了自己代码里那些经常要改的部分。以前我喜欢把“会变化的东西”散落在各个文件里,现在我会刻意把它们聚拢到一个注册区。
比如支付渠道:新增渠道不需要改动订单主流程,只需要在渠道注册区把支付方式和渠道类的关系登记好。这个就是从框架的 ToolRegistry 模式搬过来的。稳定部分是流程骨架,变化部分是注册进骨架的能力。
6.3 先写插件接口,再写核心逻辑
以前写代码的习惯是自底向上:先实现每一个功能,最后组装。这个框架给我的相反思路是:先把接口形状定下来,也就是“你的组件要向外暴露什么能力、用什么格式描述”,再去填充各种实现。
我用这个思路重构了一个告警系统:先定义好“告警插件”的接口——必须提供事件类型、标题、内容格式、发送方式;然后针对邮件、短信、企微分别实现插件。因为接口先行,各种渠道的实现互相完全隔离,新增渠道只影响注册表,不影响原有逻辑,代码可维护性提升了一个台阶。
最后再分享一个实用小技巧:拆开源框架时,不要只看源码,最好是写一个玩具插件走通全链路。只要你自己动手挂过插件、踩过坑、看过框架的报错信息,你对它的理解会比读十遍源码都深刻。毕竟框架只是骨架,真正让它跑起来的是像我这样的使用者在上面倒腾插件、填满血肉的过程。