DeepSeek Harness 不只是一个把提示词发给模型的命令行工具。它真正解决的是:如何把模型、上下文、工具、会话、安全策略和运行循环组合成一个可以持续扩展、替换与恢复的 Agent 运行时。本文从当前源码出发,先建立整个系列需要的总体认识。
项目地址:https://github.com/deepseek-ai/deepseek-harness
源码基线:本文按仓库当前 0.1.0-rc.5 代码结构整理。
一、只有模型调用,还不是 Agent
调用一次大模型 API 并不复杂:准备消息,选择模型,发送请求,再读取响应。问题是,一个真正能够工作的 Agent 远不止这四步。
它至少还需要处理这些事情:
- 用户消息怎样进入运行循环,连续输入如何排队;
- 系统提示词、运行环境和工具定义怎样在每一步重新组装;
- 模型产生工具调用后,由谁校验参数、执行工具并把结果送回模型;
- 会话怎样持久化,进程退出后如何恢复,分叉会话如何保持上下文一致;
- Shell、文件系统和网络访问怎样受到权限、审批与沙箱约束;
- 模型、工具、存储或界面需要替换时,怎样避免重写整个 Agent。
如果把这些逻辑全部塞进一个主循环,早期确实容易跑起来,但系统很快会出现两个问题。第一,模型调用、工具执行、安全策略和持久化相互缠绕,修改任何一层都可能影响整条链路。第二,命令行、Web、自动化协议等不同入口会复制同一套运行逻辑,最终产生多个行为不一致的 Agent。
DeepSeek Harness 的定位,就是为这些运行时问题提供统一的组织方式。它不是新的模型,也不是只封装了一层聊天接口,而是一套以 Cordis 为底座、由插件组合起来的 Agent Harness。
这里的 Harness 可以理解为"运行设施集合":模型仍由 Provider 提供,具体工具仍由插件提供,应用入口也可以不同;DeepSeek Harness 负责把它们装配到同一个生命周期、事件系统和会话模型中。
二、命令行只是入口,不是系统本体
第一次接触项目时,很容易因为命令叫 dsh,就把它理解成一个 CLI Agent。但从入口源码看,命令行本身承担的职责非常薄。
apps/cli/src/bin.ts 解析参数后,只在三种模式之间分发:
ts
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'profile':
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
patchFiles: invocation.patches,
args: invocation.args,
})
break
case 'plugin':
process.exit(runPlugin(invocation.profile, invocation.args))
break
case 'dump-config':
runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
break
}
真正的运行逻辑不在这个 switch 里。profile 模式把工作交给 Profile 启动流程,plugin 模式管理插件,dump-config 模式输出最终装配结果。也就是说,CLI 主要负责选择"以什么组合启动",而不是直接实现 Agent。
这一点很重要。因为同一套核心能力并不只服务命令行,它还可以被 Web 应用、ACP 服务或其他宿主复用。入口发生变化,底层的 Session、Agent Loop、Tool Runtime 和模型适配器不必复制一份。
三、启动过程本质上是在装配一棵插件树
DeepSeek Harness 的启动不是"实例化一个总控类",而是先计算有效配置,再让 Cordis Loader 装载整棵插件树。
3.1 Profile 决定使用哪些 Bundle
Profile 表示一种可运行的产品组合,例如 headless 或 web。Profile 的清单列出 Bundle,每个 Bundle 再通过自己的 package.json 声明一个 cordis.patch.yml。
packages/boot/app-boot/src/profile.ts 中的 loadProfile() 会按顺序完成以下工作:
- 找到 Profile 目录;
- 读取
dsh.profile.bundles; - 解析每个 Bundle 的
dsh.bundle.patch; - 加载 Profile 自己的
cordis.patch.yml; - 将这些 patch 按顺序叠加成最终插件配置。
dsh-base 是所有正式 Profile 的基础层。它在 packages/bundle/base/cordis.patch.yml 中挂载模型运行时、会话、Agent 注册表、工具、系统提示词、Agent Loop,以及文件、Shell、子 Agent、设置、凭据等大量能力。dsh-headless 不会复制这些配置,而是在基础层上覆盖少量配置并追加一次性运行器。
例如,基础层中的核心条目大致是:
yaml
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: agent
name: '@deepseek-ai/dsh-agent'
- id: tools
name: '@deepseek-ai/dsh-tools'
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
这不是文档中抽象出来的架构图,而是进程实际加载的插件清单。
3.2 Cordis 负责创建上下文并管理生命周期
组合完成后,packages/boot/app-boot/src/index.ts 中的 boot() 创建 Cordis Context,安装 Loader,再挂载根配置:
ts
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
await prepare?.(ctx)
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
await ctx.get('loader')?.await()
await assertEntriesActivated(ctx, binName)
这几行代码给出了启动阶段最关键的事实:运行时先有 Cordis 上下文,再由 Loader 根据配置挂载插件;插件启动失败时,boot() 会释放已经创建的上下文,而不是留下半启动状态。
我在当前仓库实际执行了:
bash
node --import tsx/esm apps/cli/src/bin.ts \
--profile headless \
--dump-config
展开后的配置共有 333 行,其中可以直接看到 dsh-base 提供的 llm、session、agent、tools、system-prompt、agent-loop,以及 dsh-headless 对部分条目的覆盖和新增。理解 DeepSeek Harness,--dump-config 往往比直接浏览数百个包更有效,因为它展示的是"这一次到底启动了什么"。
四、核心不是一个类,而是一组相互协作的服务
下图给出了从入口到能力实现的最小分层。阅读顺序从上到下:入口选择运行方式,Profile 与 Bundle 生成插件配置,Cordis 装载插件树,Agent Loop 再通过多个服务完成一次运行。

核心服务可以先按职责理解:
| 服务 | 上下文入口 | 主要职责 | 源码入口 |
|---|---|---|---|
| Agent Registry | ctx.agents |
管理活动 Agent,并把创建工作委托给 Agent Factory | packages/core/agent/src/index.ts |
| Agent Loop | ctx.agentLoop |
提供默认 Agent Factory,驱动 Turn、Step、模型请求和工具回填 | packages/core/agent-loop/src/index.ts |
| Session Store | ctx.sessions |
创建和管理会话;会话日志保存可恢复的事实 | packages/core/session/src/index.ts |
| System Prompt | ctx.systemPrompt |
按顺序组装身份、运行环境、工具说明等提示词片段 | packages/core/system-prompt/src/index.ts |
| Tool Runtime | ctx.tools |
注册工具、生成工具 Schema,并执行受控工具调用 | packages/core/tools/src/index.ts |
| LLM Runtime | ctx.llm |
注册模型 Adapter,解析 Provider 路由并提供流式调用入口 | packages/llm/llm/src/index.ts |
这几项并不是一个"大内核"内部的私有模块。它们各自注册成 Cordis Service,通过上下文注入建立依赖关系。最直接的证据在 AgentLoop 的声明中:
ts
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
constructor(ctx: Context, config: Config) {
super(ctx, 'agentLoop')
// ...
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
}
}
这段代码表达了两个关系。
第一,默认循环不是凭空访问全局对象,而是明确依赖 agents、sessions、llm、tools 和 systemPrompt。缺少必要服务时,插件无法正常激活,错误会在装配阶段暴露。
第二,Agent Registry 只负责登记和查找 Agent,具体怎样创建 Agent 由实现 AgentFactory 的插件提供。当前默认实现是 AgentLoop,但接口和实现并没有被焊死在一起。
五、一次最小运行如何串起这些服务
完整的 Turn 与 Step 会在后续文章单独分析,这里只看足以说明整体职责的主链路。
5.1 输入先进入 Agent,而不是直接请求模型
活动 Agent 拥有自己的 Session、Inbox 和作用域 Context。外部输入进入 Inbox 后,由 Agent Loop 驱动新的 Turn。这样,用户消息、工具结果、追加指令和取消信号都能进入同一套生命周期,而不是各自直接操作模型。
5.2 每个 Step 都重新组装提示词和工具
进入 Step 前,Agent Loop 调用 ctx.systemPrompt.assemble()。System Prompt 服务维护多个有序、可按作用域覆盖的提示词区段;Tool Runtime 还会把当前可用工具的 Schema 注册到这次组装中。
这意味着工具列表不是进程启动时写死的一段 JSON。不同 Agent 可以拥有不同作用域,插件也可以注册或撤销自己的工具与提示词片段。
5.3 模型历史从 Session 日志推导
packages/core/agent-loop/src/agent.ts 的 step() 没有维护另一份私有聊天数组,而是调用 session.deriveMessages() 生成模型历史,然后通过 LLM Runtime 发起流式请求:
ts
const { request, preparedCall } = await this.buildRequest(
turn,
step,
assembly.tools,
system,
this.session.deriveMessages(),
signal,
)
const stream = preparedCall?.stream(request)
?? this.loopCtx.llm.stream(request)
模型输出的 chunk、完整消息、工具调用和工具结果又会写回 Session。于是运行状态、恢复依据和模型上下文都围绕同一条事件日志组织,而不是分别维护几套容易失真的状态。这也是项目反复强调"模型可见内容必须可由日志重建"的原因。
5.4 工具和模型都通过可替换入口接入
LLM Runtime 管理 Adapter 与 Provider 路由,Agent Loop 只调用统一的 prepareCall() 或 stream()。Tool Runtime 同样维护工具注册表和执行流水线。默认 DeepSeek 模型适配器、Shell、文件系统、Web 搜索等都位于这些统一入口之后。
因此,更换模型 Provider 不需要修改 Agent Loop;更换文件系统或 Shell 后端,也不需要把工具调用逻辑复制到新的循环里。这种职责分离是 DeepSeek Harness 可扩展性的实际来源,而不是简单地把文件拆成多个 npm 包。
六、"一切皆插件"应该怎样准确理解
从 dsh-base 的配置可以看到,连 Session、Agent、Tools 和 Agent Loop 本身都是插件条目。Web、headless、模型适配器、持久化、安全策略当然也是插件。因此,"一切皆插件"基本符合代码事实。
但把它解释成"系统没有任何固定核心"并不准确。DeepSeek Harness 仍然有一组稳定约束:
- 插件运行在 Cordis 的 Context、Service、Event 和 Effect 生命周期中;
- Profile 与 Bundle 按确定顺序叠加配置;
- Agent、Session、Tool 和 LLM 通过明确接口协作;
- 事件类型及
waterfall等派发语义决定插件怎样拦截流程; - 会话事件格式决定哪些状态可以持久化、恢复和重放。
更准确的说法是:DeepSeek Harness 没有一个垄断所有行为的不可替换主类,但它有一套稳定的运行协议和组合规则。
插件化解决的是实现替换问题,不是取消规则。恰恰因为规则明确,插件才能被替换而不破坏其他部分。
七、这种设计带来了什么
7.1 同一套核心可以形成不同产品
dsh-base 提供共同能力,dsh-headless 加入一次性运行器,dsh-web-app 加入浏览器应用。不同入口复用相同的 Agent、Session 和工具体系,差异通过配置层表达。
7.2 扩展通常落在插件,而不是主循环
新增模型适配器,可以注册到 LLM Runtime;新增工具,可以注册到 Tool Runtime;新增请求策略,可以挂到相关事件;新增持久化方式,可以实现 Session Persistence。多数功能不需要修改 Agent Loop。
这对框架长期维护很重要。主循环越稳定,外围能力越容易并行演进;插件卸载时,注册项又能通过 Effect 一起撤销,减少热重载和测试之间残留状态的问题。
7.3 代价是理解门槛更高
插件化并不会消除复杂度,只会重新安排复杂度。DeepSeek Harness 的学习成本主要集中在三处:
- 需要先理解 Cordis 的上下文、服务、事件和 Effect;
- 需要分清 Profile、Bundle、patch 和最终插件树;
- 需要知道一个行为属于 Session 事实、Agent 实时事件,还是某项能力自己的事件。
如果只是写一个固定提示词、调用一次模型的脚本,这套结构明显偏重。它更适合需要多工具、多入口、可恢复会话、安全策略和持续扩展的 Agent 产品。
八、第一次读源码,建议从哪里开始
不要从 packages/ 目录第一项开始逐个阅读。更有效的顺序是:
- 阅读
docs/architecture.md,先建立 Profile、核心服务和事件域的整体概念; - 运行
dsh --profile headless --dump-config,确认实际装载了哪些插件; - 查看
packages/bundle/base/cordis.patch.yml,理解默认产品由哪些能力组成; - 查看
packages/core/agent-loop/src/index.ts的依赖声明; - 再进入
packages/core/agent-loop/src/agent.ts,跟踪一次 Turn 和 Step; - 遇到模型、工具或会话问题时,再分别进入对应服务包。
这个顺序先回答"系统装了什么",再回答"这些服务怎样协作",最后才进入具体实现,能避免一开始就迷失在大量插件包中。
九、小结
DeepSeek Harness 解决的不是"怎样调用 DeepSeek 模型",而是"怎样把一个 Agent 所需的运行能力组织成可组合、可替换、可恢复的系统"。
它的基本路径可以压缩为四步:
- 入口选择 Profile;
- Profile 与 Bundle 叠加出插件配置;
- Cordis Loader 装载并管理插件树;
- Agent Loop 通过 Session、System Prompt、Tools 和 LLM 等服务驱动实际运行。
理解这一层之后,再看某个工具或模型适配器就不会把它当成孤立代码:它们都在同一个服务和事件体系中占据明确位置。
下一篇将进入这套体系的底层:Cordis 怎样用 Context、Service、Event、Effect 和 waterfall 支撑"一切皆插件",以及为什么插件可以改写流程,却不能随意破坏生命周期。