☰
本地AI Agent架构解析:DeepSeek Harness Desktop的UI与Runtime解耦实践
2026/11/4 13:12:07 网站建设 项目流程

DeepSeek Harness Desktop 这半年在本地 AI Agent 圈子里热度一直没下来,核心卖点是多智能体编排、Skill 管理和本地模型调用。我拿到安装包之后,除了跑任务,更感兴趣的是它的架构:同一套 UI 到底怎么做到既能在浏览器里打开,又能作为桌面窗口运行,同时底下还挂着一个本地 llama-server 类的 Runtime。这篇我从源码和实际使用的角度拆一下它的解耦思路,把 Web 与本地 Runtime 之间的边界、共享 UI 的实现原理,以及我踩过的安装、认证、Skill 挂载的坑一起讲清楚。

1. 整体设计:UI 与 Runtime 的分界在哪

1.1 UI 与 Runtime 的分界不在技术栈,而在契约

很多人一听到“Web 和桌面共用一套 UI”,第一反应是“用 Electron 或者 Tauri 包一层不就行了”。但 DeepSeek Harness 的做法不太一样。我在源码目录里看到的核心结构,是 UI 层从头到尾就不知道自己在浏览器里还是在桌面窗口里,它只知道对面有一个“后端服务”,所有操作都走后端接口。

这个思路很像把遥控器和电视拆开:遥控器只负责发红外信号,它不会管你家的电视是 LCD 还是 OLED,也不关心你坐在哪个房间。UI 就是那个遥控器,Runtime 就是电视,中间那套 HTTP/WebSocket 协议就是红外信号。真正被解耦的其实不是“UI 能不能跨端”,而是“UI 和 Runtime 之间被一条稳定的接口契约隔开”。只要这条契约不变,UI 怎么换壳、Runtime 怎么换引擎,两边都不用跟着动。

我在翻代码时比较深的一个感受是:项目对“契约”的重视程度远高于对“组件复用”的重视。UI 里的页面组件是给自己看的,接口才是给系统看的。如果只把注意力放在“UI 怎么写才能两边跑”,很容易忽略真正决定架构成败的协议设计。

1.2 dsh desktop 与 dsh web 的进程拓扑差异

先看两个模式下的进程拓扑。dsh web 启动后,本机起了一个服务进程,这个进程同时做两件事:托管一份编译后的静态页面,以及承载 API 网关,把前端请求转发到推理引擎和 Agent 编排器。浏览器打开 localhost 端口,加载的就是这份静态页面。这个模式下,浏览器只是一个纯粹消费者,不承担任何 Runtime 职责。

dsh desktop 的模式则多了一层壳。壳进程是原生程序,内部加载 WebView2 组件,WebView2 里跑的仍然是同一份 dist 目录下的静态页面。壳进程与 Runtime 服务之间通过本地 HTTP 或进程间通信来交互,WebView2 只负责渲染和捕获用户操作。

从源码里的配置文件和启动脚本来看,这两个模式共享了几乎全部的静态资源目录和 API 路由定义。也就是说,前端编译产物只有一份,桌面端没有单独再写一套组件库,也没有为 WebView 特化页面——同一个 SPA,浏览器能用,WebView 也能用。这样做的直接收益是:UI 的逻辑只需要测一遍,修复一个 bug 两端同时生效。

1.3 为什么这种拆法比“Embedded Runtime”更抗造

我见过很多桌面工具是把 Runtime 直接以动态库的形式嵌进 UI 进程,表面上看启动快、调用方便,但有个致命问题:UI 进程一崩,整个工作状态就没了。尤其是本地模型推理这种长任务,如果推理跑到一半 UI 卡死崩掉,任务数据说丢就丢。

Harness Desktop 把 Runtime 单独抽成进程,好处非常明确:

  • WebView 渲染进程崩了,引擎进程还在跑,重开 UI 就能接上任务状态。
  • 引擎进程需要重启或换模型时,UI 不用跟着重启,只是收到一个“引擎重新连接”的事件。
  • 安全边界也清晰,UI 只能通过接口拿数据,拿不到引擎进程的内存和文件句柄。

这套拆法不是我瞎猜的,是看它的事件流和错误恢复机制推断出来的。我在实操里确实遇到过 WebView 渲染进程卡死的情况,桌面窗口整个白屏,但命令行里的引擎日志还在正常输出 token。杀掉桌面进程重新打开,任务状态从事件流里恢复回来了。这种体验在“UI 与 Runtime 同进程”的设计下基本做不到。

2. 共享 UI 的源码实现:从 WebView 到 Vue 组件

2.1 外壳层:WebView2 加载 SPA 并注入桥接 API

先看桌面壳这一层。以 Windows 版为例,外壳基于 WebView2 Runtime 创建宿主窗口,设置完 WebView2 环境之后,把页面导航到本地服务的地址。这里有一个很关键的细节:壳进程不会默认开启 WebView2 的地址栏导航能力,所有页面跳转都被拦截,只允许指向本地服务的白名单地址。这样既防止用户误操作导航到外网,也避免注入脚本被第三方页面劫持。

我翻到初始化逻辑时印象很深的一点是:它通过注入脚本的方式给前端暴露了一个非常小的桥接对象,这个对象只包含几个白名单方法,比如剪贴板读写、窗口最小化/最大化、选择本地文件路径。注意,这里没有把整个 Node 运行环境暴露给前端。很多桌面应用图省事,直接把 Node 能力全量注入,前端页面一旦有 XSS 漏洞就变成本地 RCE。Harness 只给了几个受控方法,安全姿态明显更谨慎。

桥接对象在源码里叫类似 nativeBridge 的东西,前端通过它调用系统能力,其他东西全部走 HTTP。这套设计的好处是:同样的前端代码,在浏览器里就用不到 nativeBridge,直接调用浏览器原生 API;在桌面里就用 nativeBridge 补足浏览器缺失的能力。两边的代码分支只有薄薄的一层,不会蔓延到整个业务逻辑。

2.2 UI 层:Vue3 + Element UI 的页面组织

前端这一侧,单页应用是基于 Vue3 写的,组件库用的是 Element UI。我打开 dist 目录里的资源结构和源码里的 views 目录,基本能看出页面组织方式:核心是几个大视图,比如 Agent 编排看板、Skill 管理页、模型配置页、任务日志页。用的是 hash 路由,不是 history 路由。原因也很务实——桌面端要把静态文件从本地服务加载,hash 路由能避免很多服务端重写规则的问题,丢到任何静态服务器上都能直接跑。

选 Element UI 这个细节也值得说一句。市面上组件库不少,但 Harness 这种工具的页面,大头是表格、表单、标签页、抽屉这类后台管理型组件。Element UI 在表格和表单上的成熟度确实高,文档全、坑少,对一个以“配置 Agent、查看任务、管理 Skill”为主要场景的应用来说,比花里胡哨的组件库更稳。我实际用下来,任务日志页的大表格加筛选,Element UI 的表格渲染和虚拟滚动配合得挺好,几千条日志不至于把页面拖垮。

2.3 数据层:fetch 封装与 SSE 事件订阅

数据层是 UI 与 Runtime 交互的中枢。前端维护了一个统一的 API client 封装,baseURL 来源于启动参数或注入配置。Web 模式直接指向当前站点根路径,Desktop 模式指向本机服务端口。这样一来,业务组件里写的请求代码完全不带端差异,比如获取 Skill 列表就是api.get('/skills'),提交一个任务就是api.post('/tasks', payload)。

比请求更值得讲的是事件推送。任务状态这种数据,如果靠前端轮询,既浪费资源又拿不到实时进度。Harness 走的是 SSE(Server-Sent Events)订阅,前端在全局建立一个事件总线,后端把任务状态变化、Agent 节点心跳这类事件实时推过来,前端收到后局部更新页面。我在源码里看到 UI 对事件类型做了白名单过滤,不是所有后端事件都往页面上怼,只有 TaskUpdated、AgentStatusChanged 这类关键事件会触发视图刷新。这个设计避免了事件风暴把 UI 搞卡。

我实跑的时候验证过:提交一个多智能体任务后,页面上 Agent 节点状态几乎实时在变,不需要手动刷新。如果某一步任务卡住,后端会推一个 TaskFailed 事件,UI 把这个节点标红并展开错误信息。这套交互的实时感,靠轮询很难做到。

3. Runtime 层如何把自己藏起来

3.1 Runtime 的构成:推理引擎与 Agent 编排器

Runtime 这层对外表现是一个本地服务,但内部实际上是两块独立的东西。第一块是模型推理引擎,走的协议是 OpenAI 兼容的接口,底层模型加载和采样交给 llama-server 这类引擎来干;第二块是 Agent 编排器,负责维护智能体实例、调度任务、执行 Skill。

我在源码和日志里看到它启动时会把两个子进程分别拉起来:引擎进程绑定一个本地端口,编排器进程绑定另一个端口,服务端再往上层提供一个聚合 API,把两个端口的信息合并暴露给 UI。这种分进程设计的好处是,想换推理后端的时候,只要对方提供 OpenAI 兼容接口,服务端配置里改一下地址就能切走,UI 一行不用改。我自己试着把本地引擎切到远程 API,体验就是改了个 base URL,页面上的模型下拉框自动多出了远程模型列表。

3.2 Skill 机制:Runtime 如何动态认识新能力

Runtime 解耦的另一个层面,是 Skill 机制。Harness 支持用户自己挂载技能,不需要改主程序。我仔细看了它的加载逻辑:运行时启动后会扫描 skills 目录,每个子目录里有一个 manifest 描述文件和一个入口脚本。manifest 声明技能名称、描述、可用工具和参数;入口脚本是实际执行逻辑。这个目录是热扫描的,不需要重启 Runtime 就能识别新挂载的 Skill。

这里有一个安全细节,它把 Skill 的权限边界卡得比较紧:内置了几个默认限制,比如默认只允许在任务指定目录内读写文件,不允许任意路径的删除操作。我看到示例 Skill 里对外部命令执行也是白名单制,不是给你一个裸 shell。这样设计,虽然写复杂 Skill 时会多一些约束,但至少 AI 生成的代码不会随随便便在宿主机上乱跑。对于要跑第三方 Skill 的用户来说,这个约束是必要的安全垫。

3.3 多智能体编排的任务分发与状态回传

多智能体编排是 Harness 的招牌功能,实现上是一个协调者加多个 Worker 的结构。协调者接收用户下达的目标任务,把它拆成多个可以并行或串行的子任务,然后分发给多个智能体实例。每个智能体实例有独立的上下文窗口、独立的 system prompt,以及自己可见的 Skill 集合。子任务完成之后,结果回传给协调者,协调者汇总后把整个任务标成完成状态。

我在使用中比较喜欢的是它在 UI 上的可视化方式:用户能看到一个任务下挂着多个子任务节点,每个节点运行在哪个 Agent、用了哪个 Skill、当前状态是什么,都一目了然。这个可视化不是简单画个流程图,而是和事件流做了绑定——节点状态的每次变化,都是后端推了真实事件过来。我调试多 Agent 协同问题时,基本就是盯着这个看板找是哪个子任务卡住,效率比看日志高很多。

3.4 进程隔离带来的实际好处

进程隔离这一段,我从实操里观察到的价值比理论更大。有一次引擎显存占用爆了,推理进程直接挂掉,当时 UI 还开着,页面弹了一个“引擎连接断开”的提示,但 Skill 列表还能正常加载。我重启引擎进程后,UI 通过事件流自动恢复了连接,连页面都没刷新。这种事情在客户端一体化的应用里,大概率就是整个应用白屏。

反过来也一样:桌面端 WebView 卡住时,引擎进程不受影响,任务继续在跑。这个特性在生产环境里特别重要,因为本地 Agent 跑的任务往往耗时很长,如果 UI 一崩任务就断,基本没法用。拆进程这个决策,从架构层面把稳定性撑住了。

4. 实操:从启动到跑通多智能体任务

4.1 环境准备清单

先列一下能顺利跑起来的环境要求,这些是我在不同机器上试出来的最低可靠配置:

  • Windows 10/11 64 位,macOS 也能跑,但下面以 Windows 为主。
  • 已安装 WebView2 Runtime(Evergreen 版会自动更新)。
  • 内存建议 16GB 起步,本地模型推理吃内存很凶。
  • 安装路径尽量选纯英文目录,我遇到过中文路径导致 Skill 扫描失败的案例。
  • 杀毒软件最好把安装目录加入信任区,否则模型文件可能被后台扫描拖慢。

安装完先用命令行跑一下版本号,确认安装成功。如果你拿到的是安装包,安装过程基本是下一步下一步,但要注意安装器可能不会自动帮你装 WebView2,这块缺失的话,启动桌面版会直接报找不到 runtime 的错误。

4.2 启动引擎并验证监听

安装好之后,第一步先启动引擎,不要直接开 UI。命令行里执行引擎启动命令,正常输出会打印一个监听地址,类似http://127.0.0.1:17853这样的格式。端口不同版本可能不一样,以实际输出为准。看到监听地址后,用浏览器或 curl 验证一下接口是否响应。

dsh engine start # 另开一个终端验证接口 curl http://127.0.0.1:17853/v1/models

如果curl能返回模型列表,说明引擎正常。这里最容易翻车的是端口冲突,尤其是你机器上已经跑了其他模型服务时。遇到端口占用,改配置里的端口号再启动就行,不用重装。

4.3 Web 模式的认证流程怎么走通

接下来启动 Web 模式,命令是dsh web。第一次启动时,终端会打印一段提示,大意是当前需要认证,请打开命令输出的 URL。这个 URL 里带了一次性 token,浏览器打开之后会看到一个授权确认页,点击确认,就完成了本机绑定。

这个设计我第一次遇到时觉得多此一举,后来想明白了:本地服务如果完全无认证,那本机上的其他进程随时可以往服务发指令,等于把 Agent 权限免费送人。让你主动打开 URL 并确认,至少证明“当前操作者是本机的主人”。这个流程体验上多了一步,但安全收益是实打实的。

实操里的两个坑:一是 URL 打印出来后终端被日志刷屏,找不到了,解决方法是重新执行dsh web并盯住最上面的几行;二是授权完成后浏览器报过期,通常是因为离打印 URL 隔了太久,重新启动一次 Web 模式,用新的 URL 授权即可。

4.4 挂载一个自定义 Skill 并调用

挂载 Skill 是熟悉 Runtime 解耦方式的最佳练习。我以“查询 SQLite 数据库”为例,完整演示一遍。

先建目录结构:

skills/ └── sqlite-query/ ├── manifest.yaml └── entry.py

然后写 manifest.yaml,声明技能和工具:

name: sqlite_query description: 在指定 SQLite 数据库上执行只读查询 tools: - name: query args: - name: db_path type: string required: true - name: sql type: string required: true

再写 entry.py 的核心逻辑:

import sqlite3 import json def query(db_path, sql): # 强制只读连接,防止误操作写坏数据库 conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) try: cur = conn.execute(sql) rows = cur.fetchall() columns = [desc[0] for desc in cur.description] return json.dumps([dict(zip(columns, row)) for row in rows], default=str) finally: conn.close()

保存之后,到 UI 的 Skill 管理页点刷新,技能列表里就会出现sqlite_query。然后你在 Agent 看板里建一个任务,让 Agent 调用这个技能并传参数。整个过程不用重启服务,说明 Skill 确实是运行时动态加载的,这比那种“加个技能就得改代码重新编译”的设计好用一个数量级。

4.5 调试 UI 与 Runtime 通信的小技巧

Web 模式的调试很简单,浏览器里直接 F12 打开开发者工具,Network 面板能看到所有 API 请求和 SSE 事件流。这是我最常用的调试方式,比桌面版方便很多。Desktop 模式也有办法打开开发者工具,一般是设置环境变量或者右键检查菜单,具体入口版本之间略有差异,我习惯的做法是:先跑 Web 模式调逻辑,调通之后再回桌面版验证。

另一个有用的手段是直接 curl 模拟 UI 请求。比如想知道 Skill 列表接口返回结构,没必要在前端打断点,直接:

curl http://127.0.0.1:17853/api/skills

这样能快速确认后端是否有问题,把“前端 bug”和“后端 bug”快速拦腰分开。调试工作里 80% 的时间其实都在“定位问题出在哪一层”,UI 与 Runtime 解耦之后,这个定位过程变得非常干净。

5. 常见问题与排查实录

5.1 UI 卡顿排查

UI 卡顿是桌面端最多人问的问题。结合我的使用经历,按概率从高到低排查:

  • GPU 加速问题:集成显卡或老显卡驱动对 WebView2 支持不好时,渲染会掉帧。先禁用 GPU 加速看是否改善。
  • 缓存膨胀:WebView2 缓存越滚越大,页面启动变慢,清掉缓存目录通常立竿见影。
  • 任务日志量太大:日志表格一次性渲染上万条,优化做法是打开分页或虚拟滚动,别让 DOM 节点无限膨胀。
  • 后台引擎抢资源:本地模型推理时 CPU/内存占用高,UI 自然会卡,这种属于正常现象,不是 bug。

我在一台 8G 内存的老笔记本上跑过,UI 确实卡得厉害,但把模型进程迁到另一台机器后,UI 流畅度马上回来了。所以遇到卡顿,先看资源,再查渲染。

5.2 WebView2 Runtime 缺失与版本不匹配

启动桌面版时如果提示找不到 WebView2 Runtime,原因很直接:机器上没装这个运行库。它不是 Windows 系统自带组件,需要单独安装。解决办法是去微软官网下载 Evergreen Bootstrapper,装上之后一般就好了。

版本不匹配的情况更隐蔽。Windows 系统更新有时会自动装一个旧版 WebView2,而应用要求的是新版 API。做法是先看已安装版本,再去官网更新。这个坑在离线环境下特别容易踩,安装包管理器不一定帮你想全。装完 WebView2 之后再跑 desktop 模式,基本都能起来。

5.3 引擎起不来:端口、虚拟化与运行库

引擎进程启动失败的常见原因有三类。第一是端口被占用,报错信息里会有address already in use这种关键词,改端口就行。第二是依赖的虚拟化支持没开,如果引擎用到了容器或虚拟化技术,而 Windows 的虚拟化功能没启用,启动就会失败。这时候去任务管理器的“性能”页看虚拟化是否已开启,没开启的需要在 BIOS 里打开 VT-x/AMD-V,并启用 Windows 的 Hyper-V 或 WSL2 功能。第三是缺少 C++ 运行库,报错会指向某个 DLL 加载失败,这种情况把 Visual C++ Redistributable 装上就好。

我遇到过最无语的是一次看到“virtualization support not detected”但是任务管理器里明明显示已开启虚拟化,后来发现是容器运行时选错了模式,换成 WSL2 后端之后顺利启动。这类问题跟 Harness 本身关系不大,但排查时会耗掉不少时间。

5.4 工具链找不到与版本回退

命令行工具偶尔会报找不到某个二进制文件,比如提示无法定位到 CLI 工具或运行时组件。这类错误大多是 PATH 环境变量没配置好,工具安装后它的 bin 目录没有加入 PATH,重启终端再试通常能解决。如果重装了多次还报同样的错,检查安装目录下是否真的存在对应文件,有可能安装被安全软件拦截了一半。

版本回退也是大家经常问的。比如 0.1.5 安装失败或某个新版本引入了你无法接受的 bug,想退回 v0.1.5-rc.2 这种旧版。做法是彻底卸载当前版本,清理残留配置目录,再从官方发布页下载指定的旧版本包。这里要注意:配置目录里的 Skill 和数据最好先备份,卸载重装不会自动保留这些。我就因为没备份,重装后丢了一组调试好的自定义 Skill,后来学乖了,改配置路径之前先整个目录打个包。

6. 源码读完后的几点体会

把这几层拆完之后,我最大的感受是:一套共享 UI 真正难的不是“复用组件”,而是“定义边界”。Harness 这套代码让我印象深刻的地方,正是它对边界的执行力——前端只管渲染和交互,Runtime 只管执行和推理,中间只有一条清晰的接口契约。这种约束短期内显得“绕”,但长期维护时,你会发现改任何一端都不用担心震到另一端。

想快速上手这类项目源码的朋友,我提供一个思路:别看 views 目录看半天,先找 API 路由定义和 WebView 的注入脚本。这两处读明白了,整个系统的骨架就清楚了。剩下的页面组件、状态管理,都是在这个骨架上填肉。

另外,这套解耦模式还能继续往外扩展。Runtime 既然已经被抽象成本地服务,那它理论上能不能变成远程服务?我在配置里改了一下模型接口地址,UI 确实直接跑通了远程 API。这也说明,解耦的终极好处不是“Web 和 Desktop 共用 UI”,而是未来无论 Runtime 换到哪、怎么部署,UI 都能保持稳定。这个思路,对想折腾本地 Agent 工具的朋友,应该有不少参考价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询