Files
memind/docs/architecture/memind-2-streaming-agent-runtime-plan.md
T
2026-07-02 07:12:18 +08:00

510 lines
20 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.
# Memind 2.0 Streaming Agent Runtime 改造计划
更新时间: 2026-07-02
执行状态:
- P0 StreamController v1: 已完成代码落地、语法检查、Portal kickstart 和基础健康验证。
- P1 Gateway SSE 配置: 已完成,`mm.tkmind.cn` 接入 Portal,SSE route 已禁缓冲。
- P2 Redis Runtime State + Router v1: 已完成可回退版本并启用 Redis。
- P3 Observability: 已完成第一版,只读 runtime 状态接口已上线。
- P4 Tool Gateway v1: 已完成第一步,普通用户默认不再暴露 Aider/OpenHands,显式用户白名单保留。
- P5 Worker Pool 运维化: 已完成第一步,Redis Router 支持 worker drain。
- 生产同步分支: 已从远程 `origin/main` 新建干净副本和分支 `memind-streaming-runtime-20260702`,用于远程开发机后续直接拉取。
- P3.5/P5.5 生产化补强: 进行中,先完成生产数据库和 `MindSpace` 备份,再增加 runtime metrics、健康检查脚本和 drain 运维脚本。
## 目标
把当前 Memind H5 + multi-goosed 架构升级为 streaming-first agent runtime:
- 易扩展: goosed worker pool 横向扩展,调度层按压力分配新会话。
- 低延迟: SSE 首 token 优先,代理层禁缓冲、及时 flush、处理 backpressure。
- 高稳定: 流式链路、工具执行、worker 状态、持久化状态分层隔离。
## 当前基线
- Memind Portal: `/Users/john/Project/Memind/server.mjs`,本机 `:8081`。
- H5 public base: `https://mm.tkmind.cn`;`m.tkmind.cn` 暂时不再作为 H5 public base。
- goosed worker pool: Docker/Colima 内 `goosed-prod-1..4`,宿主机 `18006..18009`,当前健康。
- H5 上游配置: `TKMIND_API_TARGETS=https://127.0.0.1:18006,...,18009`。
- 当前已有 session affinity: start 时分配 worker,后续 reply/events 回到同一 worker。
- imgproxy: 原生 `127.0.0.1:20082`,兼容代理 `10.10.0.2:20081`。
- 风险点: SSE 代理尚未完整处理 `flushHeaders`、`X-Accel-Buffering`、客户端断开 abort、写入 backpressure 和统一 pipeline 收尾。
## 目标架构
```mermaid
flowchart LR
U["H5 / WeChat / Web"] --> E["Edge Gateway (Nginx / Envoy)"]
E --> S["Stream Controller (Memind Portal v2)"]
S --> R["Router / Scheduler"]
R <--> Redis["Redis Runtime State"]
S <--> Redis
R --> G1["goosed-prod-1"]
R --> G2["goosed-prod-2"]
R --> G3["goosed-prod-3"]
R --> G4["goosed-prod-4"]
G1 --> T["Tool Gateway"]
G2 --> T
G3 --> T
G4 --> T
T --> A["Aider Service"]
T --> O["OpenHands Service"]
S --> PG["PostgreSQL / MySQL Persistence"]
```
## 分层原则
1. Control Plane: Redis + Router,负责 worker 心跳、active stream、错误率、首 token 延迟、session worker pointer。
2. Stream Data Plane: Memind Portal StreamController + goosed worker,负责低延迟流式传输。
3. Persistence: 现有数据库只保存最终状态、会话历史、计费、发布记录,不保存高频 runtime state。
4. Worker Affinity: goosed 尽量业务无状态,但 session 运行期必须 worker-affine;已存在 session 优先回原 worker。
5. Tool Isolation: Aider/OpenHands 从默认全开逐步改为按任务启用、队列化、限流、超时和熔断。
## 阶段计划
### P0: StreamController v1 加固
目标: 不引入新基础设施,先把现有 H5 SSE 链路做稳。
任务:
- 为 `/api/sessions/:sessionId/events` 增加 SSE 专用响应头:
- `Content-Type: text/event-stream; charset=utf-8`
- `Cache-Control: no-cache, no-transform`
- `Connection: keep-alive`
- `X-Accel-Buffering: no`
- 在响应开始后调用 `flushHeaders()`。
- 使用 `AbortController` 将客户端断开传递到 goosed upstream fetch。
- 用 backpressure-aware writable sink 替代裸 `res.write()`。
- 用 `pipeline()` 统一处理 source、sanitizer、billing、client sink 的关闭和错误。
- 保留 keepalive 和 billing balance event。
验收:
- `node --check /Users/john/Project/Memind/server.mjs` 通过。
- 本机 `http://127.0.0.1:8081/api/status` 仍返回 `ok`。
- 四个 `https://127.0.0.1:18006..18009/status` 仍返回 `ok`。
### P1: Gateway SSE 配置
目标: `mm.tkmind.cn` 新入口和本机入口不缓冲 SSE;后续 H5 不再走 105 nginx 反向隧道转发。
任务:
- 对 `/api/sessions/*/events`、`/api/agent/runs/*/events` 配置:
- `proxy_buffering off`
- `proxy_cache off`
- `gzip off`
- `proxy_read_timeout 3600`
- `proxy_send_timeout 3600`
- `add_header X-Accel-Buffering no always`
- 确认 `mm.tkmind.cn/api/status` 返回真实 H5 API,而不是维护页 HTML。
- 将 `m.tkmind.cn -> 105 nginx -> 127.0.0.1:19081 -> reverse SSH tunnel -> Portal :8081` 标记为 legacy/rollback-only。
验收:
- `mm.tkmind.cn/api/status` 返回后端 `ok` 或明确 JSON health,而不是维护页 HTML。
- SSE 响应头包含 `X-Accel-Buffering: no`。
### P2: Redis Runtime State + Router v1
目标: 从 round-robin 升级为 pressure-aware routing。
Redis key:
```text
worker:{id}:heartbeat
worker:{id}:active_streams
worker:{id}:active_sessions
worker:{id}:ewma_first_token_ms
worker:{id}:error_rate
worker:{id}:memory_pressure
worker:{id}:drain
session:{session_id}:worker
stream:{stream_id}:status
stream:{stream_id}:started_at
```
调度分数:
```text
score =
active_streams * 3
+ active_sessions * 1
+ ewma_first_token_ms * 0.01
+ error_rate * 5
+ memory_pressure * 2
```
原则:
- 已存在 session 优先使用 `session:{id}:worker`。
- 新 session 选择最低 score 的健康 worker。
- heartbeat 过期 worker 不接新 session。
- 不做无损中途迁移,除非 goose 支持完整 session restore。
### P3: Observability
目标: 调度不靠感觉。
指标:
- stream open/close/abort count
- active_streams gauge
- first_token_latency_ms
- stream_duration_ms
- worker fetch error rate
- billing finish count/error count
先写日志和内存聚合,后续接 Prometheus 或 Redis。
### P4: Tool Gateway v1
目标: Aider/OpenHands 工具隔离。
任务:
- 普通聊天默认不暴露 Aider/OpenHands。
- 代码任务按策略启用 Aider 或 OpenHands。
- Tool Gateway 提供 queue、timeout、retry、并发上限、失败熔断。
- stream 中输出 tool progress event,避免用户界面无反馈。
第一阶段实际落地:
- 普通用户默认不暴露 Aider/OpenHands。
- 保留显式 user capability override,作为白名单。
- 后续再将 Aider/OpenHands 从 goosed platform extension 进一步拆成 queue-based Tool Gateway。
### P5: Worker Pool 运维化
目标: 多 goosed 的发布、回滚、扩缩容可控。
任务:
- 固化 `goosed-prod-1..N` compose/启动配置。
- 每个 worker 独立日志、健康检查、资源上限。
- drain 模式: 不接新 session,等 active stream 清零后升级。
- 保留一键回滚镜像 tag。
Drain 操作:
```bash
docker exec memind-runtime-redis redis-cli SET memind:runtime:worker:goosed-3:drain 1
curl -sk https://mm.tkmind.cn/api/runtime/status
docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:drain
```
说明:
- `drain=1` 后 Router 不再给该 worker 分配新 session。
- 已存在 session 仍保持 worker affinity,不做中途迁移。
- 等 `activeStreams=0` 后再重启或升级该 worker。
## 执行顺序
1. P0 立即执行,低风险高收益。
2. P1 基于 `mm.tkmind.cn` 新入口执行;除非明确回滚,不再改造 105 H5 转发链路。
3. P2/P3 一起推进,先观测再调度。
4. P4/P5 在流式链路稳定后推进。
## 执行记录
### 2026-07-02 P0 StreamController v1
变更文件:
- `/Users/john/Project/Memind/server.mjs`
备份:
- `/Users/john/Project/Memind/server.mjs.bak-streamcontroller-20260702-0628`
已完成:
- `proxySessionEvents` 增加 upstream `AbortController`,客户端断开时主动 abort goosed SSE fetch。
- SSE 响应头升级为 `text/event-stream; charset=utf-8`、`no-cache, no-transform`、`X-Accel-Buffering: no`。
- 响应开始后调用 `flushHeaders()`。
- 用 `Writable` sink 处理 `res.write()` backpressure,并保留 billing balance event 注入。
- 用 `pipeline(source, sanitizer, billing, sink)` 统一管理 SSE 管道收尾。
- 通过 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal` 让变更生效。
验证:
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
- Portal 新 PID: `89465`,监听 `*:8081`。
- `http://127.0.0.1:8081/api/status` 返回 `ok`。
- `https://127.0.0.1:18006/status` 返回 `ok`。
- `https://127.0.0.1:18007/status` 返回 `ok`。
- `https://127.0.0.1:18008/status` 返回 `ok`。
- `https://127.0.0.1:18009/status` 返回 `ok`。
### 2026-07-02 H5 public base 临时切换
决策:
- H5 public base 从 `https://m.tkmind.cn` 临时切换为 `https://mm.tkmind.cn`。
- 后续 H5 公网访问不再走 105 转发链路。
- 旧链路 `m.tkmind.cn -> 105 nginx -> 127.0.0.1:19081 -> reverse SSH tunnel -> Portal :8081` 仅保留为 legacy/rollback-only 说明,不作为 Memind 2.0 改造目标。
变更文件:
- `/Users/john/Project/Memind/.env`
- `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh`
- `/Users/john/Project/Memind/RUNBOOK.txt`
- `/Users/john/Project/memind_architecture/memind-2-streaming-agent-runtime-plan.md`
已执行:
- 将 `H5_PUBLIC_BASE_URL` 改为 `https://mm.tkmind.cn`。
- 将 `VITE_MINDSPACE_BASE` 改为 `https://mm.tkmind.cn`。
- 将 Portal 启动脚本默认 `H5_PUBLIC_BASE_URL` 改为 `https://mm.tkmind.cn`。
- 更新 `RUNBOOK.txt`,明确 105 转发链路不再作为后续 H5 公网路径。
- 通过 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal` 重启 Portal。
验证:
- Portal 新 PID: `94324`,监听 `*:8081`。
- 运行环境确认: `H5_PUBLIC_BASE_URL=https://mm.tkmind.cn`,`VITE_MINDSPACE_BASE=https://mm.tkmind.cn`。
- 本机 `http://127.0.0.1:8081/api/status` 返回 `ok`。
- 四个 goosed worker `18006..18009` 均返回 `ok`。
- 公网 `https://mm.tkmind.cn/api/status` 当前返回 nginx `502 Bad Gateway`,说明域名侧入口尚未接到当前 Portal;该问题纳入 P1 新入口修复,不再回到 105 转发链路处理。
### 2026-07-02 P1 Gateway SSE 配置
变更文件:
- `/opt/homebrew/etc/nginx/servers/mm.tkmind.cn.conf`
- `/Users/john/Project/Memind/scripts/wechat-mp-menu.mjs`
- `/Users/john/Project/Memind/server.mjs`
- `/Users/john/Project/Memind/dist/dev/wechat-share-demo.html`
- `/Users/john/Project/Memind/public/dev/wechat-share-demo.html`
备份:
- `/opt/homebrew/etc/nginx/backups/mm.tkmind.cn.conf.bak-streaming-20260702-0635`
已完成:
- `mm.tkmind.cn` 通过本机 nginx 反向代理到 Portal `127.0.0.1:8081`。
- 为 `/api/sessions/<id>/events` 和 `/api/agent/runs/<id>/events` 增加 SSE 专用 nginx location:
- `proxy_buffering off`
- `proxy_cache off`
- `gzip off`
- `proxy_read_timeout 3600s`
- `proxy_send_timeout 3600s`
- `add_header X-Accel-Buffering no always`
- 通用 `location /` 增加 `proxy_read_timeout 300s` 和 `proxy_send_timeout 300s`。
- 微信菜单脚本默认入口改为 `https://mm.tkmind.cn` 和 `https://mm.tkmind.cn/space`。
- `server.mjs` 内 `H5_PUBLIC_BASE_URL` 缺省值改为 `https://mm.tkmind.cn`。
验证:
- `nginx -t` 通过。
- `nginx -s reload` 已执行。
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
- 未登录访问 `https://mm.tkmind.cn/api/agent/runs/test-run/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
### 2026-07-02 P2 Redis Runtime State + Router v1
变更文件:
- `/Users/john/Project/Memind/server.mjs`
- `/Users/john/Project/Memind/.env`
新增运行组件:
- Docker 容器: `memind-runtime-redis`
- 镜像: `redis:7-alpine`
- 绑定: `127.0.0.1:6379->6379`
- 持久化卷: `memind-runtime-redis-data`
- 启动参数: `redis-server --appendonly yes`
- 重启策略: `unless-stopped`
已完成:
- 新增可选 `createRuntimeRouter`。
- `MEMIND_RUNTIME_REDIS_URL` 未配置时自动回退到原 round-robin。
- `MEMIND_RUNTIME_REDIS_URL` 配置后启用 Redis scheduler。
- 新 session 选择最低 score 的健康 worker;同分时仍按轮转顺序打散,避免全部压到第一个 worker。
- session pointer 写入 Redis:
- `memind:runtime:session:<id>:worker`
- `memind:runtime:session:<id>:target`
- SSE 打开/关闭维护 Redis active stream:
- `memind:runtime:worker:<id>:active_streams`
- `memind:runtime:worker:<id>:heartbeat`
- `memind:runtime:stream:<id>:status`
验证:
- `docker exec memind-runtime-redis redis-cli PING` 返回 `PONG`。
- Redis AOF 已开启。
- Portal 运行环境包含 `MEMIND_RUNTIME_REDIS_URL=redis://127.0.0.1:6379/0`。
- Portal 日志出现 `[RuntimeRouter] Redis scheduler enabled`。
- Redis 当前可见 key 示例:
- `memind:runtime:worker:goosed-3:active_streams=1`
- `memind:runtime:worker:goosed-3:heartbeat=<timestamp>`
- `memind:runtime:stream:20260701_24:status=active`
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
- 四个 goosed worker `18006..18009` 均返回 `ok`。
### 2026-07-02 P3 Observability v1
变更文件:
- `/Users/john/Project/Memind/server.mjs`
已完成:
- 新增只读接口 `GET /api/runtime/status`。
- 接口返回:
- `publicBaseUrl`
- Redis Router 是否启用
- Redis namespace
- 每个 worker 的 `target`、`activeStreams`、`activeSessions`、`ewmaFirstTokenMs`、`errorRate`、`memoryPressure`、`heartbeat`
- 每个 `TKMIND_API_TARGETS` 上游的 `/status` 健康状态
- 该接口与 `/api/status` 一样作为运维健康读接口,不要求登录;不返回任何密钥。
验证:
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
- `https://mm.tkmind.cn/api/runtime/status` 返回 `ok: true`。
- 返回中 `router.enabled=true`,namespace 为 `memind:runtime`。
- 返回中四个目标 `18006..18009` 均 `healthy: true`。
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
### 2026-07-02 P4 Tool Gateway v1 第一阶段
已完成:
- 只读验证发现数据库中 `role=user` 曾将 `aider`、`openhands` 覆盖为 `allowed=1`。
- 已将 `h5_capability_grants` 中 `subject_type='role' AND subject_id='user'` 的 `aider`、`openhands` 改为 `allowed=0`。
- 保留两个显式 user override:
- `a6fb1e97-2b0f-447b-b138-4561d8e5c53e`
- `a70ff537-8908-486e-9b6c-042e07cc25db`
效果:
- 普通用户新建/恢复 session 时,Portal policy 不再把 Aider/OpenHands 放入 `extension_overrides`。
- 已授权用户仍可继续使用。
- goosed 全局 config 仍保留 Aider/OpenHands enabled,避免破坏显式授权用户和既有会话;后续 Tool Gateway 阶段再拆 queue/timeout/retry。
### 2026-07-02 P5 Worker Pool Drain v1
变更文件:
- `/Users/john/Project/Memind/server.mjs`
已完成:
- Redis Router scoring 支持读取 `memind:runtime:worker:<id>:drain`。
- 当 drain 值为 `1` / `true` / `yes` 时,该 worker 对新 session 的 score 为不可选。
- `/api/runtime/status` 返回每个 worker 的 `drain` 字段。
验证:
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
- `https://mm.tkmind.cn/api/runtime/status` 返回四个 worker `healthy=true`。
- `https://mm.tkmind.cn/api/runtime/status` 返回四个 worker `drain=false`。
- `https://mm.tkmind.cn/api/status` 返回 `ok`。
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`。
### 2026-07-02 生产同步分支
决策:
- 当前是生产环境,改造过程中不随意删除数据、不清理持久化目录、不覆盖旧本地脏改动。
- `/Users/john/Project/Memind` 是生产运行产物目录,不是 git 工作树。
- 生产 release manifest 指向 `git_head=4fc59729ee222734628dca001e172c127c84488f`。
- 远端 `https://git.tkmind.cn/tkmind/memind.git` 的 `origin/main` 当前正是该提交。
已执行:
- 新建干净源码目录 `/Users/john/Project/memind-clean-main-20260702`。
- 从 `origin/main` 创建分支 `memind-streaming-runtime-20260702`。
- 迁入本次已在生产验证的 StreamController、Redis Router、runtime status、`mm.tkmind.cn` 和 nginx SSE 配置样例。
- 新增远程开发机同步说明 `docs/architecture/remote-dev-sync.md`。
限制:
- 不提交生产 `.env`、数据库、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`logs/`、证书和密钥。
- 只提交代码、配置模板、部署样例和架构/运维文档。
### 2026-07-02 P3.5/P5.5 生产数据保护与运维补强
生产数据保护:
- 允许重启生产服务,但重启前必须保护好数据库和 `/Users/john/Project/Memind/MindSpace`。
- 备份目录: `/Users/john/Project/memind_backups/20260702-065813-pre-p35-p55`。
- `MindSpace` 使用 `rsync -a` 全量副本,文件数 `2137`,大小约 `120M`。
- 数据库为 Aliyun RDS MySQL `goose`,已导出:
- `mysql-schema.sql`
- `mysql-manifest.json`
- `mysql-jsonl/*.jsonl`
- 表数 `85`,行数 `28296`
改造内容:
- Redis Router worker 状态增加:
- `stream_open_count`
- `stream_abort_count`
- `stream_error_count`
- `last_stream_started_at`
- `last_stream_ended_at`
- `score`
- 新增只读检查脚本 `scripts/check-stream-runtime.mjs`。
- 新增 drain 运维脚本 `scripts/runtime-worker-drain.mjs`。
- runtime 构建模板同步上述脚本,并将 RUNBOOK 中主路径更新为 `mm.tkmind.cn -> local nginx -> Portal :8081`。
- 本地和远端分支提交 `4420cca chore: add runtime observability ops`。
- 修正检查脚本为 `HEAD` 探测 SSE 入口,避免健康检查打开真实上游 SSE;提交 `c9b7252 fix: avoid opening sse in runtime check`。
### 2026-07-02 P4.5 Tool Gateway 过渡层第一阶段
目标:
- 不拆 goosed 内部 Aider/OpenHands,不引入队列系统,先把普通聊天和代码工具任务隔离。
- 普通聊天默认不注入 Aider/OpenHands,即使用户有显式白名单。
- 只有显式 `toolMode='code'` 的代码任务 policy 才注入 Aider/OpenHands。
已完成:
- `buildAgentExtensionPolicy()` 新增 `toolMode`,默认 `chat`。
- `toolMode='chat'` 时不注入 `aider` / `openhands`。
- `toolMode='code'` 时才注入白名单用户的 `aider` / `openhands`。
- Aider/OpenHands extension 增加:
- `timeout_ms`
- `metadata.runtime_scope='code_tool_task'`
- `userAuth.getAgentSessionPolicy(userId)` 默认返回 chat policy。
- 新增 `userAuth.getCodeAgentSessionPolicy(userId)` 作为后续代码任务入口。
- `/api/runtime/status` 增加 `toolRuntime` 摘要:
- `defaultMode`
- `codeToolMode`
- `chatInjectsCodeTools`
- `aiderTimeoutMs`
- `openhandsTimeoutMs`
- 新增生产只读检查脚本 `scripts/check-tool-runtime.mjs`。
生产验证:
- `scripts/check-tool-runtime.mjs` 返回 `ok=true`。
- `role=user` 的 `aider` / `openhands` 均为 `false`。
- 显式白名单用户数为 `2`。
- 白名单用户 chat mode 不含 Aider/OpenHands,code mode 具备 Aider/OpenHands。
- `scripts/check-stream-runtime.mjs` 返回 `ok=true`,四个 worker healthy。
- `scripts/runtime-worker-drain.mjs status` 显示四个 worker `activeStreams=0`。
生产部署:
- 允许重启生产 Portal,已使用 `launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal` 生效。
- 本阶段不写数据库、不删除数据、不修改 `/Users/john/Project/Memind/MindSpace`。
- 生产代码备份: `/Users/john/Project/memind_backups/20260702-071020-pre-p45-tool-guard`。
## 回滚策略
- P0: 修改前保留 `server.mjs` 备份;如启动失败,恢复备份并 `launchctl kickstart` Portal。
- P1: 修改 nginx 前备份 conf;`nginx -t` 成功后再 reload。
- P2: Redis Router 默认可通过 env 开关退回当前 `pickTarget()` round-robin。
- P4: Tool Gateway 默认关闭,通过用户策略逐步放量。