☰
从脚本到插件平台:DeepSeek Harness架构演进实录
2026/10/14 8:53:17 网站建设 项目流程

先说一个反直觉的结论:一个工具最终变得难用,通常不是因为功能太少,而是因为功能太多、又互相掺和。DeepSeek Harness 最开始只是我手上一坨用来调度 DeepSeek 模型调用的 Python 脚本,后来疯长成一个带插件体系的内部平台。这个过程不是我一上来就设计了什么宏伟架构,而是被需求逼着一步一步重构,最后才发现“插件化”不是某种高深技巧,而是一套把变化隔离在固定边界之内的思维习惯。

这篇文章想把背后的演进过程完整摊开来讲:插件协议怎么定、插件怎么被发现、调度核心管了什么、事件总线为什么有必要、以及从“自己用得爽的脚手架”变成“别人也能用的产品”到底差在哪几步。如果你是正在做 AI 工具链、或者准备给内部项目上插件化改造的开发者,这篇应该能帮你少踩几个我踩过的坑。

1. 最初那坨“能干所有事”的脚本

1.1 做 Harness 的起因:不是拍脑袋想造轮子

当时我在做一个偏自动化的内容处理流水线,核心工作就是把一段段原始材料喂给大模型,拿到结果后再做清洗、判断、归档。最开始没有框架概念,每次要跑一个新任务,就复制一份脚本,改改 prompt、换换处理函数,然后手动盯着输出文件。功能不复杂时这套做法很爽,半小时就能跑通一个点子。

但是第三周开始不对劲了。同一份材料要过好几道不同的模型任务,每个任务都有自己的前置处理、后置校验和异常策略。这些逻辑散落在不同脚本里,彼此之间靠“文件落盘再读取”传递数据。谁改了文件名约定,另一个脚本立刻静默失败。最痛苦的是想加一个新任务,先要去翻老脚本里那些已经没人敢动的公共函数,生怕改坏别的流程。

当时我给自己定了一个目标:把“加一个新任务”变成“写一个文件”这么简单。这个目标后来成了整个 Harness 的设计核心。所以我做 DeepSeek Harness 的动机很朴素——不是为了做框架,是为了让自己不要再熬夜改脚本。

1.2 第一版设计:模拟真实场景的 Runner 加 Prompt 模板

第一版其实谈不上架构,就是三层:配置、执行、回调。配置里写模型参数和 prompt 模板,执行层负责调 API 和重试,回调层负责把结果写进日志或数据库。所有任务都抽象成一个 Python 类,类里有两个方法:一个叫run,一个叫export。run负责调用模型并返回结构化结果,export负责把结果格式化成你想要的形式。

这个抽象在当时很有效,因为百分之八十的任务确实能拆成这两件事。比如“把材料摘要成一百字”就是一个run;“把所有摘要合并成一个 Markdown 报告”就是一个export。我一度觉得这套设计能走很远,直到有人跑来跟我说想加一个“做摘要之前先把原文里的敏感信息打码”的步骤。

我愣住了。打码属于“前处理”,而我的抽象里根本没有前处理这个概念。如果强行塞进run里面,就意味着每个任务都要重写run。那一刻我意识到,问题不在“前处理”这个功能本身,而在于我把任务边界定义得太死:一个任务不是“一个操作”,而是一串操作。我需要的是让一串操作可以被自由组合,而不是把组合逻辑写死在类里。

1.3 失控信号:改一个流程就要碰三个文件

真正推动我重构的,是一次改需求的痛苦经历。产品侧说要做“批量判重”,判重之前要把所有材料统一转成纯文本,转完之后还要统计词频。我一看代码,发现这个流程横跨了三个文件:转文本的函数在基础工具库,词频统计在另一个任务的回调里,判重逻辑又散在配置层。为了不让代码爆炸,我先把判重逻辑写成一个新函数,然后在run里依次调用另外两个函数。

最让我崩溃的是三个文件之间的依赖关系没有任何文档,只有注释里留着几行“别动这里”。当时我就明白,这不是代码质量问题,而是抽象层级出了问题:所有东西都放在同一层,任何改动都会牵一发动全身。我需要一个层,把所有“任务的一部分”都变成独立的、可替换的单元。这个层就是后来的插件层。

2. 插件协议:扎好篱笆才能种好菜

2.1 先定义接口还是先写插件:顺序决定成败

我在一开始踩了一个特别大的坑:还没想清楚插件协议,就先写了三个插件试水。结果每个插件的入参都不一样,有的要字符串、有的要字典、有的要文件路径。调度器为了兼容它们,写了一大堆if isinstance分支。每次加插件都要去调度器里补一段判断,噩梦重演。

后来我干脆停下来,把调度器里所有分支删掉,先把协议定义清楚。一个插件要解决什么?它接收一个统一的上下文对象,处理完以后把结果写回这个对象。所有插件不需要知道别的插件存在,只需要知道上下文长什么样。这个决定让整个体系一下子清楚了。

我想强调一个经验:先定义接口永远比先写实现重要。接口是稳定面,实现是变化面。你如果先写实现,接口会被实现绑架;反之,接口定义好了之后,实现反而好写,因为约束会帮你把不确定性收窄。这不是理论,是我在 Harness 上真实经历的路线选择。

2.2 最小插件接口长什么样:一个 execute 加一个 describe

最终我把插件接口收敛得非常小,只有两个方法:

class BasePlugin: """所有插件必须实现的基类""" name: str = "" def describe(self) -> dict: """返回插件的元信息:名称、版本、用途、参数说明""" return { "name": self.name, "version": "0.1.0", "description": "", } def execute(self, context, config) -> None: """执行插件逻辑,修改 context,不返回值""" raise NotImplementedError

execute不返回结果,只是修改传入的context。这么做是故意的:如果插件返回结果,调度器就得定义“返回值应该怎么处理”,这就多了一层约定。而修改上下文的方式,让数据像流水线一样在插件之间传递,每个插件只负责一件事:读我要读的字段,写我能写的字段。

describe也非常重要。它不是为了展示给用户看的,而是给自动发现机制用的。调度器拿到一个插件对象之后,不需要知道它的具体类是什么,只需要通过describe搞清楚它能不能处理当前任务、需要哪些参数。很多插件系统失败,就是因为加载了插件却不知道该让它干什么,最后只能靠硬编码。有了describe,这件事就变成了纯粹的数据匹配。

2.3 权限边界:插件能看到的、能改的、绝对不能碰的

定义接口只是第一步,真正难的是定义边界。我见过很多插件系统最后烂掉,就是因为插件之间互相污染:A 插件改了上下文的某个字段,B 插件假设那个字段是另一个意思,结果整个链路输出都错了。

我在 Harness 里给上下文对象分了三个区——只读区、写入区、私有区。只读区放的是初始输入和全局配置,所有插件都能看但不能改;写入区放的是插件想要对外暴露的中间结果,大家都能读但只能写自己命名空间下的键;私有区是插件内部临时数据,调度器不管,也不让别的插件访问。这个设计其实就是给插件划了三块地:公共绿地可以看,自己院子可以改,别人家门禁不能进。

@dataclass class Context: # 初始化时构建,插件不要改这里 input_data: dict global_config: dict # 写入区:插件用 set_artifact 写入自己名下的结果 _artifacts: dict = field(default_factory=dict) # 私有区:插件临时数据 private: dict = field(default_factory=dict) def set_artifact(self, namespace: str, key: str, value): self._artifacts[f"{namespace}.{key}"] = value def get_artifact(self, namespace: str, key: str): return self._artifacts.get(f"{namespace}.{key}")

这个边界设计让我在后面加几十个插件时几乎没有因为字段冲突扯皮过。原因很简单:你不是靠约定约束人,而是靠结构约束行为。约定总有一天会被打破,结构不会。

3. 插件发现机制的三个阶段

3.1 手动登记:最简单但最反人性

第一批插件我非常偷懒,直接在调度器里维护了一张注册表,类似这种写法:

PLUGINS = [ PreprocessPDFPlugin(), DedupPlugin(), SummaryPlugin(), ]

好处是直白,坏处是每加一个插件都要去改PLUGINS数组。如果哪天有人忘了登记,调度器根本不知道有这个插件存在。我在内部做过一次统计,大约有三分之一的新插件犯过“忘了注册”这个错。这个阶段适合插件数量小于五个的场景,一旦超过,手动登记就是个时间炸弹。

手动登记的问题本质上是把“系统应该自动知道的事”变成了“人的义务”。而人一旦有重复性的义务,就一定会偶尔偷懒或有疏漏。所以到了第七八个插件时,我决定把发现机制换掉。

3.2 第二代:文件系统约定加元信息解析

第二代发现机制是文件夹扫描。我把插件统一放进一个plugins/目录,每个子目录就是一个插件包,里面必须有plugin.py和一个manifest.json。调度器启动时扫描目录,读取manifest.json,根据文件里的元信息决定哪些插件可用。

{ "name": "dedup_plugin", "version": "0.1.0", "entry": "plugin.py:MainPlugin", "tags": ["preprocess", "dedup"], "params": { "threshold": { "type": "float", "default": 0.82, "description": "相似度阈值" } } }

扫描逻辑不复杂,就是在启动时做一次模块导入和实例化。但这一步让我少写了无数“注册代码”。新的插件只要放进去,加一个 manifest,下一次启动就能被系统看到。目录结构本身变成了一种声明方式,文件夹在哪,插件就在哪,我看一眼目录就能知道系统支持什么能力。

这个阶段的代价是必须严格管理命名空间和目录结构,不能让无关文件混进插件目录。我后来加了校验:manifest.json缺失或解析失败直接跳过,同时把警告信息打到日志里,而不是让整个系统崩溃。

3.3 第三代插件依赖与优先级:别让你的插件互相打架

文件扫描解决了“找得到”的问题,但紧接着出来一个新问题:插件之间的顺序和依赖。比如“打码”必须在“转文本”之后、“做摘要”之前。第一次我靠配置里的order数字硬排,谁在前面谁先跑。但后来插件多了,某两个插件的相对顺序被另一个插件插入搅乱,出过一次难查的线上事故。

后来我引入了三层概念——阶段(phase)、优先级(priority)、依赖声明(depends_on)。阶段定义了插件在哪一个大环节里,比如preprocess、transform、generate。优先级决定同一阶段内的相对顺序。依赖声明则让调度器在运行前做一次拓扑排序,如果 A 依赖 B,那么 B 必须先于 A 被加载和执行。

class DedupPlugin(BasePlugin): phase = "transform" priority = 10 depends_on = ["text_normalize"]

这个设计的核心思想是,插件之间不要用“隐式顺序”来耦合,而是把顺序关系显式地写出来。调度器负责把声明变成执行序列。你不说顺序,我就按优先级排;你说了依赖,我就按依赖排。这样加插件几乎不会撞车,因为所有关系都变成了数据。

4. 调度核心到底在管什么:生命周期与事件总线

4.1 插件生命周期:从加载到销毁的六个状态

有了发现机制之后,调度器就成了整个系统的心脏。第一版调度器只是个 for 循环,跑完所有插件就结束。后来发现远远不够,因为插件不是每一个都能顺利跑完的,也不是每一个都能被直接加载的。于是我把插件的状态机建了起来:DISCOVERED表示被发现了,LOADED表示实例化成功,READY表示依赖满足可以执行,RUNNING表示正在执行,SUCCEEDED或FAILED表示执行结果,还有一个SKIPPED表示因为条件不满足被跳过。

调度器做的事情就是把这些状态流转起来:发现插件、加载插件、按依赖排序、逐个执行、收集结果。这里最容易被忽略的是SKIPPED状态。插件的execute方法执行前,调度器会先调用一个可选的should_run方法,让它自己判断当前上下文里有没有它需要的数据。没有就直接跳过,而不是进入执行逻辑之后才发现条件不满足。

class BasePlugin: def should_run(self, context) -> bool: """ 默认返回 True。 如果插件发现自己需要的字段不在上下文中,就返回 False。 """ return True

这样做的好处非常明显:一个插件可以服务于多种任务。任务 A 不需要打码,打码插件的should_run检查到上下文里没有敏感标记字段就自动跳过;任务 B 需要打码,同样的插件就会正常执行。调度器不再需要关心“这个任务要不要跑这个插件”,那是插件自己的判断。

4.2 为什么要用事件总线而不是直接调用

当时的第二个大决策是引入事件总线。有段时间插件之间偶尔要传递“异步通知”,比如摘要插件完成之后,希望通知归档插件“可以开始了”。最初的实现是给摘要插件传入归档插件实例,直接调用方法。这种做法非常痛快,代码写起来也很短,但问题很快就暴露了:插件 A 开始依赖插件 B 的存在,插拔某一个插件就会牵连另一个插件。整个系统又回到了“改一处崩三处”的老路。

事件总线把这种直接依赖打散成间接通信。摘要插件完成后发一个事件:

event_bus.publish("summary.completed", payload={"doc_id": context.input_data["doc_id"]})

归档插件订阅这个事件,收到之后再决定自己要不要执行。两个插件互不认识,修改任意一个都不会影响对方。事件总线的本质是把“谁在乎这件事”这件事交给订阅者自己决定,而不是让发布者去关心谁会来。

这个设计让“万物皆插件”真正落地了一半:功能被切成插件,通信也被切成事件。插件之间没有硬引用,整个系统就像一块块可以自由拆卸的积木。当然,事件总线也有代价,比如调试时链路不那么直观、事件多了以后不好追踪,所以我在事件总线上做了审计日志,每一个发布和订阅行为都留痕。

4.3 对失控插件的防守:超时、重试、隔离

插件是人写的,人写的代码就一定有失控的时候。我在 Harness 上遇到过三次比较严重的插件事故:一次是插件里出现了死循环,整个调度进程被卡住;一次是插件里面加了网络重试功能,导致一次调用被重复执行了七次;还有一次是插件抛出的异常没有设置上限,导致一个任务崩了以后,整个批次全部失败。

从那之后我定了一条铁律:调度器必须对每个插件设置超时、重试和熔断。超时按插件类型设置不同的上限,网络调用的插件给六十秒,纯本地处理的插件给十秒。重试只针对可重试异常,而且必须有次数上限和退避策略。熔断则是当某个插件在短时间内连续失败达到阈值,调度器直接短路,不再继续尝试。

async def run_plugin(plugin, context, timeout=10): try: return await asyncio.wait_for(plugin.execute(context), timeout=timeout) except asyncio.TimeoutError: plugin.mark_failed("timeout") context.set_artifact(plugin.name, "error", "timeout") except Exception as exc: plugin.mark_failed(str(exc)) context.set_artifact(plugin.name, "error", str(exc)) if plugin.allow_retry and plugin.retry_count < plugin.max_retries: plugin.retry_count += 1 await asyncio.sleep(plugin.retry_backoff) return await run_plugin(plugin, context, timeout)

这里我的核心观点是:调度器不能对插件充满信任,它要把插件当成外部服务来看待。信任是构建系统时最大的陷阱。插件是很好的思想,但插件作者的水平参差不齐,你必须在调度层做防守,否则某天一个插件就会把整个流程拖垮。

5. 从脚手架蜕变为产品的几个关键改造

5.1 可观测性:看不到内部运行,产品就没法上线

脚手架阶段的“日志”就是print,我盯着终端看输出。但脚本变成平台之后,使用者看不到终端,他们只有一个运行结果。最早我的插件报错信息是一串 Python 堆栈,使用者根本不知道发生了什么,只会截图发给我问“是不是坏了”。

蜕变的第一步就是补可观测性。我给每个插件自动采集三段信息:执行时长、输入参数摘要、输出结果摘要。因此在插件执行前后各埋一次点,统一格式写结构化日志。后来干脆把这些数据直接推到内部监控面板上,每个插件一行,绿的是成功、红的是失败、黄的是跳过。使用者一眼就能看出是哪一步出了问题,不用再翻堆栈。

这里有个心得:可观测性不是为了别人开发的,是为了未来的你自己。三个月后再去看一段跑挂的流程,你不可能记得每个细节。有了一套标准化的运行痕迹,排查问题的速度能快十倍。

5.2 错误信息的“人话化”:不是给栈看,是给人看

给使用者看的错误信息,和给开发者看的错误信息,应该是两套东西。早期我的 Harness 把整个异常堆栈原样抛给前端,使用者看到<class 'KeyError'>之类的东西完全懵了。后来我把错误处理改成两层:面向人的信息是一句简洁的“插件 xx 在执行预处理时失败了,原因是缺少材料编号,请检查输入数据”;面向开发者的堆栈单独存一份,不展示给普通用户。

我实现了一个异常封装,插件抛出的任何异常都会被调度器捕获,再根据异常类型和插件名称生成人话描述。关键是要有“场景感”:不能说“连接超时”,要说“外部服务响应太慢,已经自动重试 2 次,仍然失败”。这两句话的区别就是脚手架和产品的区别——脚手架只对机器说话,产品要对人说话。

5.3 配置化与热加载:让使用者自己解锁能力

产品化的另一个重要标志,是把“改代码”变成“改配置”。Harness 在早期加插件是要写 Python 代码的,我自己写没问题,但别人用就开始犯难。后来我把插件参数的默认值全部放进了配置文件,使用者只需要填写一个 YAML,不用动代码:

tasks: - name: weekly_report model: deepseek-chat plugin_sequence: - name: sensitive_mask params: {} - name: summarize params: max_length: 300 - name: md_export params: output_path: "./out/report.md"

这套配置出现之后,工具的定位彻底变了:我从一个“写代码的人”变成了“搭积木的人”。使用者想加一个流程,不需要理解插件协议,只需要在 YAML 里声明插件顺序和参数。这种转变就是“脚手架蜕变为产品”的核心——你的东西不仅仅能解决你自己的问题,还能让一个不了解内部实现的人解决同类问题。

热加载是后来才敢做的。一开始是每次改完配置要重启服务,后来我实现了一个简单的配置监听器,配置文件变更后自动触发一次插件图重算。这里有个大坑:热加载不能简单粗暴地卸载所有插件重新加载,因为已经跑完的任务需要保留上下文。我的方案是只加载新增的插件、更新配置变化的插件,正在运行的任务维持旧快照,等它跑完再切换。

6. 复盘:三招让插件架构保持不翻车

6.1 接口宁可小而锋利,不要大而全

复盘整个 Harness 的开发史,我最后悔的设计是早期给插件接口加了一堆“看起来方便”的方法。比如validate、transform、save_state、restore_state,想法是让插件能应对各种情况,实际上百分之九十五的插件用到的只有execute和describe。接口一旦大了,每个插件都要为用不到的方法操心,或者干脆写出空方法应付了事。

现在我的原则是:接口只暴露最核心的交互面,其他能力用“可选协议”来扩展——插件如果额外实现了某个接口,调度器就会多调用它;没实现就跳过。这样接口本体保持稳定,功能还能不断长出。这套玩法的哲学是:约定越少,组合越多。

6.2 永远跑一个最小插件再写调度器

如果你现在要从零做一个插件系统,我给你的第一条建议是:先把一个最蠢的插件跑通,再开始写调度器。什么叫最小插件?就是一个execute里只改一个字段、describe里只填一个名字的插件。先把它放进目录、让调度器找到它、执行它、把结果写出来。这个过程建起来之后,后续的插件都会变得非常简单,因为你已经有了一条完整的路径。反之,如果你先写各种高级特性,最后连一个插件都跑不起来,调试成本会高到让你怀疑人生。

6.3 架构的意义是让“改需求”变得便宜

最后想说的是,做架构不是为了让代码看起来漂亮,而是为了让明天改需求的时候少流一点汗。DeepSeek Harness 从一坨脚本变成一个插件平台,最本质的变化不是我引入了什么高级设计,而是我把“变化”本身变成了产品的一部分。每一个新需求,只要能被拆成一个插件,就不需要动旧代码,整个系统因此变得越来越抗折腾。

这种状态是会上瘾的。后来我再接手别的内部项目时,也会有意识地问一句:这里的变化点是什么?这个变化点是不是应该被隔离成一个独立单元?如果你在做自己的工具时也有这种感觉,那说明你已经从一个写功能的人,变成了一个设计系统的人。脚手架蜕变为产品的过程,其实是人的角色转变的过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询