Cordis 内核:五概念、生命周期与事件

Cordis 内核:五概念、生命周期与事件

本章所有代码均取自官方 docs/cordis-tutorial/,可运行、可复现。

要回答的问题:

  1. 为什么加载顺序不由配置文件决定,而由依赖决定?
  2. effect 的可逆性到底解决了什么工程问题?
  3. waterfall 不调 next() 为什么会"静默吞掉下游"?
  4. 插件不输出日志时,怎么知道它卡在哪?

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,其中的服务行需要 isolate realm。


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 就什么都看不到 。

而且它 inject timer 服务,缺 @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 可迁移的三条经验

  1. 任何「手写内容容易过期」的地方,都配一个 gen-* + verify-* 闭环。
    (给自己项目也适用------我搭这份知识库时就手工做了这件事)
  2. 可逆性必须是结构性的。 让「忘记清理」从靠自觉降级为不可能。
  3. 记录你拒绝了什么。 这是新人最快理解系统的入口。

13. 动手任务

  1. 把官方 cordis-tutorial 七篇全部手写一遍,每篇 ≤100 行
  2. 故意制造三类故障 并观察:漏 disposer、循环依赖、waterfall 忘 next()
  3. 挂上 diagnose.ts,然后故意删掉一个 provider,捕获 PENDING
  4. 把 greet-tool.ts 改造成一个真正的天气工具(schema 带 2 个字段 + 一个可选字段),仍然不接模型
  5. 对照练习 :用 mmengine 写一个 Registry,注册两个 model,用 build(dict(type=...)) 构建;
    然后对比它和 ctx.tools.register() 的行为差异(尤其是"能不能在运行中卸载")
  6. 对照练习 :在 PyTorch 里 register_forward_hook 然后故意不 remove(),
    观察它和 dsh ctx.on() 卸载自动清理的差别

14. 自测(新增题针对 §11)

  1. mmengine 的 Registry 和 dsh 的工具注册表,在构建时机上有什么根本差异?为什么这个差异决定了 mmengine 不需要可逆性?
  2. mmengine 的 parent 和 dsh 的 inject,方向上有什么不同?
  3. PyTorch 的 hook 和 dsh 的 ctx.on,在「清理」上分别是把责任交给谁?什么场景该用哪种?
  4. vLLM 为什么用自定义双向链表而不是 deque?这个例子体现了什么设计推理?
  5. Anthropic 说的 context rot 是什么?它和 "Lost in the Middle" 是同一件事吗?
  6. Claude Code 的 just-in-time 和 pre-inference 两种检索策略各有什么代价?为什么它用 hybrid?
  7. Anthropic 推荐怎么调 compaction 的 prompt?为什么这个顺序重要?最安全的轻量 compaction 是什么?
  8. dsh 的 compaction 和 Claude Code 的 compaction 有一个关键差异,是什么?它来自哪种架构选择?
  9. 为什么 step ≈ training iteration、turn ≈ 一个完整排空周期 是同构而不是类比?差在哪里?
  10. 那张「五件事总表」是哪五件事?以后读一个陌生 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 生态对照

理论

  • Shi, Y., Zhang, W., & Cui, T. (2026). A Programming Paradigm for Spatiotemporal Composability. arXiv:2608.25512
相关推荐
一缕82年的清风1 小时前
生产级多智能体协作避坑:死锁防范与确定性状态机实战
agent
lucas_AI1 小时前
Librarian Agents:别等提问了再翻原文——先训练一个「图书管理员」把长文档收拾好
agent
lucas_AI1 小时前
DocPilot:长文档问答别硬灌上下文——让小模型学会「编排」一场跨页取证
agent
lucas_AI1 小时前
同一个问题问了 14 遍,12 个部署改了答案:LLM 做医疗器械合规判定,确定性先不过关
llm
.Bryce.3 小时前
你了解 Blackboard(黑板系统)吗?听说过基于黑板系统实现的智能体吗?
java·网络·gpt·php·agent·codex·黑板系统
流烟默3 小时前
A2A 与 MCP 双协议网关设计指南
安全·agent·mcp·a2a
hpoenixf9 小时前
当每个模块都能说“不”:我们为何要收回 Agent 系统里散落的否决权
agent
遇码10 小时前
流式调用中途断连别直接报错:瞬态故障识别、退避重试与中文根因诊断的搭建方法
大数据·人工智能·ai·rust·数据分析·大模型·agent