DeepSeek Harness Cordis 教程:从零到接入真实 harness_cordis插件-CSDN博客
DeepSeek Harness Cordis 教程:从零到接入真实 harness
本教程通过 7 个可运行的动手示例,带你掌握 Cordis 插件框架,并最终把插件接入真实的 harness 服务。
一、Cordis 是什么?
Cordis 是 DeepSeek Harness 底层的插件框架:它是一个小型运行时,其中每项能力——包括工具(tool)、 LLM 适配器、文件访问,乃至 agent loop(智能体循环)本身——都是挂载到共享上下文中的插件。
换句话说,你可以把整个 Harness 想象成一张由插件组成的树:
每个插件通过一个 apply(ctx) 函数描述自己贡献的内容,而 cordis.yml 负责把这些插件"组合"成一个应用。
本教程面向 agent 开发者。你不需要深入掌握 TypeScript,每章都会给出确切的命令和预期输出。
二、准备工作(Setup)
克隆仓库并安装依赖:
| |
本教程不需要 API 密钥,所有示例均可在无密钥环境中运行。
创建各章使用的临时目录(tmp/ 已被 git 忽略,不会进入版本控制):
| |
每一章都从该目录运行同一条命令:
| |
这个单文件启动器(vendor/cordis/bin.js)会:
- 创建根
Context; - 挂载 Loader 插件;
- 让 Loader 从当前目录加载
./cordis.yml。
其余所有内容——包括有哪些插件、如何配置它们——都来自你稍后将编写的 YAML 文件。--import tsx 标志让 Node 无需构建步骤即可运行配置所指向的 TypeScript 文件。
三、第 1 章:你的第一个插件
插件是一个函数,由 loader 挂载。创建 hello.ts:
| |
name 导出项是可选的显示元数据,用于在诊断信息中标识插件。
接着创建 cordis.yml 来组合应用:
| |
cordis.yml 是一组 Cordis 配置项的列表。name 是模块指定符,可以是相对路径或 NPM 包名。各项会并发启动,所以列表顺序不保证加载先后;加载顺序由服务依赖(inject)决定,而非文件中的位置。
运行:
| |
预期输出:
hello from my first plugin
当没有任何内容继续运行时,进程会自行退出。整个过程中,你的文件里没有框架启动代码:插件只描述自己的贡献,cordis.yml 负责组合应用。例如 dsh base 就是一份更长的插件组合。
其他两种插件形态
函数是最常见的形式,但 Cordis 接受三种形态:
| |
在你需要公开服务之前,请一直使用函数形态;第 3 章会介绍何时应当使用类形态。
尝试制造错误
让 apply 抛出异常:
| |
再次运行:进程会因该错误而终止。插件加载失败会明确报错,不会仅跳过该配置项。
一个例外:如果某个配置项的模块无法被解析(例如路径或包名拼写错误),Cordis 会通过 logger 服务报告错误,而不会使进程崩溃——而且这条报告在启动阶段可能早于 logger 导出器开始观察而丢失。如果新增配置项似乎没有任何效果,请先检查拼写。
四、第 2 章:生命周期与 effect
Cordis 插件可能因修改配置、热重载、显式资源释放或所需服务消失而卸载。通过 Cordis API 建立的注册属于 effect,会在所属插件卸载时自动撤销;在这些 API 之外管理的资源必须包装在 ctx.effect() 中。
Effect
对于 Cordis 尚未管理的资源——例如定时器、连接或 watcher——应将其包装在 ctx.effect() 中并返回 disposer(资源释放函数)。
创建 lifecycle.ts:
| |
让 cordis.yml 指向该文件:
| |
运行后会得到:
heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed
请留意三点:
ctx.plugin(heartbeat)会把一个来自代码的函数挂载为插件,这与 YAML loader 为每个配置项执行的操作相同。函数插件不需要apply方法,Cordis 会直接调用该函数;只有对象形态才要求apply方法。调用会返回一个 fiber,即一个已加载插件实例的运行时句柄。- effect 主体在加载期间运行;它返回的 disposer 在卸载期间运行。对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。
fiber.dispose()会等该插件的所有清理工作(包括异步 disposer)完成后才结束,并递归卸载它挂载的所有子插件。
Fiber 状态机
每个已加载插件实例都拥有一个 fiber,并在以下状态之间转换:
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
↘ FAILED
- PENDING:已经声明,但所需服务(第 3 章)尚不可用。
- LOADING / ACTIVE:
apply正在运行 / 已经完成。 - FAILED:
apply或配置校验抛出异常。 - UNLOADING / DISPOSED:disposer 正在运行 / 一切均已拆除。
你会在第 6 章再次遇到 PENDING,它通常就是"为什么我的插件没有输出"的答案。
已经属于 effect 的操作
你很少需要亲自编写 ctx.effect(),因为内置注册 API 本身已经是 effect:
ctx.on(event, listener):监听器会在卸载时移除(第 4 章);ctx.plugin(child):子插件会随父插件一同 dispose;- 服务注册属于 effect,
ctx.tools.register(...)等 harness 注册表也会把返回的 disposer 附着到调用插件上,因此会自动撤销(第 7 章)。
顺序注意事项:disposer 会按注册顺序的逆序启动,但多个异步 disposer 会并发运行。如果拆除步骤必须按顺序执行,请把它们放在同一个 disposer 中,并在其中依次等待每步完成。
五、第 3 章:服务
服务是一个插件提供、其他插件通过 ctx 消费的具名能力。在 harness 中,ctx.tools、ctx.llm 和 ctx.agents 都是服务。消费方只指定 'tools' 之类的能力,而不导入其提供方,因此配置可以选择提供方,无需修改消费方。
提供服务
创建 greeter.ts:
| |
两部分协同工作:
- 运行时:
super(ctx, 'greeter')以名称greeter注册该实例。此后,任何插件都可以通过ctx.greeter访问它。注册属于 effect,卸载提供方时会移除该服务。 - 编译时:
declare module '@deepseek-ai/cordis'块使用 TypeScript 声明合并,把greeter加入Context接口,使ctx.greeter在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。
Service 子类本身就是插件(第 1 章介绍的类形态),因此 ctx.plugin(GreeterService) 会像挂载其他插件一样挂载它。
使用 inject 消费服务
创建 consumer.ts:
| |
inject 列出该插件需要的服务。Cordis 会让插件保持 PENDING,直到列出的每项服务都存在,因此在 apply 内可以保证 ctx.greeter 已经就绪。cordis.yml 中的加载顺序无关紧要:决定插件何时启动的是依赖关系,而不是文件顺序。
组合并运行:
| |
Hello, world!
交换 cordis.yml 中两行的顺序后重新运行,输出仍然相同。尝试彻底移除 ./greeter.ts:消费方会保持 PENDING,不输出任何内容,既不崩溃,也不会只运行一部分。处于 PENDING 的 fiber 也不会让 Node 的事件循环保持活跃,因此如果组合中没有其他运行项,进程会静默地以状态码 0 退出(第 6 章介绍如何诊断这种状态)。
加载后仍会跟踪依赖关系
inject 并非一次性的启动检查。如果应用运行期间所需服务消失——例如提供方被卸载或热替换——每个依赖插件也会随之卸载,并在服务恢复后再次加载。结合 effect(第 2 章),这能防止运行中的消费方保留对不可用服务的引用:依赖消失时,它自己的注册也会撤销。
这也是配置中可以替换服务的原因:卸载 Cordis 配置项 dsh-bash-local,挂载另一个 shell 提供方,所有注入 'shell' 的插件都会重新启动并使用新实现。
可选依赖
inject 用于硬性依赖。如果某项功能缺失时插件仍可运行,请跳过 inject,并在使用处探测:
| |
命名
每个应用中的服务名称共用一个扁平命名空间。请为自有服务添加有辨识度的前缀或命名空间(harness 已占用 tools 和 llm 等普通名称);子系统页面上生成的 cordis-surface 区块会列出 harness 注册的每个名称。
六、第 4 章:事件
服务支持直接调用;事件让插件无需知道有哪些插件正在监听,就能发出通知。harness 使用事件处理工具结果、模型请求和审批决定等交互。
声明、发出与监听
创建 stats.ts——一项负责计数并在每次变化时发出通知的服务:
| |
interface Events 合并与第 3 章的 interface Context 合并在事件系统中相互对应:它声明事件名称及其监听器签名,因此 ctx.emit 和 ctx.on 都具有完整类型。namespace/action 命名约定让扁平的事件命名空间保持易读。
创建 reporter.ts:
| |
import type {} from './stats.ts' 行不会在运行时导入任何内容;它的作用是让 TypeScript 看到声明合并。
组合并运行:
| |
[stats] tool_call -> 1
[stats] tool_call -> 2
[stats] prompt -> 1
因为 ctx.on() 属于 effect,监听器会随插件一同消失,绝不需要手动维护 removeListener。
分发模式
emit 是 5 种分发模式之一。事件采用哪种模式是其约定的一部分,决定了监听器能否返回值、能否并发运行,以及能否彼此短路:
| 模式 | 调用方式 | 行为 |
|---|---|---|
| emit | ctx.emit(name, ...args) | 同步广播;不会等待或收集返回的 promise 与值 |
| parallel | await ctx.parallel(name, ...args) | 所有监听器并发运行,并一同等待 |
| serial | await ctx.serial(name, ...args) | 监听器按顺序运行并等待;第一个非 null/false/undefined 返回值胜出,并停止后续监听器 |
| bail | ctx.bail(name, ...args) | serial 的同步版本 |
| waterfall(瀑布式事件) | ctx.waterfall(name, ...args, next) | 环绕中间件,见下文 |
每个 harness 事件都会在其所属子系统页面自动生成的参考文档中记录其模式。
waterfall:转换或短路
waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 next() continuation;它可以转换 next() 的返回值,也可以不调用 next() 就直接返回,从而短路链条的其余部分——Cordis 文档把后一种行为称为否决(veto)。
创建 waterfall-demo.ts:
| |
让 cordis.yml 只指向该文件并运行:
HELLO
** BLOCKED **
按顺序看第二行如何产生:监听器 1 先运行并调用 next(),从而调用监听器 2;监听器 2 看到 blocked 后直接返回而不调用 next(),因此最内层默认逻辑(传给 ctx.waterfall 的函数)从未运行;返回途中,监听器 1 再把替换消息转换为大写。
由此得到一项纪律:只负责观察或标注的 waterfall 监听器必须调用 next();不调用就直接返回代表有意短路。如果日志监听器忘记调用 next(),会悄无声息地吞掉所有下游的默认行为。
harness 使用 waterfall 处理协作插件可以包装或回答的决策:
agent/request允许插件替换模型调用配置;approval/request允许策略代替用户作答。
七、第 5 章:配置
cordis.yml 中的每个 Cordis 配置项都可以携带 config 块,插件则声明一个 schema,在运行 apply 前验证该块。错误配置会导致加载失败,并给出准确的错误:插件绝不会在配置不完整时启动。
可配置插件
创建 config-demo.ts:
| |
导出的 Config 既是 TypeScript 接口,也是同名的运行时 schema:消费方获得类型,Cordis 获得验证器。本仓库使用 Schemastery 定义 schema;Cordis 本身接受任意 Standard Schema 验证器,因此将普通对象导出为 Config 无法工作。
对其进行配置:
| |
运行:
Hello, alpha!
Hello, beta!
未提供 greeting,因此 schema 默认值会将其补齐:apply 始终会收到完整且经过验证的配置。
明确报错
现在向它传入无效内容:
| |
ValidationError: invalid config:
- $.targets expected array but got not-an-array (at targets)
插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。
计算得到的配置值
本仓库使用的 loader 支持 !!js 标签,用于必须在加载时计算的配置值:
| |
!!js 仅在 config 与条目 disabled 字段内有效。disabled: !!js ... 在每次挂载决策时基于 loader 上下文求值(本仓库的扩展),可以按平台或环境门控一行;其余元数据(name、id、inject 等)保持静态,其中的表达式是普通真值数据。
八、第 6 章:组合与 HMR(热模块替换)
到目前为止构建的每项能力都是插件,cordis.yml 则选择应用的插件树。本章会改变这种组合、热重载一个插件,并诊断始终无法加载的插件。
Cordis 配置项不只有名称
Cordis 配置项除了 name 和 config,还接受其他元数据:
| |
id:为 Cordis 配置项提供稳定标识,使 loader 能区分"修改现有配置项"与"先删除再添加"。disabled: true:会卸载插件而不删除其配置项;改回原值后,插件以及所有因依赖其服务而处于 PENDING 的插件都会再次加载。- 组(group):可以嵌套一份配置项子列表,并将其作为一个单元加载和卸载;
isolate:为一个组提供某项服务名称的独立实例,因此两个组可以各自看到配置不同的shell提供方,互不影响。
热模块替换
卸载会释放 effect(第 2 章),加载则遵循依赖关系(第 3 章),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。@deepseek-ai/cordis-plugin-hmr 插件会监视文件,并在保存时执行这一过程。
在 tmp/cordis-tutorial 中编写 cordis.yml:
| |
列表中增加了两个辅助插件:
- HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;
- 它还会
injecttimer服务来实现去抖,如果没有@deepseek-ai/cordis-plugin-timer,它就会永远停在 PENDING,而且不发出任何提示。
HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构,请在 tsx 下运行 Cordis:
| |
现在编辑 hello.ts,修改日志消息并保存:
hello from my first plugin
2026-07-22 15:44:36 [I] hmr watching [ '.' ]
2026-07-22 15:44:39 [I] hmr reload plugin at hello.ts
hello from my EDITED plugin
旧实例先卸载(其所有 effect 都会回卷),新代码随后加载,apply 再次运行。 按 Ctrl-C 停止进程。
编辑 cordis.yml 本身也会触发更新:loader 按 id 比较配置项,只挂载、卸载或重新配置发生变化的部分。这就是上述配置项显式携带 id 的原因:不带 id 的配置项在每次读取时都会获得一个新 id,所以只要配置文件发生任何编辑,即使自身文本未变,它也会被视为"先删除再添加"并重新挂载。
诊断始终无法加载的插件
依赖驱动加载也有另一面:如果插件的 inject 指定了无人提供的服务,它就会一直等待,不输出任何内容。这不是错误,因为 PENDING 是合法状态,提供方可能稍后才挂载。
你可以直接查看这些状态。每个上下文都能枚举插件注册表,创建 diagnose.ts:
| |
再创建一个依赖无法满足的插件 needs-timer.ts:
| |
| |
运行它(直接执行 node --import tsx ../../vendor/cordis/bin.js,按 Ctrl-C 停止):
needs-timer is PENDING — a required service is missing
inject: ['timer'] 没有提供方。向列表添加 - name: '@deepseek-ai/cordis-plugin-timer' 后,插件就会加载。
调试小技巧:如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。
九、第 7 章:进入 harness
本章会向 harness 的 tools 服务注册一个可由模型调用的工具,通过 harness 工具流水线执行它,并观察结果事件。整个示例无需密钥,也不会调用模型。
工具插件
创建 greet-tool.ts:
| |
这里的每个模式都来自前几章:
inject: ['tools'](第 3 章)会让插件等待工具注册表就绪;ctx.tools.register(...)会把注册 disposer 附着到插件(第 2 章),因此卸载时会注销工具;defineTool将parameters规约转换为向模型展示的 JSON Schema,推导args的类型,并在execute运行时校验它们。
观察插件
创建 tool-logger.ts。这是一个独立插件,通过 harness 的 tools/result 事件观察应用中的每次工具调用:
| |
import type {} from '@deepseek-ai/dsh-tools' 行会引入该包的声明合并,使 'tools/result' 及其 payload 具有类型——这与第 4 章导入 stats.ts 的做法相同,只是扩展到了包级别。
组合并运行
| |
@deepseek-ai/dsh-tools会注入systemPrompt服务,因为工具需要向系统提示词贡献 schema,所以组合中也要列出该服务的提供方。缺少提供方时,工具插件会像第 6 章所述那样保持 PENDING。
| |
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
注意 logger 先触发:tools/result 在结果物化过程中发出,发生在 execute 向调用方返回的 promise 兑现之前。两个插件都不知道另一个插件存在,它们由注册表服务和事件连接。
从这里走向完整 agent
真实 agent 就是这套组合再加上更多插件:LLM 适配器、agent loop、持久化和运行入口。对照 examples/headless-agent/cordis.yml,你现在已经可以读懂其中每个配置项——将 greet-tool.ts 加入该文件的副本即可。
后续可以阅读:
- 构建工具:深入了解
defineTool,包括呈现和更丰富的 schema; - 三层能力设计:harness 如何组织可替换能力;
- 子系统页面上生成的
cordis-surface区块:可以注入和监听的所有内容; - 架构参考:这些插件所处的系统地图。
十、TypeScript 说明
这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能:
- 类型注解描述值,但不会改变运行时行为:
ctx: Context表示ctx具备 Cordis 上下文 API,who: string接受文本,而string[]表示字符串数组。 import type { Context } from '@deepseek-ai/cordis'只导入类型信息。它在运行时会消失,因此仅为类型注解使用Context的插件文件不会增加运行时依赖。- 声明合并(
declare module '@deepseek-ai/cordis' { ... })会为 Cordis 已经声明的接口添加你的条目,例如新ctx.greeter属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件(第 3、4 章完整展示了该模式)。
第 5 章还会使用 interface 描述配置对象的字段,并使用 Schema<Config> 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。
十一、总结
| 章节 | 核心概念 | 一句话要点 |
|---|---|---|
| 1 | 插件 | 插件是函数,由 loader 挂载,cordis.yml 组合应用 |
| 2 | 生命周期与 effect | 未被管理的资源要包装在ctx.effect() 中,卸载时自动回卷 |
| 3 | 服务 | 在ctx 上公开能力,用 inject 声明依赖 |
| 4 | 事件 | 类型化事件、广播分发与 waterfall 的短路行为 |
| 5 | 配置 | 读取经过 schema 校验的配置,输入错误时明确报错 |
| 6 | 组合与 HMR | 把配置文件当作插件树,支持热重载,可诊断 PENDING 插件 |
| 7 | 进入 harness | 基于真实 harness 服务注册模型可调用的工具 |
Cordis 的精髓可以概括为一句话:把能力拆成可组合、可替换、可热更新的插件。掌握了这 7 章,你就拥有了阅读、修改和扩展 DeepSeek Harness 的核心能力。
- 原文作者:知识铺
- 原文链接:https://index.zshipu.com/ai002/post/20260818/DeepSeek-Harness-Cordis-%E6%95%99%E7%A8%8B%E4%BB%8E%E9%9B%B6%E5%88%B0%E6%8E%A5%E5%85%A5%E7%9C%9F%E5%AE%9E-harness_cordis%E6%8F%92%E4%BB%B6-CSDN%E5%8D%9A%E5%AE%A2/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com

