数据截至 (上游 commit 44ba8283e9f7)
Cherry Studio — 架构与原理
30 秒导读: Cherry Studio 是一个开源的桌面 AI 客户端(Electron),你下载它就能和 OpenAI / Anthropic / 本地模型聊天。它真正值得读的地方不是 UI,而是它在 Electron 主进程里搭的一条「流式 AI 管线」:无论你是发一句普通聊天,还是和一个能读写你磁盘文件的 长驻 agent 对话,最终都收敛成「一个
topicId上至多一条活动流」。这份文档讲清这条管线、它怎么把「真正的 agent 运行时」(Claude Code SDK)接进来,以及它在工具、审批、打断(steering)上的设计取舍。
1. 这是什么(零基础也能懂)
一句话定义。 Cherry Studio 是一个跨平台桌面应用,把市面上各家大模型(以及本地模型、MCP 工具、知识库、agent)装进一个统一的聊天界面里。
解决谁的什么问题。 假设你想用 AI,但不想为每家模型开一个网页、各自登录、各自付费、各自配工具。Cherry Studio 让你在一个本地 app 里:
- 配好各家 API key,统一切换模型;
- 给某个「助手」挂上联网搜索 / 知识库 / MCP 工具;
- 更进一步,开一个 agent 会话——它有自己的工作目录,能用 Claude Code 那套工具真的去读写你的文件、跑命令、长时间自主干活。
它能做什么(功能)。
- 多 provider 聊天(OpenAI / Anthropic / Google / 本地 / 各种聚合网关)。
- 联网搜索、知识库检索、文件附件、绘画、翻译。
- MCP(Model Context Protocol)工具:把外部能力(Gmail、浏览器、文件系统…)作为工具挂给模型。
- Agent 会话:基于 Claude Agent SDK 的长驻会话,有工作区、工具审批、压缩、断线恢复。
- 定时任务 / 后台 agent(heartbeat、周期性任务),并能把结果推到 IM 频道。
用起来什么样。 桌面应用里,你新建一个 agent、选一个工作目录、选一个模型,然后像聊天一样发指令:
你: 帮我把 src/ 下所有 console.log 删掉,跑一下测试
agent: (读文件) (改文件) (运行 pnpm test) ……
[需要批准] 运行命令: pnpm test [允许] [拒绝]
你: [允许]
agent: 测试通过了,改了 7 个文件。
注意几件事:agent 自己决定调哪些工具;危险动作会弹审批卡等你点允许;它能跑很久、跨多轮,中途你还能补一句话「顺便也删掉 debugger」——这叫 steering(中途引导)。这三件事(自主工具循环、审批、steering)就是后面要讲的核心。
一句话直觉/类比。 把 Cherry Studio 的主进程想成一个电话总机:每个会话是一条电话线(topicId),总机(AiStreamManager)保证一条线上同时只有一通电话在响,谁来听都行(多窗口平权),挂不挂线由总机决定而不是听筒——所以你关掉窗口,AI 也不会断、数据也不会丢。
2. 顶层全景(它大概怎么转)
Cherry Studio 是标准的 Electron 三段式:renderer(浏览器里的 React UI)+ main(Node 主进程,握着文件、网络、数据库)+ preload/IPC(两者之间的桥)。它的一个关键架构决定是:所有 LLM 调用都在主进程,renderer 只通过 IPC 订阅一条流。
┌─────────────── Renderer (React UI) ────────────────┐
│ useChat({ id: topicId, transport: IpcChatTransport})│
│ sendMessages → streamOpen (发消息) │
│ reconnectToStream → streamAttach (重连) │
│ abort → streamAbort (停止) │
└───────────────────────┬─────────────────────────────┘
│ IPC,一律按 topicId 寻址
┌───────────────────────┴─────────────────────────────┐
│ Main 进程 │
│ │
│ ① 分发 dispatchStreamRequest │
│ 挑一个 ChatContextProvider(普通聊天/临时/agent)│
│ → 落库用户消息 → 组装 listeners │
│ │
│ ② AiStreamManager (活动流登记处) │
│ activeStreams: Map<topicId, ActiveStream> │
│ 一条流 = listeners + N 个 StreamExecution │
│ │
│ ③ 执行 runExecutionLoop │
│ ├─ 普通聊天 → Agent.stream() → @ai-sdk/* → 模型 │
│ └─ agent 会话 → AgentSessionRuntimeService │
│ → ClaudeCode driver → SDK │
│ │
│ ④ pipeStreamLoop 把 chunk 流「一读两用」: │
│ • 广播给 listeners(窗口/SSE/IM/落库) │
│ • readUIMessageStream 累积成完整消息 │
│ │
│ ⑤ 终态 → PersistenceListener 落库(SQLite) │
└────────────────────────────────────────────── ────────┘
怎么读这张图: 从上到下是一次请求的生命周期。最关键的分叉在 ③:普通聊天走通用 Agent 循环(主进程自己跑工具循环);agent 会话则把控制权交给一个 driver(目前是 Claude Code SDK),主进程只当 host。这条分叉是整个 chat-agents 设计的脊梁。
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
IpcChatTransport | renderer 端把 useChat 接到 IPC 上 | src/renderer/services/aiTransport/ |
dispatchStreamRequest | 选 provider、落库、组 listeners、决定 start/inject | src/main/ai/streamManager/context/dispatch.ts |
AiStreamManager | 活动流登记处,注册 stream IPC,跑执行循环 | src/main/ai/streamManager/AiStreamManager.ts |
ChatContextProvider | 三种上下文(持久聊天/临时/agent会话)的差异都收在这 | src/main/ai/streamManager/context/ |
Agent | 普通聊天的单趟流式工具循环(包 @ai-sdk) | src/main/ai/runtime/aiSdk/Agent.ts |
AgentSessionRuntimeService | 长驻 agent 会话的 host(turn 队列/恢复/steer) | src/main/ai/agentSession/AgentSessionRuntimeService.ts |
ClaudeCodeRuntimeDriver | 把 Claude Agent SDK 适配成通用 driver 接口 | src/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.ts |
ToolRegistry + MCP 同步 | 统一工具目录、deferred tools、审批门 | src/main/ai/tools/adapters/aiSdk/ |
McpRuntimeService | 连 MCP 服务器、调工具(stdio/HTTP/内存) | src/main/ai/mcp/McpRuntimeService.ts |
PersistenceListener | 终态把最终消息写进 SQLite | src/main/ai/streamManager/listeners/PersistenceListener.ts |
主线走一遍(高层,不进代码)。 你发一句话 → renderer 通过 IPC Ai_Stream_Open 把它送进主进程 → 分发器挑出「这是哪种会话」并落库用户消息 → AiStreamManager 在这个 topicId 上开一条活动流 → 执行循环要么跑通用 Agent(聊天),要么把它交给 agent driver(agent 会话)→ 模型吐 chunk,pipeStreamLoop 一边广播给所有订阅窗口、一边累积成完整消息 → 流结束时落库 → renderer 重新查 SQLite 拿到最终结果。
3. 四个核心关切(本套文档的主线)
这是一个庞大项目(Electron 三进程、多个子系统)。本套文档只聚焦 chat-agents 这条价值线——AI 流式管线与 agent 运行时,刻意略过绘画/翻译/知识库/同步等周边。四章由浅入深:
① 一条流怎么跑 → 01-stream-pipeline.md
(topicId 寻址 / 三种 provider / steering 三语义)
│
▼
② 长驻 agent 怎么托管 → 02-agent-session-runtime.md
(host/driver 分层 / turn 队列 / Claude Code 接入)
│
▼
③ 工具与 MCP 怎么组织 → 03-tools-and-mcp.md
(统一注册表 / MCP 同步 / deferred tools / 审批)
│
▼
④ 精华与边界 → 04-deep-dive.md
(steer-boundary 滚动 / resume / warm query / 压缩)
阅读建议:
- 只想知道「它整体怎么转」→ 读完本页 + 第 01 章即可。
- 关心「怎么接一个真正的 agent 运行时」(本 shelf 的核心母题)→ 重点第 02 章。
- 关心工具/审批工程 → 第 03 章。
- 想抄技巧、想知道它会在哪崩 → 第 04 章。
4. 一句话记住每章
| 章节 | 一句话 |
|---|---|
| 01 流式管线 | 一条流按 topicId 寻址,主进程独占,关窗不断流;聊天 steering 是「排队+让步+续接」,不是打断重来。 |
| 02 agent 运行时 | host(AgentSessionRuntimeService)管 UI/turn 队列/恢复,driver(Claude Code)管真正的 agent 进程;两者用一组通用事件解耦。 |
| 03 工具与 MCP | 工具进一个进程级注册表;工具太多时折叠进 tool_search(deferred tools);审批由主进程独家裁决。 |
| 04 深入 | steer-boundary 把一条 agent 回答「滚」成两行、resume token 做断线恢复、warm query 预热、SDK 压缩穿透。 |
5. 横向对比(同 shelf 兄弟)
Cherry Studio 在 chat-agents 区里是「桌面客户端 + 接现成 agent 运行时」这一路:它不自研 agent 的工具循环内核,而是把 Claude Agent SDK 当 driver 接进来(第 02 章),自己专注做 host 层(会话生命周期、UI 流、持久化、审批、steering)。这与「自研 agent 内核」的兄弟项目是互补关系:前者更像「集成与编排」,后者更像「内核与算法」。详细取舍见各章的「巧妙之处 / 边界」与本 shelf 总库 doc。
6. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 流式管线入口与活动流登记 | src/main/ai/streamManager/AiStreamManager.ts | AiStreamManager |
| 分发:选 provider + start/inject | src/main/ai/streamManager/context/dispatch.ts | dispatchStreamRequest |
| agent 会话 host | src/main/ai/agentSession/AgentSessionRuntimeService.ts | AgentSessionRuntimeService |
| driver 抽象接口 | src/main/ai/runtime/types.ts | AgentSessionRuntimeDriver, AgentRuntimeConnection, AgentRuntimeEvent |
| Claude Code driver | src/main/ai/runtime/claudeCode/ClaudeCodeRuntimeDriver.ts | ClaudeCodeRuntimeDriver, ClaudeCodeRuntimeConnection |
| 通用聊天 agent 循环 | src/main/ai/runtime/aiSdk/Agent.ts | Agent |
| 工具注册表 | src/main/ai/tools/adapters/aiSdk/registry.ts | ToolRegistry, registry |
| deferred tools 判定 | src/main/ai/tools/adapters/aiSdk/exposition/shouldDefer.ts | shouldDefer |
| MCP 运行时 | src/main/ai/mcp/McpRuntimeService.ts | McpRuntimeService |
| 后台任务 agent | src/main/ai/agents/runAgentTask.ts | runAgentTask |
新鲜度。 本套文档锁定 commit
a77f6f5c。引用尽量锚在符号名上(函数/类),行号仅作辅助——上游更新后用符号名 grep 仍能定位。仓库自带的docs/references/ai/内部文档非常详尽,本套文档大量交叉核对过它们与真实源码。