DeepSeek Harness 源码解读(一):从 dsh 入口追到第一次模型请求

226 阅读5分钟

第一次打开 DeepSeek Harness 仓库,我做了一件很自然、但没什么用的事:搜索 main,再找一个像 AgentApp 或 AgentRuntime 的总控类。

没找到。

packages/ 下面是 Agent、Session、Tools、LLM、Shell、Sandbox 等几十个包。每个名字都认识,放在一起却很难看出程序究竟从哪里启动。沿着目录挨个读,很快就会在接口、插件和配置文件之间迷路。

后来我换了个办法,不再猜哪个类最重要,而是从一条真的能跑起来的命令往下追:

pnpm dsh --profile headless "列出这个仓库最值得先读的三个文件"

我只追一个问题:这段文字经过哪些代码,才变成第一次模型请求?

这条线不算长,却会经过 CLI 分发、Profile 组合、Cordis 装载、服务注册、Agent 创建和 Session 记录。走完以后,仓库里那些看起来分散的包就有了位置。

项目地址:deepseek-ai/deepseek-harness

源码基线:0.1.0-rc.5

bin.ts 没有启动 Agent

入口在 apps/cli/src/bin.ts。

我原本以为这里至少会创建模型客户端,实际代码只做命令解析和模式分发。删去另外两个分支后,profile 路径大致是这样:

const invocation = parseDshArgs(process.argv.slice(2), readVersion())

switch (invocation.mode) {
  case 'profile': {
    const { runProfile } = await import('./profile-boot.ts')
    await runProfile({
      environment: loadLayeredEnv('dsh'),
      profile: invocation.profile,
      patchFiles: invocation.patches,
      args: invocation.args,
    })
    break
  }
  // plugin、dump-config 等分支略
}

bin.ts 能看懂 --profile headless,也能把后面的任务参数交给 runProfile(),但它不知道本次使用哪个模型、有哪些工具,甚至不知道 Agent Loop 是否存在。

这里的动态导入也很实在:运行 Profile 时才加载 profile-boot.ts,执行插件管理或配置导出时,不必顺手拉起整套启动逻辑。

所以入口文件更像一个分诊台。它把命令送到对应流程,真正的运行时还在后面。

headless 不是布尔开关

接着看 packages/boot/app-boot/src/profile.ts。仓库内置的 Profile 模板只有两组:

export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
  web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
  headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}

headless 并不是 if (headless) ... 这样的功能开关。它表示:先装基础 Bundle,再叠加 Headless Bundle。

这里的 Bundle 也不是常说的前端打包产物。每个 Bundle 提供一层 cordis.patch.yml,往插件树里插入或修改配置。dsh-base 放入模型、Session、Agent、工具等通用能力,dsh-headless 再补上一条命令行任务的直接驱动器。

apps/cli/src/profile-boot.ts 会按顺序合并这些层:

Bundle patches
→ 当前 Profile 的 cordis.patch.yml
→ $DSH_HOME/cordis.patch.yml
→ 命令行 --patch
→ 必要时追加 telemetry 禁用补丁

后面的补丁可以覆盖前面的配置。也就是说,源码里有某个插件,不等于这次进程一定装了它;同一个插件存在,也不等于它使用仓库里的默认参数。

读到这里,我先记住了一个排查命令:

pnpm dsh --profile headless --dump-config

与其只翻 packages/ 猜运行时,不如先看这次启动最终合成了哪棵插件树。

boot() 先造一个空的 Context

配置准备好以后,流程进入 packages/boot/app-boot/src/index.ts 的 boot()。

把错误包装和退出检查暂时拿掉,主干很短:

const ctx = new Context()

try {
  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)
  return ctx
} catch (cause) {
  await ctx.fiber.dispose()
  throw cause
}

这里最值得注意的不是 Loader,而是没有出现一长串 new:没有 new SessionStore(),没有 new ToolRuntime(),也没有 new AgentLoop()。

boot() 只先创建一个 Cordis Context,再让 Loader 按刚才合成的配置逐项挂载插件。Session、LLM、Tools 等服务由各自插件注册进 Context,不由启动函数集中组装。

这种写法起初比手工创建对象绕一点,但生命周期很清楚。某个插件注册的服务、监听器和清理动作都挂在 Fiber 上;启动中途失败时,释放根 Fiber 就能沿插件树回收已经创建的资源。

我把前半段调用链画成了一张图。后面的文章还会不断回到这张图上:

一条 dsh headless 命令到第一次模型请求的调用路径

Agent Loop 是配置装进来的

再打开 packages/bundle/base/cordis.patch.yml,才能看到核心服务怎样进入 Context:

- 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'

这些条目不是文档目录,而是启动时真正会被 Loader 处理的插件项。

packages/core/agent-loop/src/index.ts 里的 AgentLoop 明确写出了自己依赖的服务:

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()')
  }
}

这五个名字基本就是主干:

  • ctx.agents 保存活动 Agent,并提供统一的创建入口;
  • ctx.sessions 管理追加式会话日志;
  • ctx.llm 负责找到模型 Provider 并发起请求;
  • ctx.tools 维护工具注册和执行管线;
  • ctx.systemPrompt 组装每一步要交给模型的提示词与工具定义。

AgentLoop 把它们串起来,但不把具体模型、存储实现或 Shell 工具写死在自己内部。构造函数中的 setFactory(this) 才是关键交接:Agent Registry 仍是公共入口,当前装载的 Agent Loop 成为它背后的创建工厂。

如果只看类名,很容易把 AgentLoop 当成整个程序;放回插件树里看,它只是其中一个协调服务。

Headless Runner 只负责投递任务

基础能力都装好后,packages/bundle/headless/src/index.ts 的 Runner 才开始处理命令行里的那段文字。

省去模型选择和输出汇总,主要动作是这些:

const { agent } = await agents.create({
  sessionId: SessionId(`session-${randomUUID()}`),
  meta: { cwd: process.cwd() },
  agentOptions: {
    provider: selection.provider,
    model: selection.model,
  },
})

await agent.whenIdle()
agent.followup(createUserMessage({
  content: [{ type: 'text', text: task }],
  source: { kind: 'user' },
}))
await agent.whenIdle()
await sessions.flush(agent.session)

Runner 没有把 task 直接塞给某个模型 SDK。它先通过 ctx.agents 创建 Agent,再把命令行文本包装成普通用户消息,用 followup() 投进 Agent 的 Inbox。

两个 whenIdle() 也不是多余的。第一个等 Agent 初始化完成,第二个等这次任务把 Agent 跑回空闲;随后 sessions.flush() 确保会话落盘,Runner 才从事件中整理最终文本并决定退出码。

这段代码解释了为什么 Web、Headless、ACP 和 SDK 不需要各写一套推理循环。它们接收输入的方式不同,进入运行时以后走的仍是同一个 Agent 接口。

第一次模型请求藏在 Step 里

任务进入 Inbox 后,Agent 会打开 Turn,再进入 Step。真正的模型调用在 packages/core/agent-loop/src/agent.ts:

const { request, preparedCall } = await this.buildRequest(
  turn,
  step,
  assembly.tools,
  system,
  this.session.deriveMessages(),
  signal,
)

const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request)
  ?? this.loopCtx.llm.stream(request)

for await (const chunk of stream) {
  chunkSeqs.push(
    this.session.append('assistant/chunk', { turn, step, chunk }).seq,
  )
  assembler.push(chunk)
}

走到 stream(request),这条命令才第一次碰到模型。

请求也不是临时拼一段字符串:历史消息来自 session.deriveMessages(),工具定义来自本 Step 的 Prompt 组装结果,模型返回的流式分片则逐个写成 assistant/chunk。分片收齐后还会生成一条 assistant/message,作为后续回放和下一步推理使用的正式消息。

如果这条消息只有文本,当前 Step 可以结束;如果里面带着 tool call,Agent Loop 会执行工具、记录 tool/call 和 tool/result,再开下一个 Step 请求模型继续回答。

所以“一次命令行任务”不等于“一次模型请求”。一次 Turn 里可以有多个 Step,每个 Step 才对应一次模型交互。这里先停住,后面再单独拆 Turn、Step 和工具循环。

走完以后,仓库里仍然没有“主程序”

这条调用链可以压成下面几步:

apps/cli/src/bin.ts
→ apps/cli/src/profile-boot.ts
→ packages/boot/app-boot/src/index.ts
→ packages/bundle/base/cordis.patch.yml
→ packages/bundle/headless/src/index.ts
→ packages/core/agent-loop/src/agent.ts

我最后还是没找到一个包办所有事情的 AgentApp,因为这套代码本来就没有把运行时收进一个大对象。

CLI 选择启动方式,Profile 决定插件组合,Cordis 管理装载和生命周期,Agent Loop 协调服务,Session 保存可恢复的事实,具体模型和工具则从各自的 Provider 进入。所谓 Harness,不是给模型 API 再包一层方法,而是把这些原本散落在外面的运行条件组织起来。

如果只做一次问答,直接调用模型当然更短。可一旦要恢复会话、切换 Provider、挂载工具、处理审批与沙箱,还要让 Web 和 CLI 共用同一套执行逻辑,真正麻烦的就不再是那一行 llm.stream()。

下一篇从 new Context() 这里接着读:Cordis 的 Service、Fiber、Effect 和 waterfall,究竟怎样把这棵插件树托起来。