从脚本到插件平台:DeepSeek Harness架构演进实录
先说一个反直觉的结论一个工具最终变得难用通常不是因为功能太少而是因为功能太多、又互相掺和。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 NotImplementedErrorexecute不返回结果只是修改传入的context。这么做是故意的如果插件返回结果调度器就得定义“返回值应该怎么处理”这就多了一层约定。而修改上下文的方式让数据像流水线一样在插件之间传递每个插件只负责一件事读我要读的字段写我能写的字段。describe也非常重要。它不是为了展示给用户看的而是给自动发现机制用的。调度器拿到一个插件对象之后不需要知道它的具体类是什么只需要通过describe搞清楚它能不能处理当前任务、需要哪些参数。很多插件系统失败就是因为加载了插件却不知道该让它干什么最后只能靠硬编码。有了describe这件事就变成了纯粹的数据匹配。2.3 权限边界插件能看到的、能改的、绝对不能碰的定义接口只是第一步真正难的是定义边界。我见过很多插件系统最后烂掉就是因为插件之间互相污染A 插件改了上下文的某个字段B 插件假设那个字段是另一个意思结果整个链路输出都错了。我在 Harness 里给上下文对象分了三个区——只读区、写入区、私有区。只读区放的是初始输入和全局配置所有插件都能看但不能改写入区放的是插件想要对外暴露的中间结果大家都能读但只能写自己命名空间下的键私有区是插件内部临时数据调度器不管也不让别的插件访问。这个设计其实就是给插件划了三块地公共绿地可以看自己院子可以改别人家门禁不能进。dataclass class Context: # 初始化时构建插件不要改这里 input_data: dict global_config: dict # 写入区插件用 set_artifact 写入自己名下的结果 _artifacts: dict field(default_factorydict) # 私有区插件临时数据 private: dict field(default_factorydict) 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, timeout10): try: return await asyncio.wait_for(plugin.execute(context), timeouttimeout) 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 从一坨脚本变成一个插件平台最本质的变化不是我引入了什么高级设计而是我把“变化”本身变成了产品的一部分。每一个新需求只要能被拆成一个插件就不需要动旧代码整个系统因此变得越来越抗折腾。这种状态是会上瘾的。后来我再接手别的内部项目时也会有意识地问一句这里的变化点是什么这个变化点是不是应该被隔离成一个独立单元如果你在做自己的工具时也有这种感觉那说明你已经从一个写功能的人变成了一个设计系统的人。脚手架蜕变为产品的过程其实是人的角色转变的过程。