DeepSeek Harness 源码分析
文章目录
- 1. 引言
- 2. 项目结构分析
- 2.1 顶层目录
- 2.2 packages 目录分析
- 3. 从 dsh web 到插件树
- 3.1 base bundle 做了啥?
- 3.2 web-app bundle 做了啥?
- 4. 发送一句话之后,背后发生了什么?
- 4.1 InputBar:前端输入框
- 4.2 InputMachine:输入状态机
- 4.3 InputHub:把输入接到 conversation
- 4.4 ConversationController:构造 Prompt 内容
- 4.5 Session.prompt:前端 RPC
- 4.6 Host 侧 /api:client connection + apiproxy
- 4.7 Agent Loop:turn / step 循环
- 4.8 Prompt 组装:systemPrompt + tools
- 4.9 LLM 请求:ctx.llm 到 DeepSeekAdapter
- 4.10 工具调用流水线
- 4.11 完整流程图
- 5. 流程背后的设计思想
- 5.1 Session Event Sourcing
- 5.2 Agent Loop 是一个 Reactor
- 5.3 Waterfall Event 是中间件思想
- 5.4 Capability Seam:定义、提供者、消费者分离
- 5.5 前端输入也用了状态机
- 6. 如何理解“一切皆为插件”
- 6.1 Cordis 插件是什么
- 6.2 服务是 ctx 上的命名能力
- 6.3 注册都是可回收 effect
- 6.4 为什么要这样设计
- 6.5 如何自定义插件?
- 6.5.1 一个最小工具插件
- 6.5.2 挂到 profile 或 bundle
- 6.5.3 自定义 LLM Adapter
- 6.5.4 自定义 UI 插件
- 6.5.5 自定义策略插件
- 7. 源码定位清单
- 8. 总结
1. 引言
最近 DeepSeek Harness 开源爆火了,博主也安装了试了下,发现 DeepSeek Harness 并不是一个简单的 “聊天 UI + 调模型” 的工程,它更像一个面向 Agent 的运行底座。
正如官网描述一样:它把模型、工具、会话、提示词、权限、沙箱、前端 UI、后端 API、子 Agent、MCP、Workflow 等能力全部拆成插件,然后用 Cordis 把这些插件组合成一棵可加载、可卸载、可替换的插件树。
一句话概括:
DeepSeek Harness 的核心不是某个聊天组件,而是
Cordis Context + Service + Event + Plugin Fiber组成的 Agent Harness。
如果用户在对话框里输入一句话,站在源码视角看,这句话会经历:
本文沿着这条主线,学习下整体的项目源码。
2. 项目结构分析
仓库地址:https://github.com/deepseek-ai/deepseek-harness
仓库是一个 pnpm monorepo,package.json里声明的 workspace 主要包括:
"workspaces":["vendor/*","packages/*/*","native/landlock-run","native/landlock-run/packages/*","apps/*","website"]也就是说,真正的功能包大多在packages/<能力域>/<具体包>下面,而不是直接平铺在packages第一层。
2.1 顶层目录
| 目录 | 职责 |
|---|---|
apps/cli | 命令行入口。apps/cli/src/bin.ts解析dsh web、profile、plugin、dump-config等命令,并调用 boot 层加载 profile。 |
apps/web | Web 前端壳。apps/web/src/main.ts很薄,只负责把@deepseek-ai/dsh-client-web挂载到#root。 |
packages | Harness 的主体功能区。按能力域拆分,例如core、llm、client、host、fs、shell、sandbox、subagent等。当前源码里约有 226 个 package。 |
vendor | 内置改造过的 Cordis 相关包:包括cordis、loader、include、group、hmr、timer、schemastery等。插件化的底座在这里。 |
native | Native 辅助能力:目前重点是landlock-run,服务于 Linux sandbox 场景。 |
python | Python SDK / runtime 相关代码,方便外部用 Python 侧驱动或集成 Harness。 |
docs | 架构、Cordis primer、cookbook、子系统文档、工具流水线、扩展指南等。源码分析时非常关键。 |
examples | 独立示例,例如 ACP、headless、JSON-RPC、MCP memory、web schedule 等。 |
website | 文档站点。 |
scripts | 构建、发布、类型生成、快照等脚本。 |
patches | 依赖 patch。 |
顶层其实很清晰:apps 是入口,packages 是产品能力,vendor 是框架底座,docs/examples 是说明和示范。
2.2 packages 目录分析
packages的目录非常多,第一层不是 npm 包,而是能力域,下面是按职责整理的模块视角:
这个拆法很有意思:例如文件能力不是一个FileTool包搞定,而是拆成:
fs 服务定义 fs-local 本地实现 fs-sandbox 沙箱封装 tool-fs 面向模型的工具消费者 tool-fs-search 搜索工具消费者这就是后文要讲的 “Service Definition / Service Provider / Consumer” 思路。
3. 从 dsh web 到插件树
dsh 的安装方式很简单,只需要一条命令:
npx @deepseek-ai/dsh web
安装成功后,会自动打开web页面,这里先看入口,apps/cli/src/bin.ts负责解析命令。
运行dsh web时,本质会走 boot 层,读取环境变量、profile 和 patch,然后创建 CordisContext,关键代码:
apps/cli/src/bin.ts packages/boot/app-boot/src/profile.ts packages/boot/app-boot/src/index.ts packages/bundle/base/cordis.patch.yml packages/bundle/web-app/cordis.patch.ymlprofile.ts里能看到默认 profile 模板:
所以dsh web不是硬编码启动一堆类,而是加载:
1.`dsh-base`基础插件组合。2.`dsh-web-app`Web 应用插件组合。3. 用户 profile 下的`cordis.patch.yml`。4. home patch 和命令行 patch。packages/boot/app-boot/src/index.ts的boot()会创建:
constctx=newContext()然后安装 Cordis Loader,把 bundle 和 patch 声明的插件行挂进去,插件是否真正执行,取决于它声明的inject服务是否已经 ready。
3.1 base bundle 做了啥?
源码位置:packages/bundle/base/cordis.patch.yml
base bundle 是基础 Agent 能力集合,包含:
这说明 base bundle 已经是一套可运行的 Agent spine。
3.2 web-app bundle 做了啥?
源码位置:packages/bundle/web-app/cordis.patch.yml
web-app bundle 在 base 之上挂 Web 相关插件,包括:
特别要注意一点:Web bundle 里有大量disabledbase 工具行的配置,注释说明 Web 会把 model-facing tools 移到 agent preset 平面,而不是全部挂在 Host 根上下文,这是为了让每个 Agent preset 有自己的工具可见性和作用域。
4. 发送一句话之后,背后发生了什么?
这一节是本文主线,从对话框入口开始,发送一句话,例如:
帮我分析一下这个项目它不是直接fetch('/chat'),整个链路被拆成前端输入状态机、前端会话 API、Host RPC、Agent Loop、LLM Streaming、工具执行、Session Event 投影几个阶段。
4.1 InputBar:前端输入框
入口在:
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx这里处理 textarea、按钮、键盘事件、IME 输入、菜单状态、运行中 stop 等 UI 细节。
核心行为:
- 点击发送按钮时调用
inputActions.submit(); - 按 Enter 时,如果不是 IME、不是菜单选择、不是锁定状态,就调用
keyboard.submit(...); - 如果当前 Agent 正在运行,主按钮会变成 stop。
注意:InputBar 只是 UI 壳,它不直接知道后端 API,它把动作交给输入状态机和 facade。
4.2 InputMachine:输入状态机
入口在:
packages/client/ui-conversation/src/client/input/machine.ts packages/client/ui-conversation/src/client/input/facade.tsmachine.ts文件开头的注释就说明了设计:这是一个纯粹的 per-session 输入状态机,只接收事件、产出 effect,不依赖 React、DOM、Cordis。
它解决几个问题:
- 当前输入是不是 slash command?
- 输入是否为空?
- 是否有 inline reference?
- submit 成功后清空草稿,失败后恢复草稿。
- trigger popup / slash command / normal text 的分流。
普通文本最终会产出一个default-sinkeffect。
facade.ts是 effect executor,它接到default-sink后,会序列化 inline references,然后调用:
deps.defaultSink(draft.trim(),imageIds,mode,signal)4.3 InputHub:把输入接到 conversation
入口在:
packages/client/ui-conversation/src/client/input/hub.tsInputHub负责每个 session 的输入 shell,并把 normal text 的 sink 接到:
conversation().sendSession(session,text,imageIds,mode,signal)也就是说,输入层只知道“这是一条 session prompt”,不关心 RPC 细节。
4.4 ConversationController:构造 Prompt 内容
入口在:
packages/client/ui-conversation/src/client/service.tsConversationController是前端的 conversation 服务,挂在ctx.conversation上。
它的sendSession()会把文本和图片整理成 content blocks:
constcontent=[{type:'text',text},...images]session.prompt(content,mode,signal)普通文本就是:
[{type:'text',text:'帮我分析一下这个项目'}]4.5 Session.prompt:前端 RPC
入口在:
packages/client/runtime/src/client/sessions/session.ts前端Session的prompt()会调用:
this.api.sessions.prompt({sessionId,mode,content,clientTimeZone,})this.api来自packages/api/gateway生成的 remote。实际请求由 connection 层发出去。
相关代码:
packages/api/gateway/src/client/index.ts packages/client/connection/src/client/web-api-client.ts packages/client/connection/src/client/index.tsWebApiClient做两件事:
- 普通 RPC 走 HTTP
/api。 - session event / host event 走 WebSocket。
4.6 Host 侧 /api:client connection + apiproxy
入口在:
packages/client/connection/src/index.ts packages/host/webserver/src/index.ts packages/client/connection/src/http-bridge.ts packages/host/apiproxy/src/api-proxy.tsclient/connection的 Host 插件会注册/apiroute,HTTP 请求进来后,通过http-bridge转成 fetch-shaped handler,再交给 api proxy。
最终命中:
ApiProxy.prompt(request)packages/host/apiproxy/src/api-proxy.ts的prompt()大致做这些事:
- 校验
clientTimeZone。 - 找到 session 对应的 Agent。
- 检查当前模型 provider/model 是否可用。
- 处理图片 admission 和持久化内容。
- 创建
UserMessage。 - 根据 mode 决定:
queue/ 普通消息:agent.followup(message)。steer/ 运行中插入下一步:agent.steer(message)。
- 返回
{ accepted: true }。
注意这里返回 accepted 并不代表模型已经回答完,而是“消息已经被 Agent 接收”。后续结果通过 session event 流回前端。
4.7 Agent Loop:turn / step 循环
核心入口:
packages/core/agent-loop/src/agent.tsReactLoopAgent维护一个 inbox。followup()会把用户消息放入 inbox,标记为next-turn,然后wakeDriver()。steer()则是next-step。
Agent driver 被唤醒后,会进入:
kick()->turn()->step()turn()负责一次对话轮次,里面可能包含多个 step。为什么一个 turn 会有多个 step?因为模型可能先调用工具,工具结果回来后还要继续请求模型,直到没有工具调用或被策略终止。
turn()的典型事件顺序是:
turn/start step/start user/message assistant/chunk* assistant/message tool/call* tool/result* step/end turn/end这些事件会写入 session log。前端也是靠这些事件渲染聊天内容、工具卡片、运行状态。
4.8 Prompt 组装:systemPrompt + tools
核心入口:
packages/core/system-prompt/src/index.ts packages/core/tools/src/index.ts packages/core/agent-loop/src/agent.ts在每个 step 之前,Agent 会执行preStep():
- 从 inbox claim 用户输入。
- 组装系统提示词。
- 渲染动态 runtime context。
- 触发
agent/pre-step扩展点。
system-prompt负责有序 section、动态 context、工具 schema 和 prompt variables。
tools注册表负责把当前可见工具的 schema 加入模型请求。也就是说,工具插件只要ctx.tools.register(),schema 就会自然进入 prompt assembly。
4.9 LLM 请求:ctx.llm 到 DeepSeekAdapter
核心入口:
packages/llm/llm/src/index.ts packages/llm/llm-deepseek/src/index.ts packages/llm/llm-deepseek/src/adapter.tsctx.llm是模型服务。模型 provider 不写死在 Agent Loop 里,而是通过 adapter 注册:
ctx.llm.registerAdapter(['deepseek-official'],adapter)Agent Loop 在step()里会调用:
ctx.llm.prepareCall(...)ctx.llm.stream(...)如果当前 provider 是deepseek-official,最终走DeepSeekAdapter.stream()。它会构造 OpenAI-compatible chat completions 请求,发送到:
${baseURL}/chat/completions并以 SSE 方式解析流式响应。响应 chunk 会被转换成 Harness 自己的StreamChunk,再由 Agent Loop 写成:
assistant/chunk assistant/message4.10 工具调用流水线
核心入口:
packages/core/agent-loop/src/tool-calls.ts packages/core/tools/src/index.ts docs/tool-execution-pipeline.zh.md如果模型返回 tool calls,Agent Loop 会进入executeToolCalls()。
工具调用不是简单地执行函数,而是一条流水线:
tool/call tools/pre-execute tools/execute tools/post-execute tools/result tool/result其中:
tools/pre-execute可做 allow / deny / ask。tools/execute是真正执行点,也可被 timeout、retry、metrics 等插件包裹。tools/post-execute可转换结果或注入额外上下文。tools/result是最终结果观察点。tool/result是持久会话事件,写进 session log。
这条流水线让权限、沙箱、超时、日志、UI 展示、结果裁剪都能作为插件加进来,而不是塞进每个工具实现里。
4.11 完整流程图
5. 流程背后的设计思想
5.1 Session Event Sourcing
Harness 非常强调 session event,用户消息、助手 chunk、工具调用、工具结果、turn/step 边界都会进入 session log。
好处是:
- UI 可以从事件重放出当前聊天状态。
- 崩溃后可以恢复。
- 测试可以做 replay。
- 模型请求可以从 session 历史推导,而不是依赖某个不可追踪的内存对象。
packages/core/session提供 session 基础,packages/session/session-persistence-*负责持久化,packages/session/session-projection-*负责把事件折叠成前端可用视图。
5.2 Agent Loop 是一个 Reactor
ReactLoopAgent的核心不是“一问一答”,而是一个响应式循环:
用户消息进入 inbox 后,Agent 会根据当前 phase 决定开新 turn 还是插入下一 step,模型如果调用工具,工具结果也会影响下一 step,策略插件还可以通过事件在中途介入。
所以它更像一个可扩展的调度器,而不是一个普通 chat completion wrapper。
5.3 Waterfall Event 是中间件思想
几个关键事件是 waterfall:
agent/pre-stepagent/requestllm/streamtools/pre-executetools/executetools/post-execute
waterfall 的特点是监听器必须显式调用next()才会交给下一个处理器,这和 Koa/Express 中间件很像。
例如工具执行:
permission policy -> timeout policy -> actual tool executor -> result policy每个策略都是插件,不需要改工具本体。
5.4 Capability Seam:定义、提供者、消费者分离
很多能力都遵循这个结构:
Service Definition 定义 ctx.xxx 的接口 Service Provider 提供具体实现 Consumer 把能力暴露给模型、UI 或其他插件例如文件系统:
fs 定义 ctx.fs fs-local 本地文件系统 provider fs-sandbox 沙箱封装 provider tool-fs 模型工具消费者这样同一个模型工具可以换不同底层实现,本地、沙箱、远程都可以。
5.5 前端输入也用了状态机
InputMachine是一个纯状态机,SessionInputShell负责执行 effect,这个拆分让输入逻辑脱离 React 组件,测试和维护都更稳定。
这也是整个项目的一种风格:核心逻辑和边缘实现尽量分开。
6. 如何理解“一切皆为插件”
官方文档里有一句关键判断:DeepSeek Harness 没有一个特权内核,模型适配器、工具注册表、会话日志、Agent Loop 都是插件。
这句话要分三层理解。
6.1 Cordis 插件是什么
Cordis 插件可以是函数、类或带apply的对象,它可以声明:
exportconstname='my-plugin'exportconstinject=['tools','llm']exportfunctionapply(ctx,config){}inject表示依赖哪些服务,依赖没 ready 时,插件处于 pending,依赖 ready 后才执行apply(),如果依赖服务卸载,插件也会自动卸载。
插件生命周期类似:
PENDING -> LOADING -> ACTIVE ACTIVE -> UNLOADING -> DISPOSED6.2 服务是 ctx 上的命名能力
Cordis 的Service会把能力挂到ctx.<name>:
ctx.tools ctx.llm ctx.sessions ctx.agents ctx.systemPrompt ctx.agentLoop插件之间不直接 import 具体实现,而是通过服务 key 解耦。
例如工具插件不关心 ToolRuntime 从哪里来,它只声明:
exportconstinject=['tools']然后在apply()里:
ctx.tools.register(...)6.3 注册都是可回收 effect
插件注册事件、工具、模型适配器、prompt section,本质都是 effect,插件卸载时,这些注册会自动清理。
这带来几个实际收益:
- 支持 HMR。
- 支持 profile/bundle 重组。
- 支持不同 agent preset 拥有不同工具集合。
- 插件替换后不会遗留旧注册。
- 测试中可以加载一个最小插件树,用完销毁。
可以画成这样:
6.4 为什么要这样设计
我理解主要有五个原因。
【第一,Agent 产品变化太快】:模型 provider、工具、权限、UI、协议、沙箱都可能换。如果写成一个大内核,后面会越来越难改。
【第二,安全策略必须可插拔】:工具执行涉及文件、shell、网络、子进程、用户确认,不同部署环境的策略完全不同,插件化可以把策略放在tools/pre-execute、tools/execute等扩展点。
【第三,Web、headless、SDK、ACP 这些运行形态不能共用一坨入口代码】:通过 bundle/profile,可以组合出不同产品形态。
【第四,模型工具和 UI 展示需要解耦】:工具返回 canonical JSON,模型看到的是output.render(),UI 卡片看到的是 presentation intent。这样模型协议、前端展示和持久化都不互相污染。
【第五,便于局部替换和测试】:LLM 可以注册 mock adapter,工具可以注册 fixture,session 可以切换 JSONL/SQLite persistence,Agent Loop 可以单独测。
6.5 如何自定义插件?
自定义插件前,先判断你要扩展的是哪个 seam:
| 需求 | 推荐扩展点 |
|---|---|
| 给模型增加一个能力 | ctx.tools.register() |
| 接入新的模型供应商 | ctx.llm.registerAdapter() |
| 改系统提示词 | ctx.systemPrompt.section()或相关 prompt assembly 事件 |
| 增加权限/审计/超时策略 | tools/pre-execute、tools/execute、tools/post-execute、tools/result |
| 增加 UI 区块 | Web Client 的 slot / conversation node / renderer |
| 接外部协议 | 监听session/event,输入侧调用agent.followup()/agent.steer() |
| 换存储/文件/沙箱实现 | 提供对应ctx.xxxservice provider |
6.5.1 一个最小工具插件
官方推荐使用defineTool,它会从参数 schema 推导类型,并帮你做运行时参数校验。
示例:
importtype{Context}from'@deepseek-ai/cordis'import{defineTool}from'@deepseek-ai/dsh-tools'exportconstname='tool-hello'exportconstinject=['tools']exportfunctionapply(ctx:Context){ctx.tools.register(defineTool({name:'hello',description:'Return a greeting.',parameters:{name:{type:'string',required:true,description:'Name to greet',},},output:{schema:{type:'string'},render:(_args,value)=>[{type:'text',text:value}],},asyncexecute(args){return`Hello,${args.name}`},}))}这个插件加载后,hello的 schema 会进入工具注册表,并在可见时进入模型请求,插件卸载时,工具注册会自动移除。
6.5.2 挂到 profile 或 bundle
在 profile 的cordis.patch.yml中加入插件行即可:
-insert:-id:tool-helloname:'@your-scope/dsh-tool-hello'如果是 Web 场景,要注意工具可见性,dsh-web-app里把很多 model-facing 工具从 Host 根平面移到了 agent preset 平面。如果你希望某个工具只对某类 Agent 可见,应该挂到对应 preset 的 standing composition,而不是无脑挂到 Host 根上下文。
如果是在仓库内部开发,可以按现有模式新增:
packages/<能力域>/<插件名>/package.json packages/<能力域>/<插件名>/src/index.ts packages/<能力域>/<插件名>/tests然后把 package 加入 workspace 能识别的位置,再在 bundle 或 profile patch 中声明。
6.5.3 自定义 LLM Adapter
LLM Adapter 的形态也很清楚:
importtype{Context}from'@deepseek-ai/cordis'import{LlmAdapter}from'@deepseek-ai/dsh-llm'importtype{GenerateOptions,StreamChunk}from'@deepseek-ai/dsh-llm'classMyAdapterextendsLlmAdapter{async*stream(options:GenerateOptions):AsyncIterable<StreamChunk>{// 1. 把 Harness GenerateOptions 转成供应商请求// 2. 发起 fetch 或 SDK 调用// 3. 把供应商流式输出转成 StreamChunk}}exportconstname='llm-my-provider'exportconstinject=['llm']exportfunctionapply(ctx:Context){ctx.llm.registerAdapter(['my-provider'],newMyAdapter())}关键约束:
- 必须尊重
options.signal。 - tool call arguments 要保持 raw JSON string。
- 使用统一
StreamChunk协议。 - provider/model 能力要通过
resolveModel()或模型目录暴露。 - secrets 不建议自己读文件,应该通过 Config / env fallback 交给 Cordis 配置体系。
参考实现是:
packages/llm/llm-deepseek packages/llm/llm-pi-ai6.5.4 自定义 UI 插件
Web UI 也是插件。packages/client/ui-conversation/src/client/apply.ts里可以看到它注册了 conversation nodes、renderers、input kit、composer bar 等。
如果只是展示新的业务消息,一般不应该改主 ChatView,而是注册:
- conversation node definition。
- keyed renderer。
- slot contribution。
这样 UI 插件只消费 session projection 或 event feed,不破坏主会话模型。
6.5.5 自定义策略插件
如果要做权限、审计、超时、脱敏,优先写 hook 插件,而不是改工具。
例如在工具执行前做权限判断:
importtype{Context}from'@deepseek-ai/cordis'importtype{PreToolDecision,ToolExecution}from'@deepseek-ai/dsh-tools'asyncfunctionisAllowed(exec:ToolExecution):Promise<boolean>{returnexec.name!=='dangerous_tool'}exportconstname='permission-gate'exportfunctionapply(ctx:Context){ctx.on('tools/pre-execute',async(exec,next):Promise<PreToolDecision>=>{if(!(awaitisAllowed(exec))){return{kind:'deny',reason:'Denied by policy.'}}returnnext()})}这就是插件化最有价值的地方:你不需要侵入每个工具实现,也不需要改 Agent Loop。
7. 源码定位清单
下面列一个阅读源码时最值得反复看的路径。
8. 总结
DeepSeek Harness 最值得学习的不是“怎么调 DeepSeek 模型”,而是它如何把一个复杂 Agent 产品拆成可组合能力。
它的核心链路可以理解为:
它的核心架构可以理解为:
| 模块 | 功能 |
|---|---|
| Cordis Context | 承载服务 |
| Plugin | 声明依赖和生命周期 |
| Service | 提供能力 |
| Event | 提供扩展点 |
| Effect | 保证注册可回收 |
| Bundle/Profile | 决定最终产品形态 |
这样的设计让 Harness 可以在 Web、headless、SDK、ACP、MCP、子 Agent、Workflow 等形态之间切换,也让后续业务集成有比较清晰的扩展入口。
以上是对 DeepSeek Harness 核心架构的一些拆解,源码里的设计思路还有很多值得细品的地方。整理这些内容的过程,也是博主自己学习梳理的过程,如有理解不到位的地方,也欢迎指正交流。
谢谢大家的阅读,本文完 !
