与服务发现原理)
CFzjyAI Engineer▍深耕领域AI Agent 架构设计Harness 插件开发Context Engineering多智能体协同 RAG▍技术底座PythonTypeScript·Cordis 插件MCP Skills·FastAPIDocker▍工程能力专注 Agent 运行时与 Harness 插件开发、长上下文与流式交互优化、LLM 全链路工程化落地「 让 AI 交互更智能让技术落地更高效 」欢迎技术探讨 / 项目合作关注解锁大模型与智能交互的无限可能DeepSeek Harness 插件间怎么协作Cordis 依赖注入inject与服务发现原理《DeepSeek Harness 插件架构》连载专栏 · 第 03 篇前两篇解决了插件是什么和插件怎么活这一篇解决插件之间最本质的问题插件之间怎么认识彼此。传统思路是 import——A 插件要调用 B 的能力就把 B 的模块导进来编译期焊死。Cordis 的思路是服务发现——B 把自己的能力注册成具名服务A 声明我需要这个能力内核在运行时把两者接上。官方 Cordis 教程第三章的开篇定义值得先读三遍“服务是一个插件提供、其他插件通过ctx消费的具名能力。在 harness 中ctx.tools、ctx.llm和ctx.agents都是服务。消费方只指定tools之类的能力而不导入其提供方因此配置可以选择提供方无需修改消费方。”最后半句是全部价值的浓缩换提供方不用改消费方——这是插件化生态能成立的经济学基础。痛点一协作 耦合。传统方式下插件 A 想用插件 B 的功能就必须 import B从此 A 的编译、测试、发布都拖着 B——插件之间没有真正解耦过。痛点二顺序靠祈祷。多插件项目的加载顺序靠配置文件的排列顺序调一下顺序就翻车为什么先加载 A 就报错成了玄学。痛点三换实现 改代码。想从 OpenAI 模型换成本地 Ollama 模型得改所有调用方的代码——能力替换的成本高到不敢换。本文事实全部来自官方 Cordis 教程《服务》一章与《服务与依赖》文档。读完你会拿到服务的完整解剖运行时注册 编译时类型、inject 的深层语义含一个反直觉的进程退出行为、运行时依赖跟踪服务消失与恢复的自动生命周期、和一段可运行的依赖注入模拟器。想追完整系列下一篇事件总线与扩展点可以先关注我的 CSDN 主页。文章目录DeepSeek Harness 插件间怎么协作Cordis 依赖注入inject与服务发现原理一、为什么插件间需要服务发现原理拆解篇1.1 从一个直觉误区说起import 不是协作是捆绑1.2 与经典 DI 框架的异同二、基础篇提供与消费一个服务官方示例全拆2.1 提供服务运行时注册 编译时类型2.2 消费服务inject 声明2.3 一段可运行的依赖注入模拟器三、进阶篇inject 的深层语义3.1 加载后仍跟踪依赖依赖不是一次性检查3.2 必选与可选inject vs ctx.get3.3 服务命名扁平命名空间的纪律四、高阶服务发现的完整能力拼图4.1 类型声明合并为什么是声明而不是接线4.2 与第 02 篇生命周期的交汇点4.3 内置服务一览你每天都会 inject 的对象4.4 服务隔离回顾同一服务的多个实例五、实战避坑指南高频问题与一键解决速查表结语一、为什么插件间需要服务发现原理拆解篇1.1 从一个直觉误区说起import 不是协作是捆绑两个插件要协作最自然的写法是 importimport{GreeterService}from./greeterexportfunctionapply(ctx:Context){constgreeternewGreeterService(...)console.log(greeter.greet(world))}这段代码能跑但它埋下了三个雷。雷一编译期焊死——你的插件从此依赖 greeter.ts 这个文件对方改个类名你就编译失败。雷二实例不共享——你 new 了一个 GreeterService别的插件 new 了另一个两个实例各管各的状态服务名存实亡。雷三生命周期失控——greeter 插件被卸载了你手里还攥着它的实例引用第 02 篇的幽灵引用事故重演。Cordis 的解法用一句话概括服务是能力名不是类名。提供方把能力挂到 ctx 的具名键位上ctx.greeter消费方只声明我需要 greeter内核负责把就绪的实例接上。类名是实现的私有事务能力名是协作的公共契约——这层抽象就是插件生态的全部经济学只要能力名不变实现随便换、随便升级、随便换厂商。服务发现import 硬绑定❌ 耦合✅ 解耦A import B 的类编译/测试/发布全链路绑定换实现 改所有消费方B 注册能力名 greeterA 声明 inject: [greeter]内核运行时接线换提供方 改一行配置不敢换、换不动按客户/环境装配能力1.2 与经典 DI 框架的异同做过 Java/Spring 或 Nest.js 的读者会认出依赖注入这四个字但 Cordis 的 DI 有三个更激进的设计先摆出来有个预期第一注入目标不是对象而是运行中的插件。Spring 注入的是 BeanBean 之间靠 Autowired 装配装配完就静止了Cordis 注入的是有生命周期的服务——服务可能中途消失、恢复、被替换依赖关系全程动态跟踪第三章细讲。第二声明方式是导出变量而不是注解。export const inject [tools]一行搞定没有注解处理器、没有编译期魔法、没有反射——依赖声明就是数据数据就能被工具读、被文档生成、被诊断脚本枚举。第三依赖关系决定加载顺序配置顺序毫无意义。官方教程原文“cordis.yml中的加载顺序无关紧要决定插件何时启动的是依赖关系而不是文件顺序。”Spring 里 Bean 的顺序同样靠依赖图但大量团队仍会手写 Order 注解保险Cordis 干脆把顺序这个概念从配置语法里删掉了——没有顺序字段就没有顺序 bug。二、基础篇提供与消费一个服务官方示例全拆2.1 提供服务运行时注册 编译时类型官方教程第三章的完整示例把提供服务的全部仪式浓缩在这三十几行里import{Service,typeContext}fromdeepseek-ai/cordisdeclaremoduledeepseek-ai/cordis{interfaceContext{greeter:GreeterService}}exportclassGreeterServiceextendsService{constructor(ctx:Context){super(ctx,greeter)}greet(who:string){returnHello,${who}!}}exportconstnamegreeterexportfunctionapply(ctx:Context){ctx.plugin(GreeterService)}官方原文点明这套代码是两个部分协同工作运行时部分super(ctx, greeter)以名称greeter注册该实例——此后任何插件都能通过ctx.greeter访问它。注意官方补的一句“注册属于 effect卸载提供方时会移除该服务”——服务的注册自动接入了第 02 篇的生命周期体系提供方卸载服务键位随之消失这为后面的运行时依赖跟踪埋下伏笔。编译时部分declare module deepseek-ai/cordis块用 TypeScript声明合并把greeter加进Context接口让ctx.greeter在全项目通过类型检查。官方特别强调“它不会生成代码没有该声明时服务在运行时仍能工作但消费方会失去类型安全。”这句话划清了 Cordis 的哲学边界——类型是服务契约的文档层运行时接线不依赖它。类型系统在这里是锦上添花的约束而不是决定生死的机制。还有一行容易被忽略ctx.plugin(GreeterService)——Service 子类本身就是插件第 01 篇讲过的类形态挂载方式和其他插件完全一样。服务不是凌驾于插件体系之上的特殊存在而是插件体系的一个特化形态。2.2 消费服务inject 声明消费方比提供方还简单importtype{Context}fromdeepseek-ai/cordisexportconstnameconsumerexportconstinject[greeter]exportfunctionapply(ctx:Context){console.log(ctx.greeter.greet(world))}官方教程配了一个非常有意思的验证实验交换 cordis.yml 中两行的顺序输出完全相同——因为决定启动顺序的是依赖关系不是文件顺序彻底移除 greeter 提供方consumer 保持 PENDING、不输出、不崩溃、不半运行。更绝的是官方补充的一个反直觉细节“处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃因此如果组合中没有其他运行项进程会静默地以状态码 0 退出。”——连等待都是不占资源的PENDING 是真正的休眠而非挂起。2.3 一段可运行的依赖注入模拟器把服务的注册、发现、依赖等待、动态替换写成可运行的 Python教学等价实现真实实现见 deepseek-ai/cordisCordis 依赖注入模拟器:服务注册/发现 PENDING 等待 运行时替换提供方。from__future__importannotationsfromdataclassesimportdataclass,fielddataclassclassKernel:极简内核:服务注册表 插件 fiber 列表。services:dict[str,object]field(default_factorydict)plugins:list[Plugin]field(default_factorylist)defregister_service(self,name:str,impl:object)-None:提供服务:挂到具名键位(等价 super(ctx, name))。self.services[name]implprint(f [kernel] 服务 {name} 已注册)self.wake_plugins()defunregister_service(self,name:str)-None:卸载服务:键位消失,依赖者进入 PENDING(等价提供方被卸载)。self.services.pop(name,None)print(f [kernel] 服务 {name} 已移除,依赖它的插件回到 PENDING)defwake_plugins(self)-None:服务变化时唤醒所有等待中的插件(等价内核的依赖跟踪)。forpinself.plugins:ifp.statePENDINGandset(p.inject).issubset(self.services):p.load(self)dataclassclassPlugin:插件:声明依赖,依赖就绪前保持 PENDING(静默、不占资源)。name:strinject:list[str]kernel:Kernel state:strPENDINGdefload(self,kernel:Kernel)-None:self.stateACTIVEprint(f [{self.name}] 依赖就绪,开始运行(apply))fordepinself.inject:print(f [{self.name}] 使用服务 {dep})if__name____main__:kKernel()consumerPlugin(consumer,[greeter],k)k.plugins.append(consumer)print(场景1:提供方晚到 — consumer 先声明依赖)print(f [consumer] 当前状态:{consumer.state}(静默等待,无输出无报错))k.register_service(greeter,object())# 提供方挂载print(场景2:运行时替换 — 卸载旧提供方再挂新实现)k.unregister_service(greeter)print(f [consumer] 当前状态:{consumer.state})k.register_service(greeter,新实现)# 新提供方挂载,消费者自动恢复跑一遍你会看到三个关键行为提供方晚到时消费者静默等待PENDING 无输出无报错提供方被卸载时依赖者立刻回到等待状态不会拿着旧引用继续跑新提供方挂载后自动恢复运行。这就是第 02 篇说的生命周期保险在依赖注入层面的完整形态。三、进阶篇inject 的深层语义3.1 加载后仍跟踪依赖依赖不是一次性检查这是 Cordis DI 区别于所有启动时装配式 DI 的核心特性官方原文“inject并非一次性的启动检查。如果应用运行期间所需服务消失例如提供方被卸载或热替换每个依赖插件也会随之卸载并在服务恢复后再次加载。”把这条链拆开提供方被卸载 → 服务键位消失 → 依赖者自动卸载其 effect 全部回卷→ 服务恢复 → 依赖者重新 PENDING → LOADING → ACTIVE。官方还点明了它和第 02 篇的联动“结合 effect这能防止运行中的消费方保留对不可用服务的引用依赖消失时它自己的注册也会撤销。”——幽灵引用这个事故第 02 篇事故一在 DI 层面被结构性地消灭了不是提醒你别用旧引用而是让旧引用根本不存在。官方给出的现实用例直接把价值说透“卸载dsh-bash-local挂载另一个shell提供方所有注入shell的插件都会重新启动并使用新实现。”——这就是运行时切换模型/工具提供方的标准姿势换 Bash 实现、换 LLM 适配器、换存储后端都是卸载旧提供方 挂载新提供方两行配置的事所有消费者自动迁移。3.2 必选与可选inject vs ctx.get官方对两种依赖的界定很清晰“inject用于硬性依赖。如果某项功能缺失时插件仍可运行请跳过inject并在使用处探测”exportfunctionapply(ctx:Context){// undefined when no provider is loaded; the plugin still runs.constgreeterctx.get(greeter)console.log(greeter?.greet(maybe)??no greeter available)}选型的判据在第 01 篇给过这里补一个从生命周期视角的新判据inject的依赖者会跟着服务同生共死服务消失它也卸载ctx.get的使用者则对服务的来去无感查一次算一次。所以真正的问题不是我需不需要它而是它没了我要不要跟着一起消失。要一起消失用 inject比如工具插件没了 tools 就没意义要苟着降级运行用 ctx.get比如指标插件没了 metrics 就少记点数据。3.3 服务命名扁平命名空间的纪律官方对命名有一条明确警告“每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间harness 已占用tools和llm等普通名称。”两个实操建议第一别起cache、db这类通用名——你的cache和另一个团队的cache撞车时后注册的静默覆盖先注册的两个插件组都以为自己在用自己的 cache第二查官方占用表——子系统页面生成的cordis-surface区块列出了 harness 注册的每个名称起名前去扫一眼避免和框架未来版本撞名。四、高阶服务发现的完整能力拼图4.1 类型声明合并为什么是声明而不是接线第 2.1 节说过声明合并不生成代码这一节说说这个设计为什么是对的它让类型定义成为一个可选的协作层。两个团队协作A 团队提供服务把declare module写进自己包的类型入口B 团队消费服务install A 的包拿到类型ctx.xxx全程有补全、有检查C 团队不想引入 A 的包直接写ctx.get(xxx)也能跑丢掉类型安全换零依赖。类型、运行时、依赖三个层面可独立选择——这正是插件生态需要的那种松耦合协作方想绑多紧就绑多紧。4.2 与第 02 篇生命周期的交汇点把本篇和第 02 篇串起来看服务发现和生命周期其实是一枚硬币的两面inject 声明的是依赖图Fiber 状态机执行的是依赖图上的动态调度——PENDING 是依赖未满足的状态服务消失触发的卸载是依赖被破坏的处理服务恢复触发的重载是依赖重建的恢复。理解了这层对应关系你就掌握了 Cordis 的完整心智模型依赖图是静态蓝图状态机是动态执行器两者合起来就是插件化运行时的全部骨架。4.3 内置服务一览你每天都会 inject 的对象学完原理认识一下 DSH 里最常用的几个内置服务官方子系统页面为每个服务生成完整的方法与事件参考这里给插件作者视角的速览服务名提供什么能力典型用法ctx.tools工具注册与执行流水线inject: [tools]后ctx.tools.register({...})ctx.llm模型调用与流式输出自定义模型适配器、改写流式响应ctx.agents实时智能体协调监听 agent 决策、注入策略ctx.sessions会话与持久化会话查询、投影、持久化ctx.logger统一日志第 02 篇 4.3 提过插件日志走它别用 console这些服务名就是 DSH 的公共 API 面——记服务名比记 API 文档更重要名字稳定方法会演进inject 声明了名字方法变化由类型系统提醒你声明合并的另一个红利。4.4 服务隔离回顾同一服务的多个实例第 01 篇 4.4 讲过服务隔离cordis.yml 的 isolate本篇讲完依赖注入后回头再看它会更清楚隔离的本质是让依赖图按组分叉——同一个服务名shellgroup-a 和 group-b 各解析到各自的实例两个组的依赖图互不连通。所以隔离不是服务的特性而是依赖解析的规则同样的声明、同样的能力名在不同的上下文边界里解析出不同的实例。这个理解能帮你预判隔离的行为边界跨组调用服务是不可能的没有共享键位组内共享一切。五、实战避坑指南高频问题与一键解决速查表序号遇到的报错或现象背后的真正原因一句话解决办法1apply 里 ctx.xxx 是 undefined没在 inject 里声明依赖服务尚未就绪导出inject [xxx]框架会等到就绪再调 apply2插件无输出且进程直接退出(状态码 0)插件停在 PENDING且没有其他活跃项PENDING 不保持事件循环用第 02 篇的 diagnose 代码查缺哪个服务3两个插件组互相覆盖对方服务服务名撞车扁平命名空间后注册静默覆盖自有服务加前缀命名起名前查 cordis-surface 列表4提供方热替换后消费方还在用旧实现消费方把服务引用存进了模块级变量绕过 ctx 查询每次使用走 ctx.xxx 或注入的实例不要缓存服务引用5类型检查报 ctx 上无此服务提供方没做声明合并或消费方没导入类型提供方写 declare module消费方 import type 提供方包6可选服务缺失时插件崩溃用 inject 声明了可有可无的依赖改成 ctx.get() 探测 降级逻辑7加载顺序影响行为有人在配置里依赖了条目顺序官方明确顺序无关检查是否漏了 inject 声明把顺序改由依赖图表达这张表配合第 02 篇的速查表使用两篇合起来覆盖依赖与生命周期两大主题 90% 的排障场景。两条表共同的根子就一个凡是想用顺序和记得解决的事Cordis 都要求你改成声明。结语本文把插件间怎么协作拆成了 Cordis 的服务发现体系服务是能力名不是类名——提供方用super(ctx, name)注册、消费方用inject声明内核运行时接线配置可替换提供方而消费方零改动依赖是动态跟踪的——服务消失依赖者自动卸载幽灵引用被结构性消灭服务恢复自动重载运行时切换实现只需两行配置声明合并让类型成为可选协作层——绑多紧由协作方自己选。再加上命名纪律和服务隔离一套完整的插件协作经济学就成型了能力名稳定实现自由竞争——这是所有插件生态从 VSCode 到 DSH能长大的共同秘密。一张图总结全文知识要点文字回顾① 服务 具名能力消费方不导入提供方 → 换实现不用改消费方② 提供服务 super(ctx, name) 运行时注册 declare module 编译时类型③ inject 是动态契约PENDING 等待不占事件循环、服务消失自动卸载、恢复自动重载④ 可选依赖用 ctx.get 探测判据是它没了我还要不要活着⑤ 命名走扁平空间自有服务必须加前缀⑥ 依赖图是蓝图、状态机是执行器两者合起来是插件化运行时的完整骨架。下一篇连载预告《DeepSeek Harness 扩展点全解:tools/systemPrompt/agent/llm 背后的事件总线机制》——五种分发模式的官方 API 详解与 waterfall 拦截实战关注 CSDN 主页 不错过更新。你的插件之间现在怎么协作import 硬绑定踩过什么坑?评论区聊聊,我挨个回。本文事实基于 DeepSeek Harness 官方 Cordis 教程《服务》一章(2026-07 版本),有出入评论区提醒我。参考资料正文数据来源DeepSeek Harness 官方 Cordis 教程《服务》、官方文档《服务与依赖》、deepseek-ai/cordis Service 与 Context API 文档。