在深入任何具体功能之前,先看清整张地图。
为什么需要系统地图
Agent 系统的复杂度不在于任何单一模块,在于模块之间的关系。
当一个功能出了问题,你需要能快速判断:这是 LLM 调用层的问题?工具执行层的问题?状态管理的问题?还是权限决策的问题?
没有清晰的系统地图,定位问题会变成大海捞针。
第一个决策:Electron
在写任何业务代码之前,第一个要回答的问题是:用什么技术做桌面应用?
这个选择决定了后续所有架构的形态,几乎不可撤回。我们评估了四个方案:
方案对比
| 维度 | Electron | Tauri | Flutter Desktop | 原生(Swift/C++) |
|---|---|---|---|---|
| 语言 | TypeScript | Rust + JS 前端 | Dart | Swift / C++ |
| 包体大小 | 较大 | 很小 | 中等 | 最小 |
| 内存占用 | 高(Chromium 进程) | 低 | 中 | 最低 |
| 生态成熟度 | 极高 | 中等,快速增长 | 桌面端较新 | 各平台独立 |
| 跨平台 | 三端统一代码 | 三端统一代码 | 三端统一代码 | 每平台单独写 |
| AI/Agent 工具链 | 极丰富(npm 生态) | 有限 | 有限 | 几乎没有 |
| Node.js 集成 | 原生支持 | 需 sidecar 或 API | 不支持 | 不支持 |
| 热更新 | 简单 | 简单 | 需要额外配置 | 困难 |
为什么选 Electron
决定因素是三个工程现实,包体大小反而排不上号:
第一,Node.js 生态对 Agent 开发至关重要。 MCP SDK、OpenAI 和 Anthropic 的官方 SDK、大量 AI 相关工具链,都是 TypeScript 写的。选 Tauri 意味着要么在 Rust 里重写这些 SDK 的调用逻辑,要么通过 sidecar 进程桥接 Node.js 环境。前者工作量巨大,后者引入了进程间通信的复杂度和性能损耗。
第二,主进程直接执行 shell 命令和文件操作。 Agent 的核心能力之一是代替用户执行系统命令。Electron 的主进程是完整的 Node.js 环境,可以直接执行系统命令和操作文件。Tauri 的后端是 Rust,虽然也能执行命令,但要和前端 JS 传递复杂的流式输出(stdout/stderr 实时流),需要通过额外的桥接机制,中间多了序列化和反序列化的开销。
第三,工作线程隔离。 Alice 把 Agent 循环放在独立的工作线程里运行,避免阻塞主进程的 UI 响应。Node.js 的工作线程是语言原生支持的,数据通过共享内存或消息传递进行通信。在 Tauri/Rust 里实现类似的隔离需要跨语言线程管理,复杂度高一个量级。
我们试过 Tauri
在项目早期,我们用 Tauri 做过一个为期两周的原型验证。包体大小确实很吸引人,打出来的 macOS 应用比 Electron 版本小一个数量级。
但在接入 MCP 时遇到了第一个障碍:MCP 的 stdio 传输方式需要 spawn 一个子进程并通过 stdin/stdout 通信。Tauri 的 Rust 后端可以做到这一点,但前端 JS 无法直接参与这个过程,必须通过 Tauri 的命令机制中转。每一条 MCP 消息都要经过多层转发路径,延迟增加可感知。单条消息的延迟不算什么,但一次工具列表查询可能返回几十个工具定义,累积延迟就变得明显。
第二个障碍是 AI SDK 的集成。我们需要流式调用 LLM API,解析 SSE 响应中的工具调用分片,然后实时发送给前端渲染。用 TypeScript 直接做这件事代码量很少。在 Tauri 里,要么用 Rust 的 HTTP 客户端重写 SSE 解析(工作量大,且需要跟上各家 API 格式变化),要么 spawn 一个 Node.js sidecar 进程来做这件事(回到了 Electron 的路子,但更复杂)。
最终我们决定:承受 Electron 的包体代价,换取开发效率和生态兼容性。这个决定至今没有后悔过。
代价是什么
Electron 的代价是真实存在的:
- 包体大小:安装包较大。对比 Cursor(同样基于 Electron,但包含了完整编辑器,所以更大)和 Claude Code(纯 CLI,npm 安装,很轻量),Alice 处于中间位置。
- 内存占用:Electron 启动就占不少内存,因为要加载 Chromium 渲染进程。加上 Agent 循环的工作线程,Alice 的峰值内存占用不算低。对于一个 AI 助手应用来说这不算离谱,但对低配设备不友好。
- 启动速度:冷启动需要几秒,主要是 Chromium 初始化的时间。我们通过懒加载非关键模块(MCP 连接、向量数据库初始化等推迟到首次使用时)把启动后的可交互时间优化到了可接受的范围。
分层架构
Alice 的架构分为五层,每一层职责严格界定,跨层直接调用是架构上不允许的做法。一旦开了这个口子,腐化很快。
从上到下依次是:
- UI 层(渲染进程):React 组件、事件消费、状态展示
- 编排层(主进程):会话管理、模块协调、生命周期
- Agent 循环层(工作线程):消息组装、LLM 调用、工具执行
- 工具层 + 服务层:工具是 AI 可见的,服务是 AI 不可见的
- 基础设施层:LLM 适配、存储、加密、可观测
UI 层通过 IPC 事件与编排层通信,编排层通过消息通道与 Agent 循环层通信,Agent 循环层调用工具层和服务层,它们再调用基础设施层。
三进程架构:Agent 场景的特殊挑战
Electron 的经典架构是两个进程:主进程(Node.js)和渲染进程(Chromium)。Alice 在此基础上加了第三个:工作线程(Agent 循环)。
为什么需要第三个进程
一次完整的 Agent 循环可能持续几分钟:多轮 LLM 调用、多次工具执行、上下文压缩、记忆检索。如果这些都在主进程里跑,窗口会卡死,因为主进程同时还要处理 IPC 消息、管理 MCP 连接、响应窗口事件。
把 Agent 循环放进工作线程后,主进程只做协调和路由,保持响应性。但这带来了新的问题。
三进程的数据传递成本
工作线程和主进程之间通过消息传递通信,数据会被序列化克隆。对于小消息,克隆成本可以忽略。但 Agent 场景有一种特殊情况:工具返回大文件内容。
当模型调用文件读取工具,读取一个较大的源代码文件时,这个结果需要从工作线程传回主进程(用于持久化),再传到渲染进程(用于展示)。每次传递都要做一次序列化克隆。文件越大,克隆次数越多,累积开销就越显著。
我们试过使用共享内存来避免克隆,但发现 Electron 的安全策略对此有限制,相关设置会影响某些外部资源的加载。最终选择了一个折中方案:超过一定阈值的工具结果在工作线程中截断后再传递,原始完整结果只持久化到磁盘,不经过进程间通信路径。
三进程职责分工
- 主进程:窗口管理、IPC 路由、系统权限、自动更新
- 渲染进程:UI 界面、状态管理、流式渲染
- 工作线程:Agent 主循环、工具执行、LLM 调用、记忆系统
渲染进程与主进程通过 IPC 双向通信。主进程与工作线程通过消息通道双向通信。工作线程产生的流式事件经由主进程转发给渲染进程。
进程间消息协议
一次对话的消息流转过程是这样的:
- 用户在渲染进程输入消息
- 渲染进程通过 IPC 发送给主进程
- 主进程校验权限、恢复会话,然后把消息转发给工作线程
- 工作线程运行 Agent 循环,持续产生流式事件(文字片段、工具执行状态、完成信号等)
- 这些事件从工作线程传回主进程
- 主进程做必要的持久化后,转发给渲染进程展示
整个过程是一条单向的事件流管道,消费者(UI)控制渲染节奏。
错误隔离
三进程的一个隐藏好处是错误隔离。如果 Agent 循环中的某个工具执行了恶意代码导致线程崩溃,工作线程挂了但主进程不受影响。主进程检测到工作线程断开后,可以重新创建一个工作线程,恢复会话状态,让用户继续使用。
但也有一个坑:工作线程崩溃时,其中正在进行的异步操作会丢失。如果一个 LLM 调用正在流式返回,崩溃意味着这次调用的结果全部丢失,模型的输出到一半断了。恢复后需要重新发起 LLM 调用,但上下文中已经有了上一次调用的部分结果,如果不清理会导致重复。
解决方案是在每次 LLM 调用前打一个检查点,记录当前的上下文快照。崩溃恢复后,回滚到最近的检查点重新开始。代价是浪费已经消耗的 token(崩溃前那次不完整的调用的费用)。
与 Claude Code 纯 CLI 架构的对比
Claude Code 是纯 CLI 应用,用 Ink(React 的终端渲染库)做 UI。它的架构比 Alice 简单得多:
| 维度 | Claude Code(CLI) | Alice(Electron GUI) |
|---|---|---|
| 进程模型 | 单进程 | 三进程(主进程 + 渲染进程 + 工作线程) |
| UI 框架 | Ink(终端) | React(Chromium) |
| IPC 开销 | 无 | 有(消息序列化克隆) |
| 启动速度 | 快 | 较慢 |
| 富文本渲染 | 有限(ANSI 颜色) | 完整(HTML/CSS) |
| 交互能力 | 键盘为主 | 鼠标 + 键盘 + 拖放 |
| 可观测 UI | 需要外部工具 | 可内建 Debug 面板 |
Claude Code 的架构优势
单进程意味着零 IPC 开销。Agent 循环产生的事件直接传给 Ink 组件渲染,中间没有序列化和反序列化。这在工具密集型的场景下优势明显:当模型快速连续调用多个工具时,Claude Code 的事件传递几乎是即时的,Alice 需要经过工作线程到主进程再到渲染进程的多跳。
Claude Code 的启动也更快。它不需要初始化 Chromium,Node.js 启动后直接进入业务逻辑。
Alice 的架构优势
但 GUI 有 CLI 做不到的事情:
多会话管理。Alice 可以同时打开多个会话标签页,每个标签页有独立的 Agent 循环和上下文。在 CLI 里切换会话需要退出当前会话再进入另一个,或者打开多个终端窗口。
可视化调试。Alice 可以在侧边栏实时展示 Agent 循环的内部状态:当前上下文的 token 数、工具调用队列、权限决策日志。这些信息用 HTML/CSS 排版比终端字符灵活得多。
富交互。文件拖放、图片预览、代码高亮编辑器、流式 Markdown 渲染(含图表、公式),这些在 CLI 里要么做不到,要么体验很差。
非技术用户友好。CLI 面向开发者,GUI 面向更广的用户群。Alice 的目标用户不仅是程序员,还包括写作者、研究人员、知识工作者。对他们来说,一个可以点击的界面比一个需要输入命令的终端友好得多。
代价的量化
为了 GUI 的这些好处,Alice 付出了可观的架构复杂度代价:主进程的路由和会话管理代码、工作线程的消息桥接代码,这些在 Claude Code 里完全不存在,因为 CLI 不需要。
但反过来看,Claude Code 有自己的复杂度:终端渲染本身有诸多挑战(光标管理、终端兼容性、Unicode 宽度计算),只是这些复杂度分布在不同的地方。
编排层:单例的职责
编排层通常实现为一个单例,它是所有模块的协调者:
- 持有 LLM 客户端、记忆存储、MCP 客户端、密钥管理等全局实例
- 管理每个会话的生命周期(创建、恢复、销毁)
- 对外暴露统一的对话入口接口
- 负责在会话开始时从持久化存储恢复历史,在会话结束时持久化新内容
关键设计决策:编排层不直接执行工具,也不直接调用 LLM。它的职责是把正确的东西交给正确的模块。
单例模式的争议
单例模式在软件工程中名声不好,因为它引入全局状态,降低可测试性。但在 Agent 系统中,单例有一个正当理由:LLM 客户端、MCP 连接池、密钥管理器这些资源天然是全局唯一的。创建多个 LLM 客户端实例没有意义(它们共享同一个 API key),创建多个 MCP 连接池会导致连接数翻倍且状态不同步。
我们试过用依赖注入完全替代单例。在应用启动时初始化所有依赖并逐层注入,代码确实更干净了,但带来了两个问题:一是初始化顺序变得很脆弱(A 依赖 B,B 依赖 C,C 又依赖 A 的某个配置),二是在工作线程里无法直接获取主进程中的依赖实例,需要额外的代理层。最终回到了单例 + 显式初始化的方案。
Agent 循环层:系统的心脏
Agent 循环层是整个系统最核心的部分。
它的入口是用户消息,出口是事件流。上下文压缩、LLM 调用、工具执行、结果写回,这一切都在这里编排。
循环层是无状态的。它的所有依赖(LLM 客户端、上下文管理器、权限引擎等)都通过构造函数注入,不使用全局状态。这让它可以被独立测试,也可以在工作线程里运行。
工具层与服务层的边界
这是架构里最容易被模糊的边界。
工具层:暴露给 AI 的能力。每个工具都是一个声明式的契约:名称、描述、输入格式、执行函数。工具的调用会被记录在对话历史里。
服务层:框架内部的能力。定时任务调度器、后台记忆提取、自动备份、性能追踪,这些都是服务。AI 不知道它们的存在,它们也不应该出现在对话历史里。
判断一个功能应该做成工具还是服务,只需要问一个问题:如果 AI 主动触发这个功能,是用户期望的行为吗?
如果是(比如搜索网络),做成工具。如果不是(比如定期压缩历史消息),做成服务。
边界模糊的灰色地带
现实中有很多功能不好归类。举几个例子:
Skill 改进分析。当 AI 使用了某个 Skill 并发现效果不好时,是让 AI 自己调用改进工具(做成工具),还是后台自动分析(做成服务)?我们选择了做成服务,因为用户不关心 Skill 是怎么改进的,只关心它下次能不能更好用。
记忆提取。从对话中提取值得长期记住的信息,是让 AI 自己决定(工具),还是后台自动做(服务)?Claude Code 选了让用户手动维护项目记忆文件(既不是工具也不是服务,是用户行为),Alice 选了后台自动提取(服务)。两种方案都有道理,我们选后台的原因是不想增加 AI 的工具选择负担。
数据流:一次对话的完整旅程
一次对话从用户输入到最终响应,经过以下流转:
- 用户在 UI 输入消息
- 消息通过 IPC 传到编排层
- 编排层恢复会话历史,把消息传给工作线程
- 工作线程组装系统提示词,做向量检索关联记忆
- 进入 Agent 循环:检查上下文大小按需压缩,流式调用 LLM
- LLM 返回文字和工具调用指令,工作线程向上游发射事件
- 如果有工具调用,按并发安全性分批执行,结果写回上下文,继续循环
- 循环结束后,编排层持久化新消息、刷新向量队列
- UI 渲染完成
整个过程中,事件是单向流动的:工作线程持续产生事件,经由主进程转发,最终被 UI 消费和渲染。
关键设计:核心逻辑与 UI 严格分离
业务逻辑(Agent 循环、工具执行、记忆管理)应该与 UI 框架完全解耦。
好处:
1. 业务逻辑可以在多个环境运行:主进程、工作线程、命令行
2. 可以独立对业务逻辑编写单元测试,不需要启动 UI
3. UI 只做渲染,不包含任何业务判断,降低维护成本
实现方式:业务层只输出纯数据事件,UI 层消费事件并渲染,两层之间没有任何反向依赖。
分离的边界在哪里
我们犯过一个错误:把消息格式化逻辑放在了 UI 层。Markdown 渲染、代码高亮、图表解析,这些看起来像是 UI 的事情,但实际上消息的格式化结果会影响用户体验,而体验问题需要在不启动 UI 的情况下也能测试。后来我们把消息格式化抽成了独立的纯函数模块,UI 层只调用渲染结果,不做任何解析。
配置的层级
一个 Agent 系统通常需要多层配置:
| 层级 | 内容 | 存储位置 |
|---|---|---|
| 全局默认 | 代码中的默认值 | 代码 |
| 系统配置 | LLM、权限、MCP 规则 | YAML 文件 |
| 用户设置 | 模型选择、UI 偏好 | 数据库 |
| 会话级 | 临时规则、当前模式 | 内存 |
优先级从低到高:全局默认 < 系统配置 < 用户设置 < 会话级。高优先级覆盖低优先级。
配置冲突的处理
四层配置听起来清晰,但实践中有很多纠结的场景。比如用户在系统配置里设置了「所有外部工具需要确认」,但在会话级临时设置了「自动执行」。这个会话结束后,下一个会话应该恢复到系统配置还是保留用户的最后选择?
我们选择了会话级配置不持久化的策略:会话结束就清除,下一个会话从系统配置开始。理由是会话级配置往往是临时的调试行为,持久化反而会让用户忘记自己改过什么。但这也意味着用户不能通过会话级配置来试用一个设置再决定是否永久保留,每次都要去设置面板手动修改。