一个前端工程师读完 Cordis 源码后的技术分享
目标读者:有 React / Node 经验,但对 Agent 框架完全陌生的前端同学 阅读时间:约 25 分钟 环境:DeepSeek Harness v0.2.2-rc.2 / Cordis 4.1.0
我以为要重新学一套东西
第一次打开 DeepSeek Harness(下称 DSH)的源码,我做好了心理准备------Agent 框架嘛,应该是一堆模型调用、提示词工程、向量检索,跟我写 React 的日常八竿子打不着。
结果读了 Cordis(DSH 底层的插件框架)的源码之后,我发现:
这东西的内核,我全都认识。
- 它有
tapable那样的钩子机制 → 就是 Webpack 插件系统 - 它有洋葱模型中间件 → 就是 Koa / Express
- 它有 loader + 配置文件 → 就是 Webpack 的
webpack.config.js - 它有 HMR → 就是 Vite / Webpack Dev Server
- 它有依赖注入 → 就是 React Context
- 它有 cleanup 副作用 → 就是
useEffect
没有一个是新东西。 唯一的区别是:它们被组合起来去驱动一个 LLM 循环。
这篇文章就讲清楚这件事:Cordis 到底复用了哪些我们熟悉的范式,以及它在哪些地方做了我们没想到的事。
第一部分:cordis.yml 就是 webpack.config.js
先看配置文件
Webpack 的配置:
js
java
module.exports = {
entry: './src/index.js',
plugins: [
new HtmlWebpackPlugin(),
new MiniCssExtractPlugin(),
new DefinePlugin({ ... }),
],
}
Cordis 的配置(cordis.yml):
yaml
arduino
- name: '@deepseek-ai/dsh-llm'
- name: '@deepseek-ai/dsh-session'
- name: '@deepseek-ai/dsh-tools'
- name: '@deepseek-ai/dsh-agent-loop'
结构完全同构:一个文件,列出一堆插件,运行时按这个列表组装应用。
DSH 的 base 组合包里就这么列了 99 个条目------模型适配器、会话日志、工具注册表、沙箱、审批、设置、遥测,全部是这一行行写出来的。
插件形态也一样
Webpack 插件:
js
javascript
class MyPlugin {
apply(compiler) {
compiler.hooks.emit.tap('MyPlugin', (compilation) => { ... })
}
}
Cordis 插件:
ts
javascript
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', (exec, next) => { ... })
}
同样是一个 apply 函数,拿到一个"宿主对象",往里注册钩子。
不同的是:Webpack 把 compiler 传给你,Cordis 把 ctx 传给你。ctx 比 compiler 强得多------它是服务容器 + 事件总线 + 插件注册器 + 资源管理器四合一。
关键差异:谁决定顺序
这是第一个让前端人意外的地方。
Webpack 插件的执行顺序,由 plugins 数组的位置 决定(加上 stage / before / after 之类)。
Cordis 不看数组位置。
我亲手做过这个实验:把 greeter(提供服务)和 consumer(消费服务)在 cordis.yml 里的顺序调换,结果完全一样。
yaml
bash
# 这样
- name: './consumer.ts'
- name: './greeter.ts'
# 和这样
- name: './greeter.ts'
- name: './consumer.ts'
# 输出都稳定是 Hello, world!
为什么? 因为 cordis.yml 里的条目是并发启动的。文档原话:
各项会并发启动 ,因此它们在列表中的位置不保证插件的先后。
顺序由依赖声明决定,不由文件位置决定。这就引出了下一节------
第二部分:inject 就是依赖注入,但比 import 强
前端最熟悉的对比
React Context:
jsx
ini
const ThemeContext = createContext()
// 提供
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>
// 消费
const theme = useContext(ThemeContext)
Cordis:
ts
scala
// 提供(服务)
class GreeterService extends Service {
constructor(ctx) { super(ctx, 'greeter') }
}
ctx.plugin(GreeterService)
// 消费(依赖注入)
export const inject = ['greeter']
export function apply(ctx) {
ctx.greeter.greet('world')
}
同样是"提供/消费",但 Cordis 多了两件事:
- 编译期类型安全 :
declare module声明合并,让ctx.greeter有类型提示 - 运行期调度保证 :
inject让 Cordis 在依赖就绪后才调apply
第 2 点是关键,也是为什么不能用 import。
为什么不能用 import?
ts
javascript
// ❌ 错误
import { ToolsService } from '@deepseek-ai/dsh-tools'
三个原因(来自 DSH 的 packages/client/AGENTS.md 和 docs/architecture.zh.md):
| 原因 | 说明 | 前端类比 |
|---|---|---|
| 顺序 | import 立刻执行模块,但依赖的服务可能还没注册 |
组件渲染时 Context 还没 Provider |
| 可替换 | ctx.tools 背后可能是任意实现,import 锁死了唯一实现 |
硬编码 localStorage vs 用抽象层 |
| 作用域 | ctx.tools 可能是只属于某个 agent 的注册表 |
全局变量 vs 组件局部的 Context |
🎯 一句话记住:不 import,只 inject。 和"不要直接
window.xxx,用 React Context"是同一个道理,只是更严格。
一个我踩过的坑
inject 缺失是静默失败,而且症状取决于你怎么访问:
ts
scss
// 写法 A:直接访问
ctx.greeter.greet()
// → 插件被整个跳过,apply 根本不执行
// 写法 B:安全查询
ctx.get('greeter')?.greet()
// → 能执行,但拿到 undefined
更坑的是:服务名是字符串,TypeScript 完全不管。
ts
typescript
declare module { greeter: GreetingService } // 声明成 greeter
super(ctx, 'gretter') // 注册成 gretter(多打一个 t)
ctx.get('greeter') // 查 greeter → undefined,没有任何报错
我为此卡了半小时。
第三部分:中间件模型 ------ 这是最精彩的部分
如果说前面都是"意料之中",那 waterfall 是第一个让我惊讶的设计。
我们熟悉的中间件
Koa:
js
vbnet
app.use(async (ctx, next) => {
const start = Date.now()
await next() // ← 调用下一个中间件
ctx.set('X-Response-Time', Date.now() - start)
})
Express:
js
scss
app.use((req, res, next) => {
next() // ← 同样
})
next() 前后都是你的代码 → 洋葱模型。
Cordis 的 waterfall 一模一样
ts
dart
ctx.on('demo/transform', async (input, next) => {
const downstream = await next() // ← 和 Koa 一模一样
return downstream.toUpperCase() // ← 拿下游结果再加工
})
DSH 里最核心的 tools/pre-execute 就是这样用的:
ts
dart
ctx.on('tools/pre-execute', async (exec, next) => {
if (需要拒绝) return { kind: 'deny', reason: '...' } // 短路
return next() // 放行
})
权限、沙箱、hooks 全部挂在这条链上,靠 waterfall 实现"拦截或放行"。
但我没想到:这玩意只有 15 行
我去读了 Cordis 源码(vendor/cordis/src/events.ts:246):
ts
typescript
waterfall(...args: any[]) {
const [thisArg, callbacks] = this._resolve('waterfall', args)
const inner = args.pop()
const dispatch = () => {
const callback = callbacks.shift() // ① 取下一个监听器
if (!callback) return inner() // ② 没了 → 执行默认
let called = false
const next = () => {
if (called) throw new Error('next() called multiple times')
called = true
return dispatch() // ③ 递归!
}
return Reflect.apply(callback, thisArg, [...args, next])
}
return dispatch()
}
核心就三件事:
shift()逐个消费监听器(不是靠索引,是靠耗尽数组)next()= 递归调用dispatch()------ 所以它前后都是你的代码inner是终止条件 ------ 不调next()则inner永不执行
💡
next()不是"跳到下一个",而是"递归进下一层"。 这就是洋葱模型的物理原因。
反直觉点:执行顺序和计算顺序是反的
我做了个实验:
ts
javascript
const ls = [
(n, next) => next() + 10, // L1
(n, next) => next() * 2, // L2
(n, next) => n > 100 ? -1 : next(), // L3(条件短路)
]
waterfall(ls, 5, () => 5) // → 20
waterfall(ls, 500, () => 500) // → 8
监听器执行顺序 :L1 → L2 → L3 值计算顺序:L3 → L2 → L1
反着的! L1 的 +10 最后生效,L3 的短路最先发生。
这个坑我在写 tools/post-execute 时差点踩------如果你以为"我的插件先跑就先处理",结果会完全不对。
另一个反直觉点:忘调 next() 会静默吞掉一切
ts
perl
ctx.on('tools/pre-execute', (exec, next) => {
return // ← 忘了 next()
})
结果 :后续所有监听器被跳过,gate 变成 undefined,pipeline 在 undefined.kind 处崩溃。
所以我给自己定了条铁律:
🎯 观察型监听器必须调用
next()。 不调 = 你以为在"放行",实际在"否决"。
五种分发模式:选错就是语义错误
Cordis 有五种事件分发模式,DSH 要求每个事件必须标注 @mode:
| 模式 | 语义 | 前端类比 | DSH 用例 |
|---|---|---|---|
emit |
同步广播,忽略返回值 | dispatchEvent |
tools/result(最终观测) |
parallel |
并发,全部等待 | Promise.all |
并行通知 |
serial |
顺序,首个非空返回值胜出 | Array.find |
顺序决策 |
bail |
serial 的同步版 |
Redux reducer | 同步决策 |
waterfall |
环绕中间件 | Koa 中间件 | tools/pre-execute 等 |
关键区分 :tools/result 是 emit 而不是 waterfall,因为结果已经 deep-frozen------只能看,不能改。
🎯
emit用于广播观察,waterfall用于拦截决策。
第四部分:effect ------ 比 useEffect 更强的 cleanup
同一个概念
React:
jsx
scss
useEffect(() => {
const timer = setInterval(tick, 200)
return () => clearInterval(timer) // cleanup
}, [])
Cordis:
ts
javascript
ctx.effect(() => {
const timer = setInterval(tick, 200)
return () => clearInterval(timer) // disposer
})
一模一样。 主体在加载时跑,返回的函数在卸载时跑。
但有个本质区别
React 的 cleanup 靠开发者自觉 ,忘了写就泄漏。 Cordis 的 effect 是运行时强制保证,卸载路径一定会走到 disposer。
我做了个实验验证这个差别------把 ctx.effect 拆掉:
代码块
hearbeat plugin loading
tick tick tick
disposed ← fiber 已 dispose
tick tick tick ... ← 还在跑!
而且进程根本退不出去 ,得 Ctrl+C 才能停。
根因 :fiber.dispose() 只卸载了插件,但没有任何东西知道那个 setInterval 的存在 ------没有 disposer,clearInterval 永远不会被调用。
💡 Cordis 只能清理它知道的东西。你没登记的,它不知道,也就清不掉。
这解释了为什么 DSH 到处是这种代码
ts
c
ctx.tools.register(defineTool({...})) // 注册即 effect,插件卸载时自动注销
ctx.on('tools/pre-execute', handler) // 监听即 effect,自动移除
你永远见不到 ctx.tools.unregister() 或 removeListener()。
再加上一点:effect 是热重载能成立的前提。如果每个注册都不自动撤销,改一次代码就多一份注册,跑几分钟就崩。
第五部分:依赖注入的实现 ------ epoch 指纹
这部分是我觉得最不像前端的设计。
问题:怎么知道一个插件该不该启动?
某个插件声明了 inject = ['tools', 'fs']。它怎么知道这两个服务什么时候就绪?
Cordis 的答案:给每个插件算一个"依赖指纹"
ts
javascript
_refresh() {
let epoch = `${this._revision}`
for (const name of Object.keys(this.inject)) {
const impl = this._store[name]
if (!impl) {
epoch = INACTIVE // ← 缺一个依赖就 INACTIVE
break
}
epoch += ':' + impl.uid // ← 把每个依赖的 uid 拼进去
}
this._setEpoch(epoch)
}
epoch 长这样:
代码块
arduino
"3:12:45:67" // revision : uid₁ : uid₂ : uid₃
每当任何一个依赖发生变化,字符串就变。 字符串一变,就触发重新加载:
ts
kotlin
if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
this._reload() // ← 这里才真正执行 apply
}
这个设计带来一个惊人的能力
服务被替换时,所有依赖它的插件自动重载。
DSH 里这个能力被用到极致:
文件系统与进程提供方共享同一个执行世界。把它们指向远程沙箱,Bash、PTY、LSP 就一起搬过去了,无需任何提供方专用 fork。
翻译成人话:换一个 provider,改变整个产品。
yaml
bash
# 本地执行
- name: '@deepseek-ai/dsh-bash-local'
# 换成沙箱执行(其他代码一个字不动)
- name: '@deepseek-ai/dsh-bash-sandbox'
这就是"一切皆插件"的经济学意义。
架构状态机
代码块
PENDING ──依赖满足──> LOADING ──apply完成──> ACTIVE
↑ │
└────────────依赖消失──────────────────────────┘
apply 不是启动时调用一次,而是在"依赖从缺失变满足"的那一刻调用。理论上可以多次调用。
第六部分:所以,Agent 框架到底新在哪?
到这里,所有机制 都是我们熟悉的。那新增的是什么?
新增的只有一件事:那个 while 循环
DSH 的核心代码(packages/core/agent-loop/)本质上就是:
代码块
ini
while (还有工作) {
messages = 组装上下文()
response = 调用模型(messages)
if (response 没有工具调用) break
results = 执行工具(response.tool_calls)
记录到会话日志()
}
这就是 Agent。 一个循环,加上"调用模型"和"执行工具"两件事。
其他所有东西------权限、沙箱、压缩、规划、子 agent、UI------都是挂在这个循环扩展点上的插件。
用我们熟悉的语言翻译一遍
| DSH 概念 | 前端等价物 |
|---|---|
agent-loop |
一个 Redux store + 一个 while 循环 |
session/event |
Redux action log(可回放) |
tools/pre-execute |
Express 中间件 |
ctx.tools |
一个插件化的注册表 |
inject |
React Context + 依赖排序 |
ctx.effect |
useEffect cleanup(强制版) |
agent.ctx (scope) |
组件级 Context(不向子组件继承) |
cordis.yml |
webpack.config.js |
| Hot Reload | Vite HMR |
真正的新知识只有三块
- 流式协议 :
StreamChunk怎么组装成完整消息,KV Cache 为什么要求"逐字节一致" - 上下文工程:上下文窗口是 Agent 最硬的约束,压缩策略怎么设计
- 工具调用协议:模型怎么表达"我要调用工具",结果怎么回填
除此之外,你的前端知识 100% 可迁移。
结尾:一个前端工程师的收获
我最大的收获不是"学会了 Agent"
而是发现:优秀的架构思想是跨领域的。
- Webpack 的星型拓扑(一个 compiler,所有插件往上挂)
- Koa 的洋葱模型
- React 的声明式副作用与 cleanup
- 依赖注入容器(Spring / NestJS)
这些在 Agent 框架里被重新组合了,而且组合得极其严密。
给想入门的同学三条建议
① 别一上来读架构文档,先跑通 Cordis 教程
docs/cordis-tutorial/ 有 7 章,每章都能独立运行。前 4 章跑完,你就具备读懂一切的基础。
② 遇到"这功能该放哪"就查这张表
docs/architecture.zh.md 有一张「新行为的归属位置」表,20+ 种需求映射到具体机制。这是全仓库最高频查阅的东西。
③ 别被 README 的信息密度吓到
packages/core/agent-loop/README.zh.md 一读就是 5000 多字,但它说的每个概念你现在都见过了。先读你懂的,再读你不懂的。
最后一句话
Agent 框架看起来是新物种,拆开看是老朋友。 模型会变、提示词会变、框架会变,但插件化、中间件、依赖注入、可逆副作用这些工程范式不会变。
前端工程师转型 Agent 开发,最大的优势不是"会写 UI",而是你已经被这些范式训练过了。