ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

DeepSeek Harness实战:用Cordis构建可扩展的AI插件架构

DeepSeek Harness实战:用Cordis构建可扩展的AI插件架构 很多人第一眼看到“DeepSeek Harness 的 Cordis 插件架构”这个标题会觉得它又是个“AI套壳项目”。我也一度这么想但实际把思路理清楚之后发现这里面的价值藏得挺深。它不只是在给 DeepSeek 写一个调用包装而是在解决一个更实际的问题当你把一个大语言模型接入自己的业务系统时那些零零散散的功能——知识库检索、工具调用、权限控制、多轮会话管理——怎么组织才能不变成一团乱麻我的做法是用 Cordis 这个插件框架把 DeepSeek 包进一个 Harness 层里让所有能力都以插件的形式生长出来。这篇文章就把它拆开给你看包括为什么选 Cordis、架构怎么搭、插件怎么写、哪些坑我踩过了都会讲到。不管你是想给自己的 AI 应用搭一套可以持续扩展的底座还是单纯好奇插件化架构在大模型场景里怎么落地这篇都值得你花十分钟看完。1. 为什么需要一个“Harness”而不是直接调 API1.1 直连模型看似简单只是把复杂度推迟了我见过太多项目一开始都是这样代码里写死一个chat()函数把用户的话丢给 DeepSeek拿到结果返回就完事。早期确实跑得爽但等到要加“让模型查一下订单状态”“让它调用内部搜索”“让它在回答前先过滤敏感词”这些需求时你会发现原来那个chat()函数越来越臃肿——每个新功能都要改核心代码改完上线还提心吊胆。这就是典型的“早期偷懒后期还债”。直连模型本质上没有错错的是把模型调用和业务流程焊死在了一起。你缺的不是一个函数而是一个能把模型调度、插件注册、生命周期管理、上下文传递都统一起来的中间层。这个中间层就叫 Harness。1.2 Harness 层到底“harness”住什么Harness 这个词直译是“马具、挽具”意思就是让一匹马拉起车来更可控。放在这里就是给 DeepSeek 这匹“野马”套上一套成体系的缰绳让它按你的规则办事而不是每次都要写一堆临时逻辑。具体来说这个 Harness 层至少要管住四件事会话上下文的生命周期每个用户会话自己带一份上下文互不污染会话结束能自动释放。动态插件的挂载与卸载技能、工具、知识库都是插件可以随时加装、移除不改核心。模型交互的统一出入口所有发给 DeepSeek 的请求都走同一套管线日志、限流、重试、鉴权在管线里一次性解决。权限与安全边界哪些插件能让模型调用、哪些不能由 Harness 统一控制。这四件事单拆出来谁都能做但要是没有一套机制约束很容易做成“自己写 event loop 自己写监听器 自己写插件加载器”最后又变成一个更复杂的新轮子。所以我在设计的时候决定Harness 不自己发明架构而是站在一个成熟插件框架的肩膀上——这就是 Cordis。1.3 为什么偏偏选了 Cordis而不是自研插件系统在选择插件框架时我对比了好几个方案包括在 Node.js 生态里比较常见的自研事件总线方案以及深拷贝式配置加载方案。最后定下来用 Cordis原因是它的设计哲学和我对 Harness 的期望高度一致设计维度自研插件总线Cordis插件生命周期要自己定义启动/停止钩子原生内置 apply / dispose / active / inactive依赖管理常靠约定或手动注入声明式依赖框架自动处理加载顺序上下文隔离常需要自己传 ctxContext 天然成为依赖注入的容器和边界插件间通信自定义事件结构内置 EventEmitter 服务注册动态加载手动重启应用插件可随时启用/停用无需重启Cordis 的核心模型是一个全局的Context所有插件共享同一个ctx但每个插件又可以创建自己的子上下文做隔离。这正好符合 Harness 的需求全局共享模型配置和连接池但每个会话、每个插件服务独立边界。而且 Cordis 的生态是 TypeScript 原生类型提示很完整写插件时能省掉不少排查类型错误的功夫。对于要实现“DeepSeek Harness”这种需要频繁迭代插件能力的项目来说这个开发体验非常关键。2. Cordis 插件架构的核心设计拆解2.1 生命周期插件不是“一个类”而是一段受控的生命Cordis 里插件的最小单位是一个“入口函数”不是类也不是文件。你写一个函数函数体的开头做一些初始化工作然后返回一个“销毁函数”或者在函数体内部调用ctx.on(dispose)来注册清理逻辑。当插件被停用或应用关闭时Cordis 会沿着依赖树逆序调用清理逻辑保证不会出现资源泄漏或依赖悬空。这个设计对 DeepSeek Harness 特别重要。举个例子我有一个“知识库检索插件”初始化时会创建检索客户端的连接池。如果这个插件被热停用连接池必须及时释放如果在释放期间有请求还在用它就会出问题。Cordis 的生命周期管理让我们可以做到停用插件之后Harness 先把该插件对应的路由标记为不可用等处理中的请求全部结束后再真正释放资源。我还喜欢它的“可选依赖 按需加载”特性。一个技能插件可以声明“如果存在 HTTP 请求插件我就注册一个 Webhook 入口如果不存在我依然能跑只是没有 Webhook”这在给 DeepSeek Harness 添加多入口对接时非常舒服——同一个技能既能从聊天框触发也能从 webhook 触发全看当前环境装了哪些插件。2.2 Context既共享又隔离这是会话级插件化的地基刚接触 Cordis 的人最容易懵的就是 Context 语义。我建议你把它理解成一个“作用域”全局有一个app.ctx它是根上下文所有插件都能访问。但你随时可以用ctx.derive()派生一个子上下文子上下文里的监听器和服务默认只在自己的作用域内生效被 dispose 后不会影响父级。在 DeepSeek Harness 里我用这个机制做了一件很关键的事每个用户会话都拥有一个独立的子上下文。这个子上下文里挂载了会话专属的状态比如对话历史、用户偏好、临时记忆变量。当会话结束时这个子上下文被整体销毁所有内存数据跟着释放不用自己写一堆清理代码。更重要的是子上下文还隔离了“服务可见性”。我的 Harness 里有一个“管理端插件”和“用户端插件”前者暴露管理模型的接口后者只能看到查询类的服务。因为它们是挂在不同子上下文上的服务互相不可见天然就实现了权限边界不需要额外做路由判断。2.3 服务注册插件之间怎么不通过“你家我家那样传对象”来协作早期我写插件协作最喜欢导出对象然后在别的插件里 import两三个插件还好一旦超过五个依赖关系就会绕成毛线球。Cordis 的解法是“服务注册”一个插件通过ctx.provide(serviceName, impl)把能力挂到上下文上其他插件用ctx.service.serviceName来调用而且只能在声明了依赖的前提下调用。例如我的 DeepSeek Harness 有一个核心服务叫model专门负责和 DeepSeek API 通信、处理流式响应、统计 token 用量。而各种技能插件写代码的、查资料的、提取摘要的都是model服务的下游消费者。技能插件不需要知道model的实现细节只需声明依赖model服务即可。这种解耦带来的最大收益是测试变得极其简单——我可以写一个假model服务在单测里直接替换掉真实模型调用测试某个技能插件在收到特定模型输出时的表现而不用真的去调 DeepSeek API。这在以前直接调 API 的架构里是想都不敢想的。2.4 事件系统模型“说话”和工具“动作”之间的桥梁DeepSeek 这类模型在实际使用中经常需要“中途插入工具调用”模型说“我需要查一下天气”Harness 收到这个指令后调用天气插件拿到结果再回填给上下文供下一轮模型调用。这是典型的“事件驱动”流程而 Cordis 自己就带了一套与框架深度集成的 event 机制。我用ctx.emit(harness.tool_request, payload)让 Harness 广播“模型请求调用工具”的事件工具插件监听这个事件并返回结果再通过ctx.emit(harness.tool_response, result)把结果送回去。因为事件与上下文挂钩所以事件天然带有会话 ID避免多个会话并行时互相串台。3. 实操记录我从零搭一个 DeepSeek Harness 的过程3.1 第一步初始化工程和安装 Cordis这个项目的开发环境是 TypeScript Node.js版本要求 Node 18 以上。新建目录后直接用包管理器安装依赖mkdir deepseek-harness cd deepseek-harness npm init -y npm install deepseek-ai cordis npm install typescript types/node tsx -D我习惯用tsx做开发时的热运行省掉编译步骤调试起来更快。接着简单配置tsconfig.json把模块解析方式设为 NodeNext目标代码版本定在 ES2022主要是为了用上原生 async iterable 和比较现代的错误处理。初始化Cordis主入口很直接import { Context } from cordis; const app new Context(); // 这里后续会挂载各种插件 app.start();启动之后Harness 就能开始接收插件了。注意app.start()是异步的它要等所有已加载插件的apply逻辑执行完才返回。如果你在启动前没有注册插件之后再通过app.plugin()注册插件Cordis 也会立刻执行该插件的apply这保证了热插拔的体验非常自然。3.2 第二步写一个“模型网关”核心插件任何 Harness 最重要的一层都是模型网关。我把所有对 DeepSeek 的调用收敛到一个插件里对外暴露model.chat()和model.stream()两个服务方法。为了不让模型 API 的调用细节泄露到业务层这个插件还会自己处理超时重试、错误归一化和 token 统计。import { Context, Service } from cordis; import { DeepSeekClient } from deepseek-ai; declare module cordis { interface Context { model: ModelService; } } class ModelService extends Service { private client: DeepSeekClient; constructor(ctx: Context, private config: ModelConfig) { super(ctx, model); this.client new DeepSeekClient({ apiKey: config.apiKey }); } async chat(messages: ChatMessage[], options?: ChatOptions) { try { const resp await this.client.chat({ messages, ...options }); return resp; } catch (err) { // 记录这次调用的输入输出摘要方便回溯 this.ctx.emit(harness.model_error, { messages, error: err }); throw new HarnessModelError(model call failed, err); } } } export function apply(ctx: Context, config: ModelConfig) { ctx.plugin(ModelService, config); }这里有一个容易被忽略的细节模型 API 调用带AbortSignal的能力很重要因为 Harness 经常需要“用户中断回答”或“超时取消调用”。Cordis 的 Context 类型本身不强制要求你做这件事但我在服务设计中全部支持了signal参数确保请求取消后底层连接能被及时掐断不留后台幽灵任务。3.3 第三步做一个真正能挂载的技能插件接下来是最有意思的部分。假设我要给 Harness 加一个“搜索摘要”技能它可以让 DeepSeek 在回答前先查一下本地知识库再给出有依据的回答。这个技能作为一个独立插件不需要改动模型网关的任何代码。import { Context } from cordis; import { SearchEngine } from my/lib-search; declare module cordis { interface Context { search: SearchService; } } class SearchService extends Service { constructor(ctx: Context, private engine: SearchEngine) { super(ctx, search); } async query(text: string) { return this.engine.search(text); } } function apply(ctx: Context) { ctx.plugin(SearchService); } export { apply };注意这个插件只注册了search服务它还不算是“技能”。真正的技能逻辑应该挂在更上层——一个“Agent Skill”插件它里面会定义“当模型决定使用搜索时”Harness 如何把模型输出解析成搜索请求、如何把搜索结果重新组装成下一轮对话消息。这类写起来不复杂但需要注意一个核心问题模型的输出是自然语言你要用严格的 schema 来约束它而不是用正则碰运气。我的做法是把工具调用的 schema 直接写进 system prompt让 DeepSeek 在工具调用模式下输出 JSON然后 Harness 解析 JSON校验必须字段。如果 JSON 解析失败就带着错误信息重新调一次模型让模型自己修正而不是在解析层直接崩溃。实际运行下来DeepSeek 的 JSON 输出稳定性相当不错。只要 prompt 里把字段定义清楚、类型写明白成功率很高偶尔失败也能靠“重试并附上解析错误”的方式挽回。3.4 第四步用派生上下文实现会话隔离和权限边界会话隔离是 Harness 质量的试金石。我实现了一个“会话管理器”插件它负责为每个入站消息创建或复用子上下文function apply(ctx: Context) { ctx.on(harness.message, async (sessionId, payload) { const sessionCtx ctx.derive({ name: session-${sessionId} }); // 挂载这段会话独有的临时状态 sessionCtx.shared { history: [], currentSkill: null }; // sessionCtx 里能访问到全局服务 model / search const response await sessionCtx.model.chat(sessionCtx.shared.history); // 会话结束或超时时必须清理子上下文 sessionCtx.dispose(); }); }这里的sessionCtx.dispose()不是可选的而是必须的。如果不调用子上下文里的监听器、共享变量、服务实例会一直堆积轻则内存缓慢上涨重则事件串会话。我在早期版本里偷懒漏掉了几处 dispose结果表现为“用户 A 问了一句话用户 B 的回答里竟然带着 A 的上下文”——排查了一阵才发现是子上下文没释放导致的教训极其深刻。权限边界也可以在这里做把用户端请求的 ctx 比如命名为client-scoped把管理接口的所有服务挂到admin-scoped上两边互不可见谁也没法越权调用自己不该碰的服务。这比在业务代码里判断“用户角色”干净得多。3.5 第五步加载方式配置化让部署时决定插件集为了让不同环境开发、测试、生产能加载不同插件集我把插件配置抽成了独立 JSON 文件启动时按配置装载。代码大致长这样import { Context } from cordis; import { readFileSync } from fs; import { modelPlugin } from ./plugins/model; import { searchSkill } from ./plugins/search-skill; const config JSON.parse(readFileSync(./harness.config.json, utf8)); const app new Context(); for (const item of config.plugins) { switch (item.name) { case model: app.plugin(modelPlugin, item.options); break; case search: app.plugin(searchSkill, item.options); break; // 其他插件... } } app.start();这样做的好处是换环境不用改代码只改配置文件。比如本地开发我可以挂载“mock 模型插件”不真实消费 API生产则加载真实模型插件运行模式完全由配置驱动。Cordis 的app.plugin()支持在运行期反复调用所以这种配置化加载完全没有副作用。3.6 第六步事件日志与可观测性成熟的 Harness 不能只干活不留痕。我强烈建议在架构设计里把“可观测性”贯穿全链路而不是事后补救。在 Cordis 里我利用全局事件监听把以下事件全部收集到结构化日志模型请求开始、结束、耗时、token 用量、是否重试工具调用请求、实际执行结果、失败原因插件挂载、卸载、运行时错误这些都是ctx.on()一行的事但价值极高。在做线上问题定位时沿着sessionId就能还原整个会话生命周期的所有事件比看业务代码里到处插日志要顺手太多。4. 常见问题与排查技巧实录4.1 插件加载顺序不对导致服务找不到Cordis 本身对显式声明的依赖会做拓扑排序但如果你在插件 A 的apply里去访问插件 B 的服务而 B 只是“按需提供”并不在那个上下文中就会报service missing之类的错误。排查思路先确认 B 插件是否真的在目标 Context 上注册过。常见原因是 A 加载在全局 ctx而 B 注册在某个子 ctx 上两者不在一个作用域。解决方案有两种把 B 挂到全局或更高的上下文或让 A 在子 ctx 里显式依赖 B。经验做法核心基础服务全部挂全局ctx业务技能类插件挂会话级子ctx。这么规划后作用域错乱的问题基本消失。4.2 会话上下文串号A 看到的对话历史是 B 的这个坑前面提到过就是子上下文没及时 dispose。初次出现时我一度怀疑是全局变量被并发写坏了最后查出来是ctx.derive()创建的上下文积累过多导致监听器里用了错误的sessionId定位到旧上下文。排查技巧在日志里给每个ctx打印一个唯一 IDCordis 的 ctx 对象自带唯一标识然后看用户 A 的请求到底落到了哪个 ctx 上。如果是已经销毁的旧 ctx多半是事件监听器还挂在全局应当移动到会话 ctx 内部。另外注意不要直接修改 ctx 上不存在的属性。Cordis 的 Context 对象理论上可以挂任意属性但为了类型安全和避免意外共享我改成把会话状态放到一个独立的SessionState对象里再通过ctx.shared引用。这样即便多个插件拿到同一个 ctx也能明确知道 state 是会话级的而不是全局的。4.3 模型流式响应与插件调用冲突有些技能需要在流式输出中间触发工具调用这就涉及“流式输出暂停 → 调用工具 → 得到结果 → 继续输出”这种非常规交互。直接实现很容易把状态机搞乱。我的解法流式输出阶段模型返回的数据先进入一个“pending 区”不直接发给用户。Harness 解析到tool_call标记时暂停流式收集执行工具插件把结果拼接进上下文后重新调用模型得到新一轮输出。这里必须用事件来通知“tool 调用完成”而不是在回调里同步返回否则会卡死流式管道。实用小技巧为每个 session 维护一个“当前是否为工具调用等待”的状态位所有插件在尝试向 session 推送消息前先检查这个状态位避免在等待工具结果时把用户的普通输入误当成下一轮对话内容。4.4 Cordis 插件之间的循环依赖有一次我写了两个插件A 插件注册了搜索服务B 插件注册了记忆服务但实际上 A 需要用 B 的记忆来做搜索历史过滤B 又要用 A 的搜索来触发记忆强化形成了循环依赖。Cordis 和大多数依赖注入框架一样遇到循环依赖时会直接报错启动失败。解决方案拆出一层中间服务。把“记忆”和“搜索”的共同上游拆成harness-core服务让 A 和 B 都依赖 core再由 core 统一编排两者的事件流。不要在插件层面让两个业务插件互相直接依赖而是通过 Harness 核心事件来解耦。这也引出一个架构原则插件应当是“纵向切片”而不是“横向耦合”。纵向切片意味着每个插件完整地服务一类需求横向耦合则会带来不可避免的互相引用。4.5 插件更新后热替换不生效Cordis 支持在运行期卸载和重新加载插件但如果你更新的插件仍然叫同一个名字加载前要先把旧实例释放否则新插件的apply不会执行。我踩过这个坑改了插件代码运行期调用ctx.plugin()重新注册结果发现行为没变重启应用才生效。正确做法拿到旧的插件实例引用先调用oldInstance.dispose()确认销毁事件派发完毕再注册新实例。如果插件内部有 HTTP server 或定时器必须在 dispose 里显式关闭否则端口和定时器会被重复占用。5. 这套架构能扩展到哪里5.1 从单体 Harness 到多模型路由因为插件和服务都解耦了所以 DeepSeek Harness 的架构完全可以横向扩展为“多模型网关”。比如同一个 Harness 里加载多个模型服务插件每个模型插件注册自己的model.chat再做一个路由插件按任务类型分配简单闲聊走轻量模型复杂推理走高性能模型代码生成走特定模型。这里 Cordis 的动态加载能力价值很大——某个模型服务的健康状态异常时路由插件可以动态停用该模型插件剩余流量自动转移到备用模型整个过程不用重启应用用户在会话层几乎无感知。5.2 接入多模态输入现在的大语言模型生态里文本之外还有图片、语音、文档解析等输入。DeepSeek Harness 的插件架构可以继续加“多模态解析插件”在用户输入落到模型前先经过一系列“预处理插件”把图片转成文本描述、声音转成文字、PDF 抽取正文再统一进模型。这种场景下Cordis 子上下文的并发隔离优势会进一步凸显——每个多模态请求独立拥有自己的解析管线、中间产物、最终文本互不干扰。而解析插件本身可以各自独立迭代谁坏了只影响自己的解析环节。5.3 插件市场化和团队协作当 Harness 架构稳定后插件可以像积木一样拆给团队里的不同人维护。一个人负责 prompt 优化另一个人专门写工具插件第三个人做知识库对接各自模块互不阻塞。只要接口约定清晰代码评审的压力会大幅下降。这也是 Cordis 插件架构在长期演进中最大的隐形成本收益它培养的是一种“面向服务的模块化”思维而不是把所有逻辑堆在一个 main 函数里的坏习惯。这种思维方式一旦建立后续哪怕不用 Cordis 而是用别的框架设计出来的系统依然会很清晰。5.4 围绕 Harness 的测试体系建设插件化让测试变得非常有针对性。我在 Harness 上搭了三层测试单元测试单独测每个插件mock 掉模型服务和其他依赖集成测试启动真实 Cordis 应用但mock 模型 API测试完整的多插件协作端到端测试接通真实 DeepSeek API但只跑少量回归场景防止过度消耗配额有了这三层改任何一个插件时我能快速判断是不是破坏了其他插件的契约。而测试模板可以写得非常薄只需要写一个标准的测试入口把要测的插件填进去就行剩下的交给 Cordis 自己管理生命周期。说实话这套架构我最初推出来的时候团队里也有人觉得“为了一个小项目搞这么重没必要”。但真正跑到第四个插件、第五个插件时没有人再质疑——因为后续每一个新能力都只需要往配置里加一行、往插件目录里丢一个文件以前那种“改这个功能把另一个功能搞炸”的情况再也没有出现过。如果你想给自己的 DeepSeek 应用加一层长久的、能不断长出新能力的底座Cordis 确实是一个非常扎实的选择。最后再分享一个我反复验证过的小技巧插件的边界宁细勿粗一个插件只做一件明确的小事比一个大而全的“万能插件”好维护十倍。把模型接入、工具调用、记忆存储拆成独立微积木哪怕最后某个积木要重写你也不会伤及其他部分。
RELATED READING

延伸阅读

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