摘要:本文从零讲解如何用 Wails3 (Go 桌面框架)+ 字节 CloudWeGo Eino(Go 大模型应用框架)构建一个桌面级 AI 智能体应用。覆盖技术选型、系统架构、多模型接入、TurnLoop 对话循环、Wails3 事件总线流式输出、工具调用人工审批(断点续跑)、RAG 文档问答、会话管理以及最终编译打包,并附上真实踩坑记录。全文代码均来自实际可运行项目。
1. 为什么选择 Wails3 + Eino?
1.1 桌面应用的痛:Electron 太重了
传统桌面 AI 应用大多基于 Electron:动辄 150MB 的安装包、300MB+ 的内存占用、还要内置一个 Chromium。对于"对话框 + Markdown 渲染 + 少量图表"这类轻量交互,这明显是杀鸡用牛刀。
Wails3 的方案完全不同:Go 写后端逻辑,Web 前端做界面,但不内置浏览器内核------Windows 上用 WebView2、macOS 上用 WKWebView、Linux 上用 WebKitGTK,全部调用系统自带的渲染引擎。结果:
| 指标 | Electron | Wails3 |
|---|---|---|
| 安装包体积 | ~150MB | ~15MB |
| 内存占用 | 300MB+ | < 1/10 |
| 后端语言 | Node.js | Go |
| 跨平台 | 三端 | 三端(一套代码) |
1.2 Eino:Go 生态的大模型应用框架
Python 生态有 LangChain,Go 生态对应的就是字节 CloudWeGo 开源的 Eino (github.com/cloudwego/eino)。其中 ADK(Agent Development Kit) 直接提供了生产级 Agent 三件套:
- TurnLoop:Agent 多轮"思考 → 调工具 → 再思考"的循环调度器,支持流式事件迭代器
- TypedAgent :类型安全的 Agent 封装(
Agent[M],M 为消息类型) - CheckPointStore :检查点存储,配合
StatefulInterrupt实现中断 → 人工审批 → 断点续跑
再加上 Eino-ext 的官方模型组件(agenticark / agenticopenai / agenticdeepseek / agenticqwen / agenticgemini),一套代码可以无痛切换六家模型。
1.3 组合的化学反应用
Wails3 解决"壳",Eino 解决"脑"------纯 Go 全栈,前后端通过生成的 binding 直接调用,不需要 HTTP 服务器、不需要 Node 后端,数据流用 Wails3 事件总线推送,这就是本文要实现的架构。
2. 系统架构
┌─────────────────────────── 前端(Vue3 + Arco Design)───────────────────────────┐
│ AiChatView.vue │
│ ┌─ 发送: AgentChatService.Message(id, text) (Wails3 binding 调用) │
│ └─ 接收: Events.On("sse", handleSSEEvent) (订阅后端事件) │
└───────────────┬─────────────────────────────────────────────────────────────────┘
│ Wails3 Runtime(进程内 RPC,无 HTTP)
┌───────────────▼─────────────────────────────────────────────────────────────────┐
│ service/agent_chat_service.go (Wails3 服务层,暴露给前端的绑定方法) │
│ Message / Approve / Abort / GetSessions / GetRender / Delete ... │
└───────────────┬─────────────────────────────────────────────────────────────────┘
┌───────────────▼─────────────────────────────────────────────────────────────────┐
│ logic/agent/chat_server.go (核心 Server[M]) │
│ TurnLoop(GenInput → PrepareAgent → OnAgentEvents) │
│ ├── chatmodel.newAgenticModel :多模型接入 + 深度思考注入 │
│ ├── Tool 工具集:文件读写 / grep / 执行命令 / RAG 文档检索 │
│ ├── approvalMiddleware:工具调用前 StatefulInterrupt 中断 │
│ └── a2ui.StreamToWriter → sseLineWriter → app.Event.Emit("sse", line) │
└───────────────┬─────────────────────────────────────────────────────────────────┘
│ Eino-ext 模型组件
┌───────────────▼─────────────────────────────────────────────────────────────────┐
│ 火山方舟 / OpenAI / Azure / DeepSeek / Qwen / Gemini(responses API) │
└─────────────────────────────────────────────────────────────────────────────────┘
核心思路:Service 层负责"桥" (Wails3 绑定),Server 层负责"脑" (TurnLoop 调度),流式输出走事件总线而不是 HTTP SSE。
3. 环境准备
- Go 1.25+(建议 1.24 以上)
- Wails3 CLI :
go install github.com/wailsapp/wails/v3/cmd/wails3@latest - Node.js 18+(前端构建)
- 一个模型 API Key :本文以火山方舟为例(
https://ark.cn-beijing.volces.com/api/v3),OpenAI 兼容协议,拿到 Key 后记得在方舟控制台开通目标模型服务(这一步踩坑最多,见第 12 节)
4. 工程结构
wails3_agent/
├── frontend/ # Vue3 + Vite + Arco Design
│ └── src/views/agent/chat/
│ └── components/AiChatView.vue # 对话主视图(事件订阅)
├── internal/
│ ├── service/agent_chat_service.go # Wails3 服务层(前端绑定入口)
│ ├── logic/agent/
│ │ ├── chat_agent.go # Agent 装配(NewAgentService)
│ │ └── chat_server.go # TurnLoop Server + 流式事件推送
│ └── utils/
│ ├── tools/gcfg/ # YAML 配置读取工具
│ └── extend/agents/
│ ├── chatmodel/model.go # 多模型工厂(读配置选择模型)
│ ├── a2ui/ # A2UI 协议:流式编码 + 历史渲染
│ ├── mem/ # 会话存储(jsonl)
│ └── tool/ # 工具集(含审批中断)
└── resource/config/agent.yaml # 集中配置
5. 配置管理:agent.yaml + gcfg 工具
所有模型参数、运行参数、工具开关集中在一个 YAML,换模型不换代码:
yaml
# resource/config/agent.yaml
base:
modelType: "ark" # ark / openai / deepseek / qwen / gemini
APIKey: "ark-xxx" # 你的 Key
Model: "doubao-seed-2-1-pro-260915"
BaseURL: "https://ark.cn-beijing.volces.com/api/v3"
enableThinking: true # 深度思考开关
thinkLevel: "low" # 思考等级
run:
open: false
timeout: 3 # 分钟
temperature: 0
tool:
isOrder: true # 命令执行工具
isWeb: true # 联网工具
memory:
open: true
storeType: "jsonl"
safety:
open: false
maxTokens: 3
配套的 gcfg 工具支持按点号路径 读取(base.modelType),懒加载 + 缓存,支持 yaml/yml/json/toml/ini:
go
// internal/utils/tools/gcfg/gcfg.go(节选)
// Instance 返回按 name 缓存的配置实例;path 可以是目录或具体文件
func Instance(name string, path string) *Config {
return instance.GetOrSetFunc(name, func() interface{} {
return newConfig(name, path)
}).(*Config)
}
// Get 按点号路径读取,如 "base" 或 "base.modelType"
func (c *Config) Get(key string) (interface{}, error) {
if err := c.load(); err != nil { return nil, err }
if key == "" { return c.data, nil }
value, ok := c.search(key)
if !ok { return nil, fmt.Errorf("gcfg: config key %q not found in %q", key, c.filePath()) }
return value, nil
}
6. 多模型接入:Eino-ext 模型工厂
核心是 chatmodel 包的 newAgenticModel:读配置 → 按 modelType 分支创建对应 Eino-ext 模型组件。以方舟为例,还支持注入**深度思考(Thinking)**配置:
go
// internal/utils/extend/agents/chatmodel/model.go(节选,ark 分支)
confBase, err := gcfg.Instance("agent", "resource/config").Get("base")
// ... confBase 断言为 map 后取字段 ...
enableThink := gconv.Bool(confBase_arr["enableThinking"])
switch gconv.String(confBase_arr["modelType"]) {
case "ark":
var thinkCfg *responses.ResponsesThinking
if enableThink {
thinkType := responses.ThinkingType_enabled // 开启深度思考
thinkCfg = &responses.ResponsesThinking{Type: &thinkType}
}
return agenticark.New(ctx, &agenticark.Config{
APIKey: gconv.String(confBase_arr["APIKey"]),
Model: gconv.String(confBase_arr["Model"]),
BaseURL: gconv.String(confBase_arr["BaseURL"]),
Timeout: &timeout,
Thinking: thinkCfg, // 注入思考配置
})
case "deepseek": // agenticdeepseek.New(...)
case "qwen": // agenticqwen.New(...)
case "gemini": // agenticgemini.New(...)
default: // agenticopenai.NewResponsesModel(...),支持 ByAzure + reasoningEffort
}
OpenAI 分支还可以通过 ResponseIncludableReasoningEncryptedContent 拿到思维链、用 Reasoning.Effort 控制推理强度,实现"思考等级可配"。
7. Agent 服务层:Service → TurnLoop
Wails3 生成的前端绑定调用 service.AgentChatService 的方法,Service 层再把请求交给 logic/agent 的 Server:
go
// internal/service/agent_chat_service.go(节选)
var ServerAgentic *agent.Server[*schema.AgenticMessage]
func init() { ServerAgentic = agent.NewAgentService() }
// Message 处理新的聊天消息,流式输出通过 "sse" 事件推送到前端
func (api *AgentChatService) Message(id, message string) any {
if err := ServerAgentic.HandleChat(id, message); err != nil {
return gf.Failed().SetMsg(err.Error())
}
return gf.Success().SetMsg("发送成功")
}
// Approve 以用户批准的决策恢复被中断的 Agent 运行:
// 创建新的 TurnLoop,带检查点/恢复功能,从中断处继续执行
func (api *AgentChatService) Approve(id, reason string, approved bool) any {
if err := ServerAgentic.HandleApprove(id, approved, reason); err != nil {
return gf.Failed().SetMsg(err.Error())
}
return gf.Success().SetMsg("审批成功")
}
HandleChat 内部的核心是 TurnLoop :每个会话持有一个 TurnLoop[*ChatItem, M],用 Push 提交用户消息,通过回调三件套驱动:
- GenInput :把会话历史 + 工作区上下文组装成模型输入(
EnableStreaming: true开启流式) - PrepareAgent:返回 TypedAgent(模型 + 工具 + 中间件)
- OnAgentEvents:把流式事件迭代器交给 handler 消费,等 handler 消费完再持久化中间消息
多轮并发时,新消息通过 loop.Push(item, adk.WithPreempt(adk.AfterToolCalls)) 抢占 当前回合(工具调用点之后打断),同时关闭旧 handler 的 handlerDone 通道让它立即退出,避免协程泄漏。
8. 流式输出:从 gin SSE 到 Wails3 事件总线
这是本次改造的重头戏。早期版本用 gin 起 HTTP 端口做 SSE:
text
前端 fetch("/admin/agent/chat/message") → gin SSE(data: xxx)→ 前端 consumeSSEStream
桌面应用里这套很别扭:要开 HTTP 端口、处理跨域、维护连接保活。Wails3 自带进程内事件总线 ,直接 app.Event.Emit("sse", data) 推给前端,前端 Events.On("sse", cb) 订阅即可。
8.1 后端:sseLineWriter + emitSSE
A2UI 编码器每次写一个完整 JSON 对象并以 \n 结尾,所以我们用一个 io.Writer 按行切分、逐条发事件:
go
// internal/logic/agent/chat_server.go(节选)
// sseLineWriter 实现 io.Writer,将流式输出的每一行完整 JSON 消息
// 通过 app.Event.Emit("sse", data) 推送到前端,替代旧的 gin SSE 输出
type sseLineWriter struct { buffer []byte }
func (w *sseLineWriter) Write(p []byte) (int, error) {
w.buffer = append(w.buffer, p...)
for {
idx := bytes.IndexByte(w.buffer, '\n')
if idx < 0 { break }
line := strings.TrimSpace(string(w.buffer[:idx]))
w.buffer = w.buffer[idx+1:]
if line == "" { continue }
emitSSE(line) // 每条完整 JSON 发一个事件
}
return len(p), nil
}
// emitSSE 通过 Wails3 事件总线把 data 推送到前端
func emitSSE(data string) {
app := application.Get()
if app == nil { return } // 安全兜底:未初始化时静默忽略
app.Event.Emit("sse", data)
}
在 handler 里,把流式迭代器接到这个 writer 上即可(同步阻塞,等流全部推完才返回):
go
lastContent, intermediates, interruptID, finalMsgIdx, streamErr := a2ui.StreamToWriter(
newSSELineWriter(), id, envelope.history, envelope.events,
)
8.2 前端:先注册监听 → 再调用 → finally 注销
关键时序:必须先 Events.On('sse', ...) 注册监听,再 await AgentChatService.Message(...) ,否则会漏掉开头几条渲染消息;调用结束(finally)再注销监听:
ts
// AiChatView.vue(节选)
import { AgentChatService } from "/#/gofly/internal/service";
import { Events } from "@wailsio/runtime";
// 处理 "sse" 事件推送的 A2UI 消息(替代旧的 consumeSSEStream)
const handleSSEEvent = (ev: any) => {
if (!ev || ev.data == null) return;
let msg: any;
try { msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data; } catch (_) { return; }
if (!msg) return;
if (msg.event === 'preempted') { removeQueuedMessage(); return; } // 被新消息抢占
if (msg.beginRendering) removeQueuedMessage(); // 新一轮开始渲染
processA2UIMessage(msg); // A2UI 标准渲染
};
async function streamMockReply(text: string) {
loading.value = true;
isStreaming.value = true;
// 先注册事件监听,再发送消息,避免漏掉开头的渲染消息
const offSSE = Events.On('sse', handleSSEEvent);
try {
// Wails3 绑定签名:Message(id, message)
const res: any = await AgentChatService.Message(props.sessionId + "", text);
if (res && res.code !== 0) Message.error('发送失败: ' + (res.message || '未知错误'));
} catch (err) {
Message.error('发送消息异常');
} finally {
offSSE(); // 注销监听
loading.value = false;
isStreaming.value = false;
removeQueuedMessage();
}
}
因为后端 HandleChat 是同步阻塞(等流推完才返回),所以"注册监听 → await → 注销"的时序天然不漏事件------这正是 Wails3 事件方案对比 HTTP SSE 的优势:没有连接生命周期管理。
9. 工具调用 + 人工审批:StatefulInterrupt 断点续跑
Agent 执行危险工具(执行命令、写文件)前,中间件触发 StatefulInterrupt 暂停 TurnLoop;前端弹出审批卡片,用户同意/拒绝后,后端创建带检查点恢复的新 TurnLoop 从中断处继续:
go
// handleApprove:以用户批准的决策恢复被中断的 Agent 运行
func (s *Server[M]) handleApprove(id string, req approveRequest) error {
sess, _ := s.cfg.Store.GetOrCreate(id)
interruptID := sess.GetPendingInterruptID()
if interruptID == "" { return fmt.Errorf("no pending interrupt for this session") }
var reason *string
if req.Reason != "" { reason = &req.Reason }
result := &commontool.ApprovalResult{Approved: req.Approved, DisapproveReason: reason}
sess.SetPendingInterruptID("") // 清除待审批标记,防重复审批
// 创建新的 TurnLoop,配置 CheckPointStore 支持恢复
loop := s.newLoop(sess, id, true)
loop.Push(&ChatItem{ApprovalResult: result, InterruptID: interruptID})
loop.Run(context.Background())
// ... 等待流式事件,StreamContinue 继续输出 ...
}
// GenResume 回调:把审批结果映射回被中断的目标,从断点续跑
func (s *Server[M]) makeGenResume() func(...) (*adk.GenResumeResult[*ChatItem, M], error) {
return func(ctx context.Context, loop *adk.TurnLoop[*ChatItem, M],
canceledItems, unhandledItems, newItems []*ChatItem) (*adk.GenResumeResult[*ChatItem, M], error) {
// 在 newItems 中找到审批项
var approvalItem *ChatItem
for _, item := range newItems {
if item.ApprovalResult != nil { approvalItem = item; break }
}
if approvalItem == nil { return nil, errors.New("no approval item found for resume") }
return &adk.GenResumeResult[*ChatItem, M]{
ResumeParams: &adk.ResumeParams{
Targets: map[string]any{approvalItem.InterruptID: approvalItem.ApprovalResult},
},
Consumed: canceledItems,
Remaining: unhandledItems,
}, nil
}
}
前端审批同样走"注册监听 → 调用 → 注销":
ts
const handleApprove = async (approved: boolean) => {
let reason = '';
if (!approved) reason = window.prompt('拒绝原因(可选):') || '';
const offSSE = Events.On('sse', handleSSEEvent); // 先注册
try {
const res: any = await AgentChatService.Approve(props.sessionId + "", reason, approved);
if (res && res.code !== 0) Message.error('审批失败: ' + (res.message || '未知错误'));
} catch (err) {
Message.error('审批请求异常');
} finally {
offSSE(); loading.value = false; isStreaming.value = false;
}
};
10. 会话管理与历史渲染(A2UI NDJSON)
会话元数据(列表/标题/删除/清空)由 mem.Store 管理,历史消息以 A2UI NDJSON 形式渲染------打开会话时一次性拉取,逐行 JSON.parse 后走同一套渲染管线:
go
// RenderHistory 将会话历史渲染为 A2UI NDJSON 文本
func (s *Server[M]) RenderHistory(id string) (string, error) {
sess, err := s.cfg.Store.GetOrCreate(id)
if err != nil { return "", err }
var buf bytes.Buffer
if err := a2ui.RenderHistory(&buf, id, sess.GetMessages()); err != nil { return "", err }
return buf.String(), nil
}
ts
// 前端:GetRender → 逐行解析 → processA2UIMessage
const res = await AgentChatService.GetRender(newId);
for (const line of (res?.data).split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try { processA2UIMessage(JSON.parse(trimmed)); } catch (_) {}
}
11. 编译与打包
bash
# 开发模式(热重载)
wails3 dev
# 打包:生成原生安装包(Windows 为 exe / 免安装单文件)
wails3 build
前端通过 Wails3 自动生成的 frontend/bindings/.../agentchatservice.ts 调用后端,无需手写任何 RPC 代码。最终产物单文件约 15MB,三端一套代码。
12. 踩坑记录(真实经验)
12.1 ModelNotOpen / InvalidEndpoint.ClosedEndpoint:不是没钱,是没开通
排查模型不可用,先分清楚错误类型:
text
Error code: 400 - InvalidEndpoint.ClosedEndpoint
→ 该模型端点已关闭/临时不可用(老模型 ID 失效)
Error code: 404 - {"code":"ModelNotOpen","message":"Your account xxx has not activated the model ..."}
→ 账号未开通该模型服务!(最常见)
结论 :全程没有 401/429,说明 Key 有效、不是欠费;是账号没有开通目标模型 。解决:方舟控制台 → 开通管理 → 目标模型点"开通服务",勾选"全选 + 自动开通新增模型"。另外注意 Key 与模型必须同属一个账号------换了个 Key 但模型没在这个账号下开通,照样报 ModelNotOpen。
12.2 APIKey 被硬编码,"改配置无效"
排查过一个诡异问题:界面/配置文件里改了 Key,程序仍用旧 Key。最终发现 model.go 里读取配置的那一行被注释掉,换成了硬编码 Key 生效------改配置永远不生效。教训:Key/模型这类敏感参数统一从配置读取,禁止硬编码;排查时先打印实际生效的值。
12.3 interface conversion: interface {} is nil, not map[string]interface{}
启动直接 panic:配置解析后 confBase.(gf.Map) 断言失败。原因:配置路径/格式不对导致解析结果不是期望类型。修复:
go
confBase_arr, ok := confBase.(gf.Map)
if !ok {
return nil, fmt.Errorf("chatmodel: config \"base\" has unexpected type %T", confBase)
}
任何从配置取值的断言都要做类型校验,而不是裸断言。
12.4 抢占竞态:旧 handler 阻塞 60 秒
多轮并发抢占时,旧 handler 可能一直阻塞在等待迭代器的通道上,直到 60 秒超时。解法:每个 handler 持有独立的 handlerDone 通道,被抢占时关闭它,旧 handler 立即收到信号退出并向前端发 {"event":"preempted"}。
12.5 前端 Arco 类型错误
<a-dropdown position="rt"> 在本版本 Arco Design 里不是合法值(DropdownPosition 只支持 top/tl/tr/bottom/bl/br),vite build 直接报类型错误,改成 position="br" 即可。
13. 总结
用 Wails3 + Eino 构建桌面 Agent 的核心收益:
- 轻:15MB 单文件、内存 < Electron 1/10,纯 Go 全栈无 Node 后端
- 强:Eino ADK 直接给到 TurnLoop / TypedAgent / CheckPointStore,工具审批断点续跑是开箱级能力
- 活:六家模型 yaml 切换、深度思考可配、RAG 文档问答开箱即用
- 顺:流式输出走 Wails3 事件总线,比 HTTP SSE 少一层网络栈,时序更可控
如果你也想做"Go 全栈 AI 应用闭环",这套组合是目前最值得投入的路线之一。完整源码与使用文档可通过 GoFly 社区插件市场获取(插件名:GoFly Agent · Eino 桌面智能体助手)。
效果预览


