DeepSeek Harness 动手篇
02. 五步跑通,顺便把概念都验证一遍
上篇 DeepSeek Harness 概念篇 里我说,那些词——inject、waterfall、profile、seam、deriveMessages、turn——听着都挺虚,得真跑起来才能“显形”。这一篇就是动手:从零把一个 dsh 跑起来,然后在真实代码里,把上一篇讲过的概念挨个对一遍。
先打个底:这一篇不是“照着文档抄命令”,而是“每一步都回头看上一篇讲过的东西”。如果你还没读概念篇,建议先回去补一眼,不然看到一半会有点飘。
(另外修正一个小预告:上一篇结尾我说“下一篇跑 npx @deepseek-ai/dsh web”,那是 npm 直跑的路子。真要读代码,还得走源码路线——clone 下来、pnpm 跑。这篇走的是后者,更实在。)
一、动手之前:先备好三样东西
环境要求三样:
- Node:
^22.19 || >=24(我实测是 v24.14.0); - pnpm:包管理器(实测 11.9.0);
DEEPSEEK_API_KEY:DeepSeek 的 API key。
这里有个新手特别容易懵的点,得单独说——clone 下来是没有 .env 的。
.env 是装密钥的文件,密钥绝不能进 git。你 push 到 GitHub,等于把 key 公开,网上有爬虫专门扫仓库里的 key 盗刷。所以正确姿势是:.gitignore 里写上一行 .env,仓库故意不放它,每个开发者 clone 下来后自己新建一份、填自己的 key。
所以你会遇到一个“反常识”的瞬间:clone 完发现 .env 不存在,第一反应是“这仓库是不是坏了”。不是,这是对的。本项目也没给 .env.example 模板,得自己读 README / AGENTS.md,知道它需要 DEEPSEEK_API_KEY,然后自己建。
(写 Spring 的同学可以类比:这不就是 application.properties 里塞数据库密码一样,要么走环境变量、要么走外部化配置,总之别把明文密钥提交进仓库。)
二、五步,一步一个脚印
整条链路就五步:
| 步骤 | 命令 | 结果 |
|---|---|---|
| ① 装依赖 | pnpm install | 21s,装 50+ 包 |
| ② 编译 | pnpm run build | tsc + tsdown + VitePress |
| ③ 看插件树 | pnpm dsh --profile web --dump-config | 打印叠加后的最终插件树 |
| ④ 读源码 | 读 packages/plan/plan-mode/src/index.ts | 一个真插件骨架 |
| ⑤ 跑任务 | pnpm dsh --profile headless "任务" | 模型真实回复 |
前两步平平无奇,就是个标准的 pnpm monorepo。真正的重头戏在第三、四步——它们一个是“隔着皮肤看骨架”,一个是“剖开一个器官看细胞”。
三、dump-config:给“一切皆插件”拍了张 X 光
第三步 --dump-config,会把叠加之后的最终插件树原样打印出来。
这一步直接印证了概念篇里三个点,我一个个说:
1. patch 覆盖的活证据。
打印结果里,hmr 这一行的注释写着 patched by @deepseek-ai/dsh-web-app,而且 disabled: true。这就是第 3 课讲的 patch 机制:dsh-base 里定义了这一行,dsh-web-app 按 id 把它整行覆盖、顺手关掉了。不是合并,是按 id 整行替换——你在真实输出里能看到它留下的“作案痕迹”。
2. !!js 表达式真的在工作。
配置里有 root: !!js dshHomePath('sessions')、mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'。还记得概念篇提过的小坑吗?!!js 两个感叹号,表示这一项是 JS 表达式求值,!js 是错的。这里它被用来动态算路径、读环境变量。
3. 模型是配置值,不是写死的。
agent-default-model 这行的 config.model 是 deepseek-v4-flash。你看,换模型就是改个配置值的事——这正是第 1 课“没有特权核心”的落地。
(写 Spring 的同学,这一步可以类比 Spring Boot 的 --debug:它会打印自动装配报告,告诉你哪些 Bean 被注册了、哪些被排除了。dsh 的 --dump-config 就是它的“插件版体检报告”。)
四、读最小插件
第四步才是全文最值钱的一步。我挑了一个最小的真插件来读——packages/plan/plan-mode/src/index.ts,里面是 PlanModeController。
别看它小,这一个文件,把上一篇文章中讲到的概念全装进去了。我们一段段看:
import { Context, Service } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' { // 声明合并:往 ctx 上挂 planMode
interface Context { planMode: PlanModeController }
}
declare module '@deepseek-ai/dsh-session/types' { // 声明合并:定义新事件
interface SessionEventMap { 'plan/mode': { active: boolean } }
}
export class PlanModeController extends Service {
static inject = ['tools', 'systemPrompt'] // inject 声明依赖
constructor(ctx: Context, config) {
super(ctx, 'planMode')
ctx.on('agent/pre-step', async (arg, next) => { const d = await next(); return d }) // waterfall next()
ctx.effect(() => () => { disposed = true }) // 可逆效应
ctx.systemPrompt.section({ name: 'plan:policy' }) // 往系统提示词里加一段
ctx.tools.register(defineTool({ name: 'exit_plan_mode', execute })) // 注册一个工具
}
}
逐条对上概念篇:
import { Context, Service }+extends Service→ 这是第 2 课的“插件是 Service 对象”,不是随便一个函数;static inject = ['tools', 'systemPrompt']→ 第 2 课的 inject 声明依赖。写 Spring 的直接看成@Autowired:我声明我需要tools和systemPrompt,框架会在挂载前把它们塞进ctx;declare module两处 → 第 2 课“类型化事件”用到的声明合并:往ctx上挂新服务、往事件表里加新事件,全靠 TS 的模块合并;ctx.on('agent/pre-step', ...)里的next()→ 第 2 课反复强调的 waterfall:想往下传就调next(),这里它调了,表示“我参与但不拦截”;ctx.effect(() => () => ...)→ 第 2 课的“注册是可逆效应”:内层返回的那个函数就是 disposer,卸载时逆序执行,用来做清理;ctx.systemPrompt.section(...)和ctx.tools.register(...)→ 这是最反直觉、也最关键的一点。
注意最后两点里插件的姿态:它依赖 tools 和 systemPrompt,不是为了去“调用”它们,而是为了“往里面贡献”——加一段提示词、注册一个工具。
这跟我们写 Spring 时的惯性思维很不一样:平时 @Autowired 一个依赖,大多是想调它的方法。而 dsh 里,插件经常是反过来——依赖一个公共容器,然后把自己的东西塞进去。理解了这一点,“一切皆插件”才算真的通了:每个插件都不是孤岛,而是往共享的 ctx 里添砖加瓦,最后拼成一个完整产品。
五、跑起来,看它回话
最后一步,配好 .env 里的 DEEPSEEK_API_KEY,跑:
pnpm dsh --profile headless "你的任务"
模型真的回了话。整条链路通了。
这里顺便纠一个概念篇留的小尾巴:--dump-config 不需要 API key,它只是打印插件树、纯本地动作;只有跑真实任务、真正去调模型时,才需要 key。别把这两件事混了。
收尾:七个词,全都显形了
跑完这五步,回头再念一遍概念篇结尾那条线,你会发现它不是抽象名词了,而是每一步都摸得到的东西:
“一切皆插件”是世界观 → Cordis 是承载它的机制 → profile/bundle/patch 是组装方式 → capability seam 是替换的“接缝” → append-only 日志是记忆真相 → turn/step 是运行的节拍。
--dump-config 让你看到“组装”的结果;PlanModeController 让你摸到“机制”的零件(Service、inject、事件、可逆效应);headless 任务让你看到“节拍”转起来。
两篇下来,从“它是什么”到“它怎么跑”,概念和动手就接上了。
下一篇,可能会挑一个真正感兴趣的接缝(比如 shell 的三角色)深挖,或者自己写一个最小插件试试——那才算是从“读懂”跨到“会用”。