
用 Go 和 Vue3 做一个本地运行、随时打开的 AI 桌面应用听起来是件挺酷但有点复杂的事。我在几个候选技术方案里折腾了一圈最后选择了 Wails v2把之前用 Electron 写的一个 AI 聊天客户端重写成了单二进制程序安装包体积从 180MB 一路压到 15MB 以内内存占用也下降了将近一半。这篇文章就把整个实战过程完整写出来为什么选 Wails、前后端桥接层怎么设计、流式对话怎么做、以及我在打包和发布过程中踩过的那些坑。如果你正打算做桌面端的 AI 工具或者想把自己的 Web 服务包一层桌面壳这篇内容应该能帮你少走不少弯路。1. 为什么我放弃 Electron用 Wails v2 重写 AI 桌面客户端1.1 先说说我原来那个能跑但很重的 Electron 版本我最早做 AI 桌面客户端时和大多数人一样选了 Electron理由很直接生态成熟、资料多、前端技术栈随便挑。但真正用起来之后问题就慢慢浮出来了。首先就是安装包体积一个带 node_modules 的 Electron 应用随便打出来都要 150MB 往上用户下载成本很高。其次是内存占用我那个应用打开后常驻内存轻松超过 500MB对话框一多、Markdown 渲染一复杂风扇就开始转。最难受的是多窗口场景下的崩溃恢复Electron 的 renderer 进程一旦内存吃紧整个应用白屏的几率不小。你说 Electron 完全不能用吗也不是。它的 Chrome 内核渲染能力确实强各种 Node.js 生态库随便调。但对一个 AI 聊天工具来说我需要的其实就是一个本地 Shell接大模型 API、管理会话记录、渲染 Markdown、再给用户一个体面的窗口。Electron 能做的事太多了多到大部分时间都在处理它带来的额外复杂度而不是在处理业务本身。1.2 Wails v2 的取舍逻辑单二进制、低内存、原生能力后来我接触到了 Wails v2它的核心设计非常对我的胃口用 Go 写后端逻辑用系统自带的 WebView 渲染前端界面最后打包成一个不依赖运行时的可执行文件。Windows 上它走的是 WebView2macOS 上走 WKWebView也就是说不需要把 Chromium 整个塞进安装包。用 Wails v2 重写之后我的安装包从 180MB 降到了 14MB 左右安装完打开应用静态内存占用大约 120MB 到 180MB比之前好了太多。而且 Go 编译出来的是一个原生可执行文件启动速度肉眼可见地快双击到窗口出现基本在 1 秒内。对于 AI 对话这种高频打开、用完就关的桌面工具这个体验差异是决定性的。同时 Go 后端在并发处理上天然有优势。大模型 API 的流式响应、多会话并发管理、本地向量检索这类 IO 密集操作Go 的 goroutine 模型写起来比 Node.js 的事件循环要直白也比 Python 的 GIL 舒服很多。我做 AI 应用需要处理大量 HTTP 长连接和并发请求Go 确实是更顺手的选择。1.3 Tauri 也看了为什么最后选了 Wails我知道肯定有人会问Tauri 不也是轻量桌面方案吗确实Tauri 的方案和 Wails 很接近都是系统 WebView 后端逻辑但我实际体验下来发现两个框架的侧重点不一样。Tauri 的 Rust 后端性能更强但对我来说有个比较大的学习成本问题我不太想在 AI 应用这个阶段同时兼顾 Rust 的所有权模型和异步运行时调试起来心里没底。Wails 的 Go 后端就没有这个心理负担Go 的语法简单标准库的 net/http、context、encoding/json 足够我完成 90% 的工作剩下的用几个常用库就能搞定。另外 Wails v2 的绑定机制非常直接Go 方法可以自动暴露到前端前端直接用 JavaScript/TypeScript 调用不需要像 Tauri 那样在 command 和事件之间来回做映射。对优先保证开发效率、又不想牺牲应用体积的团队来说Wails v2 确实是一个被低估的选择。2. 环境准备与脚手架Wails v2 Vue3 的工程结构一次讲清2.1 依赖安装和版本要求在开始之前先确认你本机的软件环境。当前 Wails v2 对 Go 版本有要求建议用 Go 1.21 以上的版本Vue3 项目依赖 Node.js我使用 Node.js 18 LTS 或更高版本。Windows 下还需要 WebView2 RuntimeWin11 和大部分 Win10 系统已经内置没有的话在微软官网下载安装即可。具体来说我当前的推荐版本组合是这样的组件推荐版本说明Go1.21Wails v2 需要较新的语言特性建议用官方最新稳定版Node.js18 LTS 或 20Vue3 Vite 构建版本别太老Wails CLIv2.10安装和打包都靠它虽然后续有 v3但 v2 生态更成熟WebView2最新 RuntimeWindows 的渲染内核确认系统已安装安装 Wails CLI 的命令是go install github.com/wailsapp/wails/v2/cmd/wailslatest安装完跑一下wails doctor它会检查所有依赖环境并给出缺失项提示。我第一次在 Windows 上执行时就是靠这个命令发现缺少了 WebView2 的 SDK 依赖补装后就好了。2.2 wails init 初始化一个 vue-ts 模板Wails v2 提供了多种前端模板包括 React、Vue、Svelte 等我这里用vue-ts模板因为 TypeScript 在 AI 应用里确实能减少不少低级错误尤其面对流式响应这种数据格式变化频繁的场景。wails init -n ai-desktop -t vue-ts这条命令会生成一个标准项目骨架。我建议初始化完成之后先跑一次wails dev确认开发模式能启动。它会自动编译 Go 后端、启动 Vite 开发服务器然后把整个界面嵌入到 WebView 窗口里。第一次跑会花一些时间因为需要下载前端依赖和编译 Go 代码属于正常现象。wails dev还有个我很喜欢的能力热更新。前端代码由 Vite 负责热更新Go 代码改动后会自动重新编译并重启应用。调试阶段基本不需要手动关窗口再开体验很像开发普通 Web 应用。2.3 目录结构与配置文件逐个说明项目初始化后核心目录结构大致是这样ai-desktop/ ├── frontend/ # Vue3 前端项目Vite 构建 │ ├── src/ │ │ ├── components/ │ │ ├── App.vue │ │ └── main.ts │ ├── package.json │ └── vite.config.ts ├── main.go # Wails 应用入口 ├── app.go # 后端服务结构体绑定的方法都写在这里 ├── wails.json # Wails 项目配置 └── go.mod # Go 模块定义main.go的职责是创建应用实例并声明要绑定的后端服务app.go才是你写业务逻辑的地方。前端所有的 Go 接口调用最后都会落到app.go中导出方法上。wails.json里可以配置应用窗口大小、标题、前端构建目录等信息。比如我设置了一个相对宽润的默认窗口尺寸{ name: ai-desktop, outputfilename: aidu, frontend:build: npm run build, frontend:install: npm install, wailsjsdir: ./frontend/wailsjs, assetdir: frontend/dist, info: { productName: AI Desktop, productVersion: 1.0.0, copyright: Copyright 2024 } }需要注意的是wailsjsdir这个字段它指定了前端需要的自动生成 JS 和 TS 类型定义文件的存放位置。第一次启动开发模式后这个目录会自动生成后面我会说到它的作用。3. 桥接层设计Go 方法与 Vue3 组件的通信机制3.1 bind 绑定原理Go 方法如何暴露给前端Wails v2 的核心机制是绑定。在main.go中你需要实例化自己的 App 结构体并把它传给wails.New创建应用时的Bind配置项func main() { app : NewApp() err : wails.Run(options.App{ Title: AI Desktop, Width: 1024, Height: 768, AssetServer: assetserver.Options{ Assets: assets, }, BackgroundColour: options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, Bind: []interface{}{ app, }, }) if err ! nil { println(Error:, err.Error()) } }所有在App结构体上、且可导出的方法都会自动暴露给前端。比如我在app.go里定义了一个获取会话列表的方法func (a *App) GetConversations() []Conversation { return a.store.List() }前端 TypeScript 里会自动生成对应的调用代码实际使用是这样import { GetConversations } from ../../wailsjs/go/main/App; const conversations await GetConversations();这里要注意方法名的映射规则Go 的GetConversations会直接生成一个同名函数参数和返回值也会同时生成 TypeScript 类型。这就意味着只要后端定义好结构体前端的类型安全就能自动得到保证不需要手写接口文档。3.2 事件系统后端主动推送流式内容给前端桥接层另一块重要的内容是事件系统。文档里叫 Event 系统它承担的是后端主动叫前端的场景。在做 AI 对话时最典型的场景就是流式输出后端在收到大模型的部分回复后需要不断把这些碎片推送到界面上让用户看到打字机效果。HTTP 请求在 Go 后端里持续接收但如果只是等待全部完成再一次性返回用户会面对很长的空白等待时间。所以我采用的方式是后端每收到一段增量内容就用runtime.EventsEmit推给前端前端用EventsOn监听并即时渲染。import ( github.com/wailsapp/wails/v2/pkg/runtime ) func (a *App) sendChunk(sessionID string, content string) { runtime.EventsEmit(a.ctx, ai:chunk, map[string]interface{}{ session_id: sessionID, content: content, done: false, }) } func (a *App) sendDone(sessionID string) { runtime.EventsEmit(a.ctx, ai:chunk, map[string]interface{}{ session_id: sessionID, done: true, }) }前端在使用时需要在onMounted里注册监听在onUnmounted里取消监听避免组件销毁后事件仍然触发导致内存泄漏import { EventsOn, EventsOff } from ../../wailsjs/runtime/runtime; onMounted(() { EventsOn(ai:chunk, (payload) { if (payload.session_id ! currentSessionId.value) return; if (payload.done) { streaming.value false; } else { message.value payload.content; } }); }); onUnmounted(() { EventsOff(ai:chunk); });3.3 用 wails generate 生成前端类型定义刚才讲到wailsjsdir指定的目录会在开发模式下自动生成前端调用代码。如果你想手动重新生成可以执行wails generate module这个命令会在frontend/wailsjs目录下生成三个子目录go存放后端方法的调用封装runtime存放事件和窗口相关 API。生成的.d.ts文件包含了所有绑定方法的 TypeScript 类型定义IDE 里直接有自动补全和类型检查改完 Go 后端结构体以后前端编译期就能发现接口不匹配的问题这比我之前写 Electron 时在 IPC 通信里手维护协议要省心得多。有一点需要注意如果在原有生成的调用代码上新增了方法开发模式热更新不一定每次都完整重新生成当你发现前端没有出现新方法时手动执行一次wails generate module再重启wails dev就好。4. AI 对话核心实现从普通 HTTP 请求到流式打字机4.1 模型接入选择兼容 OpenAI 接口的云服务还是本地模型AI 桌面应用最核心的功能就是对话。我这里的做法是把所有模型供应商都封装在同一套接口语义后面这样前端只需要关心发送消息和接收增量两个动作不需要知道具体接的是哪家服务。对接云端大模型时我会使用那些兼容 OpenAI Chat Completions 协议的服务因为这几乎是事实标准。Go 后端可以直接用标准net/http发起 POST 请求设置stream: true之后服务端会以 SSE 格式不断返回data:前缀的行。如果你对隐私性要求高希望完全离线运行也可以接本地 Ollama 服务。Ollama 本身就提供兼容 OpenAI 的/v1/chat/completions接口地址是http://localhost:11434区别只是模型名称、请求延迟和上下文窗口大小。我的应用里把服务地址做成了设置项云端和本地可以随时切换。方案优点缺点云端大模型 API模型强、上下文长、不用自己买 GPU有网络依赖数据经过第三方本地 Ollama 模型离线可用隐私可控需要显卡资源模型体积大效果稍弱实际开发中我建议先接云端 API 保证功能跑通再预留本地模型切换能力这样即便用户没有 API Key也能用本地小模型快速体验。4.2 后端流式请求的完整实现context、bufio、channelGo 后端发起流式请求时核心点在于如何逐行解析 SSE 数据。直接用标准库就能完成不需要引入额外的 SSE 客户端。下面这段代码是我项目里的核心方法做了简化但保留了关键逻辑func (a *App) ChatStream(sessionID string, userMessage string) error { a.ctx context.Background() ctx, cancel : context.WithCancel(a.ctx) defer cancel() reqBody : map[string]interface{}{ model: a.currentModel, messages: []map[string]string{ {role: system, content: a.systemPrompt}, {role: user, content: userMessage}, }, stream: true, } body, _ : json.Marshal(reqBody) req, err : http.NewRequestWithContext(ctx, POST, a.apiBase/v1/chat/completions, bytes.NewReader(body)) if err ! nil { return err } req.Header.Set(Content-Type, application/json) req.Header.Set(Authorization, Bearer a.apiKey) resp, err : http.DefaultClient.Do(req) if err ! nil { return err } defer resp.Body.Close() scanner : bufio.NewScanner(resp.Body) scanner.Split(scanLinesFunc) for scanner.Scan() { line : scanner.Text() if !strings.HasPrefix(line, data:) { continue } data : strings.TrimSpace(strings.TrimPrefix(line, data:)) if data [DONE] { a.sendDone(sessionID) return nil } var chunk struct { Choices []struct { Delta struct { Content string json:content } json:delta } json:choices } if err : json.Unmarshal([]byte(data), chunk); err ! nil { continue } if len(chunk.Choices) 0 { a.sendChunk(sessionID, chunk.Choices[0].Delta.Content) } } return scanner.Err() }这里有一个容易被忽略的关键点Scanner 默认按行拆分但 SSE 数据可能用\r\n结尾也可能一行很长。我用自定义的split函数处理了\r的情况避免最后解析出来的内容带奇怪的回车符。另外http.NewRequestWithContext非常重要它让后续的取消操作能真正中断底层 HTTP 请求否则用户点击停止生成时Go 协程会继续挂在那里阻塞。4.3 前端状态机设计idle、loading、streaming流式对话的前端不能只用一个loading布尔值因为用户需要知道当前是等待首包、正在接收中还是已结束。我定义了一个联合类型来描述消息状态type MessageStatus idle | loading | streaming | done; interface ChatMessage { id: string; role: user | assistant; content: string; status: MessageStatus; error?: string; }流程上用户点击发送后先在本地插入一条role: user的消息再插入一条空内容的role: assistant消息状态设为loading。后端接流后通过事件推送把内容累积到这条 assistant 消息上状态切为streaming。收到done事件后状态切为done。这样设计的好处是UI 可以针对不同状态做差异化展示loading状态显示一个三点动画streaming状态显示光标或打字机特效done状态则触发 Markdown 渲染。Chat UI 的滚动处理也依赖这个状态机——只有streaming状态下才自动滚到底部loading和idle都不滚动避免用户回看历史消息时被强行拉下去。4.4 取消、重试与会话管理流式请求最大的问题是网络中断和用户主动取消。我在App结构体里维护了一个sync.Map来保存每个会话对应的cancelFunc当用户点击停止按钮时调用CancelChat方法触发对应的 cancel同时给前端发送一个生成已中断的事件。func (a *App) CancelChat(sessionID string) { if fn, ok : a.cancelFuncs.Load(sessionID); ok { fn.(context.CancelFunc)() a.cancelFuncs.Delete(sessionID) runtime.EventsEmit(a.ctx, ai:chunk, map[string]interface{}{ session_id: sessionID, interrupted: true, done: true, }) } }会话管理的思路也比较直接所有会话和消息都放在 SQLite 里通过一个 Repository 接口访问。Go 后端启动时初始化数据库前端所有操作都通过后端方法进行数据持久化放后端避免前端直接操作本地文件。我建议用crypto/rand生成会话和消息 ID而不是依赖自增 ID因为桌面应用会有大量本地写入自增主键在迁移和同步场景下容易出问题。5. 界面落地Vue3 组件库选型、Markdown 渲染与暗色模式5.1 组件库怎么选Naive UI 还是 Element PlusVue3 项目的 UI 组件库目前主流是 Element Plus 和 Naive UI。Element Plus 生态大、组件全适合后台管理系统Naive UI 的主题系统更灵活暗色模式切换做得很顺手而且 TypeScript 支持更好。我的 AI 桌面应用需要大量自定义聊天气泡、长文本和流式内容对组件的定制需求很强所以选择了 Naive UI。它的n-config-provider配合n-global-style可以一键切换亮暗模式暗色模式下聊天界面的观感比亮色好很多也更符合 AI 工具的调性。当然如果你只是想要现成的后台模板Element Plus 的成熟度会更高。但做 AI 聊天界面我建议还是从 Naive UI 或者完全手写组件开始因为聊天 UI 的特殊交互消息流、自动滚动、复制按钮、Markdown 代码块工具栏大部分组件库都不会提供现成解决方案。5.2 Markdown 流式渲染的性能问题AI 回复的内容通常带有 Markdown 格式代码块、表格、列表都需要优雅展示。我用的库是markedhighlight.js。但这里有个问题流式输出过程中如果每个 chunk 都做一次完整的 Markdown 解析和渲染前端会非常卡。我的优化策略是流式过程中只显示纯文本用white-space: pre-wrap保持换行不对 Markdown 做完整解析。当done事件到达、内容不再变化时才执行一次marked.parse生成最终的 HTML。这样做的好处是中间过程渲染开销很小最终展示又保留了格式。唯一的代价是打字机过程中代码块没有高亮但用户大多数时间更在意的是流畅度。如果是长文档场景还可以加一层防抖200ms 内的多个 chunk 合并到一次渲染任务。实测下来一次流式回复几百个 chunk 的情况下页面没有明显掉帧。5.3 暗色模式与窗口样式的心得Wails v2 的窗口背景色可以在main.go的BackgroundColour中设置。做暗色模式时我在前端用 Naive UI 的darkTheme和themeOverrides分别管理明暗两套配置。真正的难点在于切换主题时WebView 背景需要和窗口背景保持一致否则窗口缩放时有白边闪动。我的做法是在 Vue3 的主题切换函数里额外调用一个后端方法动态更新窗口背景色和标题栏颜色import { SetTheme } from ../../wailsjs/go/main/App; function applyTheme(theme: light | dark) { document.documentElement.setAttribute(data-theme, theme); SetTheme(theme); }Windows 11 下还可以配合 WebView2 的设置调整标题栏和应用图标。App.SetTheme里通过系统 API 修改窗口的DWMWA_USE_IMMERSIVE_DARK_MODE属性让标题栏也跟随暗色主题比单纯改网页背景协调得多。6. 打包发布与体积控制从开发机到用户桌面的最后一公里6.1 wails build 的产物与资源文件处理开发完功能后执行wails build会生成最终的可执行文件。它会自动先构建前端静态资源再将资源嵌入 Go 二进制中。默认输出目录是build/bin/文件名由wails.json里的outputfilename决定。这里有个常见的坑如果你的前端代码中有对绝对路径的请求比如/assets/xxx.js在 WebView 里可能没问题但打包后 Wails 的AssetServer只接受相对路径./assets/xxx.js否则会返回 404。我在开发模式一直没发现问题打包后界面空白排查很久才发现是 Vite 的base配置没改成./。解决办法是在vite.config.ts中设置export default defineConfig({ base: ./, plugins: [vue()], });6.2 使用 ldflags 裁剪与 UPX 压缩Go 的二进制默认包含大量调试信息打包时通过编译参数可以减少体积。我在wails build时习惯加这样一组 ldflagswails build -ldflags -s -w -H windowsgui-s去掉符号表-w去掉 DWARF 调试信息-H windowsgui让程序运行时不弹出控制台窗口仅 Windows 生效。搞定后这个 exe 大约 14MB 到 18MB视前端资源大小而定。如果想进一步压缩可以使用 UPXupx --best --lzma build/bin/aidu.exeUPX 压缩后体积能再降到 8MB 到 10MB。注意杀毒软件有时候会误报 UPX 加壳程序这个打包时要有心理准备看目标用户群体再决定是否使用。我的经验是个人工具软件用 UPX 没问题但面向企业分发时尽量别加壳减少 IT 部门安全策略的误判。6.3 NSIS 安装包与自动更新思路要给普通用户分发单文件 exe 不太友好我建议打成安装包。Windows 下我用 NSIS 做了一个简单安装脚本配置了桌面快捷方式、开始菜单项和卸载入口。NSIS 脚本本身不复杂把生成的 exe、图标和许可协议塞进去编译出来一个 5MB 左右的安装包。关于自动更新Wails 本身没有内置 update 机制但你可以基于 Go 的github.com/inconshreveable/go-update实现。思路是启动应用后请求一个版本清单 JSON发现有新版本就提示用户下载更新包下载完成后替换当前 exe 再重启。如果暂时不想做自动更新至少把版本号写进wails.json的info.productVersion后续排查问题会顺畅很多。7. 实战中的踩坑记录与性能优化心得7.1 踩坑前端取消请求导致 Go 协程泄漏这个问题是我在测试停止生成按钮时发现的——点击停止后对话 UI 确实不继续显示了但 Go 进程的 CPU 占用迟迟不降。后来查明原因前端 Wails 绑定方法调用ChatStream时前端虽然不再等待返回值但后端 HTTP 请求如果没有被真正取消协程就一直在等响应。解决方案就是前面提到的cancelFuncs设计——在前端调用ChatStream时把当前会话对应的 cancel 函数存下来点击取消时主动调用。这是所有流式接口方案里最容易踩的坑务必在一开始就设计好取消链路。7.2 踩坑WebView2 缓存导致更新不生效有一段时间我给应用改了前端样式发布新版本后用户反馈界面还是旧版。排查下来发现是 WebView2 的 HTTP 缓存把旧的 JS/CSS 资源缓存了。因为 Wails 的AssetServer默认按标准 HTTP 缓存策略处理资源版本更新后资源 URL 没变化时WebView2 就会继续用本地缓存。我的解决方式是用 Vite 的build.generatedAssets或文件哈希——每次构建时给资源文件名加上内容哈希这样更新版本后资源 URL 随之变化缓存就自然失效了。在vite.config.ts中配置build: { rollupOptions: { output: { entryFileNames: assets/[name]-[hash].js, chunkFileNames: assets/[name]-[hash].js, }, }, }这样发布新版本时文件名发生变化基本可以杜绝缓存问题。7.3 踩坑并发请求时的上下文管理Wails v2 中OnStartup里拿到的context.Context是应用生命周期级的适合存窗口上下文。但如果你在多个会话中并发触发流式请求全局用一个 context 管理会导致取消一个会话全部会话都断了。所以每个ChatStream请求都应该基于它自己的context.WithCancel存放在结构体的并发安全 Map 中不能复用全局 ctx。7.4 性能优化减少序列化与频繁 GC日常使用中对话历史较长时Go 后端的 JSON 序列化开销不可忽视。我的优化主要做了两件事一是对话记录批量写入 SQLite而不是每收到一个 chunk 就写库二是流式增量只传content字段不要把完整的消息对象反复序列化。此外对于那些体积很大的 Markdown 渲染我会在前端做先纯文本、结束后再渲染的策略前面 5.2 提过这既减少了前端 DOM 频繁更新的压力也规避了解析过程中的闪烁。实测在 300 行以上的长文档场景中这个策略能让滚动保持流畅。7.5 最后再分享一个小技巧如果要在发布时保留 Go 后端日志建议在 Windows 上构建时使用-ldflags -s -w但不要把-H windowsgui加上这样程序运行时会带一个控制台窗口直接打印日志排查线上问题非常方便。等确认稳定后再用-H windowsgui去掉这个窗口改造成纯 GUI 模式。这个技巧帮助我节省了很多分发后的问题定位时间。整个项目做下来我最直观的感受是Wails v2 给我提供了一条比 Electron 轻量、比 Tauri 更易上手的桌面应用路径而 Go 后端意外地适合做 AI 应用的业务逻辑层。如果你也要做 AI 桌面工具可以试试我的这套Go 后端 Vue3 前端 事件流桥接方案希望能帮你少走一些弯路。