ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

深度拆解DeepSeek Agent框架:插件化架构的骨架级设计

深度拆解DeepSeek Agent框架:插件化架构的骨架级设计

1. 先说结论:它把“框架”做成了可以按需拼装的底座

拆这套 DeepSeek 开源的 Agent 框架之前,我先给自己设了一个预期:无非就是注册工具、写个call_llm、然后跑循环。市面上大多数 Agent 框架的“插件化”就是这个水平——你注册了多少个工具,它就给你多少把锤子,但房子还是那栋房子。

真正拆开之后,我发现自己之前的判断错得离谱。

这套框架的插件化不是“把工具变成插件”,而是“把整个 Agent 系统的骨架拆成了可以替换的插件”。模型可以换、工具可以换、记忆可以换、执行流程的中间环节也可以换。你拿到的不是一个已经盖好的房子,而是一套标准尺寸的砖和一份结构图——你可以随时换上自己的砖,甚至可以把某面墙拆掉重砌。

为了让你能直观理解“这种程度”到底是什么意思,我先把插件化的层次列成一个标尺:

插件化层级能换什么代表做法这套框架是否做到
工具级工具函数、API 调用注册函数列表是
能力级模型接入、记忆存储、知识库Provider 抽象是
骨架级工作流编排、循环策略可替换的 Runner / 策略是
协议级消息协议、上下文传递方式自定义协议层部分开放,核心用协议定义

大多数框架能做到第一层就不错了,做到第二层的已经算良心产品。这套框架做到了第三层,而且是“主动设计”出来的那种第三层,不是堆代码堆出来的偶然结果。

我这次拆解用的方法很简单粗暴:先把源码包下载下来,按目录结构看一遍;然后从主入口文件往回追,找出所有“接口”和“实现”的关系;最后跑一个最小 Demo,再往里挂自己的插件,逐个验证它的扩展点是不是真的像文档说的那样能无缝接入。整个过程大概花了一个周末,下面这些内容就是这次拆解的主干记录。

这篇拆解适合两类人看:一是想自己设计 Agent 框架或给现有系统做插件化的架构师,二是正在用这类框架、想搞清楚“我到底能改到什么程度”的业务开发者。第一类人能在这里找到比较完整的扩展点设计思路,第二类人能直接抄走我验证过的插件写法。

2. 插件边界定义:框架先决定“谁可以不被替换”

2.1 从“整套运行时”到“组件包”

先说一个我拆完以后最大的感受:这套框架的代码组织方式,从一开始就奔着“权限分明”去的。

一般的开源框架,主目录下面通常是一堆core、llm、tools、memory之类的业务目录,每个目录里的类互相 import,边界模糊得很。这套框架的主目录结构不太一样,代码量最大、层级最根部的部分是一个纯粹的“协议目录”,里面几乎没有业务逻辑,只定义了一堆抽象接口和数据类。业务实现全部放在独立的目录里,而且必须依赖那层协议,协议层绝不反向依赖任何业务实现。

这个设计的价值,我是在尝试换模型的时候才真正体会到的。我之前折腾过很多框架,换模型时最痛苦的不是改 API 地址,而是要顺着调用链,把框架内部所有硬编码的模型判断逻辑全部翻出来改一遍。这套框架不会出现这种情况,因为协议层定义了“一个模型插件必须长什么样”,框架核心只和这个定义打交道,不和某个具体模型的三方 SDK 直接耦合。

2.2 约定优于配置:插件目录和 manifest

除了协议层,这套框架还有一个很“工业化”的设计——插件不靠代码里逐个 import 来加载,而是靠目录结构和描述文件来发现。

每个插件包是一个独立的目录,目录里必须有一个描述文件(类似plugin.yaml或manifest.json),里面声明这个插件的名称、入口模块、依赖关系、需要注册的组件类型。框架启动时扫描插件目录,根据描述文件加载对应模块。也就是说,你新增一个插件,不需要去改框架的启动代码,只要把目录放进去,在描述文件里声明好,框架就能在运行时找到它。

这种做法的好处在团队协作时特别明显。一个项目里如果有人负责模型接入、有人负责工具开发、有人负责记忆策略,各有各的插件目录,互不干扰。合并代码时不会有那场经典的“同一个core/config.py被五个人改了十个版本”的冲突。

2.3 依赖方向的单向性:为什么这能让插件随便换

我读源码时特意画过一张依赖关系脑图:协议层在最底层,框架核心层(调度器、上下文管理)在中间,具体插件在最顶层。整张图里,只存在“上层依赖下层”的箭头,没有任何一条从上往下、从下往上逆行的依赖。

这个单向依赖的约束,就是“插件可以随便换”的底层保证。因为协议层是稳定的,核心层面对的永远是协议层定义好的接口,而不是某个插件的具体实现。插件 A 和插件 B 只要都实现了同一个 Provider 接口,核心层就不关心你换的是 A 还是 B。

很多人在自己项目里做插件化失败,问题就出在依赖方向失控:核心代码直接 import 了某个插件的具体类,表面上做了接口抽象,实际上 pdb 一调试全是硬引用,换一个插件就要改核心代码。这套框架在这一点上卡得很死,协议层之外的代码几乎不允许被核心层直接引用,这是它插件化能真正落地的地基。

3. 模型接入层:把“换个模型”从改代码变成改配置

3.1 ModelProvider 的设计:模型不是内置功能,是插件

我在拆解时重点盯的第一个扩展点是模型接入层。它这里的模型接入不叫“模型管理模块”,而是叫 ModelProvider,直译过来就是“模型提供方”。名字的细微差别其实就是设计理念的差别:框架不认为自己和某个模型绑定了,框架只认“谁能给我提供模型的调用能力”,谁有这个能力谁就进来。

ModelProvider 这个协议接口,在源码里大致长这样的形态:

# 协议层定义的模型提供方接口(示意) class ModelProvider(Protocol): def complete(self, messages: list[dict], **kwargs) -> StreamingResponse | Response: ...

注意complete的返回值有两个形态:普通响应和流式响应。这个设计几乎没给自己留退路——任何一个模型插件,如果要接入这套框架,就必须兼容这两种返回方式。反过来说,你在上面写业务代码时,永远不需要关心“这次是 DeepSeek 还是别的模型”,因为框架已经把模型调用封装成了统一的接口。

3.2 为什么一个“OpenAI 兼容协议”就够了

模型插件内部具体怎么实现,框架基本不设限。但源码和文档里反复强调一种推荐做法:尽量把自己的模型接口转换成 OpenAI 兼容的格式。这说的不是直接调用某个官方 SDK,而是指插件的输入输出数据结构,尽量套用 OpenAI 定义的 messages 结构。

很多模型厂商的 API 格式各不相同:有的用user/assistant直接传,有的要把多轮对话拆成 list,有的要用特殊字段声明推理强度,有的对temperature的取值范围都定义得不一样。如果每个模型插件都按自己的格式传参,框架核心就得为每一种格式写一套适配逻辑,插件化的意义就废了。

而 OpenAI 兼容格式,本质上已经成了行业默认的“对话协议语言”。框架里收到的所有消息、上下文、工具调用结果,都以这套格式在流动。模型插件要做的,就是把它自家模型的参数翻译成这套语言,或者把自家的响应翻译回这套语言。

翻译的过程永远是“插件内的事”,核心层完全不关心。我后来带着这个思路去写自己的模型插件,整个过程清爽得不行:不需要碰框架任何核心代码,只需要老老实实做好翻译层。

3.3 参数归一化与错误重试:被大多数人忽略的难点

真正拆到模型插件的具体实现时,我发现最考功夫的是三个细节:参数换算、流式处理、错误重试。这三个细节做得不好的模型插件,接进来以后看起来能用,但实际运行一久全是暗坑。

先说参数换算。不同模型的超参命名和语义差异很大,有的叫temperature,有的叫top_p,取值范围完全不同;还有上下文长度,有的模型是 32K,有的是 128K,如果不做归一化换算,同一个 Agent 任务在不同模型上跑出来的结果可能完全不同。框架的做法是在插件内部维护一个统一的参数字典,插件负责把框架侧传进来的标准参数,映射成待接入模型自己的参数。这个映射逻辑是插件的一部分,框架不认识也没必要认识。

然后是流式返回。有些 Agent 任务的交互感靠的就是逐字输出,所以模型插件的complete一定要同时支持普通返回和流式返回两种模式。框架的协议层定义了一套流式包装逻辑,插件只需要按协议要求吐chunk,框架负责把它们组装成完整响应。我自己写测试时才发现,很多模型 SDK 的流式和普通返回用的是两套代码路径,一旦没做区分,长任务跑起来就是卡死、断流、首字延迟高这一类毛病。

最后是错误重试。模型接口在线时稳定,但真跑到生产环境,限流、超时、连接重置这类问题一个接一个。框架的协议层对错误重试没有写死策略,但模型插件的实现里普遍会带入“重试消耗预算”的概念:连续失败达到上限,就不无限重试,而是把错误转换成结构化信息,交还给 Agent 去做后续判断。这个思路比“单纯重试三次”高级,因为 Agent 能根据错误信息自己决定是换一种问法,还是结束这个子任务。

4. 工具层与记忆层:运行时动态发现插件

4.1 JSON Schema + callable:工具描述本身就是一种插件

工具插件的长相,比我预想的要现代。它不要求你实现一堆回调,它只要求你提供两个东西:一份 JSON Schema 格式的参数描述,和一个可调用对象。

# 工具插件结构示意 def read_table(path: str, sheet: int = 0) -> list[dict]: """读取表格数据,返回行列表""" ... tool_meta = { "name": "read_table", "description": "读取本地 Excel/CSV 表格数据,支持指定工作表", "parameters_schema": { "type": "object", "properties": { "path": {"type": "string"}, "sheet": {"type": "integer"} }, "required": ["path"] }, "entrypoint": read_table }

工具描述和实现分离,带来的直接好处是:大模型看到的“工具能力说明”是经过设计的,而不是从函数签名里瞎猜的。比如你的函数参数叫p,没人知道那是指“路径”,但如果 JSON Schema 里写着"description": "传入待读取文件的路径",模型调用你工具的成功率会明显高一个档次。

框架在加载工具插件时,会读取tool_meta,把name/description/parameters_schema统一塞进模型上下文的 tools 列表,而entrypoint则保留在本地,等模型“决定”调用时再真正执行。这个流程其实和 LangChain 之类工具的注册逻辑相似,差别在细节做得深:它把工具的“发现、描述、加载、执行”拆成了四个不同阶段,每个阶段都有插件可以介入的缝隙。

4.2 自动注册与懒加载:新工具不需要重启框架

这套框架在工具加载机制上,比我预期要“聪明”一点。它扫描插件目录时,只读取描述文件建立索引,并不实际 import 工具模块。真正执行工具调用时才把模块加载进来。

这个懒加载设计一开始我没当回事,直到我自己测试了一个“冷门工具”:一个只是偶尔用一次的函数,从索引建立到真正加载,框架能跳过所有不必要的初始化开销。对于一个工具数量很多、每个工具都要连数据库或者加载模型权重的重场景,这个设计的性能价值会被放大得很明显。如果你的项目里要挂几十个工具,尽量让每个工具模块保持轻量,别在模块顶层做重初始化——框架的懒加载能帮你省掉不少损耗,但也没法帮你躲过你在 import 时就开始读大文件这种硬伤。

自动注册还带来了一个体验上的质变:真实业务中频繁新增工具、修改工具描述,不用每次改完就重启框架、再重新跑一遍初始化。前提是你不要用重初始化风格的插件模块。这个原则对插件开发者来说应该当成默认规范。

4.3 记忆不是胶水,是观察点:MemoryProvider 的钩子位

很多 Agent 框架把“记忆”做成了夹在上下文里的字符串拼接工具,恨不得全文塞进模型窗口里拉倒。这套框架把记忆做成了另一个 Provider——MemoryProvider,而且它给记忆预设了几个明确的观察点。

协议层里记忆相关的钩子位大概包括:某一步执行完成后、某个插件注册事件发生后、Agent 准备提交最终结果之前。每个钩子都向记忆插件开放“写入/读取/压缩/归档”的权限,但框架不会主动调用这些方法——它只负责在正确的时机把控制权让渡给记忆插件。

这几行设计的直接作用是:记忆策略变成了一种可插拔能力。你想用“最近 N 条消息覆盖”的便宜策略,可以;你想做“向量化 + 语义检索”的高级策略,也可以;甚至你想让 Agent 完全失忆,不挂记忆插件就行。这套框架不会因为某个策略写得稀疏就拖累核心流程,反而给上层业务保留了充足的探索空间。

我实际验证下来发现,记忆插件最容易踩的坑是“写入时机不对”。有些策略需要全量快照,有些需要增量日志,如果框架没有一个明确的观察点机制,这些策略只能自行找时机,要么写重复了,要么写漏了。这套框架的观察点设计,等于把这个问题从插件开发者手里接管了,开发者只需要决定“在这个时机做什么”,不需要关心“怎么触发这个时机”。

5. 动作循环的钩子系统:拦截所有中间态

5.1 Agent 流程里的六个可拦截节点

工具层和模型层只是插件化的第一层,真正让我“服”的是它对动作循环本身的处理。Agent 的完整执行流程,本质上是一个循环:“接收任务 -> 决定下一步 -> 调用工具或模型 -> 观察结果 -> 再决定下一步 -> 输出最终答案”。这套框架没有把这段流程封装成一个黑盒,而是在每个关键节点都开了口子。

我数了一下,它至少暴露了六个可拦截节点:

节点位置发生时机适合挂什么逻辑
任务开始前Agent 收到任务,但还没做任何处理初始化上下文、加载用户偏好
模型调用前决定下一步动作前提前注入提示词、调整上下文
模型返回后模型给出了下一步行动建议校验输出格式、记录原始响应
工具调用前即将执行某个工具权限校验、参数审查、成本拦截
工具返回后工具执行结果回传中结果清洗、确认重试策略
最终结果输出前Agent 准备产出最终答案最终校验、格式化、日志归档

每一个节点的钩子,都接收同一个上下文对象,修改后再返回,形成一个“可拦截、可修改、可放行”的通道。你可以在任何节点插进去写逻辑,框架不会在意你是谁——只要你遵守“传入上下文、传出上下文”这个约定。

5.2 写一个“观察型钩子”的实操示例

我当时为了验证钩子的实用性,写了一个非常简单的“观察型钩子”——它不修改任何行为,只是把每一步模型请求和工具调用的原始记录打印到日志里。

# 钩子插件示意:记录每一步的请求与响应 def before_model_call(ctx): logger.info("【模型请求】%s", ctx.current_user_message) return ctx def after_model_call(ctx): logger.info("【模型响应】%s", ctx.last_model_output) return ctx def before_tool_call(ctx): logger.info("【工具调用】%s,参数%s", ctx.next_tool_name, ctx.next_tool_args) return ctx def after_tool_call(ctx): logger.info("【工具结果】%s", ctx.last_tool_result) return ctx

挂上这个钩子以后,整个 Agent 的每一步决策过程都在日志里看得到。这对排查问题特别有用——以前排查 Agent 的“诡异输出”,你得靠猜,现在可以直接从日志里回溯是哪一步给了错误信息、是模型误判还是工具返回出错。

我建议所有刚开始用这套框架的人都先写一个同样的观察型钩子。它不改变任何行为,但能让你短时间内建立起对框架运行时状态的直觉。等你把日志里那十几步看得明明白白,再去写复杂的拦截逻辑,思路会顺很多。

5.3 钩子之间怎么通信:上下文传递与状态共享

钩子写多了以后,自然会遇到一个问题:多个钩子之间怎么共享状态?假设 A 钩子在某个节点做了权限校验,B 钩子在另一个节点想读取校验结果,怎么办?

框架的答案是:把上下文对象当作承载状态的信使。钩子可以在上下文里写入自定义字段,后面任何节点的任何钩子都能读到,前提是你要显式地在上下文里声明好命名空间。

def permission_hook(ctx): ctx.extra_state["allowed"] = True return ctx def audit_hook(ctx): if ctx.extra_state.get("allowed"): ... return ctx

这个设计像极了 Web 框架里的 request 对象——大家共用同一个请求上下文,但各自往里面塞自己的业务标记。好处是钩子之间解耦,坏处是如果没人管,extra_state会变成垃圾堆。所以我自己的习惯是:每批钩子维护一个固定的命名空间,用完的字段及时清理,且不跨命名空间读取别人字段,避免写出一堆互相依赖的魔法值。

6. 一次真实改造:给报表监控需求挂上插件

6.1 业务场景与改造目标

理论讲再多,不如实际操作一遍。我拿一个真实的业务小需求来完整走一遍这套流程。

需求背景很简单:团队每天要人工检查一份销售数据表,看有没有异常(比如某一天某项指标跌破阈值)。人工检查又慢又容易漏。我准备用这套 Agent 框架做一个自动巡检员:每到一个时间点,让 Agent 读表格、与预设阈值比对、发现问题就发一条消息到内部通讯群。

如果用传统方式做,我得在整个系统里新写一个“读表格并判断”的模块,再把它接到某个定时器上。而在这套框架里,我不需要动任何核心代码,只需要写两个插件、声明一个编排配置。

6.2 第一步:写一个“读表检查”工具插件

第一个插件是工具类插件:负责读取表格、根据阈值判断异常。我实现了一个相对通用的函数,把表格数据拉出来后封装成与阈值匹配的逻辑。

# 工具插件:检查销售表格指标 def check_sales_table(path: str, thresholds: dict) -> dict: table = load_table(path) abnormal_items = [] for row in table: for key, limit in thresholds.items(): if key in row and row[key] < limit: abnormal_items.append({ "date": row["date"], "metric": key, "current": row[key], "threshold": limit }) return {"pass": not abnormal_items, "abnormalities": abnormal_items} tool_meta = { "name": "check_sales_table", "description": "读取销售表格,依据阈值字典检查是否存在指标异常", "parameters_schema": {...}, "entrypoint": check_sales_table }

这个函数本身没有魔法,就是普通的表格处理和阈值比对。但挂上框架的机制以后,“模型决定要不要调用它”就变成了 Agent 的判断能力。模型看到需求描述是“检查今天的报表有没有问题”,它会自动决定去读取哪些路径、以什么顺序调用哪些工具,不需要我写死调用流程。

6.3 第二步:写一个“通知上报”钩子插件

第二个插件是钩子类插件,作用在于执行结果返回后的通知上报。当check_sales_table返回异常列表时,钩子会检查上下文里的工具结果,发现pass为 False 就发通知。

def after_tool_call(ctx): if ctx.last_tool_name == "check_sales_table": result = ctx.last_tool_result if not result["pass"]: send_notification(result["abnormalities"]) return ctx

这里妙就妙在“检查行文逻辑”被搬到了钩子里。工具插件只管查询和判断,通知发送是另一个插件的职责,两者之间通过上下文传递数据,互不依赖。后续如果不想发消息了,或者想改成一键导出报告,只需要换个钩子插件,工具本身完全不用动。

6.4 第三步:配置编排与运行验证

写完两个插件后,我用“配置编排”的方式把它们组织起来:启用哪些插件、各自的加载顺序、每个插件需要的参数(表格路径、阈值字典、通知目标)。这些全部放在配置文件里,框架启动时读取并加载插件。

实测效果比预想的好:Agent 收到“检查今天报表”的任务后,自己完成了“识别需要读取的文件、按阈值判断、发现三条异常记录、发出通知”的完整流程。整个过程我没有写一行 Agent 逻辑判断代码,只写了工具、钩子和配置。

这个改造最有价值的感受是:需求变更时几乎零成本。隔了一周,团队说“我想把阈值改成动态从另一张表读取”,我只需要改工具插件的内部逻辑或配置里的阈值来源;团队说“告警消息想再加一个抄送人”,我只改钩子。两个插件的改动互不影响,整个系统的可维护性上了不止一个台阶。

6.5 为什么这套设计能跑通

拆下来最大的体会是:这套设计能跑通,不是因为具体哪一行代码写得好,而是因为框架在抽象层次上留对了缝隙。

工具只负责“做”,钩子只负责“观察和干预”,模型只负责“决策”,框架只负责“编排”。每一层都只依赖协议,不知道其他层的具体实现。所以业务逻辑可以像积木一样拆开、替换、单独测试。这种设计见得多了以后,我自己做项目的架构时也开始刻意给自己留“缝隙”——先把稳定的协议定义清楚,再把可变的部分做成插件,而不是把所有东西揉成一团。

7. 拆完框架之后,我踩过的几个坑

7.1 插件加载顺序与依赖关系:文档没说透的隐藏规则

我没有被框架设计惊艳到失去理智,实际用下来还是踩了几个不轻不重的坑。第一个坑出在插件加载顺序上。

框架虽然支持声明插件依赖关系,但它是按“描述文件里的声明顺序”进行加载的,而不是自动拓扑排序。如果你的钩子插件要在工具插件加载完之后才能注册成功,就必须在描述文件里显式声明依赖。我开始没注意,结果钩子总是报“找不到工具元数据”。后来在描述文件里把依赖字段补齐才解决。

这个坑虽然一踩一个准,但也让我理解了框架设计者的用意思图:显式声明依赖,比自动推理依赖更可控。因为自动拓扑排序听着省事,但一旦插件数量多了,隐式依赖会变成隐形炸弹,查起来比显式声明慢十倍。所以我的建议是:不管框架是否支持自动排序,一律在描述文件里把依赖写清楚。

7.2 热重载不是万能的:改 manifest 还是要重启

第二个坑是热重载。框架支持运行中替换工具函数的具体实现(开发期体验很好),但如果你改了描述文件里的字段——比如改了工具名、参数描述、依赖列表——热重载基本失效,必须重启框架才能生效。

其实想想也合理:描述文件是框架建立插件索引的依据,索引都变了,不改重启等于让框架拿着旧地图找新路。这个限制对生产环境部署不算大问题,但对开发调试来说是个常犯的错:你改了tool_meta,以为刷新页面就能测新描述,结果模型永远看不到更新,白白怀疑了半天人生。我的教训是:改工具逻辑,用热重载;改工具描述,先重启。

7.3 记忆持久化:框架给了钩子,但没给存储

第三个坑是关于记忆的。框架把记忆抽象成 Provider,提供了读取和写入的观察点,但没有原生实现很多团队期望的“重启不丢记忆”能力。默认情况下,记忆是存在内存里的,Agent 进程一重启,所有历史就清空了。

这不算框架的缺陷,因为记忆持久化本来就应该由业务方根据实际环境去选型——文件存储、数据库、向量库都可以。但它确实是个容易在项目中期才发现的坑:你辛辛苦苦调好了 Agent 的多轮对话记忆策略,一重启全没了,然后才想起要补一个 storage adapter。

我自己的做法是写了一个轻量级的持久化插件:把记忆内容序列化后落入本地 SQLite,重启后自动加载回来。代码量不大,但确实属于“框架不管、业务必需”的那一类。如果你准备拿这套框架做长期运行的服务,记忆持久化这项工作是绕不开的。

7.4 多个钩子重复触发的隐患

最后一个坑是我自己写钩子时搞出来的。我在工具调用前和模型调用后分别挂了统计逻辑,结果统计出来的数字翻倍了。调试了很久才发现,某个场景下同一个钩子被注册了两遍——第一次加载插件时自动注册,我在业务代码里又手动注册了一遍。

这个坑提醒我一个重要原则:钩子注册应该遵循单一事实源。要么全部靠插件目录自动扫描注册,要么全部靠业务代码手动注册,千万别混着来,否则就是在给自己埋雷。

最后再分享一点个人体会

整个拆解和改造做完之后,我最深的感受反而不是“这个框架真牛”,而是“插件化”这三个字在很多项目里被严重低估了。大部分框架的插件化只是给使用者一扇门,而这套框架的插件化是把整面墙都做成了可拆的模块,每个接缝处还预留了标准的连接件。

对我个人来说,这次拆解最大的收获还不是学会怎么挂插件,而是建立了一套判断框架可扩展性的直觉:拿到一个新框架,先看它的接口层定义了什么、核心层没引用什么,再看看它敢不敢把核心流程的中间环节全部开放出来。能做好这两点的框架,扩展性是装不出来的。

如果你也想拿这套框架做点东西,我的建议是从一个最小需求开始,先写一个观察型钩子跑通全流程,再逐步加工具、加记忆、加自己的编排逻辑。动手之前最好把协议层几个核心接口通读一遍,花半小时能给你省掉后面好几个小时的排查时间。插件化不是“加个配置就能无限扩展”的银弹,但当你理解了它的边界在哪里,你会发现它能替你扛掉的复杂度,远超你的预期。

RELATED READING

延伸阅读

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