- 架构和规划文档更新 - 开发、工程、生产发布规则更新 - 服务隔离和升级指南 - README 更新
22 KiB
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 负载均衡 · memindadm Goose 网关设计 · 105 服务器操作规范
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 完整五层详图
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 策略 | 无状态 | 水平 | 🟡 设计文档有,未落地 |
| ③ 任务队列 | —— | 中间件 | ❌ 完全没有 |
| ④ goosed Worker | 必须改成无状态 | 水平加 Pod | ❌ 现在有状态、单机 |
| ⑤ 状态存储 | 有状态 | 读写分离/分片 | ❌ MySQL 10 连接、Experience 在进程内 |
4. 落地路线(分阶段,不跳步)
Phase 0 — 立即做(低成本,堵眼前的洞)
- 连接池:
db.mjs:32的connectionLimit: 10按portal 实例数 × 单实例并发重算,先提到 50–100,加监控。 goosed_node下标 → 实例 URL/ID:现在是整数 0/1,扩到第 3 台即失效,是定时炸弹;同时把该列正式写进schema.sql(目前靠columnExists运行时补,脆弱)。- 重启截断:goosed 发布重启前先导出活跃会话,避免静默截断进行中的对话。
Phase 1 — Worker 化(用户 > 100 DAU)
- goosed 容器化:从个人 Mac 搬进容器 —— 所有后续扩容的前提。
- Session/Conversation 外置到 Redis + MySQL/PG,goosed 启动从外部 load,实现真 stateless。
- Experience 抽成独立服务:
GET /experience/search+POST /experience/record,所有 Worker 共享(goose_execution_log表是雏形)。
Phase 2 — 异步化(用户 > 1000 DAU / 并发 > 100)
- 引入任务队列,长任务投递即返回,goosed 变消费者 Worker。
- 结果走 Webhook 回推(微信通知 / 前端 SSE),彻底解决微信 5s 超时。
- Worker 自动扩缩:按队列深度(K8s HPA)。
Phase 3 — 数据层扩展(真到 1 万+)
- MySQL 读写分离 / 引入 PG+pgvector 专门承载 Experience 向量检索。
- 会话状态分片,审计日志冷热分离。
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 内核。
拆分顺序(务实版)
- 先做 等级 C Experience 外置 —— 正交、收益快、解决「经验共享」。
- 再做 等级 A 路由升级(下标→URL)—— 低成本撑到并发 100。
- 最后决策 等级 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-servercrate 的goosedbin)。
- 关键发现: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,这是第一步要补的。 - 会影响现有用户吗? 可以做到几乎零影响,靠你已有的两件武器:
- 路由已支持按实例 URL(第 6.1 节
goosed_target)。 - 入口已有 Caddy 加权分流(
g2-lb.Caddyfile)。
- 灰度搬迁:把容器化的新 goosed 作为新 target 加入,只把新 session路由过去,老 session 继续钉在旧实例直到自然结束(drain),再摘掉旧实例。唯一硬影响:搬迁瞬间若强制迁移进行中的 session,其内存上下文会丢(等级 B 未做前状态搬不走)——所以要 drain、不要硬切。
- 路由已支持按实例 URL(第 6.1 节
- 一机能多 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)
- ✅ 路⑤ Experience 外置 —— 服务骨架 + 表 + 5/5 测试已落地(8.1)。待接 goosed 触发点。
- ✅ 路③ 容器化 Dockerfile ——
tkmind_go/Dockerfile.goosed(buildgoosedbin、GOOSE_HOST=0.0.0.0、data volume、/statushealthcheck)。待本机docker build验证。 - ✅ 路④ SSE 端点 ——
GET /mindspace/v1/agent/jobs/:jobId/stream(轮询进度、终态/断开即关),server.mjs。待前端接订阅。 - 🔍 路① session 后端 —— 已勘察:seam =
SessionStorage(SQLite/sqlx)。先 B1 共享 volume,再 B2 换 PG。 - ⏸️ 路② 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 活跃后摘除