DeepSeek Harness 原来还是我们熟悉的 Webpack 和中间件模型

0 阅读11分钟

一个前端工程师读完 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

module.exports = {
  entry: './src/index.js',
  plugins: [
    new HtmlWebpackPlugin(),
    new MiniCssExtractPlugin(),
    new DefinePlugin({ ... }),
  ],
}

Cordis 的配置(cordis.yml):

yaml

- 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

class MyPlugin {
  apply(compiler) {
    compiler.hooks.emit.tap('MyPlugin', (compilation) => { ... })
  }
}

Cordis 插件:

ts

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

# 这样
- name: './consumer.ts'
- name: './greeter.ts'

# 和这样
- name: './greeter.ts'
- name: './consumer.ts'

# 输出都稳定是 Hello, world!

为什么?  因为 cordis.yml 里的条目是并发启动的。文档原话:

各项会并发启动,因此它们在列表中的位置不保证插件的先后。

顺序由依赖声明决定,不由文件位置决定。这就引出了下一节——


第二部分:inject 就是依赖注入,但比 import 强

前端最熟悉的对比

React Context:

jsx

const ThemeContext = createContext()

// 提供
<ThemeContext.Provider value={theme}>
  <App />
</ThemeContext.Provider>

// 消费
const theme = useContext(ThemeContext)

Cordis:

ts

// 提供(服务)
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 多了两件事:

  1. 编译期类型安全:declare module 声明合并,让 ctx.greeter 有类型提示
  2. 运行期调度保证:inject 让 Cordis 在依赖就绪后才调 apply

第 2 点是关键,也是为什么不能用 import。

为什么不能用 import?

ts

// ❌ 错误
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

// 写法 A:直接访问
ctx.greeter.greet()
// → 插件被整个跳过,apply 根本不执行

// 写法 B:安全查询
ctx.get('greeter')?.greet()
// → 能执行,但拿到 undefined

更坑的是:服务名是字符串,TypeScript 完全不管。

ts

declare module { greeter: GreetingService }   // 声明成 greeter
super(ctx, 'gretter')                          // 注册成 gretter(多打一个 t)
ctx.get('greeter')                             // 查 greeter → undefined,没有任何报错

我为此卡了半小时。


第三部分:中间件模型 —— 这是最精彩的部分

如果说前面都是"意料之中",那 waterfall 是第一个让我惊讶的设计。

我们熟悉的中间件

Koa:

js

app.use(async (ctx, next) => {
  const start = Date.now()
  await next()                          // ← 调用下一个中间件
  ctx.set('X-Response-Time', Date.now() - start)
})

Express:

js

app.use((req, res, next) => {
  next()                                // ← 同样
})

next() 前后都是你的代码 → 洋葱模型。

Cordis 的 waterfall 一模一样

ts

ctx.on('demo/transform', async (input, next) => {
  const downstream = await next()       // ← 和 Koa 一模一样
  return downstream.toUpperCase()       // ← 拿下游结果再加工
})

DSH 里最核心的 tools/pre-execute 就是这样用的:

ts

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

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()
}

核心就三件事:

  1. shift() 逐个消费监听器(不是靠索引,是靠耗尽数组)
  2. next() = 递归调用 dispatch()  —— 所以它前后都是你的代码
  3. inner 是终止条件 —— 不调 next() 则 inner 永不执行

💡 next() 不是"跳到下一个",而是"递归进下一层"。  这就是洋葱模型的物理原因。

反直觉点:执行顺序和计算顺序是反的

我做了个实验:

ts

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

ctx.on('tools/pre-execute', (exec, next) => {
  return          // ← 忘了 next()
})

结果:后续所有监听器被跳过,gate 变成 undefined,pipeline 在 undefined.kind 处崩溃。

所以我给自己定了条铁律:

🎯 观察型监听器必须调用 next()。  不调 = 你以为在"放行",实际在"否决"。

五种分发模式:选错就是语义错误

Cordis 有五种事件分发模式,DSH 要求每个事件必须标注 @mode:

模式语义前端类比DSH 用例
emit同步广播,忽略返回值dispatchEventtools/result(最终观测)
parallel并发,全部等待Promise.all并行通知
serial顺序,首个非空返回值胜出Array.find顺序决策
bailserial 的同步版Redux reducer同步决策
waterfall环绕中间件Koa 中间件tools/pre-execute 等

关键区分:tools/result 是 emit 而不是 waterfall,因为结果已经 deep-frozen——只能看,不能改。

🎯 emit 用于广播观察,waterfall 用于拦截决策。


第四部分:effect —— 比 useEffect 更强的 cleanup

同一个概念

React:

jsx

useEffect(() => {
  const timer = setInterval(tick, 200)
  return () => clearInterval(timer)     // cleanup
}, [])

Cordis:

ts

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

ctx.tools.register(defineTool({...}))   // 注册即 effect,插件卸载时自动注销
ctx.on('tools/pre-execute', handler)     // 监听即 effect,自动移除

你永远见不到 ctx.tools.unregister() 或 removeListener()。

再加上一点:effect 是热重载能成立的前提。如果每个注册都不自动撤销,改一次代码就多一份注册,跑几分钟就崩。


第五部分:依赖注入的实现 —— epoch 指纹

这部分是我觉得最不像前端的设计。

问题:怎么知道一个插件该不该启动?

某个插件声明了 inject = ['tools', 'fs']。它怎么知道这两个服务什么时候就绪?

Cordis 的答案:给每个插件算一个"依赖指纹"

ts

_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 长这样:

代码块

"3:12:45:67"     // revision : uid₁ : uid₂ : uid₃

每当任何一个依赖发生变化,字符串就变。  字符串一变,就触发重新加载:

ts

if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
  this._reload()      // ← 这里才真正执行 apply
}

这个设计带来一个惊人的能力

服务被替换时,所有依赖它的插件自动重载。

DSH 里这个能力被用到极致:

文件系统与进程提供方共享同一个执行世界。把它们指向远程沙箱,Bash、PTY、LSP 就一起搬过去了,无需任何提供方专用 fork。

翻译成人话:换一个 provider,改变整个产品。

yaml

# 本地执行
- name: '@deepseek-ai/dsh-bash-local'

# 换成沙箱执行(其他代码一个字不动)
- name: '@deepseek-ai/dsh-bash-sandbox'

这就是"一切皆插件"的经济学意义。

架构状态机

代码块

PENDING ──依赖满足──> LOADING ──apply完成──> ACTIVE
   ↑                                            │
   └────────────依赖消失──────────────────────────┘

apply 不是启动时调用一次,而是在"依赖从缺失变满足"的那一刻调用。理论上可以多次调用。


第六部分:所以,Agent 框架到底新在哪?

到这里,所有机制都是我们熟悉的。那新增的是什么?

新增的只有一件事:那个 while 循环

DSH 的核心代码(packages/core/agent-loop/)本质上就是:

代码块

while (还有工作) {
  messages = 组装上下文()
  response = 调用模型(messages)
  if (response 没有工具调用) break
  results = 执行工具(response.tool_calls)
  记录到会话日志()
}

这就是 Agent。  一个循环,加上"调用模型"和"执行工具"两件事。

其他所有东西——权限、沙箱、压缩、规划、子 agent、UI——都是挂在这个循环扩展点上的插件。

用我们熟悉的语言翻译一遍

DSH 概念前端等价物
agent-loop一个 Redux store + 一个 while 循环
session/eventRedux action log(可回放)
tools/pre-executeExpress 中间件
ctx.tools一个插件化的注册表
injectReact Context + 依赖排序
ctx.effectuseEffect cleanup(强制版)
agent.ctx (scope)组件级 Context(不向子组件继承)
cordis.ymlwebpack.config.js
Hot ReloadVite HMR

真正的新知识只有三块

  1. 流式协议:StreamChunk 怎么组装成完整消息,KV Cache 为什么要求"逐字节一致"
  2. 上下文工程:上下文窗口是 Agent 最硬的约束,压缩策略怎么设计
  3. 工具调用协议:模型怎么表达"我要调用工具",结果怎么回填

除此之外,你的前端知识 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",而是你已经被这些范式训练过了。