最近在梳理 DeepSeek Harness Desktop 这套源码,里面最值得展开讲的核心设计,是一套 React UI 同时跑在 Web 和本地 Runtime 之上,中间通过抽象层做解耦。说白了就是:同一份界面,在浏览器里走远程服务,在桌面容器里拉起本地进程,UI 层却不写死任何一条调用路径。
这种“一份界面、两种运行环境”的模式,在 AI 工具链、开发者工具里越来越常见。尤其是 DeepSeek Harness Desktop 这种以模型调度、推理管理为核心的工具,Runtime 可能是远端推理服务,也可能是本地的 llama-server、自定义 Python 进程,甚至是一组编排任务。UI 如果和任何一种 Runtime 强耦合,后续每加一个后端都等于重写一遍前端。
所以这套源码的价值不在界面本身,而在那个连接 UI 与 Runtime 的中间层。这篇文章我会从架构分层、适配器设计、通信链路、源码模块、二次开发到踩坑排查,完整讲一遍。适合正在做多端架构的前端开发、桌面客户端开发,以及想给自己的工具加“Web + 本地”双形态支持的读者,不管基础如何,看完都能直接照思路落地。
1. 先搞清楚这套项目的整体架构思路
1.1 一套 UI 为什么会成为难题
先从一个朴素的问题说起:同一个页面,浏览器里能访问,桌面程序里也能跑,这不就是把网页塞进壳里吗?如果只做到这一步,确实不难,套个 webview 就完事。但真正麻烦的地方在数据源和生命周期。
浏览器的 Web 端,Runtime 通常是远程服务,只要你有一个 HTTP 接口,UI 发起请求即可。本地桌面端则完全不一样,你得管理一个子进程的启动、销毁、崩溃回收,有时候还要处理端口分配、认证令牌、模型加载进度。这些状态如果全部由 UI 层自己感知,界面逻辑会迅速变成一团乱麻。
DeepSeek Harness Desktop 的做法是,把这两种差异完全挡在 UI 之外。UI 只认识一个统一的 Runtime 接口,不管它背后是远程还是本地、是 HTTP 还是 stdio,对界面来说都一样。
核心矛盾其实在于:Web 和本地 Runtime 的差异不是接口路径的差异,而是通信模型、生命周期、错误恢复的差异。你没法通过简单把 URL 换个前缀来同时解决。
1.2 分层与边界:UI 只依赖协议,不依赖实现
这套源码里最关键的边界,是严格遵守了“UI 不依赖任何 Runtime 实现”的原则。整体分为三层来看:
- Renderer UI 层:纯组件与状态管理,只引用 shared 目录下的类型定义和运行时抽象接口。
- Bridge 协议层:负责把 UI 的意图翻译成统一的 Command,再分发给对应适配器。
- Runtime Adapter 层:分 Web Adapter 和 Desktop Adapter,各自处理真实的通信细节。
这个分层的好处很实在。UI 开发可以完全并行,团队里有人专心做组件,有人负责适配器实现;只要接口不变化,两边互不阻塞。
有一个很容易踩的误区是:为了省事,多人会把 Runtime 的特有逻辑直接塞进 UI 组件,比如在某个按钮点击事件里直接启动子进程。这套源码里是严格禁止的。启动进程属于 Adapter 层的行为,UI 里你只会看到一个runtime.start(),至于这个方法是发了一个请求还是 fork 了一个进程,UI 根本不在乎。
1.3 为什么用适配器模式而不是到处 if 判断
有人可能会说,直接在代码里判断环境不行吗?比如if (isDesktop) { exec(...) } else { fetch(...) }。小项目没问题,一旦场景多了,这种分支会散落在各个文件里,改起来全是坑。
更实际的问题是:Runtime 不止 Web 和桌面两种。DeepSeek Harness Desktop 的场景里,同一个推理请求可能落在本地 llama-server,可能是连接远程 API,也可能是通过内部插件加载的新引擎。每增加一种环境,就要在 UI 的所有调用点再加一遍分支,这完全违背开闭原则。
所以代码里最核心的结构是:一个RuntimeFactory根据启动配置返回不同的 Adapter 实例,UI 从头到尾只依赖接口。后面要接国产模型、接自定义 agent、或者接远程集群,全部只是新增一个 Adapter 文件的事。
2. 核心解耦机制:Runtime 抽象层的设计
2.1 Runtime 接口:稳定的契约
先给出这套源码里最常见的接口轮廓,帮助理解后续内容。接口定义一般在shared/runtime.ts这样的位置,所有上层代码只 import 这个类型:
export interface RuntimeAdapter { readonly mode: 'web' | 'desktop'; start(options?: RuntimeStartOptions): Promise<void>; stop(): Promise<void>; healthCheck(): Promise<HealthStatus>; chatCompletion(request: ChatRequest): Promise<ChatResponse>; streamCompletion(request: ChatRequest): AsyncGenerator<StreamChunk>; listModels(): Promise<ModelInfo[]>; onStatusChange(listener: (status: RuntimeStatus) => void): void; }接口的选取本身是有讲究的。启动停止和健康检查对应生命周期,聊天请求和模型列表对应核心业务,状态监听对应异常处理。
每类行为都是 Web 和本地必然要实现的场景,不是拍脑袋想出来的。
使用统一返回结构也避免了多端数据的口径不一致。比如 UI 要做“模型加载进度条”,Web 端可能是轮询返回loading 50%,本地端可能是进程日志里的progress = 0.5。如果 UI 分别处理这两种格式,那就失去了抽象的意义。
2.2 Web 适配器与本地适配器的实现差异
先看 Web Adapter。它本质上是一个 HTTP Client 封装。
export class WebRuntimeAdapter implements RuntimeAdapter { mode = 'web' as const; constructor(private baseUrl: string) {} async chatCompletion(request: ChatRequest): Promise<ChatResponse> { const res = await fetch(`${this.baseUrl}/v1/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: this.token }, body: JSON.stringify(request), }); if (!res.ok) throw new RuntimeError(`HTTP ${res.status}: ${await res.text()}`); return res.json(); } }Web 端要处理的坑主要是鉴权、CORS、超时重试,这些在桌面端可能根本不出现。
再看 Desktop Adapter。它的工作量大一些,因为要管一个真实进程。
export class DesktopRuntimeAdapter implements RuntimeAdapter { mode = 'desktop' as const; private child: ChildProcess | null = null; private healthTimer: NodeJS.Timeout | null = null; async start(options: RuntimeStartOptions): Promise<void> { const { command, args, port, stdioHandler } = options; this.child = spawn(command, [...args, '--port', String(port)], { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: stdioHandler ? ['ignore', 'pipe', 'pipe'] : 'ignore', }); if (stdioHandler) { this.child.stdout?.on('data', stdioHandler.onStdout); this.child.stderr?.on('data', stdioHandler.onStderr); } this.child.on('exit', (code, signal) => { this.handleCrash(code, signal); }); await this.waitForReady(port, options.readyTimeout ?? 30000); } }两种实现差异极大,但这不妨碍 UI 把二者都当作RuntimeAdapter来用。只要语义一致,实现细节随便换。
2.3 注入机制:通过工厂切换运行上下文
抽象接口有了,还差最后一环,如何在运行时决定实例化哪一个 Adapter。这套源码里的做法是工厂加上下文注入:
export function createRuntime(context: RuntimeContext): RuntimeAdapter { switch (context.mode) { case 'web': return new WebRuntimeAdapter(context.webUrl, context.token); case 'desktop': return new DesktopRuntimeAdapter(context.runtimeConfig); default: throw new RuntimeCreationError(`Unknown runtime mode: ${context.mode}`); } }创建之后,这个实例被放在全局的 Provider 里。UI 组件通过useRuntime()直接获取,不需要关心实例是怎么创建的。
实际项目里还会在启动时把选定的 mode 输出到控制台,方便调试时确认当前走的是哪条链路。这在联调多端问题时非常有用,能快速判定问题是出在 UI、协议层还是 Runtime 层。
3. 通信链路:UI 与 Runtime 之间到底在传什么
3.1 请求与响应链路的统一封装
理解了抽象层,再仔细看通信设计。这套源码里的 UI 不会直接调用 fetch 或 spawn,而是统一发 Command。
UI 层要做的事,是构造一个语义化请求,例如:
{ "type": "chat.completion", "requestId": "7c2a9e1f", "payload": { "model": "harness-model-x", "messages": [ { "role": "user", "content": "解释一下这套架构" } ] } }然后这个 Command 会被序列化,交给 Adapter 执行。对 UI 来说它只依赖chatCompletion(request),底层到底怎么传输一概不知。
这样做最大的好处是,链路里任何一段都可以被替换。想加鉴权,在协议层统一加;想记录调用日志,在协议层统一记;想模拟数据测试,做一个 FakeAdapter 返回假命令即可。UI 组件在开发时甚至可以完全脱离真实 Runtime。
3.2 流式输出的统一消费方式
做 LLM 工具,流式输出是绕不开的点。Web 端常见方案是 Server-Sent Events(SSE),本地 Runtime 则可能是从 stdout 按行输出。
这套源码把两者的差异再一次封装进 Adapter。streamCompletion返回一个统一的AsyncGenerator,UI 层只有一个消费入口:
for await (const chunk of runtime.streamCompletion(req)) { setOutput((prev) => prev + chunk.delta); if (chunk.done) break; }具体来说,SSE 在 Web Adapter 里的实现可以这样写:
async *streamCompletion(request: ChatRequest): AsyncGenerator<StreamChunk> { const res = await fetch(`${this.baseUrl}/v1/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (!res.ok || !res.body) throw new RuntimeError('stream request failed'); const reader = res.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value, { stream: true }); const lines = text.split('\n').filter((l) => l.startsWith('data:')); for (const line of lines) { yield JSON.parse(line.slice(5)); } } }而桌面端如果是进程内管道,则要做一个转 AsyncGenerator 的封包层。UI 层完全无感知。
3.3 状态与心跳:怎么判断 Runtime 还活着
在真实使用中,Runtime 并不总是可靠的。本地进程可能 OOM 被系统杀掉,远端服务可能挂掉,网络抖动会导致请求超时。
共享 UI 要有能力感知这类异常。这套源码的做法是每个 Adapter 维护一个状态机,状态流转是starting → ready → degraded → stopped或者error。
心跳检查间隔一般取 5 到 15 秒。Web 端直接发一个轻量的/healthz请求,桌面端则检查子进程状态,如果进程还在但模型未加载完,可以标记为 degraded。UI 拿到状态后,在所有需要展示 Runtime 位置统一提示。
心跳还要防止误报。一个经验是连续三次失败才判定为离线,单次超时不立刻置为 error。进程偶尔慢一次很正常,立刻弹红色警报会让用户非常烦躁。
4. 源码模块拆解与关键实现
4.1 目录结构里的分层痕迹
再看源码的具体模块。一个典型的分层目录是这样组织的:
src/ renderer/ # UI 组件、页面、状态管理 components/ pages/ providers/ shared/ # 跨端共享的类型、常量、协议定义 runtime.ts commands.ts types.ts main/ # 桌面主进程逻辑 runtimeManager.ts processSpawner.ts authServer.ts web/ # Web 端入口 bootstrap.ts adapters/ # 各种 Runtime 适配器 webAdapter.ts desktopAdapter.ts fakeAdapter.ts这个目录结构本身就在强制约定边界。UI 在 renderer 下,类型在 shared 下,具体 Runtime 行为在 adapters 下。任何人新进入项目,看目录就能大概明白修改规则。
让我特别提一个有意思的地方:shared 目录是纯类型和纯协议定义,不包含任何平台相关代码。这样 Web 和桌面打包时,都可以安全地引用 shared 而不会引入浏览器或 Node 专属 API。
4.2 构建配置与多端环境的适配
一份代码要同时跑 Web 和桌面,构建阶段还需要处理环境差异。关键点是环境变量的注入。
Web 端构建通常需要知道的是RUNTIME_MODE=web和远程服务的API_BASE_URL;桌面端构建则需要知道本地 Runtime 二进制路径、是否启用调试日志等。
Vite 构建时,可以通过环境变量文件实现:
# .env.web VITE_RUNTIME_MODE=web VITE_API_BASE_URL=https://api.example.com# .env.desktop VITE_RUNTIME_MODE=desktop VITE_RUNTIME_BINARY=./runtimes/llama-server VITE_RUNTIME_ARGS=--ctx-size 4096在启动代码里,通过import.meta.env读取这些变量,传给createRuntime。这里要留意:所有以VITE_开头的变量都会被打包进前端产物,不要把本地绝对路径写进去,否则会有安全隐患。
桌面端还有一个很常见的坑:开发模式和生产模式的进程管理不同。开发时可能是 Vite 直接启动,生产时则是加载打包后的 HTML,Runtime 进程的cwd会发生变化。这个我在后面的常见问题里会细讲。
4.3 生命周期管理:从启动到可用
结合源码来看,整个应用启动时序大概是这样的:
- 渲染进程加载 HTML 与 JS,UI 进入加载状态。
- 初始化模块根据
RUNTIME_MODE生成对应 Adapter。 - 桌面端会先执行进程准备:检查二进制是否存在、分配空闲端口、启动子进程。
- 子进程启动后,监听 stdout 中的 ready 标记,比如
"server started on 127.0.0.1:11434"。 - UI 在收到 ready 信号后,从加载态切换为可交互态。
- 如果启动过程超过超时时间,UI 展示具体错误原因。
伪代码大致如下:
async function bootRuntime(context: RuntimeContext): Promise<RuntimeAdapter> { const runtime = createRuntime(context); setUiStage('starting'); try { await runtime.start(); const health = await runtime.healthCheck(); if (health.status !== 'ok') { throw new RuntimeStartError('Runtime health check failed'); } runtime.onStatusChange((status) => persistStatus(status)); setUiStage('ready'); return runtime; } catch (err) { setUiStage('error'); reportError(err); throw err; } }注意一个细节:桌面端start()里有个waitForReady操作,这里不建议用固定 sleep 去等待,而是要监听 stdout 里的关键标记。理由很简单:模型加载耗时受机器性能影响很大,固定 3 秒可能太快或太慢,轮询健康接口才是稳妥方案。
5. 二次开发:接一个新的本地 Runtime
5.1 实现一个 Adapter 即可接入
既然这套架构的解耦设计足够干净,那么接入新 Runtime 的步骤就非常轻量。假设你要接入一个自定义 Python 推理服务,只需要做三件事:
- 写一个新的
PythonRuntimeAdapter,实现RuntimeAdapter接口。 - 在
createRuntime工厂里增加一个 mode 分支。 - 配置 UI 上的启动参数,让它能正确传进 Runtime。
第一步的真实工作量集中在进程管理和通信。Python 服务如果通过端口通信,Adapter 里直接复用DesktopRuntimeAdapter的进程管理逻辑,改一改启动参数即可;如果你想走 stdio 管道通信,那要多做一层 JSON Lines 协议的编解码。
第二步很简单,甚至不需要改动 UI。只要新 mode 下的返回数据遵循同一个结构,界面无需任何调整。
5.2 参数配置与透传,避免硬编码
接本地方运行时,端口、上下文窗口、量化参数这些配置项不该写死在代码里。这套源码里,UI 启动面板会收集用户的偏好设置,然后转成RuntimeStartOptions:
export interface RuntimeStartOptions { command: string; args: string[]; port: number; env: Record<string, string>; readyTimeoutMs: number; }一个常见做法是启动参数只暴露一层白名单,UI 层不允许任意传参。因为本地 Runtime 往往支持很多命令行参数,但有些参数一旦配错,进程根本起不来。白名单配置会让非技术用户更安全,也让调试更容易定位问题。
另一个容易忽略的点是端口分配。不建议写死固定端口,因为用户机器上可能已经有人占了。正确做法是由主进程向系统申请一个空闲端口,再把端口写进子进程启动参数。主进程负责回收,进程退出时端口自动释放。
5.3 实战:跑通一个最简 Python 服务
我实际跑通过一个模拟 Runtime,这里给出一个可以复现的最小方案,帮助你验证整个链路。
假设本地有一个 Python 脚本,它启动一个 HTTP 服务,监听--port参数,提供/healthz和/v1/chat/stream两个接口。代码大概如下:
# harness_fake_runtime.py import argparse import json import time import sys from http.server import BaseHTTPRequestHandler, HTTPServer class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/healthz': self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(b'{"status":"ok"}') def do_POST(self): if self.path == '/v1/chat/stream': self.send_response(200) self.send_header('Content-Type', 'text/event-stream') self.end_headers() for i in range(5): data = json.dumps({"delta": f"chunk {i}", "done": False}) self.wfile.write(f"data: {data}\n\n".encode()) time.sleep(0.2) self.wfile.write(b"data: {\"delta\":\"\",\"done\":true}\n\n") parser = argparse.ArgumentParser() parser.add_argument('--port', type=int, default=8000) args = parser.parse_args() print(f"listening-ok port={args.port}", flush=True) HTTPServer(('127.0.0.1', args.port), Handler).serve_forever()注意启动时那行print(f"listening-ok port={args.port}", flush=True),这就是桌面 Adapter 的waitForReady要监听的标记。不加flush=True的话,子进程 stdout 是行缓冲还是块缓冲可能会各有不同,容易让主进程迟迟等不到 ready 信号。
然后在桌面 Adapter 的waitForReady里监听 stdout:
private waitForReady(port: number, timeoutMs: number): Promise<void> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new RuntimeStartError('start timeout')), timeoutMs); const onStdout = (buf: Buffer) => { const line = buf.toString(); if (line.includes(`listening-ok`)) { clearTimeout(timer); resolve(); } }; this.child?.stdout?.on('data', onStdout); }); }这套流程跑通之后,UI 里点“启动”就能看到加载态消失并出现输入框。切到 Web 模式,填上一个远程服务地址,界面上甚至看不出任何变化。这就是解耦达到的效果。
6. 常见问题与排查技巧实录
6.1 常见问题速查
我在调试这类“双形态工具”时积累了不少问题,也踩过不少坑。整理成一个速查表,按出现频率排:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 启动后 UI 白屏 | 构建时注入的RUNTIME_MODE为空或错误 | 检查.env和控制台输出,确认 mode 值 |
| 桌面端请求报 CORS 错误 | 本地 Runtime 响应未携带 CORS 头 | 给子进程服务添加Access-Control-Allow-Origin: * |
| 进程明明启动了,UI 一直 loading | waitForReady没匹配到 stdout 标记 | 子进程启用--verbose输出,确认标记字符串一致 |
| 切换模型后端口冲突 | 子进程没有正常退出,端口未释放 | 查 pid、kill 残留进程,退出 hooks 里补 kill |
| 桌面端和 Web 端行为不一致 | 两者参数走的不同配置路径 | 输出两端最终的 RuntimeStartOptions,逐一对比 |
| 心跳误报 | 健康检查超时设置过短 | 连续三次失败再改状态,超时时间调到 5s 以上 |
| 构建后路径抽风 | 生产环境cwd与开发时不同 | 用app.getAppPath()拼接绝对路径,别依赖相对路径 |
6.2 白屏与模式注入问题
白屏算是最常见的启动问题。多数情况不是组件写错了,而是VITE_RUNTIME_MODE没有正确注入。比如在桌面端构建时误用了 Web 的 env 文件,导致创建了 WebRuntimeAdapter,然后去请求一个根本不存在的远程地址。
排查时我会先在控制台打印一个横幅:
console.log(`[harness] runtime mode = ${context.mode}, adapter = ${runtime.constructor.name}`);这一步能帮你立刻判断是哪一层出了问题。UI 层面没有拿到 Adapter,或者拿到了错误的 Adapter,问题定位就很清晰了。
6.3 进程残留与端口冲突
本地 Runtime 的进程管理是最容易出问题的地方。用户关闭应用时,如果子进程没有跟着退出,它会一直占着端口。下次启动时端口被占用,新进程起不来,或者起来后端口指向了旧进程。
解决思路是在主进程退出钩子里统一做清理:
app.on('before-quit', async () => { if (runtimeAdapter.mode === 'desktop') { await runtimeAdapter.stop(); } });stop()里要做的并不是只调用child.kill('SIGTERM'),更好的做法是先发一个 shutdown 命令让 Runtime 自己优雅退出,等 2 秒后如果还活着,再发 SIGKILL。
如果在 Linux 或 macOS 上调试时发现残留,可以直接查进程:
ps aux | grep llama-server kill -9 <pid>6.4 一次性认证 URL 与鉴权闭环
Web 端和桌面端对鉴权的处理其实也有差异。Web 模式下,你可能需要通过远程服务提供的授权地址换取 token;桌面模式下,常用的做法是在本地起一个临时回调服务,用户浏览器完成认证后把 code 回调到本机。
我之前遇到一个比较典型的问题:桌面端启动时打印了一个认证 URL,但用户点击后页面提示无法访问。原因是回调端口写成了 127.0.0.1 而实际监听绑定了 IPv6 的::1。排查的时候用curl访问本地回调端口比直接看浏览器更容易发现问题。
每次在源码里看到处理鉴权的代码时,我的建议都是:认证回调要预留清楚的日志,至少写清楚“URL 已被重新打开/已过期”这种状态,否则用户根本不知道卡在哪一步。
6.5 插件加载失败与混合状态
经常会看到日志里出现类似harness failed to load plugins web boot: 2 entries did not activate的报错。这种问题多半不是核心链路挂了,而是某个插件在 Web 模式下没有激活条件。
我遇到这种情况会先区分是插件依赖了桌面专属 API,还是插件本身启动条件没被满足。前者需要做能力检测,插件不能假定window.electronAPI一定存在;后者则需要看插件注册表的激活条件。这个源码里的插件机制做得比较好的地方是引入了激活失败的原因收集,所有未激活的插件会把原因汇总到日志里,不会出现无声失败。
需要提醒的是:遇到这类报错不要先去改插件代码,先看失败原因是“缺依赖”还是“缺配置”,很多情况下一行配置就能解决。
6.6 调试时的一个实用技巧
我在调试 Web 与桌面双形态时,习惯同时启动两个实例,一个浏览器 tab 用 Web 模式连远端服务,一个桌面客户端用本地 Runtime。做一次相同的请求,把两边的返回结构和耗时记录对比。很多问题一眼就能看出是 Runtime 实现差异还是 UI 层误用。
还有个技巧是可以做一个 FakeAdapter,它在本地生成模拟数据,不依赖任何底层服务。写 UI 的时候一直开着 FakeAdapter,UI 开发完再切换真实 Runtime,效率会高很多。
export class FakeRuntimeAdapter implements RuntimeAdapter { mode = 'web' as const; async chatCompletion() { return { choices: [{ message: { content: 'fake response' } }] }; } async *streamCompletion() { yield { delta: 'fake ', done: false }; yield { delta: 'response', done: false }; yield { delta: '', done: true }; } }以我个人的实际体会来说,把 UI 开发从 Runtime 依赖中解放出来,是这套架构最值的地方。你不需要等模型就绪就能开发界面,也不需要为了调一个按钮逻辑来回启动进程。基于这个思路去改造成自己的工具项目,收益远大于那点抽象成本。