2026/10/17 21:58:39

本地优先的AI编码代理:Electron+Rust双引擎架构实战与踩坑记录

本地优先的AI编码代理:Electron+Rust双引擎架构实战与踩坑记录 今年有个问题一直绕不开AI 编码代理到底应该长在云端还是本地PI-Desktop 是我最近在推进的一个答案——它是一款本地优先的 AI 编码代理桌面应用用 Electron 做交互层Rust 做推理和计算层两个引擎各管各的又通过一套本地通信协议拧在一起。这篇文章会把 PI-Desktop 的架构决策、工程落地的实际选型、以及我从原型走到可用版本踩过的坑一次说清楚适合正在做桌面端 AI 工具、或者对 Electron Rust 混合架构感兴趣的朋友也适合想把自己本地模型接进编辑器工作流的人参考。1. 从上云到本地优先PI-Desktop 要解决的核心问题1.1 编码代理的两种流派痛点在哪现在市面上的 AI 编码代理基本分两条路线。一条是把代码上下文上传到云端模型换来的是最强的大模型能力和零硬件门槛但代价同样明显代码片段、业务逻辑、甚至整个仓库结构都会离开本机合规要求严格的公司基本没法用另外还有网络延迟和按量计费遇到弱网环境补全一个函数能转圈三秒以上。另一条是完全本地跑模型隐私和离线都满足了可要自己搞模型部署、搞上下文工程、搞编辑器插件这些事情叠在一起工程复杂度直接劝退大部分团队。PI-Desktop 想走的是第三条路本地优先但不是本地唯一。整个应用的核心决策和状态管理都放在本地代码上下文默认不出设备只有用户明确开启远程模型或需要云端增强时才走网络请求。这样既保住了隐私底线又给模型能力留了升级空间。和我自己用过的几个助手相比这个设定最大的区别是先本地、后云端而不是默认把数据往外送。1.2 本地优先到底优先了什么简单说就是三条底线。隐私底线代码库索引、补全请求、会话历史默认全部存在本地用户可以不登录、不联网完成大部分编码辅助工作。延迟底线核心链路不走公网从触发补全到模型返回第一个 token 的延迟可以压到百毫秒级而不是被网络抖动绑架。可替换底线模型层做成可插拔本地用 llama.cpp 或 ONNX Runtime远端可以接任意兼容 OpenAI 协议的 API用户自己选项目不绑死某一家。这个设计直接决定了技术选型的方向因为本地优先所以必须有一个能在本地高性能处理代码的引擎因为要给人用所以必须有一个交互足够流畅的界面。于是出现了 Electron Rust 这组组合。1.3 为什么不做成 CLI 或普通 IDE 插件很多第一次看到 PI-Desktop 的人会问既然已经有 Continue、Copilot 这类插件为什么还要单独做一个桌面应用我的理由很实际AI 编码代理的交互不只是按下 Tab 接受补全它需要展示推理过程、提供代码改动预览、管理多文件重构、记录会话做回滚这些都需要一个稳定的宿主环境。如果做成 IDE 插件隔离性和权限控制都要跟着宿主走如果做成 CLI又没法提供编辑器里那种实时内联体验。所以 PI-Desktop 采用桌面应用形态用 Electron 内嵌 Monaco 编辑器作为交互和控制中心把 AI 相关的高频重计算拆到 Rust 进程里避免把编辑器 UI 的主线程卡死。这样两者的优点都能保留这也是整个双引擎架构的出发点。2. 双引擎不是炫技Electron 和 Rust 的职责边界2.1 Electron 的赛道编辑器体验和工具链生态Electron 这层负责的事情很明确窗口管理、菜单、快捷键、系统托盘、Monaco 编辑器、渲染进程里的各种面板以及几乎所有用户看得见的东西。为什么选 Electron 而不是更轻的 Tauri很多人提到 rust tauri 确实能把安装包体积压得很小但 PI-Desktop 第一优先是编辑器工作流而不是安装包大小。Monaco 是 VS Code 同款编辑器内核Electron 对它的支持已经非常成熟语法高亮、断点调试、多光标、代码折叠这些能力都是现成的团队不用重造轮子。另一个原因是团队里有大量 TypeScript/Vue 的背景Electron 生态里的 vue-tsc、webpack、vite 这些工具链可以直接复用。当时我们评估过 Tauri发现它虽然好但要替换编辑器内核的成本太高了还会牺牲一部分系统级集成能力比如快捷键冲突处理和原生菜单的深度定制。所以最终定格为 Electron 做 UIRust 做引擎。2.2 Rust 的赛道把重计算和 AI 调度压在本地Rust 侧处理的是任何涉及性能的分代码库扫描与语义索引需要快速遍历和解析大量源文件补全上下文的组装要按语法结构裁剪代码而不是简单截断字符串本地模型的加载、推理调度和流式输出解析多请求并发控制、缓存管理、会话持久化。这些任务如果丢给 Electron 的 Node 进程CPU 密集操作会直接导致渲染卡顿如果丢给主进程里的 Node worker又受限于 Node 的内存模型和线程模型。Rust 在这里最大的优势不是快而是可控的并发和零成本抽象。我可以很放心地开多个线程同时做索引也可以随时取消一个正在执行的推理任务而不用担心回调地狱或者事件循环阻塞。2.3 两边的通信不是 IPC 硬桥而是本地回环服务一开始我也想过用 Electron 自带的 ipcMain/ipcRenderer 来做进程通信但测下来发现高频次的小请求还行一旦涉及大字段的代码块传输序列化和事件分发就会变成瓶颈。尤其本地 AI 补全会把代码片段、文件树、光标上下文揉成一个较大的 JSON连发几十个请求Electron 的 IPC 通道会明显抖动。所以 PI-Desktop 最终的方案是Rust 侧起一个只监听127.0.0.1的本地回环 HTTP/gRPC 服务Electron 渲染进程通过 preload 暴露出来的客户端来调用它。所有请求都走本机网络栈传输效率高而且天然支持流式响应。更重要的是两个进程之间的故障边界变得非常清楚Rust 服务崩了Electron 还能弹一个引擎已重启的提示而不是整个应用跟着退出。职责Electron 侧Rust 侧核心 UI / 编辑器负责不负责文件系统监听与索引仅触发负责解析和建索引补全上下文组装负责提供选中态和光标信息负责语法级裁剪与排序模型加载与推理不负责负责会话历史存储展示负责 SQL 持久化崩溃恢复负责拉起重启负责自身 panic 兜底2.4 回应为什么不直接用 Tauri每次聊到 Electron Rust都会有人跳出来说Tauri 不是正好吗。我用 PI-Desktop 的实际需求来解释Tauri 的 webview 在 Windows 和 Linux 上用的是 WebView2/WebKitGTK不同平台的渲染表现有差异尤其在 Monaco 这种重度编辑器组件上细节兼容成本很高Electron 则自带 Chromium所见即所得。另外 Tauri 的 Rust 后端和前端通信方式虽然更安全但要做大量自定义协议才能替代我们现在这套 HTTP 流式通道。加上 PI-Desktop 的路线图里有插件系统和协议扩展Electron 的成熟度仍然是最稳的选择。Tauri 不是不好而是它的优势领域不在这里。3. 工程化落地中的关键选型与坑3.1 前端选型Vue 3 TypeScript 的真实理由Electron 的热搜词里经常看到vue-tsc、typescript版本号因为前端编译类型检查确实是第一个坑。PI-Desktop 的渲染层选了 Vue 3 TypeScript理由很朴素团队熟、组件的响应式模型适合做流式渲染而且 Vue 的模板编译错误提示比手写 DOM 操作友好得多。这里提醒一句electron-vite 脚手架生成的项目里vue-tsc和typescript的版本必须锁定不能随手升级。我们当时的package.json里写的是vue-tsc: ^1.8.27、typescript: ^5.3.3看着都是新版本但两个库之间存在一个版本匹配窗口一旦 typescript 升到 5.4vue-tsc 的类型检查可能报一堆跟代码无关的协议错误。CI 里我还加了vue-tsc --noEmit作为打包前置步骤避免类型问题一直拖到发布才暴露。3.2 Rust 侧异步运行时与数据存储Rust 侧不是那种写个函数算完就返回的小工具它要同时处理来自 Electron 的多个请求、模型推理任务和后台索引任务所以需要一个行为明确的异步运行时。我选了 tokio理由不用多说社区就是它生态里各种库默认都有 tokio feature。省心的点是tokio::select!可以用来同时监听模型是否返回了新的 token和用户是否取消了补全请求这两个事件是异步编码代理的常态。会话历史我直接用了 SQLx 连接 MySQL连接池MySqlPool的方式避免每次请求都新建连接。表结构很简单核心就两张表session和messagemessage 表里存角色、内容、相关的文件路径还有一个 JSON 字段存当时的代码上下文快照方便用户回滚到某次补全之前的版本。Rust 代码里异步操作长这样use sqlx::mysql::{MySqlPool, MySqlPoolOptions}; #[derive(Clone)] struct AppState { pool: MySqlPool, } async fn init_pool(url: str) - ResultMySqlPool, sqlx::Error { MySqlPoolOptions::new() .max_connections(5) .connect(url) .await } // 保存一条补全消息 async fn save_message(pool: MySqlPool, session_id: i64, role: str, content: str) - Result(), sqlx::Error { sqlx::query(INSERT INTO message (session_id, role, content) VALUES (?, ?, ?)) .bind(session_id) .bind(role) .bind(content) .execute(pool) .await?; Ok(()) }这个方案其实也踩过坑SQLx 编译期检查 SQL 需要连上数据库CI 环境里没有 MySQL 就编译不过。后来我把部分模块切到运行时query或者用offline mode生成.sqlx缓存才解决打包机的问题。3.3 Electron 打包原生模块我选择 sidecar 而不是 node-gyp很多 Electron 项目一旦涉及原生能力第一反应是写 Node 原生模块然后面临一堆 node-gyp 编译问题。PI-Desktop 的 Rust 引擎就不走这条路我把 Rust 部分编译成独立的可执行文件作为 sidecar 随 Electron 应用一起打包。Electron 主进程直接用child_process.spawn把它拉起来之后走 HTTP 通信。这样带来的三个好处很实际不需要处理 Electron 版本与 Node ABI 的匹配问题Rust 二进制独立于 Node 运行时Rust 引擎崩溃不会拖垮 Electron 主进程可以自动重启可以在不修改 Electron 代码的情况下独立更新 Rust 引擎。坏处是安装包体积变大一个编译好的 Rust 可执行文件大约 20MB 到 30MB加上 Electron 本体最终安装包比纯 Electron 应用大一圈。但对一个本地优先的编码代理来说这个体积换来的性能和稳定性完全值得。3.4 electron-builder 在 Linux 下 FPM 报错的完整复现打包这关我印象很深因为踩了整整一个下午。electron-builder 在 Linux 下如果要产出.deb或.rpm包会调用 fpm 工具而 fpm 本身是一个 Ruby gem。如果构建机器上没有完整 Ruby 环境或者 Ruby 版本不匹配打包时就会出现类似fpm: command not found或者Unable to execute fpm的错误。当时我在一个干净的 Ubuntu 容器里打包连续报错排查了很久才发现是缺依赖。解决办法是先装好 Ruby 和 fpm# 安装 Ruby 环境Debian/Ubuntu apt-get install -y ruby ruby-dev rubygems build-essential # 安装 fpm gem install fpm # 再执行 electron-builder npx electron-builder --linux deb如果你不想为了一个安装包格式引入这么多依赖可以直接改目标格式比如用 AppImage 来分发AppImage 不需要 fpm打包流程会简单很多。我个人的建议是本地验证用 AppImage正式发布给用户再走 deb/rpm并把这个 fpm 安装步骤写进 CI 的构建脚本里别每次都在新机器上手动补一遍。4. AI 编码代理在本地是如何流转的4.1 一次内联补全从触发到展示的完整链路这是 PI-Desktop 最核心的一条路径也是用户感知最强的地方。假设我在 Monaco 里刚敲完一个函数的参数列表按下回车补全面板开始提示Electron 渲染进程监听编辑器内容变化经过约 120ms 的防抖收集光标位置、当前文件语言、最近修改的代码块。渲染进程通过 preload 暴露的客户端把请求以 JSON 形式 POST 到本地 Rust 服务路径类似/v1/completion。Rust 服务先做语法分析判断光标上下文属于函数体、类成员还是全局作用域再决定把哪些代码作为 prompt 拼进去。Rust 调用本地模型或远端 API拿到流式 token 后通过 HTTP chunked response 一段一段发回给 Electron。Electron 边收 token 边更新 Monaco 的 inline suggestion直到收到结束标记再决定是显示灰色预览还是直接替换。整个过程里最容易被忽略的一步是第 3 步如果不做语法级裁剪把整个文件塞进模型不仅 token 消耗会暴涨补全质量也会因为无关代码太多而下降。4.2 模型在哪里跑本地推理与远端 API 的混合模式本地优先不代表一定要所有模型都在本机跑。我的做法是把模型抽象成统一的接口本地推理走 llama.cpp 的 server 模式Rust 服务把 prompt 发给localhost:8080通过流式接口拿结果远端模型则走一个 OpenAI 兼容的客户端指向用户配置的 endpoint。这样设计的好处是我可以根据任务复杂度动态选择模型。简单的单行补全用本地小模型响应时间大约 100ms复杂的大规模重构建议用远端大模型Rust 服务负责把整个索引结果和 diff 信息打包进去。用户甚至可以在设置页手动切换而 Rust 引擎不关心后端到底是本地进程还是远程 API它只认统一的接口协议。4.3 代码库语义索引tree-sitter 解析是不是必需品如果要给一个项目做跨文件的编码代理纯靠全文搜索是不够的。PI-Desktop 在 Rust 侧引入了 tree-sitter 来做增量解析针对不同的语言加载对应的 grammar比如 Go、TypeScript、Python、Rust。tree-sitter 的好处在于它可以在文件修改后做增量解析不需要重新扫描全量内容这在大型仓库里是决定性的性能差异。索引文件时的粒度是符号而不是行我们记录函数、类、导入语句的范围和依赖关系。当用户请求补全时Rust 服务会把附近的符号定义、引用关系、最近编辑的文件列表一起放入上下文。这个过程是后台异步进行的用户不需要主动触发索引只是第一次打开大仓库的时候会看到短暂的内存上涨等索引建立完就平稳了。4.4 长上下文与异步任务编排AI 编码代理的实际体验很大程度取决于任务编排能力。一次补全请求往往不是一次模型调用就能完成的可能需要先索引、再检索、再拼接 prompt、再调用模型、再过滤输出这其中任一步都可能被用户的新输入打断。所以我用 tokio 的任务队列和取消机制来处理。核心思路是维护一个当前请求的 CancellationToken当用户继续打字时前一个补全请求就被标记为取消Rust 侧立即停止模型推理并销毁临时状态。这样既能避免多线程里堆积大量过期请求也能保证内存不会随着操作次数无限上涨。代码层面用tokio::select!非常直观let cancel CancellationToken::new(); let mut cancel_guard cancel.clone(); tokio::select! { token stream.next_token() { // 拿到一个 token继续处理 } _ cancel_guard.cancelled() { // 用户取消立即退出 } }5. 踩坑实录从原型到可用版本的几个教训5.1 Electron 渲染进程内存上涨--expose-gc 与监控Electron 渲染进程的内存问题几乎是绕不过去的。PI-Desktop 的编辑器里长时间挂着一个大仓库Monaco 的 model 数量会持续增长即使页面没有明显卡顿内存也会缓慢爬升。一开始我怀疑是代码泄漏后来排查发现是渲染进程的 V8 堆没有及时触发 GC。Electron 打包成应用后可以通过增加 Node 启动参数来暴露底层的 GC 方法这就是热搜词里提到的--expose-gc参数。我在主进程加载渲染窗口时这样写app.commandLine.appendSwitch(expose-gc); // 或者通过 webPreferences 增加 new BrowserWindow({ webPreferences: { preload: path.join(__dirname, preload.js), // 允许外部传入的 js 访问 gc } });配合前端定时判断用performance.memory.usedJSHeapSize监控堆大小超过阈值就调window.gc()主动回收。实测下来长跑 6 小时之后内存从 1.2GB 降到 700MB 左右效果非常明显。需要注意window.gc()只在打开--expose-gc时可用生产环境要确认这个参数确实被带上了否则会在线上环境拿到undefined。5.2 Rust 侧崩溃怎么不拖垮编辑器Rust 比 Electron 稳定但本地模型加载本身就容易出幺蛾子某个算子不支持、显存不足、模型文件损坏都可能导致子进程异常退出。如果我不做守护用户最直观的感受就是补全突然没了。我在 Electron 主进程里用一个简单的 watchdog 来管理 sidecar启动时 spawn Rust 可执行文件监听 stdout/stderr 和 exit 事件如果退出且没有收到主动关闭信号就等待 800ms 后重新拉起重启成功后再让渲染进程重新初始化本地客户端。这个设计让崩溃变成了一次无感知的自愈。大多数时候 Rust 进程秒级重启补全服务恢复后编辑器不需要重新打开。5.3 流式响应把 UI 卡住的教训第一版做流式补全时我把模型返回的整个 JSON 一次性塞给了渲染进程的状态管理结果是 UI 要等模型全部生成完才更新速度感知非常差。后来改成真正的流式Rust 服务每拿到一小段 token就通过 HTTP chunk 发回Electron 侧用ReadableStream接收再逐段更新 Monaco 的 inline suggestion。这个改动让用户体验完全不同从等三秒看到整段话变成300ms 后开始逐字显示。同时要注意控制更新频率不要每个 token 都触发一次 React/Vue 的渲染我是在每收到 10 个字符或 30ms 间隔进行一次 UI 刷新这样既流畅又不会把渲染线程打满。5.4 跨平台菜单和快捷键的差异Electron 应用的菜单模板在 macOS、Windows、Linux 上的表现差异很大。比如 macOS 的菜单第一项是应用名必须通过app.name动态获取Windows/Linux 则没有这个惯例。快捷键里CmdOrCtrlI这种写法虽然 Electron 会自动映射但 CommandOrControl 在 macOS 外部键盘上偶尔会失灵。PI-Desktop 的做法是维护一个平台相关的快捷键表根据process.platform切换const isMac process.platform darwin; const accelerator { suggest: isMac ? CmdK : CtrlK, openPanel: isMac ? CmdShiftL : CtrlShiftL, }[action];同时把系统托盘和全局快捷键也做成可配置项避免和 VS Code 这类宿主工具冲突。这个点很小但在 Windows 和 Linux 双平台发版后才发现属于那种文档里不会细讲、自己踩一遍才懂的细节。6. 性能表现与后续扩展空间6.1 当前原型的体感数据PI-Desktop 目前还是早期版本但基本链路已经跑通。我记录了几组这段时间实测下来的数据给想搭类似架构的人一个参考安装包体积deb 包约 180MB其中 Electron 约占 120MBRust sidecar 约 30MB前端资源约 20MB。常驻内存空闲时 Electron 约 350MBRust 服务约 180MB打开一个中型仓库并完成索引后总计约 700MB。本地小模型单行补全从发起请求到第一个 token 平均 120ms完整补全平均 450ms。远端大模型代码解释Rust 服务做上下文组装约 80ms网络耗时另算。这个数据说明本地优先不是不能用而是需要精细化裁切提示词。只要你舍得在 Rust 侧做语法级压缩小模型完全可以承担日常的补全任务。6.2 接下来我准备做的几个方向一是把语义索引升级成本地向量检索用 Rust 里的轻量向量库把符号定义和注释转成 embedding这样跨文件的代码问答就不再依赖严格的符号匹配。二是做多模型热切换不重启服务动态加载和卸载模型文件照顾不同任务类型。三是考虑给 Rust 引擎做插件 ABI让社区可以不用碰 Electron 代码就扩展自定义补全规则和后处理逻辑。至于 Tauri 那个方向短期内我不会迁移。PI-Desktop 的核心竞争力在于编辑器体验和本地计算链路的稳定性Electron Rust 的组合在这个场景下依然是最务实的答案。后续如果 WebView 兼容性和性能再上一个台阶我也不排除在某个轻量子模块里做 POC但主线架构不会轻易换。最后分享一个实操技巧在处理 Electron 与 Rust 双进程的项目时不管你觉得 IPC 够不够用都建议在早期就把 sidecar 的 stdout 日志全部落盘。出问题的时候Rust 侧日志和 Electron 主进程日志一对比很多奇怪的问题都能定位到是跨进程通信丢了包还是模型推理超时。这比在 UI 上打console.log要靠谱得多。