Cordis 内核:五概念、生命周期与事件
本章所有代码均取自官方
docs/cordis-tutorial/,可运行、可复现。要回答的问题:
- 为什么加载顺序不由配置文件决定,而由依赖决定?
- effect 的可逆性到底解决了什么工程问题?
- waterfall 不调
next()为什么会"静默吞掉下游"?- 插件不输出日志时,怎么知道它卡在哪?
0. 概念定义速查
完整版见
00-基础/01-概念词典与原理.md。本章先把词说清楚,后面才好讲机制。
0.1 五个词的一句话定义
| 术语 | 一句话定义 | 不是什么 |
|---|---|---|
| Plugin 插件 | 一个贡献能力的加载期单位 ,形态为函数 / 带 apply 的对象 / Service 子类 |
不是 npm package(一个包可有 0 个或多个插件) |
Context 上下文(ctx) |
作用域内所有服务的容器 ,同时是所有可逆副作用的记账本;底层是 Proxy | 不是全局变量,是树状的、分作用域的 |
| Service 服务 | 插件公开、供他人消费的具名能力 ,占据一个稳定的 ctx.<key> |
Definition 不是 TypeScript interface(interface 不存在于运行时) |
| Fiber 纤维 | 每个已加载插件实例的运行时句柄,跟踪状态与清理栈 | 不是插件对象本身;同一份代码可同时有多个 fiber |
| Effect 副作用 | ctx.effect(fn) 中 fn 在加载期 运行、其返回值在卸载期运行 |
不是事务(事务保原子性,这里保可撤销性) |
「不是什么」这一列比定义更重要------一个概念如果举不出反例,多半还没真懂。
0.2 Effect 与 Coeffect 的区别(最容易混的一对)
#mermaid-svg-kh6xfylfvOKdLvsk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kh6xfylfvOKdLvsk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kh6xfylfvOKdLvsk .error-icon{fill:#552222;}#mermaid-svg-kh6xfylfvOKdLvsk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kh6xfylfvOKdLvsk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kh6xfylfvOKdLvsk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kh6xfylfvOKdLvsk .marker.cross{stroke:#333333;}#mermaid-svg-kh6xfylfvOKdLvsk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kh6xfylfvOKdLvsk p{margin:0;}#mermaid-svg-kh6xfylfvOKdLvsk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kh6xfylfvOKdLvsk .cluster-label text{fill:#333;}#mermaid-svg-kh6xfylfvOKdLvsk .cluster-label span{color:#333;}#mermaid-svg-kh6xfylfvOKdLvsk .cluster-label span p{background-color:transparent;}#mermaid-svg-kh6xfylfvOKdLvsk .label text,#mermaid-svg-kh6xfylfvOKdLvsk span{fill:#333;color:#333;}#mermaid-svg-kh6xfylfvOKdLvsk .node rect,#mermaid-svg-kh6xfylfvOKdLvsk .node circle,#mermaid-svg-kh6xfylfvOKdLvsk .node ellipse,#mermaid-svg-kh6xfylfvOKdLvsk .node polygon,#mermaid-svg-kh6xfylfvOKdLvsk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kh6xfylfvOKdLvsk .rough-node .label text,#mermaid-svg-kh6xfylfvOKdLvsk .node .label text,#mermaid-svg-kh6xfylfvOKdLvsk .image-shape .label,#mermaid-svg-kh6xfylfvOKdLvsk .icon-shape .label{text-anchor:middle;}#mermaid-svg-kh6xfylfvOKdLvsk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kh6xfylfvOKdLvsk .rough-node .label,#mermaid-svg-kh6xfylfvOKdLvsk .node .label,#mermaid-svg-kh6xfylfvOKdLvsk .image-shape .label,#mermaid-svg-kh6xfylfvOKdLvsk .icon-shape .label{text-align:center;}#mermaid-svg-kh6xfylfvOKdLvsk .node.clickable{cursor:pointer;}#mermaid-svg-kh6xfylfvOKdLvsk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kh6xfylfvOKdLvsk .arrowheadPath{fill:#333333;}#mermaid-svg-kh6xfylfvOKdLvsk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kh6xfylfvOKdLvsk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kh6xfylfvOKdLvsk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kh6xfylfvOKdLvsk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kh6xfylfvOKdLvsk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kh6xfylfvOKdLvsk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kh6xfylfvOKdLvsk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kh6xfylfvOKdLvsk .cluster text{fill:#333;}#mermaid-svg-kh6xfylfvOKdLvsk .cluster span{color:#333;}#mermaid-svg-kh6xfylfvOKdLvsk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kh6xfylfvOKdLvsk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kh6xfylfvOKdLvsk rect.text{fill:none;stroke-width:0;}#mermaid-svg-kh6xfylfvOKdLvsk .icon-shape,#mermaid-svg-kh6xfylfvOKdLvsk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kh6xfylfvOKdLvsk .icon-shape p,#mermaid-svg-kh6xfylfvOKdLvsk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kh6xfylfvOKdLvsk .icon-shape .label rect,#mermaid-svg-kh6xfylfvOKdLvsk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kh6xfylfvOKdLvsk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kh6xfylfvOKdLvsk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kh6xfylfvOKdLvsk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Coeffect · 余效应 · 方向:向内
外面对我变成了什么
我需要什么才能活
声明:export const inject
Effect · 副作用 · 方向:向外
我对外做了什么
注册监听器 / 开定时器
声明:ctx.effect()
数学类比:函数
f : X → Y
数学类比:逆映射
f⁻¹ : Y → X
一句话 :effect 是「我出去做事」,coeffect 是「事情对我发生」。
0.3 背后的学术地基
Cordis 的一切设计都来自一篇论文:
Shi, Y., Zhang, W., & Cui, T. (2026). A Programming Paradigm for Spatiotemporal Composability. arXiv:2608.25512(cs.PL / cs.SE,92 页)。
它把动态组合拆成两个正交维度:
| 维度 | 定义 | 在 dsh 里对应 |
|---|---|---|
| 时间可组合性 Temporal composability | 移除组件时能完全回退它产生的所有副作用 | ctx.effect() 的 disposer |
| 空间可组合性 Spatial composability | 能声明并响应式管理组件间依赖 | export const inject |
所以本章的每个机制都有形式化依据,不是工程经验之谈。
术语解读见
00-基础/01-概念词典与原理.md§1。
1. 插件的三种形态
Cordis 接受三种插件形态。在你需要暴露服务之前,一律用函数形态。
ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello'
export function apply(ctx: Context) {
console.log('hello from my first plugin')
}
ts
import { Service, type Context } from '@deepseek-ai/cordis'
// 1. 函数插件 ------ 最常见,plugin-name 是诊断元数据
export function apply(ctx: Context) {}
// 2. 对象插件 ------ 带 apply 方法的对象
export const objectPlugin = {
name: 'object-plugin',
apply(ctx: Context) {},
}
// 3. 类插件 ------ Service 子类(本章第 3 节展开)
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myTutorialService')
}
}
1.1 输入输出契约
| 项 | 约定 |
|---|---|
导出 name |
可选,仅用于诊断信息(Inspector、日志) |
apply(ctx, config) |
生命周期入口,第二个参数是已通过 schema 校验的完整配置 |
| 返回值 | 无。副作用通过 ctx 注册 |
| 依赖 | export const inject = ['tools', 'llm'] |
| 配置 | export const Config = Schema.object({...}) |
1.2 一个必须知道的失败模式
ts
// ❌ 插件模块路径/包名拼错
- name: './helllo.ts'
Cordis 对解析失败 和执行失败的处理不一样:
apply抛异常 → 进程终止,fiber 进入FAILED(会明确报错)- 模块解析不出来 → 只通过 logger 服务报告错误,进程不会崩,而且很可能什么都看不到(因为 console exporter 可能还没开始观察)
实操结论:新增配置项"似乎没有任何效果",第一件事是查拼写,而不是查依赖。
2. 上下文是服务的容器
一个服务占据一个稳定的 ctx.<key>。其他插件通过 key 查找,不 import 实现。
ts
ctx.tools // ToolRuntime 服务
ctx.llm // LLM 服务
ctx.agents // Agent 服务
这条规则是整个 seam 架构的基石:消费方只声明"我需要一个 tools 能力",配置层决定谁来提供。
#mermaid-svg-gy2NcXJmaRHGUsYb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gy2NcXJmaRHGUsYb .error-icon{fill:#552222;}#mermaid-svg-gy2NcXJmaRHGUsYb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gy2NcXJmaRHGUsYb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gy2NcXJmaRHGUsYb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gy2NcXJmaRHGUsYb .marker.cross{stroke:#333333;}#mermaid-svg-gy2NcXJmaRHGUsYb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gy2NcXJmaRHGUsYb p{margin:0;}#mermaid-svg-gy2NcXJmaRHGUsYb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb .cluster-label text{fill:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb .cluster-label span{color:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb .cluster-label span p{background-color:transparent;}#mermaid-svg-gy2NcXJmaRHGUsYb .label text,#mermaid-svg-gy2NcXJmaRHGUsYb span{fill:#333;color:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb .node rect,#mermaid-svg-gy2NcXJmaRHGUsYb .node circle,#mermaid-svg-gy2NcXJmaRHGUsYb .node ellipse,#mermaid-svg-gy2NcXJmaRHGUsYb .node polygon,#mermaid-svg-gy2NcXJmaRHGUsYb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gy2NcXJmaRHGUsYb .rough-node .label text,#mermaid-svg-gy2NcXJmaRHGUsYb .node .label text,#mermaid-svg-gy2NcXJmaRHGUsYb .image-shape .label,#mermaid-svg-gy2NcXJmaRHGUsYb .icon-shape .label{text-anchor:middle;}#mermaid-svg-gy2NcXJmaRHGUsYb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gy2NcXJmaRHGUsYb .rough-node .label,#mermaid-svg-gy2NcXJmaRHGUsYb .node .label,#mermaid-svg-gy2NcXJmaRHGUsYb .image-shape .label,#mermaid-svg-gy2NcXJmaRHGUsYb .icon-shape .label{text-align:center;}#mermaid-svg-gy2NcXJmaRHGUsYb .node.clickable{cursor:pointer;}#mermaid-svg-gy2NcXJmaRHGUsYb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gy2NcXJmaRHGUsYb .arrowheadPath{fill:#333333;}#mermaid-svg-gy2NcXJmaRHGUsYb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gy2NcXJmaRHGUsYb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gy2NcXJmaRHGUsYb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gy2NcXJmaRHGUsYb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gy2NcXJmaRHGUsYb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gy2NcXJmaRHGUsYb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gy2NcXJmaRHGUsYb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gy2NcXJmaRHGUsYb .cluster text{fill:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb .cluster span{color:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gy2NcXJmaRHGUsYb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gy2NcXJmaRHGUsYb rect.text{fill:none;stroke-width:0;}#mermaid-svg-gy2NcXJmaRHGUsYb .icon-shape,#mermaid-svg-gy2NcXJmaRHGUsYb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gy2NcXJmaRHGUsYb .icon-shape p,#mermaid-svg-gy2NcXJmaRHGUsYb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gy2NcXJmaRHGUsYb .icon-shape .label rect,#mermaid-svg-gy2NcXJmaRHGUsYb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gy2NcXJmaRHGUsYb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gy2NcXJmaRHGUsYb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gy2NcXJmaRHGUsYb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 按 key 查找
super(ctx,'tools')
配置层换掉
consumer.ts
export const inject = 'tools'
ctx.tools
dsh-tools
本地实现
dsh-tools-remote
远程实现
3. inject:依赖驱动加载
这是 Cordis 最精妙的一处设计,也是与"手动编排启动顺序"的分水岭。
3.1 最小完整示例
ts
// greeter.ts ------ 提供方
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
greeter: GreeterService
}
}
export class GreeterService extends Service {
constructor(ctx: Context) {
super(ctx, 'greeter') // ← 运行时:以名字 'greeter' 注册
}
greet(who: string) {
return `Hello, ${who}!`
}
}
export const name = 'greeter'
export function apply(ctx: Context) {
ctx.plugin(GreeterService) // ← Service 子类本身就是插件
}
ts
// consumer.ts ------ 消费方
import type { Context } from '@deepseek-ai/cordis'
export const name = 'consumer'
export const inject = ['greeter']
export function apply(ctx: Context) {
console.log(ctx.greeter.greet('world')) // ← 编译期:类型已由 declare module 补齐
}
yaml
# cordis.yml
- name: './greeter.ts'
- name: './consumer.ts'
关键实验 :把
cordis.yml里两行交换顺序 ,输出完全一样。把
./greeter.ts整行删掉 ,consumer 不崩溃、不半跑,只是永远不执行。
3.2 三个必须理解的语义
语义一:加载顺序由依赖图涌现,不由文件顺序决定。
语义二:PENDING 是合法状态,不是错误。
插件的 fiber 会停在 PENDING,直到 inject 列出的每一项服务都存在。
⚠️ 最坑的推论:PENDING 的 fiber 不会让 Node 事件循环保持活跃。
如果组合里只有它,进程会静默地以状态码 0 退出 ------不报错、有日志、退出码正常。
这就是"为什么我的插件什么都没有"的经典答案。
语义三:依赖是持续跟踪的,不是一次性启动检查。
GreeterService consumer (inject: greeter) GreeterService consumer (inject: greeter) #mermaid-svg-0I1RhCiq41fRkvg8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0I1RhCiq41fRkvg8 .error-icon{fill:#552222;}#mermaid-svg-0I1RhCiq41fRkvg8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0I1RhCiq41fRkvg8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0I1RhCiq41fRkvg8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0I1RhCiq41fRkvg8 .marker.cross{stroke:#333333;}#mermaid-svg-0I1RhCiq41fRkvg8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0I1RhCiq41fRkvg8 p{margin:0;}#mermaid-svg-0I1RhCiq41fRkvg8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0I1RhCiq41fRkvg8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0I1RhCiq41fRkvg8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-0I1RhCiq41fRkvg8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-0I1RhCiq41fRkvg8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-0I1RhCiq41fRkvg8 .sequenceNumber{fill:white;}#mermaid-svg-0I1RhCiq41fRkvg8 #sequencenumber{fill:#333;}#mermaid-svg-0I1RhCiq41fRkvg8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-0I1RhCiq41fRkvg8 .messageText{fill:#333;stroke:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0I1RhCiq41fRkvg8 .labelText,#mermaid-svg-0I1RhCiq41fRkvg8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .loopText,#mermaid-svg-0I1RhCiq41fRkvg8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-0I1RhCiq41fRkvg8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-0I1RhCiq41fRkvg8 .noteText,#mermaid-svg-0I1RhCiq41fRkvg8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-0I1RhCiq41fRkvg8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0I1RhCiq41fRkvg8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0I1RhCiq41fRkvg8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-0I1RhCiq41fRkvg8 .actorPopupMenu{position:absolute;}#mermaid-svg-0I1RhCiq41fRkvg8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-0I1RhCiq41fRkvg8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-0I1RhCiq41fRkvg8 .actor-man circle,#mermaid-svg-0I1RhCiq41fRkvg8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-0I1RhCiq41fRkvg8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 正常运行 PENDING,不再持有失效引用 提供方被卸载(配置改 disabled) 服务消失 → consumer 自动 dispose 提供方重新加载 服务恢复 → consumer 自动重新加载
这条语义直接支撑了热替换:卸载 dsh-bash-local、挂载另一个 shell provider,所有注入 'shell' 的插件自动重启并用上新实现。
3.3 硬依赖 vs 可选依赖
ts
// 硬依赖:inject ------ 服务不在就不加载
export const inject = ['tools']
// 可选依赖:用 ctx.get() 在使用处探测
export function apply(ctx: Context) {
const greeter = ctx.get('greeter') // undefined when absent
console.log(greeter?.greet('maybe') ?? 'no greeter available')
}
判断标准:这个能力缺失时,插件还有意义吗?有 → 可选依赖;没有 → 硬依赖。
3.4 服务隔离(isolate)
同一个服务名可以有多个互不相干的实例:
yaml
- id: group-a
name: '@deepseek-ai/cordis-plugin-group'
group: true
isolate:
shell: true # ← 为 shell 服务开一个独立实例
config:
- name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 5000
- name: './src/plugin-a.ts'
- id: group-b
name: '@deepseek-ai/cordis-plugin-group'
group: true
isolate:
shell: true # ← 另一个独立实例
config:
- name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 60000
- name: './src/plugin-b.ts'
plugin-a 看到 timeout 5 秒的 bash,plugin-b 看到 60 秒的,互不影响。
这条在研究场景极有价值 :它让你能在同一个进程里 做对照实验(A/B 两组不同策略),而不是跑两遍。
文档里对应的说法是:某个会话要拥有不同能力集合时,组装一个 agent preset,其中的服务行需要
isolaterealm。
4. effect:可逆副作用
4.1 问题从哪来
插件会因为四种原因卸载:改配置、热重载、显式 dispose、所需服务消失。
如果插件在 apply 里裸开了一个定时器,卸载后它还在跑------资源泄漏 + 热重载后行为叠加。
🏨 一个必须记住的类比:酒店房间
把插件加载想象成客人入住 :
客人进门后打开灯、打开水龙头、调高暖气、插上电器
(= 注册事件监听器、开定时器、建立数据库连接、挂 HTTP 路由)。
传统 JavaScript 框架 :客人退房时人走了,但水龙头还在流、灯还亮着、暖气还在泵。
为什么?因为 Node.js 里
bus.on('message', cb)会让 emitter 持有对你回调的引用 ;你就算把插件代码
delete了,回调还在内存里。更糟的是
setInterval()------那个句柄会让 Node 事件循环永远保持活跃,进程根本退不出来。反复热重载插件的结果,就是所谓的 Disposal Abyss(清理深渊) :
僵尸监听器、重复处理、悬挂 socket,最终 OOM。
Cordis 的做法 :客人进门时,Cordis 在一个私有清理栈上记下"退房时要做的事"。
客人退房,Cordis 自动按栈的逆序 把这些事逐条做完。
客人不需要自己记得关灯。
4.2 解法
把 Cordis 不管理的资源包进 ctx.effect(),返回 disposer:
ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'lifecycle-demo'
function heartbeat(ctx: Context) {
console.log('heartbeat plugin loading')
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => { // ← disposer:卸载时运行
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}
export function apply(ctx: Context) {
const fiber = ctx.plugin(heartbeat) // ← 返回 fiber 句柄
ctx.effect(() => {
const timer = setTimeout(async () => {
await fiber.dispose() // ← 等待所有清理(含异步 disposer)完成
console.log('disposed')
process.exit(0)
}, 700)
return () => clearTimeout(timer)
})
}
实测输出:
heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed
4.3 你其实很少需要手写 effect
以下操作本身已经是 effect,自动撤销:
| 操作 | 卸载时 |
|---|---|
ctx.on(event, listener) |
监听器自动移除,不需要手动 removeListener |
ctx.plugin(child) |
子插件随父插件递归 dispose |
ctx.effect(fn) |
执行 fn 返回的 disposer |
ctx.tools.register(...) |
工具自动注销 |
Service 注册(super(ctx, 'name')) |
服务自动移除 |
官方原话:「对于生命周期与插件一致的资源,你绝不需要自行调用 disposer。」
4.4 Fiber 状态机
#mermaid-svg-I814BDge0TtDkNWC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-I814BDge0TtDkNWC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I814BDge0TtDkNWC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I814BDge0TtDkNWC .error-icon{fill:#552222;}#mermaid-svg-I814BDge0TtDkNWC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I814BDge0TtDkNWC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I814BDge0TtDkNWC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I814BDge0TtDkNWC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I814BDge0TtDkNWC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I814BDge0TtDkNWC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I814BDge0TtDkNWC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I814BDge0TtDkNWC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I814BDge0TtDkNWC .marker.cross{stroke:#333333;}#mermaid-svg-I814BDge0TtDkNWC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I814BDge0TtDkNWC p{margin:0;}#mermaid-svg-I814BDge0TtDkNWC defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-I814BDge0TtDkNWC g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-I814BDge0TtDkNWC g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-I814BDge0TtDkNWC g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-I814BDge0TtDkNWC g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-I814BDge0TtDkNWC g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-I814BDge0TtDkNWC .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-I814BDge0TtDkNWC .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-I814BDge0TtDkNWC .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-I814BDge0TtDkNWC .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-I814BDge0TtDkNWC .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-I814BDge0TtDkNWC .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-I814BDge0TtDkNWC .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-I814BDge0TtDkNWC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I814BDge0TtDkNWC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-I814BDge0TtDkNWC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I814BDge0TtDkNWC .edgeLabel .label text{fill:#333;}#mermaid-svg-I814BDge0TtDkNWC .label div .edgeLabel{color:#333;}#mermaid-svg-I814BDge0TtDkNWC .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-I814BDge0TtDkNWC .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-I814BDge0TtDkNWC .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-I814BDge0TtDkNWC .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-I814BDge0TtDkNWC .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-I814BDge0TtDkNWC .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I814BDge0TtDkNWC .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I814BDge0TtDkNWC #statediagram-barbEnd{fill:#333333;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I814BDge0TtDkNWC .cluster-label,#mermaid-svg-I814BDge0TtDkNWC .nodeLabel{color:#131300;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-I814BDge0TtDkNWC .note-edge{stroke-dasharray:5;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-note text{fill:black;}#mermaid-svg-I814BDge0TtDkNWC .statediagram-note .nodeLabel{color:black;}#mermaid-svg-I814BDge0TtDkNWC .statediagram .edgeLabel{color:red;}#mermaid-svg-I814BDge0TtDkNWC #dependencyStart,#mermaid-svg-I814BDge0TtDkNWC #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-I814BDge0TtDkNWC .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-I814BDge0TtDkNWC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 已声明,等待 inject 的服务
服务齐备
apply 完成
apply 或配置校验抛错
配置变更 / 热重载 / 显式 dispose / 服务消失
disposer 全部完成
PENDING
LOADING
ACTIVE
FAILED
UNLOADING
DISPOSED
合法状态,不是错误。
但它不会保持事件循环活跃,
空组合下进程静默 exit 0。
4.5 ⚠️ disposer 的顺序规则(官方明确警告)
disposer 按注册顺序的逆序启动,但多个异步 disposer 会并发运行。
这意味着:如果你的拆除步骤有严格的先后依赖,把它们放进同一个 disposer 里,在其中 await 每一步。
ts
// ❌ 错误:以为顺序有保证
ctx.effect(() => { const a = open(); return () => { close(a) } })
ctx.effect(() => { const b = open(); return () => { close(b); close(a) } })
// ✅ 正确:单个 disposer 内部串行
ctx.effect(() => {
const a = open()
return async () => { await step1(); await step2(); close(a) }
})
5. 事件:五种分发模式
5.1 声明、发出、监听
ts
// stats.ts
import { Service, type Context } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context {
stats: StatsService
}
interface Events {
'stats/report'(name: string, count: number): void // ← 事件签名声明
}
}
export class StatsService extends Service {
private counts = new Map<string, number>()
constructor(ctx: Context) { super(ctx, 'stats') }
bump(name: string) {
const next = (this.counts.get(name) ?? 0) + 1
this.counts.set(name, next)
this.ctx.emit('stats/report', name, next)
}
}
ts
// reporter.ts
import type { Context } from '@deepseek-ai/cordis'
import type {} from './stats.ts' // ← 运行时零导入,只为让 TS 看到声明合并
export const name = 'reporter'
export const inject = ['stats']
export function apply(ctx: Context) {
ctx.on('stats/report', (name, count) => {
console.log(`[stats] ${name} -> ${count}`)
})
ctx.stats.bump('tool_call')
ctx.stats.bump('prompt')
}
[stats] tool_call -> 1
[stats] prompt -> 1
5.2 五种模式对照表(背下来)
| 模式 | 调用签名 | await? | 顺序 | 返回值 | 一句话 |
|---|---|---|---|---|---|
emit |
ctx.emit(n, ...a) |
否 | 按注册 | 否 | 同步广播,不等不收集 |
parallel |
await ctx.parallel(n, ...a) |
是 | 并发 | 否 | 全部并发跑,一起等 |
serial |
await ctx.serial(n, ...a) |
是 | 按注册 | 是 | 首个非 null/false/undefined 胜出,停止后续 |
bail |
ctx.bail(n, ...a) |
否 | 按注册 | 是 | serial 的同步版 |
waterfall |
ctx.waterfall(n, ...a, next) |
否 | 按注册 | 是 | 洋葱式环绕中间件 |
重要:分发模式是事件公开约定的一部分。每个 harness 事件在子系统页面的自动生成参考里都标注了自己的模式。
5.3 waterfall:环绕中间件(最需要讲清的一个)
ts
declare module '@deepseek-ai/cordis' {
interface Events {
'demo/transform'(input: string, next: () => Promise<string>): Promise<string>
}
}
export function apply(ctx: Context) {
// 监听器 1:包装下游结果
ctx.on('demo/transform', async (input, next) => {
const downstream = await next() // ← 调 next() 才往下传
return downstream.toUpperCase()
})
// 监听器 2:拥有决策权时短路
ctx.on('demo/transform', async (input, next) => {
if (input.includes('blocked')) return '** blocked **' // ← 不调 next() = 否决
return next()
})
void (async () => {
console.log(await ctx.waterfall('demo/transform', 'hello', async () => 'hello'))
console.log(await ctx.waterfall('demo/transform', 'blocked words', async () => 'blocked words'))
})()
}
输出:
HELLO
** BLOCKED **
第二行的完整执行路径:
默认逻辑 传给 waterfall 的函数 监听器2 策略 监听器1 包装 调用方 ctx.waterfall 默认逻辑 传给 waterfall 的函数 监听器2 策略 监听器1 包装 调用方 ctx.waterfall #mermaid-svg-GURMV2GcHICHTgXr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GURMV2GcHICHTgXr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GURMV2GcHICHTgXr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GURMV2GcHICHTgXr .error-icon{fill:#552222;}#mermaid-svg-GURMV2GcHICHTgXr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GURMV2GcHICHTgXr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GURMV2GcHICHTgXr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GURMV2GcHICHTgXr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GURMV2GcHICHTgXr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GURMV2GcHICHTgXr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GURMV2GcHICHTgXr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GURMV2GcHICHTgXr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GURMV2GcHICHTgXr .marker.cross{stroke:#333333;}#mermaid-svg-GURMV2GcHICHTgXr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GURMV2GcHICHTgXr p{margin:0;}#mermaid-svg-GURMV2GcHICHTgXr .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GURMV2GcHICHTgXr text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GURMV2GcHICHTgXr .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GURMV2GcHICHTgXr .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GURMV2GcHICHTgXr .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GURMV2GcHICHTgXr .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GURMV2GcHICHTgXr #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GURMV2GcHICHTgXr .sequenceNumber{fill:white;}#mermaid-svg-GURMV2GcHICHTgXr #sequencenumber{fill:#333;}#mermaid-svg-GURMV2GcHICHTgXr #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GURMV2GcHICHTgXr .messageText{fill:#333;stroke:none;}#mermaid-svg-GURMV2GcHICHTgXr .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GURMV2GcHICHTgXr .labelText,#mermaid-svg-GURMV2GcHICHTgXr .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GURMV2GcHICHTgXr .loopText,#mermaid-svg-GURMV2GcHICHTgXr .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GURMV2GcHICHTgXr .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GURMV2GcHICHTgXr .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GURMV2GcHICHTgXr .noteText,#mermaid-svg-GURMV2GcHICHTgXr .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GURMV2GcHICHTgXr .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GURMV2GcHICHTgXr .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GURMV2GcHICHTgXr .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GURMV2GcHICHTgXr .actorPopupMenu{position:absolute;}#mermaid-svg-GURMV2GcHICHTgXr .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GURMV2GcHICHTgXr .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GURMV2GcHICHTgXr .actor-man circle,#mermaid-svg-GURMV2GcHICHTgXr line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GURMV2GcHICHTgXr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 默认逻辑被跳过 input="blocked words", next await next() return "** blocked **" (不调 next,故 F 从未执行) toUpperCase() 包装返回值 "** BLOCKED **"
5.4 waterfall 的纪律(写插件时最容易犯的错)
只负责观察或标注的 waterfall 监听器,必须调用
next()。不调用就直接返回 = 有意否决(veto)。
如果一个日志监听器忘了调
next(),它会悄无声息地吞掉所有下游的默认行为 。这是本仓库的常驻规则。
自查口诀:
- 我在记录 吗?→ 必须
next() - 我在决策/拦截吗?→ 有权短路
- 我只是打点 ?→ 永远
next()
harness 里真实用 waterfall 的地方:
agent/request------ 允许插件替换模型调用配置approval/request------ 允许策略代替用户作答tools/pre-execute/tools/execute/tools/post-execute------ 工具流水线三层
6. 配置:schema 即契约
ts
import Schema from '@deepseek-ai/schemastery'
export const name = 'config-demo'
export interface Config {
greeting: string
targets: string[]
}
export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(String).default(['world']),
})
export function apply(ctx: Context, config: Config) {
for (const target of config.targets) {
console.log(`${config.greeting}, ${target}!`)
}
}
yaml
- name: './config-demo.ts'
config:
targets: ['alpha', 'beta'] # greeting 走 schema 默认值 'Hello'
Hello, alpha!
Hello, beta!
6.1 输入非法时
yaml
- name: './config-demo.ts'
config:
targets: 'not-an-array' # ❌ 字符串不是数组
ValidationError: invalid config:
- $.targets expected array but got not-an-array (at targets)
fiber 进入 FAILED,进程以状态码 1 退出。
硬规则 :
Config必须导出为 Standard Schema (本仓库用 Schemastery)。导出一个普通对象是无效的------运行时不会报错,但 Cordis 拿不到验证器。
6.2 官方设计原则(插件作者必读)
| 原则 | 说明 |
|---|---|
| 无硬编码可调参数 | 凡不同部署可能取不同值的参数,都必须是配置字段 |
| 配置错误要响亮 | 在 schema 里表达完备约束,让无效配置在加载时就失败 |
| 对服务/资源的引用用 inject | 不要在 config 里写死一个期望存在的服务名 |
检验标准:能否在 cordis.yml 中改这个值而不动代码?
6.3 两个高级特性
!!js ------ 必须在加载时计算的配置值
yaml
- name: './config-demo.ts'
config:
greeting: !!js process.env.DEMO_GREETING ?? 'Hello'
- name: './sandboxed-on-mac.ts'
disabled: !!js process.platform !== 'darwin' # 按平台门控一整行
!!js 只在 config 与条目的 disabled 字段内有效;其余元数据(name/id/inject)保持字面值。
.volatile() ------ 不触发重挂载的热更新字段
ts
export const Config = Schema.object({
greeting: Schema.string().default('Hello').volatile(),
})
export function apply(ctx: Context, config: ReturnType<typeof Config>) {
ctx.on('loader/volatile-update', () => {
console.log(config.greeting.get()) // ← 用 .get() 读,不触发重新挂载
})
}
普通字段变化 → 卸载重挂载;仅 volatile 变化 → 就地更新引用 + 发一次
loader/volatile-update,不重新挂载 。约束:只能在固定对象路径上声明;数组/字典/union/transform 内部的独立引用会被拒绝。
7. 组合与 HMR
7.1 配置项元数据不止 name
yaml
- id: greeter # 稳定身份 ------ 没有它,改任何配置都会被视为「删除+新增」重挂载
name: './greeter.ts'
- id: consumer
name: './consumer.ts'
disabled: true # 保留条目但不挂载(改回 false 会连同依赖方一起重新加载)
id为什么重要 :loader 按id比较配置项,只挂载/卸载/重配变化的部分 。没有
id的条目每次读取都拿到新生成的 id,所以配置文件任意一处编辑都会让它整个重挂载。
7.2 开启热重载
yaml
- id: logger
name: '@deepseek-ai/cordis-plugin-logger-console'
- id: timer
name: '@deepseek-ai/cordis-plugin-timer' # ← HMR inject timer 做去抖,缺了就永远 PENDING
- id: hmr
name: '@deepseek-ai/dsh-hmr'
config:
root: ['.']
- id: hello
name: './hello.ts'
保存 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 重跑。
⚠️ HMR 通过 logger 服务打日志,没有 console exporter 就什么都看不到 。
而且它
injecttimer服务,缺@deepseek-ai/cordis-plugin-timer会静默停在 PENDING。
8. 诊断手册:插件不工作时
8.1 症状 → 原因对照
| 症状 | 最可能的原因 | 查法 |
|---|---|---|
| 没有任何输出,也不报错 | fiber 在 PENDING,缺 inject 的服务 | 跑 8.2 的诊断插件 |
| 进程静默 exit 0,什么都没打 | 组合里只有 PENDING 的插件 | 同上 |
| 新增配置项没效果 | 路径/包名拼错(解析失败只走 logger) | 逐字核对 name |
| HMR 不生效 | 缺 logger exporter 或缺 timer 服务 | 补 @deepseek-ai/cordis-plugin-logger-console + cordis-plugin-timer |
| 卸载后资源还在跑 | 资源没包在 ctx.effect() 里 |
检查裸 setInterval/监听器 |
| 监听器好像把下游吞了 | waterfall 忘记调 next() |
见 5.4 |
| 工具不生效 | 缺 systemPrompt provider(tools 会 inject 它) |
补 @deepseek-ai/dsh-system-prompt |
8.2 诊断插件:把 PENDING 打印出来
ts
// diagnose.ts
import { FiberState, type Context } from '@deepseek-ai/cordis'
export const name = 'diagnose'
export function apply(ctx: Context) {
setTimeout(() => {
for (const runtime of ctx.registry.values()) {
for (const fiber of runtime.fibers) {
if (fiber.state === FiberState.PENDING) {
console.log(`${fiber.name} is PENDING --- a required service is missing`)
}
}
}
}, 500)
}
needs-timer is PENDING --- a required service is missing
这是全仓库性价比最高的一段代码。 以后你写任何插件卡住不动,先挂上它。
不加 PENDING 过滤遍历时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE ------ 因为配置文件本身也是通过插件挂载的。
9. 进入 harness:注册一个真工具
把上面所有模式一次性用上:
ts
// greet-tool.ts
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallId } from '@deepseek-ai/dsh-llm'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet the named person.',
parameters: {
name: { type: 'string', required: true, description: 'Who to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
// 手动驱动一次调用,代替模型
void (async () => {
const result = await ctx.tools.execute({
callId: brandString<ToolCallId>('demo-1'),
name: 'greet',
arguments: { name: 'Cordis' },
signal: new AbortController().signal,
})
console.log('tool replied:', JSON.stringify(result.content))
})()
}
ts
// tool-logger.ts ------ 独立观察插件
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools' // 引入 @deepseek-ai/dsh-tools 的事件声明合并
export const name = 'tool-logger'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
const text = result.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
console.log(`[tool-logger] ${exec.name} -> ${text}`)
})
}
yaml
- name: '@deepseek-ai/dsh-system-prompt' # tools 会 inject systemPrompt,必须提供方在列
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'
运行(无需任何密钥,也不调用模型):
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]
注意输出顺序 :logger 先触发。
tools/result在结果物化过程中发出,早于execute返回给调用方的 promise 兑现。两个插件互不知晓对方存在,靠注册表服务 + 事件连接。
9.1 defineTool 的四个部分
| 字段 | 作用 |
|---|---|
parameters |
规约,同时用于:生成给模型看的 JSON Schema + 推导 args 类型 + 执行前校验模型给的参数 |
output.schema |
工具返回的规范值类型 |
output.render |
Native renderer,另行生成可持久化的结果内容 |
execute(args) |
真正的副作用代码 |
11. 带着 AI 生态的眼镜回看这一章
本节的用法 :前面十章讲的是「dsh 里怎么做的」。
这一节反过来做一件事------告诉你这些东西你在 PyTorch / MMLab / vLLM / Claude Code 里早就见过了 。
目的不是复述 dsh,而是让你以后读任何 AI 系统框架时,能自动调用同一套词汇。
11.1 Registry:MMLab 是另一个「字符串 → 类」的世界
OpenMMLab 的 mmengine 有一套完整的 Registry 系统,和 dsh 的注册表解决同一个问题:
python
# mmengine 的注册与构建
MODELS = Registry('models')
@MODELS.register_module() # 装饰器注册
class ResNet:
def __init__(self, depth, stages=4): ...
model = MODELS.build(dict(type='ResNet', depth=50))
# ↑ build_from_cfg 弹出 type,查表,用剩余键实例化
mmengine 在 mmengine/registry/root.py 里定义了 20+ 个根注册表 :
MODELS、DATASETS、TRANSFORMS、HOOKS、LOOPS、STRATEGIES、OPTIMIZERS、PARAM_SCHEDULERS、VISBACKENDS、LOG_PROCESSORS...
它还支持层级注册表:
python
DETECTORS = Registry('detectors', parent=MODELS, scope='mmdet')
# 建 child 时会自动注册进 parent._children
以及 locations 参数实现惰性导入 (首次 get() 时才 import 模块)。
对照表:
| 概念 | mmengine | dsh |
|---|---|---|
| 注册 | @MODELS.register_module() |
ctx.tools.register(...) |
| 构建 | MODELS.build(dict(type='ResNet', ...)) |
loader 读 cordis.yml 挂载条目 |
| 标识 | 字符串 type |
name(模块路径/NPM 包名) |
| 层级 | parent=MODELS(向下包含) |
inject=['tools'](向上依赖) |
| 命名空间 | _scope_ + DefaultScope 上下文管理器 |
扁平命名空间 + isolate 组 |
| 重名保护 | 抛 KeyError: already registered |
加载失败 → FAILED |
⚠️ 一条最重要的差异 :mmengine 是「构建一次,之后对象固定」;dsh 是「运行中持续装卸」 。
mmengine 的
build()产出的是一个稳定的对象图 ,改配置 = 重新跑一次脚本。dsh 的插件可以在进程活着的任意时刻被卸载、替换、重新加载。
这个差异就是 Cordis 论文要解决的问题:mmengine 不需要「可逆」,因为它不重复拆装;Agent Harness 需要。
另一处反直觉的设计:mmengine 的
parent是子注册表挂在父注册表下 (从上往下找),而 dsh 的
inject是消费方声明自己需要谁 (从下往上找)。两种方向都能构建出层级,但心智模型完全不同------后者才是「加载顺序由依赖涌现」的基础。
11.2 Hook:PyTorch 的 hook 和 dsh 的事件差在哪
PyTorch 有 hook 系统,这是和 dsh 事件最像的东西:
python
h = model.register_forward_hook(my_hook) # 返回 RemovableHandle
h.remove() # 必须手动调用
mmengine 则把它做成了可配置的 HOOKS 注册表(CheckpointHook、LoggerHook...)挂在 Runner 上。
关键差异:
| PyTorch hook | dsh ctx.on |
|
|---|---|---|
| 返回值 | RemovableHandle |
无(清理是自动的) |
| 清理方式 | 手动 h.remove() |
effect 自动撤销 |
| 漏掉的代价 | 回调永久残留,显存/内存泄漏 | ------ |
这一条最有教学价值 :
同样叫 hook,一个把清理交给开发者自觉,一个把清理交给结构保证。
这正是第 4.1 节「酒店房间」类别的具体化:
PyTorch 假设你记得 调
remove();Cordis 假设你不可能忘记。什么时候该用哪种?组件会被重复装卸的运行时(Agent、热重载服务)用后者 ,
一次构建长期运行的静态图(训练)用前者反而更直白。没有普适最优。
11.3 vLLM:把「隔离」这件事做到了进程级
vLLM V1 的架构是 Agent Harness 的强力对照组。它的核心分层:
#mermaid-svg-74FFkauURMltiRW4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-74FFkauURMltiRW4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-74FFkauURMltiRW4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-74FFkauURMltiRW4 .error-icon{fill:#552222;}#mermaid-svg-74FFkauURMltiRW4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-74FFkauURMltiRW4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-74FFkauURMltiRW4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-74FFkauURMltiRW4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-74FFkauURMltiRW4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-74FFkauURMltiRW4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-74FFkauURMltiRW4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-74FFkauURMltiRW4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-74FFkauURMltiRW4 .marker.cross{stroke:#333333;}#mermaid-svg-74FFkauURMltiRW4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-74FFkauURMltiRW4 p{margin:0;}#mermaid-svg-74FFkauURMltiRW4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-74FFkauURMltiRW4 .cluster-label text{fill:#333;}#mermaid-svg-74FFkauURMltiRW4 .cluster-label span{color:#333;}#mermaid-svg-74FFkauURMltiRW4 .cluster-label span p{background-color:transparent;}#mermaid-svg-74FFkauURMltiRW4 .label text,#mermaid-svg-74FFkauURMltiRW4 span{fill:#333;color:#333;}#mermaid-svg-74FFkauURMltiRW4 .node rect,#mermaid-svg-74FFkauURMltiRW4 .node circle,#mermaid-svg-74FFkauURMltiRW4 .node ellipse,#mermaid-svg-74FFkauURMltiRW4 .node polygon,#mermaid-svg-74FFkauURMltiRW4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-74FFkauURMltiRW4 .rough-node .label text,#mermaid-svg-74FFkauURMltiRW4 .node .label text,#mermaid-svg-74FFkauURMltiRW4 .image-shape .label,#mermaid-svg-74FFkauURMltiRW4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-74FFkauURMltiRW4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-74FFkauURMltiRW4 .rough-node .label,#mermaid-svg-74FFkauURMltiRW4 .node .label,#mermaid-svg-74FFkauURMltiRW4 .image-shape .label,#mermaid-svg-74FFkauURMltiRW4 .icon-shape .label{text-align:center;}#mermaid-svg-74FFkauURMltiRW4 .node.clickable{cursor:pointer;}#mermaid-svg-74FFkauURMltiRW4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-74FFkauURMltiRW4 .arrowheadPath{fill:#333333;}#mermaid-svg-74FFkauURMltiRW4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-74FFkauURMltiRW4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-74FFkauURMltiRW4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-74FFkauURMltiRW4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-74FFkauURMltiRW4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-74FFkauURMltiRW4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-74FFkauURMltiRW4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-74FFkauURMltiRW4 .cluster text{fill:#333;}#mermaid-svg-74FFkauURMltiRW4 .cluster span{color:#333;}#mermaid-svg-74FFkauURMltiRW4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-74FFkauURMltiRW4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-74FFkauURMltiRW4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-74FFkauURMltiRW4 .icon-shape,#mermaid-svg-74FFkauURMltiRW4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-74FFkauURMltiRW4 .icon-shape p,#mermaid-svg-74FFkauURMltiRW4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-74FFkauURMltiRW4 .icon-shape .label rect,#mermaid-svg-74FFkauURMltiRW4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-74FFkauURMltiRW4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-74FFkauURMltiRW4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-74FFkauURMltiRW4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} EngineCore(独立后台进程)
前端(进程)
InprocClient
AsyncMPClient
ZMQ
MultiprocExecutor
collective_rpc
Executor / Worker(每 GPU 一进程)
Worker 0
Worker 1
LLM
离线入口
AsyncLLM
服务入口
Scheduler
连续批处理
KVCacheManager
BlockPool
三个值得学的点:
(1)三种执行模式,一个接口。 EngineCoreClient 有三个实现:
InprocClient(同进程,LLMEngine 同步)、SyncMPClient(多进程同步)、AsyncMPClient(多进程异步,服务 API 用)。
这正是 dsh 的五个 profile 的同构思想------同一套内核,不同运行形态。
(2)把清理也写进架构。 vLLM 用 weakref.finalize() 管理 BackgroundResources:
引擎核心进程崩了,客户端的 socket、后台线程、队列都能被回收。
对照第 4 节的 disposer 纪律:两套系统独立地得出「资源回收必须是结构性的,不能靠自觉」。
(3)Strategy 模式的教科书实现。 vLLM 有两处干净的可插拔后端:
python
# 注意力后端:按硬件能力自动选择
FlashAttention / FlashInfer / xFormers / Triton
# 投机解码 proposer:不同生成策略
NgramProposer / EagleProposer / MedusaProposer
这就是 dsh 的 Service Provider,只不过 vLLM 的 Provider 是「CUDA kernel 实现」,dsh 的是「本地 shell / 远程沙箱」。
(4)一个反直觉的性能细节 。vLLM 的空闲块队列用自定义双向链表 而不是 deque:
因为前缀缓存命中时,需要从队列任意中间位置 O(1) 移除一个块。
collections.deque只支持两端高效删除。
这个细节的教学价值 :数据结构的选择往往不是为了「更优雅」,而是为了支持某个具体能力 。前缀缓存是产品能力,双向链表是它的必然代价。和 dsh 里「为
isolate而用 Proxy」是同一类推理。
11.4 Claude Code:dsh 的同构物,但取舍不同
Anthropic 在《Effective context engineering for AI agents》里给出了和 dsh 高度重叠的框架。
先记住三个概念:
| 概念 | 原文含义 | 我的理解 |
|---|---|---|
| context rot 上下文腐烂 | 随着上下文 token 增加,模型准确召回的能力下降------各模型都有,只是衰减速度不同 | 上下文不是「越多越好」,是边际收益递减 |
| attention budget 注意力预算 | 上下文必须被当作有限资源 | 和 vLLM 的 KV cache 预算、dsh 的 compaction 触发是同一个物理直觉 |
| 好的上下文工程 | 找到能最大化目标达成概率的、最小的、高信号 token 集合 | 这就是 dsh system-prompt/assemble 的本质目标 |
注意
context rot和 "Lost in the Middle" 是同一件事的两种说法 。一个是学术论文的现象命名,一个是工程博客的落地表达。
两种检索策略:
| 做法 | 代表 | |
|---|---|---|
| just-in-time | 只留轻量标识符(文件路径、存储的查询、链接),运行时用工具动态加载 | Claude Code 对大数据库做分析:用 Bash 的 head/tail,从不把完整数据对象塞进上下文 |
| pre-inference | 推理前用 embedding 检索把相关上下文捞出来 | 很多 AI-native 应用的传统做法 |
Claude Code 实际用的是 hybrid :CLAUDE.md 直接丢进上下文(naively dropped in),同时用 glob / grep 按需取。
为什么要 hybrid :pure just-in-time 会遇到「索引过期」和「语法树太复杂」;
pure pre-inference 会遇到「检索不到真正需要的东西」。
官方结论是:"do the simplest thing that works"。
长任务的三种技术,dsh 全部有对应:
| Anthropic 的技术 | dsh 的对应 | 差别 |
|---|---|---|
| Compaction(压缩) | dsh-compaction-basic |
Anthropic:摘要 + 最近 5 个文件;dsh:先剪枝工具结果,再摘要 |
| Structured note-taking(结构化笔记) | todo 子系统 / NOTES.md |
dsh 把它做成了持久事件 |
| Multi-agent | subagent / Agent Team |
dsh 有 scope 隔离与显式 lineage |
| memory tool(文件式,公开 beta) | spill / session-query |
dsh 提供的是可查询的会话检索 |
Anthropic 调 compaction 的方法论值得直接抄:
先最大化 recall (确保 trace 里的每条相关信息都被捕获),
再迭代提升 precision(删掉多余内容)。
最安全的轻量级 compaction 是「清空工具结果」------历史深处调过的工具,Agent 为什么还要再看一遍原始输出?
这一条和 dsh 完全一致 :docs/agent-lifecycle.zh.md里,
dsh-compaction-basic在摘要之前会先做可选的工具结果剪枝 。两个团队独立选了同一个顺序:先丢最没信息量的,再丢有信息量的。
dsh 比 Claude Code 强的地方:
Claude Code compaction 之后,被丢弃的历史基本不可回放。
dsh 因为有仅追加的会话日志,压缩前的内容仍然完整存在于
session.vN.jsonl里 ------模型看不到,但你看得到。
这不是功能差异,是架构选择差异 :事件溯源 vs 原地修改。
它让 dsh 天然具备做轨迹研究的能力。
11.5 训练循环 vs Agent 循环:step 和 turn 的由来
如果你写过训练代码,这一节会让你秒懂 turn / step。
python
# MMLab Runner 的循环是「注册表驱动的固定流程」
cfg = dict(
runner_type='Runner', # 从 RUNNERS 注册表取
train_dataloader=dict(type='CocoDataset', ...),
model=dict(type='ResNet', depth=50), # 从 MODELS 注册表取
)
runner = RUNNERS.build(cfg)
runner.train()
mmengine 的 LOOPS 注册表里有 EpochBasedTrainLoop、IterBasedTrainLoop------
训练循环本身也是可替换的组件 ,这和 dsh 把 agent-loop 做成插件是同一个直觉。
| 训练 iteration | dsh 的 step | |
|---|---|---|
| 一次做什么 | 跑一个 batch 前向+反向 | 一次模型请求 + 它引发的工具执行 |
| 谁决定下一步 | 固定(顺序遍历 dataloader) | 模型(ReAct 的 thought→action) |
| 何时停 | 跑完 epoch / 达到 max_iters | 不再欠下任何工作时 |
| 一次完整训练 | dsh 的 turn | |
|---|---|---|
| 边界 | dataloader 跑完一轮 | 领取首条输入时开,不再欠工作时关 |
| 失败处理 | 直接抛出 | 重试 / 记 assistant/attempt / 取消 |
所以「step ≈ iteration,turn ≈ 一个完整的排空周期」不是类比,是同构。
区别只在于:Agent 的下一步由模型决定,而且这个决定本身会失败、会取消、会超时。
这也解释了为什么 dsh 的
agent-loop会有 15,054 行------训练循环只要处理「跑」和「停」,
Agent 循环要处理 ReAct 决策 + 工具失败 + 重试 + 取消 + 流式中断 + 压缩触发 + 子代理调度。
11.6 一张总表:你在 AI 生态里已经会的五件事
| 你在别处见过的 | dsh 里的名字 | 本文章节 |
|---|---|---|
| 注册表(mmengine Registry / PyTorch 工厂方法) | ctx.tools、服务注册表 |
§9 §11.1 |
钩子 (PyTorch register_forward_hook) |
ctx.on / ctx.waterfall |
§5 §11.2 |
| 可插拔后端(vLLM attention backend / proposer) | Service Provider(bash-local / bash-sandbox) |
§3.1 §11.3 |
配置驱动构建 (mmengine build_from_cfg) |
cordis.yml + cordis.patch.yml |
§6 §7 |
Runner 循环 (mmengine Runner + LOOPS) |
core/agent-loop + agent/* 事件 |
§9 §11.5 |
这张表就是这一章的压缩包 。
以后看到一个陌生的 Agent 框架,先问:「它的注册表在哪?它的钩子怎么清理?它的循环怎么停?」
这三问能穿透绝大多数系统。
12. 工程文化:这些数字本身就是答案
dsh 仓库里有些数字,读完会改变你对「什么叫工程化」的看法。以下均为实测。
12.1 生成---校验闭环
| 指标 | 数量 |
|---|---|
package.json 总脚本 |
199 |
其中 verify-*(校验) |
64 |
其中 gen-*(生成) |
19 |
| 测试文件 | 1801 |
verify 是 gen 的三倍多。 大量文档是从源码生成的,且每个生成物都有配套校验:
| 生成物 | 校验脚本 |
|---|---|
tool-catalog.zh.md(102 KB) |
verify-tool-catalog |
config-catalog.zh.md(223 KB) |
verify-config-catalog |
module-graph.zh.md(132 KB) |
verify-module-graph |
cli-help.zh.md |
verify-cli-help |
| Mermaid 图 | verify-mermaid |
| 双语配对 | verify-translation-pairing |
它解决的问题 :手写 223 KB 的配置目录,必然过期且没人检查。
生成 + 校验之后,文档和代码不可能不一致 。
cli-help.zh.md顶部直接写着「Generated by ... do not edit by hand」。
和 AI 生态的对照 :mmengine的 Registry 对重名抛KeyError,PyTorch 用
RemovableHandle强制清理显式化------它们是把约束编码进结构 。dsh 的 64 个 verify 是把这件事规模化的版本。
12.2 决策记录:连「否决」都存档
.agents/notes/ 有 2526 篇 笔记,四个状态:proposed/(提案)、implemented/(已实现)、rejected/(已否决) 、archived/(归档)。
rejected/的存在是最了不起的地方。大多数项目的 ADR 只记做了什么,几乎没人记拒绝了什么 。
半年后新人问「为什么不直接用继承?」,
有一篇记录当时权衡的 rejected note,比一百次口头解释都有用。
实际例子(架构文档直接引用了这些决策):
| 决策记录 | 内容 |
|---|---|
2026-09-02-system-prompt-as-surface-node.zh.md |
为什么系统提示词是 surface node 而非固定前缀 |
2026-09-01-v2-embedded-assistant-streams.zh.md |
为什么把 stream 内嵌进 assistant/message |
2026-08-19-session-projection-mandatory-seam.zh.md |
为什么投影必须是强制 seam,不能静默兜底 |
12.3 公开事故复盘
docs/postmortem/ 有 10 篇事故复盘。
「公开事故复盘 + 公开 rejected 决策」是一个项目成熟的强信号 ------
它意味着维护者相信:贡献者的信任来自可见的诚实,而不是完美的表现。
12.4 可迁移的三条经验
- 任何「手写内容容易过期」的地方,都配一个
gen-*+verify-*闭环。
(给自己项目也适用------我搭这份知识库时就手工做了这件事) - 可逆性必须是结构性的。 让「忘记清理」从靠自觉降级为不可能。
- 记录你拒绝了什么。 这是新人最快理解系统的入口。
13. 动手任务
- 把官方
cordis-tutorial七篇全部手写一遍,每篇 ≤100 行 - 故意制造三类故障 并观察:漏 disposer、循环依赖、waterfall 忘
next() - 挂上
diagnose.ts,然后故意删掉一个 provider,捕获 PENDING - 把
greet-tool.ts改造成一个真正的天气工具(schema 带 2 个字段 + 一个可选字段),仍然不接模型 - 对照练习 :用
mmengine写一个Registry,注册两个 model,用build(dict(type=...))构建;
然后对比它和ctx.tools.register()的行为差异(尤其是"能不能在运行中卸载") - 对照练习 :在 PyTorch 里
register_forward_hook然后故意不remove(),
观察它和 dshctx.on()卸载自动清理的差别
14. 自测(新增题针对 §11)
- mmengine 的
Registry和 dsh 的工具注册表,在构建时机上有什么根本差异?为什么这个差异决定了 mmengine 不需要可逆性? - mmengine 的
parent和 dsh 的inject,方向上有什么不同? - PyTorch 的 hook 和 dsh 的
ctx.on,在「清理」上分别是把责任交给谁?什么场景该用哪种? - vLLM 为什么用自定义双向链表而不是
deque?这个例子体现了什么设计推理? - Anthropic 说的 context rot 是什么?它和 "Lost in the Middle" 是同一件事吗?
- Claude Code 的 just-in-time 和 pre-inference 两种检索策略各有什么代价?为什么它用 hybrid?
- Anthropic 推荐怎么调 compaction 的 prompt?为什么这个顺序重要?最安全的轻量 compaction 是什么?
- dsh 的 compaction 和 Claude Code 的 compaction 有一个关键差异,是什么?它来自哪种架构选择?
- 为什么
step ≈ training iteration、turn ≈ 一个完整排空周期是同构而不是类比?差在哪里? - 那张「五件事总表」是哪五件事?以后读一个陌生 Agent 框架,你会先问哪三个问题?
15. 参考
dsh 官方
docs/cordis-primer.zh.md------ 五概念速查docs/cordis-tutorial/01..07-*.zh.md------ 本章代码出处docs/user/develop/basic/config.zh.md------ 插件配置docs/user/develop/framework/service.zh.md------ 服务与隔离docs/user/develop/basic/tool.zh.md------defineTool深入docs/subsystems/core.zh.md------ 自动生成的cordis-surface区块:可注入和可监听的全部内容docs/subsystems/compaction.zh.md------ 压缩实现docs/agent-lifecycle.zh.md------ 官方轮次时序图
AI 生态对照
- mmengine Registry:https://mmengine.readthedocs.io/en/latest/tutorials/registry.html
- mmengine
registry.py源码:https://github.com/open-mmlab/mmengine/blob/master/mmengine/registry/registry.py - PyTorch hooks 文档:https://pytorch.org/docs/stable/notes/extending.html
- vLLM V1 Engine Core 架构(第三方代码索引):https://deepwiki.com/koush/vllm/2-core-engine-architecture
- vLLM Internals 深度解析:https://enricopiovano.com/blog/vllm-internals-architecture-deep-dive/
- Anthropic《Effective context engineering for AI agents》:https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
- Anthropic《Building effective agents》:https://www.anthropic.com/engineering/building-effective-agents
理论
- Shi, Y., Zhang, W., & Cui, T. (2026). A Programming Paradigm for Spatiotemporal Composability. arXiv:2608.25512