# Goose 执行层规模化架构评估与拆分设计

> **背景：** 评估当前 MeMind/TKMind 的 Goose 执行架构在用户量突破 1 万、峰值并发突破 100 时能否扛住，以及如何拆分演进。
>
> **结论先行：** 当前架构稳定并发上限约 **20–30**，扛不住 100，更扛不住 1 万用户的峰值并发（通常是 DAU 的 5–10%，即 500–1000）。**演进方向（Experience 共享、Session 外置、Stateless Worker、任务队列）是对的**，但必须分阶段落地，不能跳步。
>
> **核心资产：** 真正值钱的是 `Experience Learning Engine + Skill Library`，这两层要最先从 goosed 进程里剥出来独立扩。goosed 本身是可替换的执行壳。
>
> 关联文档：[g2 负载均衡](g2-load-balancing.md) · [memindadm Goose 网关设计](memindadm-goose-gateway-design.md) · [105 服务器操作规范](105-server-operations.md)

## 1. 现状盘点（基于代码事实，非设计文档假设）

| 维度 | 设计文档假设 | 代码/部署实际 | 影响 |
|------|------|------|------|
| 数据库 | PostgreSQL + pgvector | **MySQL**（`db.mjs:2` `mysql2/promise`） | 没有向量检索能力，Experience 检索需另建 |
| DB 连接池 | —— | **`connectionLimit: 10`**（`db.mjs:32`，写死） | 100 并发 × 多查询 → 连接耗尽、请求排队 |
| Goose 实例 | "双 Goose 负载均衡" | **同一台 Mac（Studio/103）上的两个进程** `:18006` / `:18007` | 不是负载均衡，是单点的两进程；共享 CPU/内存/libuv |
| Goose 运行环境 | —— | 个人开发 Mac `john@58.38.22.103` | 生产 Agent 集群跑在个人 Mac 上 = 最大架构债 |
| Session 路由 | —— | DB `goosed_node` 整数下标（0/1），`getSessionNode` @ `user-auth.mjs:651` | 扩到第 3 台直接失效；且该列在 `schema.sql` 里不存在，靠 `columnExists` 运行时动态补 |
| 长任务 | —— | 纯 HTTP 流式透传（`tkmind-proxy.mjs`），无队列 | 连接被占满整个执行周期；微信 5s 超时直接死 |
| Experience / 上下文 | 共享经验库 | **在各 goosed 进程内存中** | 两实例经验孤岛；重启/部署即丢上下文 |

> **关键认知校准：** 瓶颈不在 portal（无状态 Node，易扩），而在**有状态的 goosed** 和**写死 10 连接的 MySQL**。

## 2. 并发压力测算：为什么扛不住 100

"100 并发"对 Agent 系统 ≠ 100 个 HTTP 请求，而是 **100 个同时在跑的 Agent 会话**，每个会话：

- 占一条到 LLM 的流式连接（几十秒～几分钟）
- 可能在跑工具（浏览器 / 代码执行 / 部署），单会话 5–20 分钟
- 每轮查 MySQL：`getSessionNode` + `getAgentSessionPolicy` + LLM provider 切换 + reconcile（≥3–4 次/轮）

### 三个瓶颈，按崩溃顺序

**① 两个 goosed 进程 / 一台 Mac —— 最硬的物理天花板**
单进程乐观扛 10–20 个活跃会话，两进程合计 ~30 个开始抖。100 并发时要么 OOM，要么 LLM 调用排队到超时。**调参解决不了，是单机上限。**

**② MySQL `connectionLimit: 10` —— 第二个崩**
100 并发 × 4 查询争抢 10 条连接，`waitForConnections: true` → 请求排队等连接而非报错 → 延迟雪崩，用户感觉"卡死"。

**③ HTTP 长连接占用 —— 微信场景直接死**
长任务占满连接整个执行周期。微信公众号 5s 超时，`wechat-mp.mjs` 已被迫做异步 ACK —— 说明问题已暴露。

## 3. 目标架构（支撑 1 万用户 / 500+ 并发）

核心思想：**把 goosed 榨成无状态 Worker，所有状态外置，长任务异步化。**

### 3.0 概念骨架版（先看主干）

```
                    Gateway  ← 含策略层(鉴权/过滤/任务识别/路由/审计)
                        │
         ┌──────────────┴──────────────┐
         │                             │
     API Server                  WeChat Server
         └──────────────┬──────────────┘   ← 两条入口汇到同一 Dispatcher
                        ▼
                 Task Dispatcher
                        │
            ┌───────────┴───────────┐
       短任务(<10s 同步)        长任务 → Task Queue (RabbitMQ/pg-boss)
            │                        │      ← 隔一层队列,否则长任务占满连接
            ▼                        ▼
     Goose1 Goose2 Goose3 Goose4 (Stateless Worker)
            │
            ▼
   State Layer (4 个并列服务,非 Experience 一条线):
   Session/Conv(Redis+PG) · Experience(PG+pgvector) · Skill Lib · 对象存储(MinIO)
            │
            ▼
   Webhook 回推 → 微信通知 / 前端 SSE  ← 长任务结果闭环
```

> 易漏的两环：**队列**(让长任务异步)和**回推**(把异步结果送回用户)；状态层是 4 个并列服务，不是挂在 Experience 下。下面是完整五层详图。

### 3.1 完整五层详图

```mermaid
flowchart TD
  U["用户 Web / H5 / WeChat"] --> LB["① 接入层 Caddy/Nginx LB → portal × N (无状态)"]
  WX["微信回调 5s 内 ACK"] --> GW
  LB --> GW["② 网关+策略层 memindadm Gateway<br/>鉴权/内容过滤/任务识别/执行器路由/审计"]
  GW -->|短任务 <10s 同步| W
  GW -->|长任务 投递即返回| Q["③ 任务队列 RabbitMQ / pg-boss<br/>优先级 + 重试 + 死信"]
  Q --> W["④ 执行层 goosed Worker 集群<br/>Stateless / K8s Pod / 1→N 台<br/>只做 Planner/ToolCalling/MCP"]
  W --> ST["⑤ 状态层 (所有 Worker 共享)"]
  ST --> SESS["Session/Conv: Redis + MySQL/PG"]
  ST --> EXP["Experience: PG + pgvector"]
  ST --> SKILL["Skill Lib: PG / Git"]
  ST --> OBJ["对象存储: MinIO / OSS"]
  W --> CB["Webhook 回调 → 微信通知 / 前端 SSE"]
```

### 分层拆分原则

| 层 | 有/无状态 | 扩容方式 | 当前差距 |
|---|---|---|---|
| ① portal 接入 | 无状态 | 水平加实例 | ✅ 已无状态，只需多机 |
| ② Gateway 策略 | 无状态 | 水平 | 🟡 [设计文档](memindadm-goose-gateway-design.md)有，未落地 |
| ③ 任务队列 | —— | 中间件 | ❌ 完全没有 |
| ④ goosed Worker | **必须改成无状态** | 水平加 Pod | ❌ 现在有状态、单机 |
| ⑤ 状态存储 | 有状态 | 读写分离/分片 | ❌ MySQL 10 连接、Experience 在进程内 |

## 4. 落地路线（分阶段，不跳步）

### Phase 0 — 立即做（低成本，堵眼前的洞）
1. **连接池**：`db.mjs:32` 的 `connectionLimit: 10` 按 `portal 实例数 × 单实例并发` 重算，先提到 50–100，加监控。
2. **`goosed_node` 下标 → 实例 URL/ID**：现在是整数 0/1，扩到第 3 台即失效，是定时炸弹；同时把该列正式写进 `schema.sql`（目前靠 `columnExists` 运行时补，脆弱）。
3. **重启截断**：goosed 发布重启前先导出活跃会话，避免静默截断进行中的对话。

### Phase 1 — Worker 化（用户 > 100 DAU）
4. **goosed 容器化**：从个人 Mac 搬进容器 —— 所有后续扩容的前提。
5. **Session/Conversation 外置**到 Redis + MySQL/PG，goosed 启动从外部 load，实现真 stateless。
6. **Experience 抽成独立服务**：`GET /experience/search` + `POST /experience/record`，所有 Worker 共享（`goose_execution_log` 表是雏形）。

### Phase 2 — 异步化（用户 > 1000 DAU / 并发 > 100）
7. **引入任务队列**，长任务投递即返回，goosed 变消费者 Worker。
8. **结果走 Webhook 回推**（微信通知 / 前端 SSE），彻底解决微信 5s 超时。
9. **Worker 自动扩缩**：按队列深度（K8s HPA）。

### Phase 3 — 数据层扩展（真到 1 万+）
10. MySQL 读写分离 / 引入 PG+pgvector 专门承载 Experience 向量检索。
11. 会话状态分片，审计日志冷热分离。

## 5. goosed 拆分多个的三个等级与 fork 决策点

> **最硬的约束：** goosed 的会话状态（完整带 tool_call 的消息历史 + 内存活跃上下文 + 活的 MCP/工具子进程连接）存在那台实例的**本地磁盘 jsonl + 内存**里。状态搬不走 —— 这就是现在必须用 `goosed_node` 粘性路由的根本原因。
>
> `h5_session_snapshots`（`session-snapshot.mjs`）只是**只读展示缓存**，不是权威 Agent 状态；`session-reconcile.mjs` 是会话已在某实例上之后重新套策略，不能跨实例重建状态。

### 等级 A：粘性分片（现状路子，能扩但有上限）
每个 session 钉死在一台 goosed，实例间互不知道。扩容 = 加机器 + 路由表从「整数下标」改「实例 URL」。
- ✅ 改动最小，goosed 不用动
- ❌ **无故障转移**：某台挂了，其上活跃 session 内存上下文直接丢（DB 只有展示快照）
- ❌ 负载不均；Experience 仍孤岛（除非走等级 C 单独外置）
- **适用并发 30→100 过渡期，是 Phase 0/1 该走的。**

### 等级 B：共享会话存储 + 按轮重水合（真正 stateless）
goosed 不再本地存 jsonl，每一轮：从共享存储 load 完整状态 → 执行本轮 → save 回去 → 释放。下一句可落任意 Worker。
| 路径 | 做法 | 代价 |
|---|---|---|
| B1 共享文件 | session 目录挂网络盘（NFS/JuiceFS/OSS） | 改动小，但并发写 jsonl 有锁/一致性问题 |
| B2 改存储后端 | goosed 从本地 jsonl 改成 PG/Redis load-save | 要动 **upstream Rust goose 源码**，最干净但工作量大 |

> **最大技术决策点：要不要 fork goosed。** 在啃下 B2（或上游支持可插拔 session 存储）之前，「多实例」只能是等级 A 粘性分片，不是真 stateless。你架构图里「Goose1-4 任意消费」属于等级 B。

### 等级 C：有状态服务剥离（与 A/B 正交，现在就能先做）
把 Experience / Skill 从 goosed 进程抽成独立 HTTP 服务，goosed 只当调用方（执行前 `GET /search`，执行后 `POST /record`）。**即使还是等级 A 粘性分片，经验也不再孤岛。** 收益最快、不碰 goosed 内核。

### 拆分顺序（务实版）
1. 先做 **等级 C** Experience 外置 —— 正交、收益快、解决「经验共享」。
2. 再做 **等级 A** 路由升级（下标→URL）—— 低成本撑到并发 100。
3. 最后决策 **等级 B** —— 到并发 >100 / 需故障转移时，正面回答「要不要 fork goosed」。

## 6. Task Dispatcher 现状（已有雏形，但不在主链路）

| 维度 | 现状 | 证据 |
|------|------|------|
| 任务状态机 | ✅ 已有，且做对了最难的部分 | `h5_agent_jobs` 表：queued/running/completed/failed/timed_out/cancelled、幂等键、`claimJob` 租约、heartbeat、可重试错误码（`mindspace-agent-jobs.mjs`） |
| Worker | ✅ 有 | `mindspace-agent-runner.mjs`：认领→起 goosed 会话→发 prompt→流式收→回写 |
| 真队列中间件 | ❌ 无 | package.json 零队列库（无 amqp/bull/pg-boss） |
| 触发方式 | ❌ 进程内 fire-and-forget | `server.mjs:1820` `void runJob(jobId).catch()`，在收请求的 portal 进程里跑，不是独立 Worker 池从队列拉 |
| 跨实例分发 | ❌ 无 | runJob 在本进程执行，goosed 仍走老粘性路由 |
| 覆盖范围 | ❌ 仅 3 类 job | `generate_page`/`analyze_asset`/`summarize`（`JOB_TYPES`）；**主聊天流量仍走 `tkmind-proxy.mjs` 同步流式，不经此系统** |

> **结论：** `h5_agent_jobs` 的状态机就是真 Dispatcher 的地基（最难的已完成）。差三件事：① 前面架真队列 + 独立 Worker 池；② 把主聊天长任务也 route 进来；③ Worker 拆成独立 fleet 而非 portal 进程内。当前它挡不住并发崩溃，因为没在主路径上。

### 6.1 已落地的代码（2026-06-26）

> 队列后端决定：**复用 MySQL**（零运维，撑到中等并发够用），后续可换真 MQ。

| 项 | 改动 | 文件 |
|----|------|------|
| 连接池 | `connectionLimit` 写死 10 → 可配置 `MYSQL_POOL_SIZE`（默认 50）+ `queueLimit`，两条创建路径都覆盖 | `db.mjs` |
| Session 路由 | 新增 `goosed_target` 列存实例 URL；路由优先按 URL、回退旧整数索引；存量行向后兼容；不动 `.runtime` 产物 | `db.mjs` / `user-auth.mjs`（`getSessionTarget`）/ `tkmind-proxy.mjs` |
| Dispatcher 消费模型 | `claimNextJob()` 用 `SELECT … FOR UPDATE SKIP LOCKED` 抢最老 queued job，多实例安全不重复 | `mindspace-agent-jobs.mjs` |
| 独立 Worker 循环 | server 内 DB 轮询消费 + 并发上限，替代进程内 fire-and-forget；opt-in `MINDSPACE_AGENT_WORKER_ENABLED`（105 不开，遵守 g2 约束） | `server.mjs` |
| 重启韧性 | `reapStaleJobs()` 把心跳超时的 running job 标记 `failed/worker_crashed`（可重试），避免永久卡死与毒任务死循环 | `mindspace-agent-jobs.mjs` |
| Runner 兼容 | `runJob(jobId, preClaimed?)`：worker 预认领后免二次 claim，HTTP 旧路径不变 | `mindspace-agent-runner.mjs` |

验证：job/runner/wechat 相关 40/40 通过；全套与改动前一致（374 pass / 10 既有失败），零回归。

### 6.2 仍需基础设施/决策（代码改不动的部分）

- 主聊天长任务 route 进队列（现仍走 `tkmind-proxy.mjs` 同步流式）—— 需配合前端 SSE / Webhook 回推改造。
- Worker 拆成独立进程 fleet（现是 portal 内循环）—— 需容器化。
- 真 MQ（RabbitMQ）/ Redis / pgvector / MinIO —— 外部中间件，需运维提供环境。
- **fork upstream Rust goosed 改 session 存储后端（等级 B）—— 最大决策点，未决。**
- Experience 服务（等级 C）建在 MySQL 还是等上 PG+pgvector —— 需先定数据层方向。

## 7. 一句话总结

- 当前能扛 ~20–30 并发，扛不住 100，更扛不住 1 万用户峰值。
- 不是最优，但演进方向对。最大两个隐患：① goosed 跑在个人 Mac 上的"伪负载均衡"；② MySQL 写死 10 连接。
- 拆分顺序：**先 goosed 容器化+无状态化（P1）→ 再异步队列化（P2）→ 最后扩数据层（P3）**。没有先做无状态，加多少 Worker 都会被 Session 漂移和 Experience 孤岛拖死。
- 核心资产是 Experience Engine + Skill Library，最先剥离独立扩；goosed 是可替换执行壳。

## 8. 五路并行推进计划（2026-06-26 决策）

> 用户要求 5 条同时推进。但它们**解锁状态不同**：有的是纯代码（可立即写）、有的卡基础设施/外部环境（需运维步骤）、有的卡上游 Rust 源码（需 fork）。下面标注每条的「谁能做、卡在哪、第一步」。

### 路①：改 goosed session 存储后端（等级 B）—— 已勘察，难度大幅低于预期

> goose 源码：`/Users/john/Project/tkmind_go`（Rust workspace，goosed = `goose-server` crate 的 `goosed` bin）。

- **关键发现**：session **已经用 SQLite + sqlx 存储**（`crates/goose/src/session/session_manager.rs`，`SessionStorage` @ line 525，`sqlx::sqlite::SqlitePool`），**不是裸 jsonl**（jsonl 是 `session/legacy.rs` 旧格式）。可插拔 seam 已存在。
- **这意味着**：外置不需从零重写持久化。两条路：
  - **B1（最省，零 Rust 改动）**：多个 goosed 容器**挂同一个 data volume**（SQLite WAL 文件），同机多实例即可共享 session/experience。代价：多进程并发写单 SQLite 有锁竞争。
  - **B2（真 stateless）**：把 `SessionStorage` 的 `SqlitePool` 换成 `sqlx::Postgres` 指向共享 PG。sqlx 本就多后端，但代码有 SQLite 专用 SQL（`sqlite_master`/`PRAGMA`/WAL）和 `FromRow<SqliteRow>`，需移植 SQL + FromRow → 中等工作量，但远小于「fork 重写 jsonl」。
- **结论**：fork 范围已明确 = **只改 `SessionStorage` 一个结构体的后端**，不是大改。第一步先 B1 挂共享 volume，并发写痛了再上 B2。

### 路②：RabbitMQ 部署在 103 —— 直接答你的问题

- **能直接上 103 吗？** 技术上能（`brew install rabbitmq` 或 docker 跑 broker）。**但不建议现在上。** 理由：你**刚拿到一个能用的 MySQL 队列**（第 6.1 节），RabbitMQ 只有在 MySQL 轮询成为瓶颈（数千 job/s、需要 fanout/优先级路由）时才值得。过早引入 = 多一个要运维、要监控、会宕的有状态中间件。
- **要先在本机测吗？** 要。无论何时上 RabbitMQ，都**必须先本机 docker 跑一遍**：① 验证 producer/consumer 代码对真实 AMQP 的行为；② 不能在serving 用户的 103 上调试。流程：本机 `docker run rabbitmq:3-management` → 验证 → 再上 103（或更好：上一台独立 infra 机，别和 goosed 抢 103 的 CPU/内存）。
- **决策建议**：**先不上 RabbitMQ**。把第 6.1 节的 MySQL 队列压测到出现真瓶颈，再换。换的时候 `claimNextJob` 的接口已经抽象，替换消费层即可。

### 路③：goosed 容器化 + 搬离 Mac —— 直接答你的问题

- **如何搬？** goose 是单个 Rust 二进制 + 配置（`~/.config/goose` 的 provider/extension 配置 + `TKMIND_SERVER__SECRET_KEY`）。容器化 = 写 Dockerfile（基础镜像 + goose 二进制 + 配置 + 暴露 goosed 端口），session 目录与配置用 volume 外挂。**仓库当前没有 Dockerfile，这是第一步要补的。**
- **会影响现有用户吗？** **可以做到几乎零影响**，靠你已有的两件武器：
  1. 路由已支持按实例 URL（第 6.1 节 `goosed_target`）。
  2. 入口已有 Caddy 加权分流（`g2-lb.Caddyfile`）。
  - 灰度搬迁：把容器化的新 goosed 作为**新 target 加入**，只把**新 session**路由过去，**老 session 继续钉在旧实例**直到自然结束（drain），再摘掉旧实例。**唯一硬影响**：搬迁瞬间若强制迁移进行中的 session，其内存上下文会丢（等级 B 未做前状态搬不走）——所以要 drain、不要硬切。
- **一机能多 goose 吗？** **能，你现在就是**（18006/18007 两进程）。容器化后就是同机多容器/多端口，上限取决于 CPU/内存。要扩就是加端口加 target，再登记进路由列表。
- **如何保证 goose 能力同步？** 能力 = ① MCP extensions（`mindspace-sandbox-mcp.mjs`）② skills（`skills-registry.mjs`）③ 学到的 experience。前两者靠**同一份代码部署 + 同一份配置**就同步（你 105 已经是「同代码同 secret」模式）；**第三者 experience 是唯一真孤岛**，必须走路⑤外置成共享服务，否则多实例各学各的。**结论：能力同步 = 代码/配置同步（已有机制）+ Experience 外置（路⑤）。**

### 路④：主聊天 route 进队列 + SSE/Webhook 回推

- **现状**：主聊天走 `tkmind-proxy.mjs` 同步流式，长任务占满连接。
- **要做**：网关识别「长任务」→ 投递到队列（复用第 6.1 节 MySQL 队列）→ 立即返回 job id → 结果异步回推：Web 端走 **SSE**（订阅 job 进度），微信端走 **Webhook → 客服消息**（`wechat-mp.mjs` 已有异步 ACK 雏形，扩展为结果回推）。
- **卡点**：需前端配合（SSE 订阅 UI）+ 任务识别策略（哪些算长任务，可复用 `memindadm-goose-gateway-design.md` 的任务识别器）。
- **第一步（纯后端、可先做）**：在 job 系统上加一个 `GET /api/agent/jobs/:id/stream` 的 SSE 端点，前端可先不接；微信回推复用现有 ACK 通道。

### 路⑤：Experience 服务（等级 C）—— 现在就动手，建在 MySQL（可换 PG）

- **决策**：**先建在 MySQL**（你已经在用，零新基础设施），检索先用关键词 + 时间衰减；**接口设计成 store 可插拔**，将来 `h5_experience` 迁到 PG+pgvector 只换实现、不动调用方。不等 PG，不阻塞。
- **要做**：`h5_experience` 表 + `experience-service.mjs`（`search`/`record`/`reflect`）+ 内部 HTTP 路由 `GET /internal/experience/search`、`POST /internal/experience/record`，goosed 执行前后调用。所有实例查同一张表 → 经验不再孤岛。
- **状态**：✅ 本轮已落地服务骨架（见 8.1）。

### 8.1 路⑤已落地代码（2026-06-26）

| 项 | 文件 |
|----|------|
| `h5_experience` 表（MySQL，预留 `embedding` 列待 pgvector） | `schema.sql` + `db.mjs` 迁移 |
| `createExperienceService`：`record` / `search`（关键词+时间衰减）/ `reflect` 占位，store 可插拔 | `experience-service.mjs` |
| 单元测试 | `experience-service.test.mjs` |

> 接入点（2026-06-26）：已接进 `mindspace-agent-runner.mjs` —— 执行前 `search(instruction)` 把相关经验注入 prompt，成功后 `record()` 写回；全 best-effort 不阻塞任务。server.mjs 用 `MINDSPACE_EXPERIENCE_ENABLED`（默认开）实例化并注入 runner。HTTP 检索路由（供 goosed 直连）待与网关一起接。

### 路线优先级与进度（更新于 2026-06-26）

1. ✅ **路⑤ Experience 外置** —— 服务骨架 + 表 + 5/5 测试已落地（8.1）。待接 goosed 触发点。
2. ✅ **路③ 容器化 Dockerfile** —— `tkmind_go/Dockerfile.goosed`（build `goosed` bin、`GOOSE_HOST=0.0.0.0`、data volume、`/status` healthcheck）。待本机 `docker build` 验证。
3. ✅ **路④ SSE 端点** —— `GET /mindspace/v1/agent/jobs/:jobId/stream`（轮询进度、终态/断开即关），`server.mjs`。待前端接订阅。
4. 🔍 **路① session 后端** —— 已勘察：seam = `SessionStorage`（SQLite/sqlx）。先 B1 共享 volume，再 B2 换 PG。
5. ⏸️ **路② RabbitMQ** —— 暂缓，等 MySQL 队列出真瓶颈。

> 全部代码改动测试零回归（392 项 / 379 通过 / 10 既有失败，新增 5 个 experience 测试全过）。未提交、未发布（遵守 105 发布规范）。

### 路③ 灰度搬迁 goosed（零影响操作序列）

```
1. 本机 docker build -f Dockerfile.goosed -t tkmind/goosed:local .
2. 本机起容器，curl http://127.0.0.1:18006/status 验证
3. 在 103 起容器化 goosed（新端口/新 IP），加进 portal 的 targets 列表
4. 路由层只把【新 session】指向新实例（goosed_target 已支持按 URL）
5. 老 session 钉在旧实例自然 drain（别硬迁，内存上下文搬不走）
6. 旧实例 0 活跃后摘除
```
