Files
qiming/qimingclaw/crates/agent-electron-client/docs/architecture/ACP-ENGINE-PERF-OPTIMIZATION.md

171 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
version: 1.0
last-updated: 2026-03-07
status: stable
---
# ACP 引擎性能优化说明
> 针对 `/computer/chat` 首次响应延迟约 10s 的优化引擎预热池、ACP SDK 预加载、SSE 事件缓冲。
> **适用引擎**`claude-code-acp-ts`、`qimingcode`。
> **最后更新**2026-03-07
---
## 1. 问题与根因
### 1.1 现象
使用 qimingcode或 claude-code新会话首次请求的 HTTP 总耗时约 **7.5s**,加上模型推理后用户体感接近 **10s**
### 1.2 耗时分解(来自日志)
| 阶段 | 耗时 | 说明 |
|------|------|------|
| `ensureEngine` | ~4.35s | 每次新 `project_id` 冷启动 ACP 进程spawn + initialize handshake |
| `ACP newSession` | ~3.1s | 每次新 project 创建 ACP session含 MCP 连接等) |
| `ACP prompt resolved` | ~2.7s | 模型推理,无法通过客户端优化 |
| SSE 早期事件 | - | `prompt_start` 等在 SSE 连接建立前推送,前端收不到 |
### 1.3 根因
- **UnifiedAgentService** 按 `project_id` 懒惰创建 `AcpEngine`,每个新 project 都会触发一次进程冷启动。
- **rcoderTauri** 在 init 时预启动进程并跨项目复用Electron 侧未做等效预热与复用。
- **SSE**`POST /computer/chat` 先返回 `session_id`,前端再建 `GET /computer/progress/{session_id}`,存在时间差,早期事件在无客户端时被丢弃。
---
## 2. 优化方案概览
| 优化项 | 文件 | 效果 |
|--------|------|------|
| 引擎预热池 | `unifiedAgent.ts` | 新 project 的 ensureEngine 从 ~4.35s 降至 ~0ms复用预热引擎 |
| ACP SDK 预加载 | `unifiedAgent.ts` | claude-code 首次 init 时 ESM 加载不再占关键路径 |
| SSE 事件缓冲回放 | `computerServer.ts` | 无客户端时先缓冲SSE 连接建立后回放,避免丢失早期事件 |
---
## 3. 实现说明
### 3.1 引擎预热池unifiedAgent.ts
**思路**`agentService.init()` 完成后,后台**同时预热两种引擎**`claude-code``qimingcode`避免「init 用 claude-code、请求用 qimingcode」时永远无法复用。池按引擎类型存储新 project 请求到来时按 `requestedEngine` 取用;若存在且关键配置一致则复用,并立即再预热同类型以补充池子。
**新增/修改**
- **字段**`warmEnginePool: Map<AgentEngineType, AcpEngine>`(按类型各一)、`warmEngineTasks: Map<AgentEngineType, Promise<void>>`
- **startWarmingEngine(engineType)**:若该类型未在池中且无进行中任务、有 baseConfig则异步执行`new AcpEngine(engineType)``init({ ...baseConfig, engine: engineType })` → 成功则 `pool.set(engineType, engine)`,失败则 destroy。
- **init()**:末尾调用 `startWarmingEngine('claude-code')``startWarmingEngine('qimingcode')`,双引擎同时预热。
- **getOrCreateEngine()**
-`requestedEngine = effectiveConfig.engine || this.engineType || 'claude-code'` 从池中 `get(requestedEngine)`,若有则 `delete` 取出。
- 若取到且与 `effectiveConfig` 的 apiKey/baseUrl/model 一致,则挂事件、`updateConfig(effectiveConfig)`、写入 `engines`/`engineConfigs`、调用 `startWarmingEngine(requestedEngine)` 补充、返回。
- 若取到但配置不一致,则对取出的引擎 `removeAllListeners` + `destroy`,避免泄漏。
- **destroy()**
-`await Promise.all([...warmEngineTasks.values()])` 并清空 tasks再遍历 `warmEnginePool.values()` 并 destroy、清空池。
**配置匹配**:按请求的引擎类型从池中取用后,**必须 apiKey 与 baseUrl 一致**才复用;否则复用的进程仍是 init 时的认证claude-code-acp-ts 会返回 "Authentication required"、内容为空。复用后调用 `updateConfig(effectiveConfig)` 同步 model/mcpServers 等;认证不一致时销毁预热引擎并走冷启动。
**MCP 与复用**复用预热引擎时AcpEngine 的 `this.config` 仍是 init 时的 baseConfig`createSession()` 使用 `this.config.mcpServers`。若不复用后更新,本请求的 `context_servers`(已通过 ensureEngineForRequest 同步到 proxy 并写入 effectiveConfig.mcpServers不会生效。因此复用路径中在挂事件、写入 engines 之前调用 **`warm.updateConfig(effectiveConfig)`**AcpEngine 新增方法),使后续 `chat()``createSession()` 使用本请求的 MCP 配置,不影响 MCP 加载。
### 3.2 ACP SDK 预加载unifiedAgent.ts
**思路**`loadAcpSdk()` 首次调用会动态 `import('@agentclientprotocol/sdk')`,在 Electron CJS 环境有一次性解析/编译开销。在 `init()` 末尾非阻塞调用一次,将此次开销移到应用启动阶段。
**实现**:在 `init()` 中、`startWarmingEngine()` 之前增加一行:
```ts
loadAcpSdk().catch(() => {});
```
不 await失败静默忽略不影响主流程。
### 3.3 SSE 事件缓冲回放computerServer.ts
**思路**`pushSseEvent(sessionId, eventName, data)` 被调用时,若该 `sessionId` 尚无 SSE 客户端,则将事件写入内存缓冲;当有客户端通过 `GET /computer/progress/{session_id}` 连接时,先回放缓冲再正常推送新事件。
**实现**
- **常量**`SSE_EVENT_BUFFER_MAX = 50`,单 session 最多缓冲条数。
- **结构**`sseEventBuffers: Map<sessionId, { events: string[]; createdAt: number }>`
- **pushSseEvent()**:无客户端时,创建或取已有 buffer`events.length < SSE_EVENT_BUFFER_MAX` 则 push 当前 payload 后 return有客户端时逻辑不变直接写 response。
- **GET /computer/progress/{session_id}**:在把 `res` 注册到 `sseClients` 之后,若存在该 sessionId 的 buffer则依次 `res.write(eventPayload)` 回放,然后 `sseEventBuffers.delete(sessionId)`,并打日志。
- **stopComputerServer()**:在清空 `sseClients` 后调用 `sseEventBuffers.clear()`
- **Cancel 与缓冲**`POST /computer/agent/session/cancel` 会调用 `acpEngine.abortSession(sessionId)`**会中止 ACP 会话**(停止产生 SSE 的进程);取消成功后调用 `clearSseEventBuffer(sessionId)` **清除该 session 的 SSE 缓冲**,避免用户重连 `GET /computer/progress/{session_id}` 时仍回放已取消会话的旧事件。
- **Stop重启动/停止)与缓冲**`POST /computer/agent/stop` 会调用 `agentService.stopEngine(project_id)`**会停止该 project 的整个引擎**(进程销毁,其下所有 session 终止);停止前先通过 `acpEngine.listSessions()` 取得该引擎下所有 session id并逐一调用 `clearSseEventBuffer(sessionId)` **清除这些 session 的 SSE 缓冲**,避免之后重连或新建 SSE 时仍回放旧事件。
- **客户端停止/重启所有服务**:客户端通过 `services.stopAll()``services.restartAll()` 停止/重启时,会调用 `serviceManager.stopAllServices()``restartAllServices()`,在调用 `agentService.destroy()` **之前**调用 **`clearAllSseEventBuffers()`**computerServer 导出),清空全部 SSE 事件缓冲,避免重启后前端重连仍回放旧会话事件。
---
## 4. 并发与生命周期注意点Review 结论)
- **预热池并发**:从池中取引擎必须“先 shift 再使用”;若配置不匹配,对已取出的引擎做 destroy避免同一实例被多个请求复用或泄漏。
- **destroy 顺序**:先 await `warmEngineTask`,再销毁 `warmEnginePool` 中所有引擎并清空池,否则任务结束时 push 进池的引擎可能未被销毁。
---
## 5. 预期效果
| 阶段 | 优化前 | 优化后 |
|------|--------|--------|
| ensureEngine新 project | ~4.35s | ~0ms复用预热时 |
| loadAcpSdk 首次加载claude-code | 串在 init 内 | 预加载,已缓存 |
| ACP newSession | ~3.1s | ~10msqimingcode v1.1.68+ MCP 懒加载) |
| 模型推理 | ~2.7s | ~2.7s(不变) |
| **HTTP 响应总时间** | **~7.5s** | **~2.7s** |
| SSE 早期事件 | 易丢失 | 缓冲回放,不丢失 |
同项目再次请求仍走现有 session 复用,无变更。
---
## 6. 相关文件
- `src/main/services/engines/unifiedAgent.ts`预热池、SDK 预加载、getOrCreateEngine 复用与 destroy 顺序。
- `src/main/services/engines/acp/acpClient.ts``loadAcpSdk()` 定义。
- `src/main/services/computerServer.ts`SSE 缓冲、回放、`pushSseEvent``stopComputerServer` 清理。
---
## 7. 单测覆盖
- **unifiedAgent.test.ts**`UnifiedAgentService — 引擎预热池ACP 性能优化)`
- `init()``loadAcpSdk` 被调用
- `getOrCreateEngine` 在预热完成后复用池中引擎且配置一致
- 从池取出的引擎配置不匹配时被 `destroy`
- `destroy()` 清空预热池且不抛
- **computerServer.test.ts**`ComputerServer — SSE 事件缓冲`
- `getSseEventBufferSize` 在无缓冲时返回 0
- 无客户端时 `pushSseEvent` 将事件写入缓冲
- 缓冲条数上限 50`SSE_EVENT_BUFFER_MAX`
运行:`npm run test:run``npm run test:coverage`
## 8. 可选后续优化
- **SSE buffer TTL**:已实现。对长期无客户端连接的 sessionId 做 buffer 过期清理30s`SSE_EVENT_BUFFER_TTL_MS`),在 `pushSseEvent` 无客户端路径中调用 `pruneExpiredSseEventBuffers()`,避免 Map 在极端场景下增长。
- **预热配置扩展**:若未来需按 `env``mcpServers` 区分引擎,可在复用前增加比对,不匹配则销毁取出的预热引擎。
- **qimingcode 进程级 vs session 级 MCP**`OPENCODE_CONFIG_CONTENT` 在进程 spawn 时注入(来自 baseConfig会话级 MCP 以 ACP `newSession``mcpServers`(来自 `this.config`,复用路径已由 `updateConfig(effectiveConfig)` 更新)为准。当前实现以 session 级为准,进程级仅作默认/权限等用途。
## 9. 日志分析与可优化点2026-03-07
### 9.1 首包 ensureEngine 耗时分解(典型 qimingcode 冷启动)
| 阶段 | 耗时 | 说明 |
|------|------|------|
| parseCtxServers | 0ms | 解析请求 context_servers |
| **syncMcpConfigToProxyAndReload** | **~700ms** | 首请求同步 MCP 到 proxy、写 DB、必要时重启 bridge同 project 后续请求已跳过12ms |
| **ensureMemoryReady** | **~500ms** | 仅冷路径memory index dirty 时做 fileSync已通过 init 时后台预执行 `ensureMemoryReadyForSession()` 减少首包等待 |
| **getOrCreateEngine (engine.init)** | **~6s** | qimingcode 进程冷启动;复用预热池或同 project 复用后降至 0ms 级 |
| **同 project 后续请求** | **12ms** | 引擎复用生效ensureEngine 仅做 getEngineForProject + 早期返回 |
### 9.2 已做优化
- **init 时后台预做 memory 同步**`memoryService.ensureMemoryReadyForSession().catch(() => {})`,首包 getOrCreateEngine 时 ensureMemoryReady 多为快路径index 已同步),减少约 500ms。
- **同 project 不误判配置变更**:已有引擎且引擎类型未切换时直接复用,避免 detectConfigChange 误判导致每次重建。
- **session_id / project_id 查找**`getEngineForProject(engineKey)` 支持按 session_id 命中「key=project_id 但含该 session」的引擎同一会话续传复用。
### 9.3 仍可考虑的优化(未实现)
- **syncMcp 首包 ~700ms**:当前首请求必须走一次同步;若多 project 共用相同 context_servers可做「上次同步配置 hash」缓存相同则跳过 sync降低多 project 首包成本。
- **预热池命中率**:若首请求为 qimingcode 而 qimingcode 预热未就绪(约 6s仍会冷启动可考虑在 UI 侧延迟首条 chat 或提示「引擎准备中」,或接受首包 6s 仅发生一次。