以前提到“插件化”三个字,我脑子里浮现的都是浏览器扩展、IDE 插件这类老画面。直到我把 DeepSeek 开源的那套 Agent 框架源码翻了一遍,才发现插件化这个老概念,在一个 AI Agent 框架里能钻得这么深——不只是工具能扩展,而是思考方式、记忆后端、模型接入、调度编排,整个骨架都可以按需替换。这篇文章不是文档翻译,是我实际 clone 代码、跑 demo、读主循环、亲手写自定义插件后的完整记录。
我会按拆解顺序来讲:先搞清楚 Agent 框架的主循环是什么,再一层层看工具、推理策略、记忆、模型适配、编排逻辑是如何被插件化的,最后给出一段能直接抄的实战代码和踩坑实录。无论你是想自己基于这套框架搭 Agent,还是单纯想学习高水平插件架构设计,这篇都适合你。
1. 拆源码前,先搞清 Agent 框架的“插件化”边界在哪里
1.1 Agent 主循环到底是什么
任何 Agent 框架,说白了都在干一件事:把一个用户请求,推进一个“理解任务→规划步骤→调用工具→观察结果→循环直到完成→生成回答”的流程。这个流程有个约定俗成的叫法:Agent 主循环。
拆这套框架之前,我先把主循环要做的事列了出来:
- 接收用户请求,组装上下文
- 决定下一步动作(是直接回答,还是调用某个工具)
- 如果是调用工具,解析工具名、填充参数、执行工具
- 把工具结果写回上下文,再次决定下一步
- 当条件满足时,输出最终回答
这个循环看起来简单,但每一个环节都有设计空间。插件化的深浅,就看框架允许你在哪些环节做替换。
1.2 多数框架的“插件化”只做了一半
我拆过不少 Agent 项目,大部分号称“支持插件”的框架,其实只开放了一个口子:工具注册。你想加个搜索、加个计算器,通过装饰器或者配置文件注册进去就能用。这当然算插件化,但只是最浅的一层。
因为工具列表虽然可扩展了,但主循环的骨架是写死的:用什么模型、怎么记忆、如何决定下一步、多个 Agent 之间怎么调度,全部锁死在里面。你只能在固定流水线上“加料”,没办法“换骨架”。
这也是我拆这套框架前最大的疑问:它说自己的插件化做到很深的程度,到底深在哪?
1.3 我给自己列出的“插件化体检清单”
带着这个疑问,我拆代码前先列了一个检查清单,每个维度都做了测试:
| 检查维度 | 常见框架的通常做法 | 理想插件化应该能做到 |
|---|---|---|
| 工具扩展 | 装饰器注册函数 | 工具可热插拔,Schema、校验、运行分离 |
| 推理策略 | 内置 ReAct 写死 | 不碰主循环,直接换一套决策逻辑 |
| 记忆后端 | 内置一个固定上下文 | 可替换成向量库、Redis、文件,且可组合 |
| 模型接入 | 硬编码厂商 SDK | 统一适配层,运行时自由切模型 |
| 编排逻辑 | 固定单 Agent | 多 Agent 调度方式也可配置、可替换 |
这张表是后面所有拆解的地图。我也是照着它,一层一层读源码的。
2. 工具插件:不只是注册函数,而是把“做什么”和“怎么做”拆开了
2.1 “三件套”式的工具定义
先说工具插件。这也是所有 Agent 框架最容易做好的部分,但做好的标准差别很大。多数框架的工具定义就是“一个函数 + 一段描述”,大模型调用时靠自然语言去猜参数。
这套框架里,工具不是一个函数,而是一个类,而且把三件事拆开了:
- input_schema():入参的 JSON Schema,机器可读,大模型根据它生成参数
- run():具体的执行逻辑
- validate():对入参做校验,避免非法参数进入执行阶段
你可能会觉得麻烦,但实际用下来会发现,这是非常高明的设计。只要你把 Schema 写清楚了,框架可以自动把工具描述拼进提示词里,大模型的参数命中率会明显提升。
2.2 一个真实的工具插件长什么样
下面这段代码是我基于框架风格写的一个网页抓取工具插件,保留了核心结构:
# plugins/web_fetch.py from agent_core import ToolBase, ToolContext, register_tool @register_tool("web.fetch") class WebFetchTool(ToolBase): """抓取网页并提取正文内容""" name = "web.fetch" description = "根据URL抓取网页正文,常用于收集资料、阅读文章" def __init__(self): self.timeout = 10 self.max_chars = 20000 def input_schema(self): return { "type": "object", "properties": { "url": { "type": "string", "description": "目标网页的完整URL" } }, "required": ["url"] } def validate(self, args: dict) -> list: errors = [] url = args.get("url", "") if not url.startswith(("http://", "https://")): errors.append("url必须以http或https开头") return errors def run(self, ctx: ToolContext, args: dict) -> dict: url = args["url"] # 实际代码里会做HTTP请求、HTML解析、正文提取 return { "title": "示例标题", "content": "正文文本……", "url": url, "length": 1234 }注意两个细节:第一,注册名用了“web.fetch”这种带命名空间的名字,避免多个插件撞名;第二,validate() 和 run() 分开,非法参数在进入执行前就被拦截。拆到这一步我就意识到,这个框架对工具插件的定位不是“加个数”,而是“加一个规范化的服务”。
2.3 动态装载是怎么实现的
读注册中心源码时,我看到了一个很标准的“装饰器 + 注册表”模式:
# agent_core/registry.py(沿用源码思路简化) PLUGIN_REGISTRY = {} def register_tool(key: str): def decorator(cls): PLUGIN_REGISTRY[key] = cls return cls return decorator启动时框架会扫描配置里指定的插件目录,自动导入模块,把所有带有 @register_tool 的类收集到注册表里。之后配置文件中直接用字符串“web.fetch”引用,框架通过注册表找到类、实例化、注入给主循环。
这种写法的好处是:新增一个工具不需要改任何已有代码,不产生“改一处崩一片”的连锁反应。
3. 推理策略插件:最出乎意料的设计,思考方式本身也能换
3.1 推理策略是什么
用大白话说,推理策略就是 Agent “决定下一步做什么”的思维方式。
以最常见的 ReAct 为例,它的逻辑是“边想边做”:大模型先想我要不要用工具,用了之后看结果再想下一步。而 Plan-and-Execute 则相反:先让大模型一次性生成完整计划,然后按计划逐步执行,不轻易改变路线。还有一种 ReWOO,把“计划”“执行”“推理”三个角色拆开。
大多数框架会内置其中一种,然后写死。但这里不一样,策略被抽象成了一个独立接口。主循环不再负责“思考”,只负责“执行动作、拿结果、再问策略”,思考的活全部交给了策略插件。
3.2 策略接口的核心方法
策略插件的接口设计很简洁,核心就两个方法:
- on_start():在处理用户请求的最开始被调用,通常用来生成初始计划
- decide():每轮循环都会调用,输入当前的会话上下文,输出“下一步动作”
我读这段代码的时候,最大的感受是抽象得非常干净。会话上下文是一个透传对象,策略可以从里面读历史消息、读工具结果,也可以往里面写备注。主循环完全不关心策略内部怎么想,它只执行 decide() 返回的结果。
3.3 手写一个“先计划后执行”的策略
受这个抽象的启发,我写了一个极简的 PlanFirstStrategy:
# strategies/plan_first.py from agent_core import StrategyBase, StrategyContext, register_strategy @register_strategy("plan_first") class PlanFirstStrategy(StrategyBase): def on_start(self, ctx: StrategyContext): messages = ctx.history() prompt = "请先输出完整执行计划,不要调用任何工具,只列步骤。" plan = ctx.llm.chat(messages + [{"role": "user", "content": prompt}]) ctx.set_note("plan", plan) return {"type": "reply", "content": plan} def decide(self, ctx: StrategyContext): plan = ctx.get_note("plan") if not plan: return self.on_start(ctx) remaining = ctx.get_note("remaining_steps") or [] if remaining: next_step = remaining.pop(0) return {"type": "tool", "tool": next_step} return {"type": "finish"}实际执行起来,它的行为是先让大模型列出步骤列表,然后每轮从列表里取一个步骤执行。它和内置 ReAct 策略在效果上差异很大:处理“批量数据处理”这类目标明确的任务时,PlanFirst 更稳定;处理“用户需求不明确”的开放式任务时,ReAct 更灵活。
这就是策略插件化的价值:同一套工具、同一个模型,换一个策略插件,Agent 的行为模式就换了。做实验对比的时候,配置改一行字符串就行。
4. 记忆插件:四个方法支撑起一套可组合的记忆体系
4.1 记忆不只是“存聊天记录”
很多人觉得记忆就是把聊天记录攒起来,这是最大的误解。在一个 Agent 里,记忆起码有四种:
- 短期上下文:当前会话窗口内的消息
- 长期事实库:用户偏好、持久化的结论
- 向量检索记忆:从历史记录中语义召回相关内容
- 遗忘与衰减:太旧的信息自动降低权重
如果记忆后端写死,那以上任何一种扩展都意味着改动主代码。而这里,记忆被抽象成了四个方法。
4.2 源码里的记忆接口
我读到的记忆接口,核心只有四个方法:
- read():读取全部或部分记忆内容
- write():写入一条新记忆
- recall():根据当前问题检索相关历史记忆
- forget():删除或衰减某部分记忆
这个接口精炼到什么程度?精炼到你能用不到二百行代码实现一个全新的记忆后端。接口越窄,扩展反而越容易。
4.3 让我眼前一亮的“组合式记忆”设计
最令我意外的是记忆插件支持组合。默认的基础记忆是“缓存列表式”的,只保存原始消息流。但框架允许你用装饰器包一层:
from agent_core import MemoryBase class SummarizeMemory(MemoryBase): """对太长的历史做摘要压缩""" def __init__(self, inner: MemoryBase): self.inner = inner def write(self, message): self.inner.write(message) def recall(self, query, top_k=5): history = self.inner.recall(query, top_k) # 对历史先做摘要再返回 ...这种装饰器模式让我想起 Python 里给函数加缓存的写法。你可以一层一层地叠加能力:先原始存储,再包摘要压缩,再包向量检索,最外层甚至还可以包一个关键词过滤。组装方式完全由配置文件决定,底层代码一行不用动。
4.4 把默认记忆换成向量检索记忆
实际替换记忆后端时,我只写了一个类:
# memories/vector_memory.py from agent_core import MemoryBase, register_memory @register_memory("vector") class VectorMemory(MemoryBase): def __init__(self, collection_name="agent_memory"): self.collection_name = collection_name # 这里连接你自己的向量库 def write(self, message): # 把消息向量化之后写入向量库 pass def recall(self, query, top_k=5): # 向量检索,返回最相似的top_k条历史 return [] def read(self): return self.recall("", top_k=20) def forget(self, key): pass然后在配置里把 memory 字段从 default 改成 vector 就完成了。从拆源码到跑通整个替换流程,我大概只花了二十分钟。这就是插件化做到位之后,开发者的真实效率。
5. 模型适配与编排逻辑插件:换模型、换调度员都不动业务代码
5.1 模型适配器解决的是“翻译问题”
大模型接入这种事,理论上很简单,实际上每家厂商的 API 都不一样:有的接口叫 messages,有的叫 prompt;有的返回字段叫 choices,有的叫 outputs;工具调用更是各家有各家的格式。
模型适配器插件的职责就是把这些差异全部挡在外面。框架内部统一用一种 Message 结构对话,适配器负责把 Message 翻译成各家 API 的格式,再把返回结果翻译回统一的 AgentResult。
一个写得好的适配器需要处理三件事:
- 流式输出:逐字返回时如何累积成完整回复
- 工具调用映射:把模型返回的 tool_calls 翻译成统一的调用意图
- 用量统计:记录 token 消耗,方便观察成本
5.2 实测中一个非常有用的特性
我用这套适配器机制同时接了两个模型:一个快速便宜的模型用来走推理策略的“规划”环节,一个更强的模型用来做“最终回答”环节。由于策略插件可以随时调用 ctx.llm,我可以在同一个任务里给不同环节指定不同模型,而这个切换全部发生在配置层面。
对一个做 Agent 应用的人来说,这意味着什么?意味着你不必绑定任何一家模型厂商,也不用因为换了模型就重构产品逻辑。
5.3 编排逻辑插件:多人团队的调度方式也能换成插件
如果只是一两个采访问答,单 Agent 就够了。但一旦涉及“一个主 Agent 派发任务给多个子 Agent”的团队场景,编排逻辑就变得非常重要。谁先调用谁、结果怎么汇总、冲突怎么处理,这些在过去都是写在流程图里的。
这套框架的做法是:把编排器也做成插件。主 Agent 把任务拆成子任务,决定交给哪个子 Agent,然后收集结果。子 Agent 的配置本身就是独立的 Agent 实例,有各自的模型、策略、记忆。
5.4 一个我实际组合过的团队型 Agent
我搭过一个资料整理场景,用了三个角色:
- 一个“统筹者” Agent,使用 PlanFirst 策略,负责拆解任务
- 一个“检索者” Agent,工具只有 web.search 和 web.fetch
- 一个“写手” Agent,把检索结果整理成成稿
这三个 Agent 的模型不同、策略不同、记忆不同。整套流程全部通过配置文件完成,业务代码只有一个入口:
from agent_core import Agent agent = Agent.from_config("team_config.yaml") result = agent.chat("帮我整理一篇关于开源社区协作模式的综述")看到这段代码的时候,我算是彻底理解了标题里那句“插件化可以做到这种程度”——不仅是模块可替换,整个团队的工作方式都可以被插拔和重组。
6. 注册中心与依赖注入:整个插件体系的“地基”长什么样
6.1 注册中心的真相:一个类属性字典
如果把插件比作士兵,注册中心就是花名册。我原以为这套框架的注册中心会有多复杂,结果读下来发现核心只有几十行:一个类属性字典,在类定义时通过元类或装饰器自动登记。
之所以用类属性而不是全局变量,是为了隔离不同作用域。每个 Agent 实例可以持有自己的注册表副本,多个 Agent 之间不会互相污染。这个细节让并发场景下调试变得舒服很多。
6.2 依赖注入:插件不再自己找同伴
插件很多时候需要依赖其他插件。比如一个“搜索增强”工具,需要依赖“网页抓取”工具,那它怎么拿到对方实例?
我见过最差的写法是在工具内部写“import 另一个工具”,直接导致循环依赖和版本错乱。这里用的是标准的依赖注入思路:插件在构造函数里声明依赖,注册中心负责解析并注入。
@register_tool("search.enhanced") class EnhancedSearchTool(ToolBase): def __init__(self, fetch_tool: WebFetchTool): # 注册中心会自动找名为 web.fetch 的实例传入 self.fetch_tool = fetch_tool注册中心启动时会做一次拓扑排序,把依赖关系理顺,先实例化被依赖的插件,再实例化依赖者。它让我想起 Spring 里 IoC 容器的简化版,但实现得更轻量。
6.3 怎么验证你读懂了:写一个加载顺序打印器
读源码的时候,我写了个小工具来验证自己的理解。核心逻辑是遍历注册表,按依赖关系排序,然后打印每个插件的加载顺序:
for plugin_name in sorted(registry, key=lambda x: registry[x].load_order): print(f"{plugin_name}: {registry[plugin_name].__class__.__name__}")打印出来的结果,刚好和依赖关系一致。看到输出的一瞬间,整个注册中心的轮廓就清晰了。强烈建议你也这么来一遍,比看十遍文档都有用。
7. 实战:用这套框架 30 分钟跑通一个资料整理 Agent
7.1 场景设定
我选了一个非常实用的场景来做实战收尾:给 Agent 一个网址,让它抓取文章、提取要点、按 Markdown 格式输出笔记。
这个场景看似简单,却同时用到工具插件、策略插件、记忆插件和模型适配器,非常适合综合检验。
7.2 需要写的组件清单
一共四块:
| 组件 | 类型 | 作用 |
|---|---|---|
| WebFetchTool | 工具插件 | 抓取网页正文 |
| MarkdownNoteStrategy | 策略插件 | 规定“先抓取→再提炼→最后格式化”流程 |
| VectorMemory | 记忆插件 | 记录已抓取的 URL,避免重复抓取 |
| DeepSeek 模型适配器 | 模型插件 | 调用大模型完成分析理解 |
7.3 配置文件和启动过程
配置文件核心长这样:
agent: model: deepseek.chat strategy: markdown_note memory: vector plugins: - plugins.web_fetch - strategies.markdown_note - memories.vector_memory启动代码依然是那几行:
from agent_core import Agent agent = Agent.from_config("note_agent.yaml") note = agent.chat("抓取 https://example.com/article,整理成 Markdown 笔记") print(note)我从零开始写这些插件,到跑通完整流程,确实只用了三十多分钟。之前手动做这种事,需要写爬虫脚本、写解析逻辑、写摘要 prompt,没有一个统一的调度者。有了框架,这些东西自动衔接起来了。
7.4 实际踩到的两个坑
第一个坑:工具返回结构不统一。我一开始在 WebFetchTool 里返回的是一个字符串,但在另一个场景里却返回了一个字典。策略插件读结果时用 result["content"] 取内容,字符串直接炸了。
解决方法是统一工具返回的键名,框架里约定所有工具返回对象必须包含 content 字段,我用一个基类强制约束了这一点。
第二个坑:参数校验太严格导致中文 URL 报错。我在 validate() 里校验 url 前缀时,忘了处理 URL 编码后的中文路径,导致一些带中文参数的文章链接被拦截。后来我在 validate 里加了一步先尝试解码再判断,问题才消失。
这两个坑都算不上源码 bug,更多是插件作者在边界处理上的不严谨。但这也暴露了插件化的两面性:自由度越大,规范越要靠自己守住。
8. 拆完之后的实话:哪些设计值得学,哪些坑要避开
8.1 我会毫不犹豫抄的三处设计
第一,工具的三件套拆分。Schema、校验、执行分离,从源头保证了工具与大模型的配合质量。如果你自己写 Agent,哪怕不做插件化,也应该用这种结构组织工具代码。
第二,策略抽象下沉。把“决定下一步”从主循环中剥离出来,是我拆完代码后最大的收获。它让 Agent 的行为模式可以像换衣服一样切换,也让主循环的逻辑简单到只剩“执行—反馈—再决策”的骨架。
第三,记忆的组合装饰器。用叠加而不是造轮子的方式扩展记忆能力,既保持了基础实现的简单,又打开了无限组合空间。
8.2 需要为此付出的代价
插件化做得好,不代表没有代价。最明显的是调用链变深了:以前一个错误栈三四行就能定位问题,现在一个工具调用要经过“策略 → 编排 → 插件注册表 → 工具执行 → 模型适配”五六个层级,查起来费劲很多。
第二个代价是新人上手成本。我刚接触这套框架时,光理解“策略和编排到底谁指挥谁”就花了半天。文档如果只讲用法不讲设计意图,很容易让新手在前两周陷入混乱。
第三个代价:过度插件化带来的选择困难。如果你在做一个小型项目,内置默认策略、默认工具基本够用,反而无需为了“扩展性”去自定义一堆插件。架构的深度是能力,不是负担。
8.3 如果我继续深入,会先做三件事
- 给插件的依赖关系画一个可视化视图,依赖图一目了然
- 加一个 debug 模式,打印每一步是哪个插件在做决策
- 做一个插件隔离沙箱,防止某个工具执行时耗死整个进程
这些不一定是框架的问题,但凡是复杂系统,调试体验和可观测性永远值得放在更高优先级。
回到开头那个问题,一个 Agent 框架的插件化能到什么程度?拆完这套框架,我的答案是:可以把“思考”“记忆”“调用”“编排”全都打散重组。工具能换、模型能换、策略能换、记忆能换、团队的协作方式也能换,而你的业务代码只需要面对一个极简的入口。
我个人的实战体会是:插件化的价值不在于把每行代码都抽象成可插拔,而在于把“经常变化的维度”和“相对稳定的骨架”清晰分开。这套框架让我看到了一条很成熟的分界线。最后再分享一个小技巧:插件数量不要贪多,把功能相近的工具合并成一个插件,注册表干净了,调试时你会感谢自己。