DeepSeek Harness 源码解读(一):它究竟解决了 Agent 的什么问题

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 表示一种可运行的产品组合,例如 headlessweb。Profile 的清单列出 Bundle,每个 Bundle 再通过自己的 package.json 声明一个 cordis.patch.yml

packages/boot/app-boot/src/profile.ts 中的 loadProfile() 会按顺序完成以下工作:

  1. 找到 Profile 目录;
  2. 读取 dsh.profile.bundles
  3. 解析每个 Bundle 的 dsh.bundle.patch
  4. 加载 Profile 自己的 cordis.patch.yml
  5. 将这些 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 提供的 llmsessionagenttoolssystem-promptagent-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()')
  }
}

这段代码表达了两个关系。

第一,默认循环不是凭空访问全局对象,而是明确依赖 agentssessionsllmtoolssystemPrompt。缺少必要服务时,插件无法正常激活,错误会在装配阶段暴露。

第二,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.tsstep() 没有维护另一份私有聊天数组,而是调用 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/ 目录第一项开始逐个阅读。更有效的顺序是:

  1. 阅读 docs/architecture.md,先建立 Profile、核心服务和事件域的整体概念;
  2. 运行 dsh --profile headless --dump-config,确认实际装载了哪些插件;
  3. 查看 packages/bundle/base/cordis.patch.yml,理解默认产品由哪些能力组成;
  4. 查看 packages/core/agent-loop/src/index.ts 的依赖声明;
  5. 再进入 packages/core/agent-loop/src/agent.ts,跟踪一次 Turn 和 Step;
  6. 遇到模型、工具或会话问题时,再分别进入对应服务包。

这个顺序先回答"系统装了什么",再回答"这些服务怎样协作",最后才进入具体实现,能避免一开始就迷失在大量插件包中。

九、小结

DeepSeek Harness 解决的不是"怎样调用 DeepSeek 模型",而是"怎样把一个 Agent 所需的运行能力组织成可组合、可替换、可恢复的系统"。

它的基本路径可以压缩为四步:

  1. 入口选择 Profile;
  2. Profile 与 Bundle 叠加出插件配置;
  3. Cordis Loader 装载并管理插件树;
  4. Agent Loop 通过 Session、System Prompt、Tools 和 LLM 等服务驱动实际运行。

理解这一层之后,再看某个工具或模型适配器就不会把它当成孤立代码:它们都在同一个服务和事件体系中占据明确位置。

下一篇将进入这套体系的底层:Cordis 怎样用 Context、Service、Event、Effect 和 waterfall 支撑"一切皆插件",以及为什么插件可以改写流程,却不能随意破坏生命周期。

相关推荐
武子康2 小时前
单卡 A6000 跑 27B FP8:我把“能跑”拆成了五组证据
人工智能·llm·agent
Henry1432 小时前
大家的需求都听到了:熬了个大夜,给 DeepSeek Harness 桌面版装上内置浏览器
deepseek
不一样的少年_2 小时前
图解 AI Agent ①:大模型接上 API,为什么还不算 Agent?
人工智能·agent·ai编程
ch8562 小时前
机器能思考吗?“——图灵测试、达特茅斯会议与 AI 的诞生
agent
yunwei372 小时前
eBPF 教程:用 BPF Qdisc 实现出口限速
linux·云原生·开源
小白说大模型3 小时前
Spring AI 框架中集成 MCP 的完整指南:从服务端到客户端的全流程实践
大数据·数据库·人工智能·安全·spring·chatgpt·开源
anew___3 小时前
Agent 流:从概念到落地的全景解读
agent
一个处女座的程序猿3 小时前
Computer之Tool:firecrawl/anydoc(Markdown)的简介、安装和使用方法、案例应用之详细攻略
agent·tool·anydoc