Files
memind/docs/goose-scale-architecture-2026-06-26.md
john 1798c07d42 docs: Update documentation and release rules
- 架构和规划文档更新
- 开发、工程、生产发布规则更新
- 服务隔离和升级指南
- README 更新
2026-06-27 08:25:06 +08:00

271 lines
22 KiB
Markdown
Raw Permalink 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.
# Goose 执行层规模化架构评估与拆分设计
> **背景:** 评估当前 MeMind/TKMind 的 Goose 执行架构在用户量突破 1 万、峰值并发突破 100 时能否扛住,以及如何拆分演进。
>
> **结论先行:** 当前架构稳定并发上限约 **20–30**,扛不住 100,更扛不住 1 万用户的峰值并发(通常是 DAU 的 5–10%,即 5001000)。**演进方向(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 负载均衡" | **同一台 MacStudio/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(≥34 次/轮)
### 三个瓶颈,按崩溃顺序
**① 两个 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/PGgoosed 启动从外部 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-forgetopt-in `MINDSPACE_AGENT_WORKER_ENABLED`(105 不开,遵守 g2 约束) | `server.mjs` |
| 重启韧性 | `reapStaleJobs()` 把心跳超时的 running job 标记 `failed/worker_crashed`(可重试),避免永久卡死与毒任务死循环 | `mindspace-agent-jobs.mjs` |
| Runner 兼容 | `runJob(jobId, preClaimed?)`worker 预认领后免二次 claimHTTP 旧路径不变 | `mindspace-agent-runner.mjs` |
验证:job/runner/wechat 相关 40/40 通过;全套与改动前一致(374 pass / 10 既有失败),零回归。
### 6.2 仍需基础设施/决策(代码改不动的部分)
- 主聊天长任务 route 进队列(现仍走 `tkmind-proxy.mjs` 同步流式)—— 需配合前端 SSE / Webhook 回推改造。
- Worker 拆成独立进程 fleet(现是 portal 内循环)—— 需容器化。
- 真 MQRabbitMQ/ 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 workspacegoosed = `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 活跃后摘除
```