DeepSeek Harness架构解析:一套共享UI如何端平Web与桌面

发布时间:2026/11/4 3:34:11
DeepSeek Harness架构解析:一套共享UI如何端平Web与桌面 DeepSeek Harness 的 Desktop 端源码我前后读了三遍才把这套“一套共享 UI 同时喂给 Web 和本地 Runtime”的架构真正理顺。这个项目表面上是多智能体编排工具但真正有嚼头的是它在 UI、本地服务和推理引擎之间划出的那条边界。你在浏览器里打开 dsh web 看到的界面和桌面窗口里看到的界面构建产物几乎完全一样而两者背后连的 Runtime可以是本机的 llama-server、Ollama也可以是任意 OpenAI 兼容的远端服务UI 层甚至不需要知道自己连的是哪一路。本文我按源码解析的路子把这套解耦设计拆开讲顺带给出实操链路和常见问题排查适合做 AI 工具、桌面应用或前端架构的同学参考。1. 先搞清楚架构为什么一套 UI 能同时端平 Web 和桌面1.1 这个项目到底在解决什么问题DeepSeek Harness命令行里叫 dsh是一个本地优先的多智能体编排工具。它不止是一个聊天框而是一个带画布、技能skill市场、多智能体运行状态监控的“AI 工作台”。日常使用有三条路径纯 CLI跑dsh agent run xxx浏览器访问dsh web启动的本地服务以及打开dsh desktop桌面窗口。三条路径底层共享同一套配置、同一个运行时管理逻辑和同一份 UI 代码。如果按大多数团队的习惯这种形态通常会做成“多个前端各自调用 runtime”。桌面端用 Electron 直接起 Node 子进程跑模型Web 端再写一套 HTTP API两边 UI 各自维护。结果就是UI 里加一个字段后端要改两次模型从本机引擎切到远端桌面端代码要改、Web 端也要改。dsh 源码的高明之处在于它把“UI 所依赖的 runtime 能力”收敛成了一组稳定的 HTTP 协议和接口UI 只面向这份协议桌面壳只负责提供窗口和基础本地能力真正的模型进程、技能调度、多智能体编排全部放在一个独立的本地服务里。这个服务同时服务 Web 和桌面相当于一个“软件即本地服务”的形态。单一 UI、双端复用、runtime 可插拔这套思路本质上是把“客户端-服务端”结构搬到了本地单机环境里。对于任何想做“同一个产品同时出 Web 版和桌面版”的团队这个架构都值得抄作业。1.2 三种运行形态与请求链路我把源码里三条运行链路的请求过程捋了一遍可以描述成这样浏览器 / WebView2 窗口 → Vue3 共享 UI → dsh/ui 的 api-client → 本地 HTTP 服务127.0.0.1:31777 → Runtime Driver → llama-server / Ollama / 远端 OpenAI 兼容 API浏览器这条链路最好理解。dsh web启动一个本地 Node 服务监听 127.0.0.1 的随机端口同时把前端构建产物直接托管出来。你打开http://127.0.0.1:31777/?tokenxxx页面从 URL query 里取出会话 token之后所有请求都带着这个 token 走 HTTP。桌面这条链路关键差别只在前面的“交通方式”。dsh desktop 用的是 Tauri 壳窗口内是系统自带的 WebView2Windows或 WKWebViewmacOS渲染同一套 UI 产物。UI 里发请求时api-client 会判断运行环境如果在 Tauri 下就优先走 Tauri 的 IPC 命令由 Rust 侧把 HTTP 请求转发到本地服务如果检测不到 Tauri API就退化成普通浏览器的 fetch 请求。对页面组件来说它调用的还是同一个getModels()、sendMessage()。这个设计让桌面端几乎不需要维护自己的 UI 分支。注意这里有个容易被忽略的细节。桌面端并不是把 UI“打个包”塞进 WebView而是让 WebView 去加载本地服务托管的同一个地址。所以你的 Web 端能用的功能桌面端天生就能用反过来桌面端新增的 IPC 能力Web 端代码里也有对应降级方案。这比 Electron 常走的“打包静态文件 内部 nodeIntegration”要清爽得多。1.3 解耦的核心边界UI 不感知 Runtime源码里最值得学习的一条设计原则UI 层不直接跟模型进程通信也不直接读取模型配置文件。UI 只知道“server”这个抽象server 再把请求交给对应的 runtime driver。runtime 是本地进程还是远端服务对 UI 完全透明。同时认证端到端贯穿。本地服务每次启动会生成随机 tokentoken 会拼在 URL 里打印出来。UI 拿到 token 后放进请求头。这样即便服务监听在 localhost也没有放开成一个谁都能访问的空门。后面常见问题里要讲的那个dsh web authentication required报错就是这套机制中的一个经典场景。这种边界的价值在长线维护里非常明显加了新模型、换了推理框架只动 runtime driver 与 server 之间的适配UI 加一个新面板、新编排节点也不太需要关心底层是哪个引擎。两个团队只要把server暴露的 API 契约对齐就能并行开发。dsh 的共享 UI 能同时端平 Web 和桌面本质是把这份契约焊死了。2. 源码骨架从仓库根目录看模块划分2.1 顶层目录和包结构我读的这份源码采用 pnpm monorepo 结构。每个小版本的路径可能略有出入但大的模块边界是稳定的deepseek-harness/ ├── packages/ │ ├── shared/ # 跨端共享的类型定义、协议常量、配置 schema │ ├── server/ # 本地服务认证、路由、skill 调度、runtime 管理 │ ├── runtime/ # Runtime Driver 接口与各引擎实现 │ ├── ui/ # Vue3 Element Plus 共享 UI 工程 │ ├── desktop/ # Tauri 桌面壳 preload Rust 命令 │ └── cli/ # dsh 命令行入口 ├── skills/ # 内置技能集合 ├── schemas/ # 配置与技能格式的 JSON Schema ├── scripts/ # 构建、打包脚本 └── pnpm-workspace.yaml这个划分从根上保证了“共享 UI”不是靠复制粘贴实现的。shared 包里放着 UI 和服务端都要用的类型比如 AgentMessage、RuntimeInfo、SkillManifest。ui 包 import 的是dsh/shared的类型server 包也 import 它两边的数据结构永远对得上。我在很多项目里见过 UI 端和后端各写一套 DTO然后靠联调去对齐惨痛教训太多了。dsh 的做法是直接把 DTO 变成 npm 包UI、server、runtime 三方共享从编译期就消灭一大部分“字段对不上”的问题。2.2 共享 UI 层怎么做到不感知 RuntimeUI 代码里最核心的一个文件是packages/ui/src/api/client.ts。它把所有 runtime 相关操作封装成了几个方法listModels()、chat()、runAgent()、listSkills()等。页面组件里不允许出现直接 fetchllama-server或直接读~/.dsh/config.json的代码。所有请求统一走client而 client 的 baseURL 和认证头则按环境判断const isTauri typeof window ! undefined __TAURI_INTERNALS__ in window; async function request(path: string, init?: RequestInit) { const token getSessionToken(); const headers { Content-Type: application/json, ...(token ? { Authorization: Bearer ${token} } : {}), ...init?.headers, }; if (isTauri) { // 桌面端优先走 IPC由 Rust 侧转发规避 WebView 的 CORS 和混合内容限制 return invoke(proxy_fetch, { path, payload: init?.body }); } // Web 端直接 fetch 同源服务 return fetch(path, { ...init, headers }); }注意这里面的“降级”思路。同样是这个方法在浏览器里 fetch 的是同源相对路径因为静态资源和 API 都由同一个本地服务提供不存在跨域在 Tauri 里走 IPC 也并不是为了性能而是为了规避 WebView 加载本地地址时的差异、以及在纯静态打包场景下没有后端托管的情况。这段代码的注释我特意保留下来因为它把“为什么这么做”讲得很清楚比一堆炫技代码有价值多了。2.3 桌面外壳与 Web 启动器的差异桌面壳在packages/desktop下Tauri 的src-tauri/src/commands.rs里注册了proxy_fetch和open_devtools等命令。整个 Rust 侧代码量并不大它不负责业务逻辑只负责两件事把 HTTP 请求转发到本地 server以及把应用窗口以正确的方式创建出来。Web 启动器在packages/cli/src/commands/web.ts里。它的逻辑是读取配置、生成 token、启动 server、创建随机端口监听、打印带 token 的 URL然后阻塞等待退出信号。“两种启动器同一个 UI 产物”这是桌面与 Web 解耦的核心。构建时ui 包会输出一套distserver 把 dist 作为静态资源托管出来供浏览器访问desktop 壳在开发模式下直接加载 dev server 的地址在生产模式下加载打包好的 dist 托管地址。也就是说桌面端不是“复制了一个前端”而是“同一个前端的另一个宿主”。后续 UI 发布只要更新共享产物即可。其实这种形态你早就在别的桌面工具里见过。Redis Desktop Manager 也是同一套 UI 去连接本地或远程 Redis runtimeUI 侧只认连接配置和命令协议底层是哪个 Redis 实例并不重要。dsh 只是把同样的道理用在了模型推理服务上。3. 核心解耦点Runtime Driver 接口与本地引擎接入3.1 为什么 UI 不能直接调 llama-server如果直接把 UI 和 llama-server 通信刚开始会觉得很爽反正 llama-server 已经暴露了 OpenAI 兼容的/v1/chat/completions请求直接“透传”就行。但用着用着就会发现三个坑。第一是认证。llama-server 默认根本没有 token 鉴权即使加了 API key这个 key 是模型服务的 key跟 dsh 页面会话没有关系。桌面 UI 直接调用引擎等于本地任何网页都能向本地模型服务发请求模型会不知不觉被本机其他程序调用。第二是协议差异。虽然 OpenAI 兼容协议已经成了事实标准但 Ollama 和 llama-server 在工具调用、消息格式、模型列表接口上仍有小差异UI 端不可能为每个引擎写一套参数拼装。第三是进程生命周期。模型进程要由哪个组件拉起、崩溃了怎么重启、多智能体并发怎么限流这些应该由服务端统一管理而不是散落在 UI 各处。所以 dsh 在 UI 和真实引擎之间插了一层 Runtime Driver。UI 只发 “chat / listModels / health / runAgent”server 翻译成具体引擎的调用。3.2 driver 接口定义与注册机制源码中packages/runtime/src/driver.ts的核心接口长这样export interface RuntimeDriver { readonly type: llama-server | ollama | openai-compatible; listModels(): PromiseModelInfo[]; chat(request: ChatRequest): PromiseChatResponse; stream(request: ChatRequest): AsyncIterableChatChunk; health(): PromiseHealthStatus; dispose(): Promisevoid; }不同引擎只需要实现这个接口然后在runtime/registry.ts里注册。server 启动时读~/.dsh/config.json的runtime.type字段从注册表里挑一个 driver 实例持有到服务生命周期结束。以llama-serverdriver 为例它做的事情非常薄把ChatRequest转换成 llama-server 的 OpenAI 兼容请求体处理好stream字段把响应流转成统一的事件结构。整个 driver 可能只有两三百行但它把“引擎差异”关在了门内。这种模式我在工作中叫“网关模式”的本地版。UI 面向的永远是一份稳定契约新的引擎随时可以在 registry 里加一行不影响上层任何页面。3.3 配置解析与模型参数如何落到 Runtime配置解析集中在packages/server/src/config.ts。配置文件在~/.dsh/config.json最小结构如下{ runtime: { type: llama-server, baseUrl: http://127.0.0.1:8080/v1, apiKey: local-insecure, executablePath: llama-server, args: [-m, /models/Qwen2.5-14B-Instruct-Q4_K_M.gguf, --port, 8080] }, web: { host: 127.0.0.1, port: 0, openBrowser: true } }这里port: 0表示随机端口服务启动后会把实际端口打印出来。为什么不用固定端口因为固定端口容易跟别的本地服务冲突而且固定端口意味着任何本机进程都可以精准命中token 防护形同虚设。随机端口加 token等于给本地服务加了一道不显眼但很实用的门禁。配置加载之后server 会启动 runtime provider如果type是llama-server并且声明了executablePathdsh 会尝试拉起这个进程等它在baseUrl上 ready 之后再向 UI 报“runtime 可用”。如果只想连已经跑起来的 Ollama配置里不写executablePath即可driver 只管连接不管拉起。这个“可拉起可连接”的弹性是本地优先工具比较成熟的形态。3.4 认证与本地 token从启动 URL 到请求头认证链路看着简单真出问题会让人抓狂。dsh web 启动时会生成一个随机会话 token把它拼进 URLconst token crypto.randomBytes(32).toString(hex); const url http://127.0.0.1:${address.port}/?token${token}; logger.info(dsh web listening: ${url});UI 加载后从window.location.search里取 token存到内存的 sessionStore 里之后client.ts里每个请求都会带上Authorization: Bearer token。server 中间件对所有/api/*路径做校验token 不匹配直接返回 401并在响应体里带上一段固定的提示文本dsh web authentication required; reopen the url printed by dsh web.这个报错我在很多 issue 里见过后面第 5 章会专门讲排查。这里想强调的是它不是一个 bug恰恰是设计的一部分token 只存在内存和 URL 中服务一重启旧页面里的 token 就失效。你重新打开新打印的 URL 即可这正是为了让过期会话能被明确感知而不是悄悄拿到 401 后页面还傻乎乎地弹一堆错误。4. 从源码到跑通Web 模式与桌面模式的实操链路4.1 环境准备与仓库构建实际动手前建议先确认环境Node.js 18 或 20我用的 20 LTSpnpm 9pnpm 已安装如果是 Windows准备 WebView2 RuntimeTauri 依赖如果要用本地 llama-server提前备好 GGUF 模型文件然后按顺序执行git clone repo-url deepseek-harness cd deepseek-harness pnpm install pnpm build:shared pnpm dev:server第一次构建 UI 会比较慢耐心等。如果构建报错先看是不是 shared 包没先构建——monorepo 最容易踩这个坑。dsh 的 shared 包被 ui、server、runtime 三方依赖一定要按顺序来。实际我建议用pnpm --filter dsh/shared build先单独构建再整包构建排查问题更快。4.2 启动 dsh web 并完成认证服务起来之后另开一个终端执行dsh web输出会类似[info] dsh web listening: http://127.0.0.1:31777/?token4f3a... [info] Runtime ready: llama-server on http://127.0.0.1:8080/v1直接把整个 URL 复制到浏览器打开不要单独访问http://127.0.0.1:31777否则没有 token进页面后会遇到认证失败。页面加载后在 API 状态面板里应该能看到 runtime 健康、模型列表。如果 runtime 没 ready一般是 llama-server 没起来或配置里的baseUrl不对。此时看 server 日志日志默认写到~/.dsh/logs/server.log里面会打出引擎进程的 stderr——大多数时候原因都在那里。验证 API 是否走通也可以直接 curlcurl -H Authorization: Bearer 4f3a... \ http://127.0.0.1:31777/api/models能返回模型列表说明全链路已通。4.3 桌面模式与 WebView2 桥桌面模式有两种启动方式。开发调试用pnpm desktop:devTauri 会拉起一个 WebView2 窗口窗口里加载的界面和浏览器里的 web 界面几乎一模一样。此时你可以在 Rust 命令里打断点看proxy_fetch的转发过程。生产打包pnpm desktop:build打包出的安装包在packages/desktop/src-tauri/target/release/bundle下。Windows 上如果系统缺少 WebView2 Runtime会看到 “Could not find the WebView2 Runtime” 之类的报错按系统提示安装 Evergreen 版即可。桌面端和 Web 端最大的行为差异在于文件访问。浏览器因为安全策略很难让页面直接读本地目录桌面端可以通过 Tauri 的dialog、fs命令把能力透给 UI。dsh 的 skill 导入功能就用了这层能力。UI 里引入tauri-apps/api后通过invoke(select_skill_dir)让用户选择本地 skill 目录这在纯 Web 端是做不到的。代码里对这一层做了能力探测如果在浏览器环境就降级为让用户手动输入目录路径保证功能可用性一致。4.4 添加 skill 与多智能体编排的 UI 入口skill 是 dsh 里扩展能力的基本单位。实际流程是把技能定义成一份 Markdown 或 YAML放在~/.dsh/skills/下dsh 会自动扫描并出现在 UI 的 skill 面板中。一个最简 skill 格式name: web-search description: 对本地索引执行关键词搜索返回 top-k 结果 tools: - type: function function: name: search_local_index parameters: type: object properties: query: { type: string }添加后在 UI 的 agent 节点配置里勾选这个 skill智能体在对话中会按需调用。多智能体编排则是另一个高频入口打开编排画布从左侧拖出多个 agent 节点连线定义消息流向保存后整张编排图会作为一次 workflow 提交到 server由 server 调度每个 agent。这里 UI 依然只发送 workflow 定义不真正执行 agent 循环真正执行逻辑在 server 的workflow-engine里。新手容易在第一步卡住在 UI 里创建了 agent却发现 runtime 没 ready节点一直报错。记住核心原则先确认 runtime 健康界面右上角或 API 状态面板再配置 agent否则所有编排实验都是无效的。我自己的习惯是先开一个终端跑dsh runtime start再开一个跑dsh web这样任何一端崩了都能快速定位。5. 常见问题与排查技巧实录5.1 认证报错dsh web authentication required 的三种场景这个报错字符串几乎成了 dsh 新手必见。我总结了三种高发场景。场景一你手工改了点 URL比如清了 query 参数或者复制时只复制了http://127.0.0.1:31777没带?token...。解决办法很简单重新执行dsh web把新打印的完整 URL 重新打开。场景二页面开着但本地服务重启过。旧页面的 token 已经失效刷新页面也没用因为 URL 里的旧 token 本身就是过期值。正确做法是关闭页面重新拿新 token 打开。场景三反代或集成环境。如果你把 dsh web 服务放进内网穿透或反向代理token 只会出现在首次生成的 URL 里后续访问如果代理改了路径或丢 query一样会复现。我的经验是dsh web 定位就是“本机单用户工具”尽量别往外代理。真要暴露给局域网先做好网络层访问控制再考虑 token。现象页面加载后所有 API 请求返回 401 排查对比当前 URL 的 token 与 server 日志中实际生成的 token 修复重新执行 dsh web复制新 URL5.2 Runtime 与引擎进程相关llama-server、Codex CLI、CodeMeter这类问题的共性表现是UI 正常但 runtime 状态是 red。llama-server 最常见的是executablePath写的命令不在 PATH 里或者模型路径写错。源码里 server 启动引擎进程时会捕获 stdout/stderr日志在~/.dsh/logs/runtime-type.log优先看这个日志。二进制找不到、显存不足、GGUF 文件损坏都会在这里留下明确信息。如果你在配置里接入了 Codex CLI 之类的执行型 runtime 组件报错unable to locate the codex cli binary or required runtime components基本都是 PATH 或安装目录问题。先在终端执行which codex确认二进制可被当前用户访问然后在 dsh 配置里显式填executablePath为绝对路径不要依赖 PATH 继承。Tauri 桌面壳启动时环境变量可能没有完整继承终端 shell 的环境这一点最容易踩雷。CodeMeter runtime 是另一类问题。部分企业版授权方案依赖 CodeMeter 容器dsh 检测不到授权容器时会提示下载 CodeMeter Runtime。这个跟 dsh 本身无关属于宿主环境授权组件缺失按提示安装对应系统的 runtime 即可安装后要把服务加进开机启动。5.3 桌面壳与 UI 卡顿渲染进程状态UI 卡顿问题在桌面端比 Web 端更常见因为 WebView2/WKWebView 的渲染进程行为和你平时用的 Chrome 不完全一样。我的排查顺序是这样的。先看任务管理器里 WebView2 渲染进程的 CPU 占用。如果占用高多半是前端自身的问题比如 Element Plus 表格在大列表下疯狂重渲染。dsh 的会话列表和日志面板数据量不小如果 UI 层直接把巨量数据塞进响应式对象卡是很正常的。前端这边可以参考 dsh 自己的做法日志面板用虚拟滚动会话消息用分页取数大对象更新用shallowRef控制。再看是不是 GPU 相关问题。WebView2 在某些老显卡上会反复崩溃表现就是“WebView 无响应”或白屏。可以尝试环境变量WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS--disable-gpu重启桌面端能定位到是不是 GPU 合成问题。还有一种容易被忽略的情况模型引擎在推理时打满 CPU/GPU宿主窗口和 UI 进程被系统调度挤到一边表现也是 UI“卡”。这不是 UI 代码问题而是资源竞争。处理方式是给 llama-server 进程设置 CPU 亲和性或限制并发数以及在配置里限制单 agent 的最大并行数。我实测过限并发之后界面流畅度提升非常明显。5.4 环境依赖类WebView2、Docker Desktop 与虚拟化Windows 上如果系统一直报找不到 WebView2 Runtime直接去微软官网下载 Evergreen Bootstrapper 安装。装完会弹出一个 WebView2 窗口能打开就说明可用。Docker Desktop 的问题则是另一类。如果你把引擎跑在容器里比如用 llama.cpp 镜像在 Docker 里起模型Windows 上 Docker Desktop 启动报virtualization support not detected本质是虚拟化层没开。先检查 BIOS 里的虚拟化开关再确认“Windows 虚拟机监控程序平台”和 WSL2 功能已启用。这个问题常见于老机器、以及装了第三方虚拟机软件后 Hyper-V 被占用的环境。Docker Desktop 这类“桌面 UI 容器 Runtime”的架构其实跟 dsh 的“桌面 UI 本地 Runtime”是一个套路UI 面向的是一套稳定的 API底层 runtime 无论是本机进程、容器还是远端服务对 UI 都一样。学会这种分层思维遇到任何 Desktop 工具的环境问题都能更快定位。为了直观我把高频问题整理成速查表现象大概率原因处理动作页面 API 全部 401URL 缺少 token 或 token 过期重新执行 dsh web复制带 token 的完整 URLWindows 提示找不到 WebView2 Runtime系统级 WebView2 未安装下载安装 Evergreen BootstrapperDocker Desktop 提示 virtualization support not detectedBIOS/Windows 虚拟化未打开开启 BIOS 虚拟化启用 WSL2 和虚拟机监控程序平台runtime 状态 red日志报 executable not foundPATH 未生效或路径写错配置 executablePath 为绝对路径确认引擎可独立启动UI 操作卡顿渲染层数据过大或引擎资源竞争启用虚拟滚动、限制并发数、必要时禁用 GPU集成外部 runtime 报 binary missing环境变量未完整继承显式配置二进制绝对路径最后说点读源码之外的个人体会。多智能体编排、skill 管理这些都是面上的功能真正值得抄的作业是 runtime driver 那层只有两三百行的薄封装。我自己后来做内部工具时也照着这个模式重写了工具链UI 只保留一份所有后端能力全部通过统一 API 暴露桌面端再薄也不要塞业务逻辑。踩过几次坑之后我才明白解耦不是把文件拆得多散而是把“会变的东西”和“不该变的东西”分开。对于 dsh 来说引擎会变、UI 会变、桌面壳也会变但 UI 和 runtime 之间的契约一旦定下来整个项目的复杂度就被压住了一大半。这个思路比任何具体功能都更值钱。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询