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

一个前端工程师读完 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 多了两件事:

  1. 编译期类型安全 :declare module 声明合并,让 ctx.greeter 有类型提示
  2. 运行期调度保证 :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()
}

核心就三件事:

  1. shift() 逐个消费监听器(不是靠索引,是靠耗尽数组)
  2. next() = 递归调用 dispatch() ------ 所以它前后都是你的代码
  3. 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

真正的新知识只有三块

  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",而是你已经被这些范式训练过了。

相关推荐
沉默王二33 分钟前
Gemini 4 Carbon 正式开放,正面硬刚 Fable 5.2。
ai编程·gemini·vibecoding
NingBo2 小时前
别再让 AI「心算」CSV:为 DeepSeek Harness 增加精确的数据分析工具
deepseek
孟健3 小时前
我用Claude Skills把出海视频月账单从$86打到了$16
人工智能·ai编程·claude
宅小年3 小时前
DeepSeek Harness 完全指南,打造你的专属工作台
deepseek
泯泷3 小时前
同样是多 Agent,为什么有的稳,有的却天天翻车?
人工智能·llm·agent
阿里云云原生3 小时前
关于 DeepSeek 的 SuperAgent,以及我们的实践
云原生·agent
威哥爱编程4 小时前
模拟面试智能体开发实战 04:面试聊到哪算哪?用状态机把整场面试编排起来
python·agent·harmonyos
威哥爱编程4 小时前
模拟面试智能体开发实战 03:AI 面试官太客气?人设 Prompt 让它像个真考官
python·agent·harmonyos
七夜zippoe5 小时前
Agent 上线第一天就炸了:6 个生产事故的根因分析与修复记录
大数据·ai·agent·根因分析·修复记录