DeepSeek 开源了一个 Agent Harness,代号 dsh。装起来一行命令:npx @deepseek-ai/dsh web,默认在 127.0.0.1:3080 起一个 Web UI。
README 里两句话值得先抄下来。第一句:「built on an everything-is-a-plugin architecture and powered by Cordis」。第二句:项目处于 developer preview,迭代很快,「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」——全大写的免责声明。
我把源码读了一遍,这篇先讲整体架构。根包是 @deepseek-ai/dsh-root,版本 0.1.2-alpha.1,pnpm 11.7.0 的 workspace monorepo,要求 node ^22.19.0 || >=24.0.0,全仓库 ESM。
Cordis:不是依赖,是 vendored 进来的框架层
Cordis 是一个插件框架,上游在 cordiverse/cordis,设计思路有篇配套论文(arXiv:2608.25512,标题是「A Programming Paradigm for Spatiotemporal Composability」)。有意思的是它不是普通的 npm 依赖——源码被 vendored 进 vendor/ 目录,rescope 成 @deepseek-ai/cordis,pin 在上游 4.0.0-rc.7。
vendor/README.md 里写了原因:「so that the harness fully owns its framework layer (auditable, patchable, pinned)」。框架层要可审计、可打补丁、版本钉死,干脆整份源码拿进来。这个决定本身就是一种架构表态。
Cordis 的核心机制就四件事:
- 插件即 Service:每个插件向共享上下文注册服务,比如
ctx.tools、ctx.sessions、ctx.shell; - 依赖注入:插件用
inject声明自己需要什么服务,框架据此解析加载顺序,声明的服务不存在就一直等; - 类型化事件:五种分发模式,见下;
- 可逆注册:所有注册都走
ctx.effect()或ctx.on(),插件卸载时自动回收。仓库规约里直接写「Registrations are effects」。
五种事件分发模式定义在 vendor/cordis/src/events.ts:
export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
emit 是发后即忘不等候;parallel 并发等候;serial 顺序等候;bail 同步执行、遇到第一个 bail 值就停;waterfall 用 next() 续延把监听器组合成链。最后这个在 dsh 里用得最关键,模型调用前的拦截管线全靠它,下一篇细讲。
Profile + Bundle:一份核心,叠出四种形态
dsh web 和 dsh --profile headless "跑一下测试" 启动的是同一套核心的不同组合。Profile 目前有四个:web、headless、sdk、acp,另有一个独立的 sdk-minimal。
每个 profile 不是一个大配置,而是一摞 bundle 的 patch 层自底向上叠加。每个 bundle 包在 package.json 里声明自己的 patch 文件:
// packages/bundle/web-app/package.json
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" }
}
packages/bundle/README.md 的原话:「The web, headless, acp, and sdk profiles build on dsh-base, while sdk-minimal supplies its complete tree in one bundle.」也就是 dsh-base 是四个主 profile 共享的第一层,各自的形态 bundle(dsh-web-app、dsh-headless、dsh-sdk-app、dsh-acp-app)再往上叠,用户还能叠自己的层。想换掉某个插件,不用 fork,叠一层 patch 覆盖就行。
52 个 group,核心只有几个包
packages/ 是两级结构 packages/<group>/<pkg>/,数了一下有 52 个 group,包名统一 @deepseek-ai/dsh-*。group 大致按能力域切:llm、shell、fs、subprocess、terminal、lsp、web、subagent、workflow、webhook、sandbox、interaction……每个能力域基本都自带接口包和若干实现包,这个「能力接缝」模式值得单独写一篇。
产品的主干在 packages/core/,一共 8 个包:
| 包 | ctx key | 职责 |
|---|---|---|
| session | ctx.sessions | 只追加的 SessionEvent 日志,一切模型可见事实的唯一来源 |
| system-prompt | ctx.systemPrompt | 每步组装系统提示词和工具 schema |
| tools | ctx.tools | 工具注册 + 带守卫的执行管线(策略、包装、结果检查) |
| agent | ctx.agents | Agent 句柄、活跃注册表、agent/* 事件词汇表 |
| agent-loop | ctx.agentLoop | 唯一的具体循环实现,驱动 turn 和 step 生命周期 |
| scope | — | 给注册划分 per-agent 作用域 |
packages/core/README.md 用一句话把 turn 的旅程讲完了:循环认领输入、在 session log 上开一个 turn、经 system-prompt 组装请求、流式接收模型响应、把工具调用派发给注册表,「append every model-visible fact back to the log」。这句话里藏着整个项目最重要的一条不变量,下一篇展开。
外部程序怎么接进来
除了人用的 web 和 headless,dsh 还有两个给程序的 profile:
- sdk:
packages/sdk提供 JSON-RPC 协议、stdio 服务端和 TypeScript 客户端。外部程序通过 stdio 驱动一个完整的 dsh。 - acp:Agent Client Protocol 服务器,纯自动化场景。
pnpm dsh --profile acp起一个即用的 stdio 服务。
Python SDK 的做法比较直接:python/ 下有两个包,一个是高层 turns API 加 JSON-RPC 客户端,另一个干脆把 dsh 可执行文件和 native sidecar 打进了 wheel——SDK 启动时直接拉起捆绑的 dsh --profile sdk 运行时,用户不用自己装 Node 环境。
反方向也留了口子:packages/mcp/ 的 mcp-client 可以把外部 MCP 服务器的工具桥接进来,挂在 mcp__github__create_issue 这类稳定名字下。只桥接工具(resources 和 prompts 不支持),默认不启用任何服务器,按需 opt-in。
几条值得抄走的明文规则
根目录的 AGENTS.md 把项目规约写成了给 AI 协作者的「常备命令」,里面有几条放在任何项目里都成立:
- Plugins, not loop changes:新行为挂在文档化的扩展点上,改 agent-loop 本身必须同步更新架构文档。核心无特权,扩展在旁边挂载,不是 patch 核心。
- Explicit > implicit:包边界处的默认值是一个显式的
resolve(request): Spec步骤,不许在run()里藏?? default。 - Misconfiguration fails loud:配置错误能自包含就在加载时报,否则在最早能判定的点报,绝不静默跳过。
- 预发布姿态:「Remove at the first tagged release」——首个正式 tag 之前,宁可自由重命名、重构、破坏兼容,也不写兼容性垫片。
最后一条最符合 developer preview 的定位:这个阶段把地基修对,比假装稳定重要。
下一篇讲 dsh 怎么驱动一个 turn,以及「模型可见 = 已记录」这条不变量是怎么落地的。