文章目录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把输入接到 conversation4.4 ConversationController构造 Prompt 内容4.5 Session.prompt前端 RPC4.6 Host 侧 /apiclient connection apiproxy4.7 Agent Loopturn / step 循环4.8 Prompt 组装systemPrompt tools4.9 LLM 请求ctx.llm 到 DeepSeekAdapter4.10 工具调用流水线4.11 完整流程图5. 流程背后的设计思想5.1 Session Event Sourcing5.2 Agent Loop 是一个 Reactor5.3 Waterfall Event 是中间件思想5.4 Capability Seam定义、提供者、消费者分离5.5 前端输入也用了状态机6. 如何理解“一切皆为插件”6.1 Cordis 插件是什么6.2 服务是 ctx 上的命名能力6.3 注册都是可回收 effect6.4 为什么要这样设计6.5 如何自定义插件?6.5.1 一个最小工具插件6.5.2 挂到 profile 或 bundle6.5.3 自定义 LLM Adapter6.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 monorepopackage.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/webWeb 前端壳。apps/web/src/main.ts很薄只负责把deepseek-ai/dsh-client-web挂载到#root。packagesHarness 的主体功能区。按能力域拆分例如core、llm、client、host、fs、shell、sandbox、subagent等。当前源码里约有 226 个 package。vendor内置改造过的 Cordis 相关包包括cordis、loader、include、group、hmr、timer、schemastery等。插件化的底座在这里。nativeNative 辅助能力目前重点是landlock-run服务于 Linux sandbox 场景。pythonPython 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-appWeb 应用插件组合。3. 用户 profile 下的cordis.patch.yml。4. home patch 和命令行 patch。packages/boot/app-boot/src/index.ts的boot()会创建constctxnewContext()然后安装 Cordis Loader把 bundle 和 patch 声明的插件行挂进去插件是否真正执行取决于它声明的inject服务是否已经 ready。3.1 base bundle 做了啥源码位置packages/bundle/base/cordis.patch.ymlbase bundle 是基础 Agent 能力集合包含这说明 base bundle 已经是一套可运行的 Agent spine。3.2 web-app bundle 做了啥源码位置packages/bundle/web-app/cordis.patch.ymlweb-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 referencesubmit 成功后清空草稿失败后恢复草稿。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 blocksconstcontent[{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 侧 /apiclient 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 插件会注册/apirouteHTTP 请求进来后通过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 Loopturn / 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 callsAgent 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 SourcingHarness 非常强调 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 是一个 ReactorReactLoopAgent的核心不是“一问一答”而是一个响应式循环用户消息进入 inbox 后Agent 会根据当前 phase 决定开新 turn 还是插入下一 step模型如果调用工具工具结果也会影响下一 step策略插件还可以通过事件在中途介入。所以它更像一个可扩展的调度器而不是一个普通 chat completion wrapper。5.3 Waterfall Event 是中间件思想几个关键事件是 waterfallagent/pre-stepagent/requestllm/streamtools/pre-executetools/executetools/post-executewaterfall 的特点是监听器必须显式调用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的对象它可以声明exportconstnamemy-pluginexportconstinject[tools,llm]exportfunctionapply(ctx,config){}inject表示依赖哪些服务依赖没 ready 时插件处于 pending依赖 ready 后才执行apply()如果依赖服务卸载插件也会自动卸载。插件生命周期类似PENDING - LOADING - ACTIVE ACTIVE - UNLOADING - DISPOSED6.2 服务是 ctx 上的命名能力Cordis 的Service会把能力挂到ctx.namectx.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工具可以注册 fixturesession 可以切换 JSONL/SQLite persistenceAgent 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 provider6.5.1 一个最小工具插件官方推荐使用defineTool它会从参数 schema 推导类型并帮你做运行时参数校验。示例importtype{Context}fromdeepseek-ai/cordisimport{defineTool}fromdeepseek-ai/dsh-toolsexportconstnametool-helloexportconstinject[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){returnHello,${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 AdapterLLM Adapter 的形态也很清楚importtype{Context}fromdeepseek-ai/cordisimport{LlmAdapter}fromdeepseek-ai/dsh-llmimporttype{GenerateOptions,StreamChunk}fromdeepseek-ai/dsh-llmclassMyAdapterextendsLlmAdapter{async*stream(options:GenerateOptions):AsyncIterableStreamChunk{// 1. 把 Harness GenerateOptions 转成供应商请求// 2. 发起 fetch 或 SDK 调用// 3. 把供应商流式输出转成 StreamChunk}}exportconstnamellm-my-providerexportconstinject[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}fromdeepseek-ai/cordisimporttype{PreToolDecision,ToolExecution}fromdeepseek-ai/dsh-toolsasyncfunctionisAllowed(exec:ToolExecution):Promiseboolean{returnexec.name!dangerous_tool}exportconstnamepermission-gateexportfunctionapply(ctx:Context){ctx.on(tools/pre-execute,async(exec,next):PromisePreToolDecision{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 核心架构的一些拆解源码里的设计思路还有很多值得细品的地方。整理这些内容的过程也是博主自己学习梳理的过程如有理解不到位的地方也欢迎指正交流。谢谢大家的阅读本文完