第一次打开 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 就能沿插件树回收已经创建的资源。
我把前半段调用链画成了一张图。后面的文章还会不断回到这张图上:
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,究竟怎样把这棵插件树托起来。