主题
付费 → Hermes 上线全流程
这页解释用户付完钱到在微信里能跟自己的 Hermes 对话之间,twilight 这一层做了什么。 涉及 4 个进程:app backend(NestJS)、provision worker(NestJS 内的 timer)、
spawn-profile.sh、Hermes 容器。 关键事实:Hermes 容器内部用 iLink(gateway/platforms/weixin.py写死ILINK_BASE_URL),但 twilight-backend 不直接调 iLink。iLink 是 Hermes 镜像的实现细节,从 twilight 的角度它是一个黑盒。
Phase 1 验证结论(2026-05-18 跑过,实测)
| 假设 | 实测 | 后果 |
|---|---|---|
Hermes 暴露 GET /wechat/qr HTTP 路由 | ❌ 不存在。SPA catch-all 全返回 dashboard HTML | target 流程必须改:用 docker exec + 桥脚本,不是 HTTP 直连 |
Hermes 暴露 GET /wechat/status HTTP 路由 | ❌ 不存在。同上 | 同上 |
| Hermes openapi.json 有 weixin/wechat/qr 任何 endpoint | ❌ 80+ 路由扫过,0 匹配 | 想要 HTTP 接口,得自己加 plugin(高工作量),先用 docker exec |
| Hermes 单 process 多 profile 并行 | ❌ hermes profile 是 sticky select,process 内只有 1 active profile | 一容器多用户 = 一容器多 hermes process(supervisord),不是同 process 多 profile |
容器内 qr_login() 函数存在 | ✅ /opt/hermes/gateway/platforms/weixin.py:1041,async 函数 | 桥脚本调它 |
| 容器 reuse profile dir 时复用历史 binding | 部分(spawn-profile.sh 会重写 .env,但 ${HERMES_HOME}/weixin/<id>.json 凭据文件不动) | spawn-profile.sh 不要重写 .env 里的 WEIXIN_* 值 / 或干脆删那俩字段 |
| spawn-profile.sh 在 backend 容器里跑 | ✅ docker.sock 已挂载,能 docker run | 但 backend image 缺 openssl + python3,要加 |
Hermes 镜像 tag 是 :0.13.0 | ❌ 本地只有 :latest | spawn-profile.sh 用 HERMES_IMAGE 覆盖,或预先 tag |
| Hermes 容器能从 NewAPI 拿响应 | 未测 | 见 Phase 2 |
MCP_URL=127.0.0.1:9100/mcp 在容器里通 | 未测 | 见 Phase 2 |
多租户架构决策(2026-05-18)
Hermes 单 process 单 profile 是硬约束。三种容器拓扑:
| 方案 | 实现 | RAM @ 100 用户 | 隔离 | 工作量 |
|---|---|---|---|---|
| A. 一容器一用户(现状) | spawn-profile.sh 每人一个 docker run | ~30GB | 强 (cgroups) | 0 |
| B. 容器内 supervisord + 多 hermes process | 单容器跑 N 个 hermes gateway run 子进程 | ~15-20GB | 中(进程级) | 高(重做镜像+端口分配) |
| C. 多容器同主机 + 后续 host packing | 现有方案,靠加 ECS 节点 | ~30GB | 强 | 0(但要钱) |
短期(v0.5 范围内):所有 tier 走方案 A。即使 Plan.instanceKind=SHARED 也发独享容器(暂时不实现真共享)。
长期(user > 30 之后):lite/standard 切方案 B,plus 留方案 A。
目标流程(target state,post-refactor)
User Frontend App backend Provision worker Hermes container WeChat
(React) (NestJS) (NestJS timer) (per-user) (via Hermes)
│ │ │ │ │ │
│ 付款 (WeChat Pay) │ │ │ │ │
├─────────────────►│ │ │ │ │
│ │ createOrder │ │ │ │
│ ├─────────────────►│ │ │ │
│ │ wechat-pay QR │ │ │ │
│ │◄─────────────────┤ │ │ │
│ scan + pay │ │ │ │ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────► │
│ │ │ 付款回调 webhook │ │ │
│ │ │◄─────────────────────────────────────────────────────────────────┤
│ │ │ provisionPaidOrder()│ │ │
│ │ │ ├ create Subscription │ │
│ │ │ ├ create Instance(PROVISIONING_INITIAL) │
│ │ │ ├ NewAPI.createUserToken → llmApiKey │ │
│ │ │ └ enqueue ProvisionTask(SPAWN) │ │
│ │ │ │ pickup task │ │
│ │ │ │ spawn-profile.sh │ │
│ │ │ ├────────────────────►│ docker run │
│ │ │ │ │ hermes-gateway 启动 │
│ │ │ │ │ 等 weixin 凭据(未配) │
│ │ │ │ │ (无 weixin platform) │
│ │ │ │ poll baseUrl/health │ │
│ │ │ │◄────────────────────┤ │
│ │ │ │ Instance.status='AWAITING_BIND' │
│ │ │ │ + baseUrl, hostPort │ │
│ │ │ │ │ │
│ │ poll /services/instance/<sid>/wechat-qr │ │
│ ├─────────────────►│ │ │ │
│ │ │ docker exec hermes-<id> python /opt/data/scripts/weixin-qr-bridge.py start
│ │ ├────────────────────────────────────────────►│ │
│ │ │ │ subprocess 跑 qr_login()
│ │ │ │ qr_login → Tencent iLink ─► (Hermes 自己调,不是 twilight)
│ │ │ │◄── QR url + token ─── │
│ │ │ │ 写 /opt/data/.weixin-bridge/state.json
│ │ │◄── {qrcode, scanUrl} (stdout JSON) ────────┤ │
│ │◄─────────────────┤ │ │ │
│ 扫码绑定 │ │ │ │ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────► │
│ │ │ │ │ subprocess poll iLink status
│ │ │ │ │ status=confirmed │
│ │ │ │ │ save_weixin_account() │
│ │ │ │ │ 写 ${HERMES_HOME}/weixin/<id>.json
│ │ │ │ │ 更新 state.json: confirmed
│ │ poll /services/instance/<sid>/wechat-status │ │
│ ├─────────────────►│ │ │ │
│ │ │ docker exec ... weixin-qr-bridge.py poll │ │
│ │ ├────────────────────────────────────────────►│ │
│ │ │◄── {status: confirmed, accountId} ────────┤ │
│ │ │ Instance.weixinChannel=<accountId> │ │
│ │ │ Instance.status=READY │ │
│ │ │ docker exec ... hermes gateway restart │ │
│ │ ├────────────────────────────────────────────►│ gateway 重启 │
│ │ │ │ 读 weixin/*.json │
│ │ │ │ weixin platform enabled│
│ │ │ │ long-poll getupdates ►│ (Hermes ↔ iLink)
│ │◄─────────────────┤ │ │ │
│ 在微信里说"你好" │ │ │ │ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────► │
│ │ │ │ │ Hermes 处理 │
│ │ │ │ │ NewAPI → OpenRouter │
│ │ │ │ │ → reply │
│ agent 回答 │ │ │
│◄────────────────────────────────────────────────────────────────────────────────── │核心原则:
- 付款 → 立即 spawn 容器。容器是 QR 的来源,spawn 完成才进入 AWAITING_BIND。
- twilight-backend 不直接调 iLink。所有 iLink 交互在 Hermes 容器内部由
gateway/platforms/weixin.py执行。 weixin-qr-bridge.py是中介:跑在 Hermes 容器内,包装qr_login()为「fire-and-forget + 状态文件」模式;twilight-backend 用 docker exec 调它。- 绑定凭据由 Hermes 持有:成功后
${HERMES_HOME}/weixin/<accountId>.json是 source of truth;twilight 只在HermesInstance.weixinChannel缓存 accountId 用于展示。 - NewAPI token 在付款时一次性发,跟 Subscription 绑死。
各组件职责
App backend(backend/src/modules/)
| 模块 | 职责 |
|---|---|
billing/ | 接 WeChat Pay webhook → 标记 Order=PAID → 触发 provisioning.service |
provisioning/provisioning.service.ts | 创建 Subscription、Instance;发 NewAPI token;入列 ProvisionTask |
provisioning/provision-worker.service.ts | timer polling,发现 PENDING task → 跑 spawn-profile.sh → 健康检查 → 写回 Instance |
service-runtime/service-runtime.service.ts | docker exec 调 bridge script,把 QR / status 转给前端;polling confirmed → update weixinChannel |
newapi/newapi.service.ts | LLM 网关 token 生命周期(create / revoke / usage) |
Provision worker
- 单进程 timer(
POLL_INTERVAL_MS=5s)从ProvisionTask拉 PENDING - 调
provisioning.spawnHermesProfilePublic执行 spawn-profile.sh - 健康检查 baseUrl 直到 OK(
HEALTHCHECK_TIMEOUT_MS=90s) - 写回
HermesInstance - 失败 retry
MAX_ATTEMPTS=3,超出 → status='FAILED'
spawn-profile.sh(ECS 上)
输入:profileName, tier, KEY=VALUE 列表(LLM_API_KEY 必填)。weixin 入参全部删除(不再 inject WEIXIN_HOME_CHANNEL/TOKEN)。 输出:CONTAINER_NAME, HOST_PORT, TWILIGHT_API_TOKEN。 副作用:
- 复制
profile/template-stock-research-pro/→/twilight/hermes-profiles/<profileName>/ - 写
.env(无 WEIXIN_*)、config.yaml - 把
scripts/admin/weixin-qr-bridge.py拷贝到<profile_dir>/scripts/(容器内路径/opt/data/scripts/) docker runhermes-agent 容器,挂 profile dir 到/opt/data- 绑 dashboard port 9119 到主机随机端口
weixin-qr-bridge.py(新,落在 scripts/admin/)
- 输入:
start/poll/cancel start:fork 子进程运行await qr_login(hermes_home='/opt/data');子进程把 QR url + status 写入/opt/data/.weixin-bridge/state.json;主进程立刻 stdout 返回{qrcode, scanUrl}并退出poll:读state.json,stdout 返回{status, accountId?}cancel:kill 子进程,清 state.json- 凭据落盘由
qr_login内部的save_weixin_account()完成
Hermes 容器(每用户独立)
- 镜像:
nousresearch/hermes-agent:0.13.0(本地 tag) - 数据目录
/opt/data= host profile dir - 启动时 hermes-gateway 检查
${HERMES_HOME}/weixin/— 没有凭据则WARNING: No messaging platforms enabled,等 bridge 完成 +hermes gateway restart - HTTP 接口(不暴露 weixin API):dashboard SPA +
/api/...内部管理 - 绑定持久化:
${HERMES_HOME}/weixin/<accountId>.json+channel_directory.json+gateway_state.json
NewAPI(llm.fsagent.cc,Vultr JP)
- 单 channel:
openrouter-default(OpenRouter free pool) - token:每个付费用户独立,
unlimited_quota=true暂时 - backend 用 admin 凭据 session-auth,发完 token 把完整 key 写
HermesInstance.llmApiKey
状态机(HermesInstance.status)
| 状态 | 含义 | 谁写 |
|---|---|---|
PROVISIONING_INITIAL | 付款完成,正在 spawn 容器 | provisionPaidOrder |
AWAITING_BIND | 容器跑起来了,bridge 可调用,等用户扫码 | provision-worker.runSpawn 完成时 |
READY | 容器跑、WeChat 已绑定、gateway 重启完成 | service-runtime.pollWeixinStatus 发现 confirmed 时 |
FAILED | spawn 超过 MAX_ATTEMPTS | worker |
ERROR | NewAPI 发 token 失败 / bridge 异常 / 其它 fatal | provisioning service |
DESTROYED / DECOMMISSIONED | 取消 / 退订 | 退订流程 |
作废:CREATED / PENDING_BIND / BIND_CONFIRMED(旧设计遗留;migration 时只停写,保留枚举值避免破坏旧行)。
现状 → 目标的 gap
A 类:删旧代码(破坏性)
| # | 文件 | 内容 |
|---|---|---|
| A1 | backend/src/modules/service-runtime/service-runtime.service.ts:9-83 | 删 ILINK_BASE 常量、getWeixinQr / pollWeixinStatus 对 iLink 的直接调用、onBindConfirmed 整段(不再走 callback 路径) |
| A2 | scripts/admin/spawn-profile.sh:22-23, 146-148 | 删 WEIXIN_HOME_CHANNEL / WEIXIN_TOKEN 入参 + .env 写入对应行 |
| A3 | backend/src/modules/provisioning/provisioning.service.ts:191-194 | spawnHermesProfilePublic 移除 weixin 参数 |
| A4 | backend/src/modules/provisioning/provision-worker.service.ts:147-148 | runSpawn 移除 weixin 入参 |
| A5 | profile/template-stock-research-pro/.env.example + secrets.schema.json + README.md | 移除 WEIXIN_* 描述 |
| A6 | backend/src/modules/provisioning/provisioning.service.ts spawnAfterBind | 整段删除(bind 后不再触发 spawn,spawn 已经先于 bind) |
B 类:加新代码
| # | 文件 | 内容 |
|---|---|---|
| B1 | backend/prisma/schema.prisma InstanceStatus enum | 加 PROVISIONING_INITIAL、AWAITING_BIND |
| B2 | new migration | ALTER TYPE InstanceStatus ADD VALUE 'PROVISIONING_INITIAL'、同样加 AWAITING_BIND |
| B3 | scripts/admin/weixin-qr-bridge.py (新文件) | qr_login 的 fork-and-state-file 包装 |
| B4 | scripts/admin/spawn-profile.sh | 拷贝 bridge 到 profile dir |
| B5 | backend/Dockerfile | apk add openssl python3 py3-pip(运行 spawn-profile.sh + bridge 需要) |
| B6 | backend/src/modules/provisioning/provisioning.service.ts | provisionPaidOrder 末尾创建 ProvisionTask(spawn 在付款时触发) |
| B7 | backend/src/modules/provisioning/provision-worker.service.ts runSpawn | 写回 status='AWAITING_BIND' |
| B8 | backend/src/modules/service-runtime/service-runtime.service.ts | getWeixinQr(subId) → execFile('docker', ['exec', containerName, 'python3', '/opt/data/scripts/weixin-qr-bridge.py', 'start']) → 解析 JSON stdout;pollWeixinStatus 类似 → poll;confirmed 时 docker exec ... hermes gateway restart,update Instance.weixinChannel + status='READY' |
| B9 | frontend/src/pages/StatusPage.tsx | canBind = instanceStatus==='AWAITING_BIND';instanceLabel 加 PROVISIONING_INITIAL / AWAITING_BIND |
| B10 | frontend/src/pages/PurchasePage.tsx | 删 markPaidDev 按钮在生产 build 的显示;error fallback 「重试」改成 reload 当前订单状态 |
| B11 | deploy/Dockerfile 或 backend image | 确保 hermes-agent 镜像在 ECS 本地有 :0.13.0 tag(CI 或部署脚本一次 tag) |
C 类:未验证的依赖(依然要验)
| # | 假设 | 怎么验 |
|---|---|---|
| C1 | bridge fork 子进程能在 docker exec 退出后继续跑 | 在容器内试一次:python3 -c "import os; pid=os.fork(); ..." → 看父子是否独立 |
| C2 | 容器内 import gateway.platforms.weixin 在 docker exec 环境下能 work | docker exec ... python3 -c "from gateway.platforms.weixin import qr_login; print('ok')" |
| C3 | hermes gateway restart 不丢现有 session(kanban / cron / 等) | 重启前后看 gateway_state.json |
| C4 | Hermes 容器能从 NewAPI 拿响应 | docker exec ... curl -H "Authorization: Bearer $LLM_API_KEY" https://llm.fsagent.cc/v1/models |
| C5 | MCP_URL=http://127.0.0.1:9100/mcp 在容器(非 host net)里能通 | docker exec ... curl MCP_URL — 预期 fail;要么改 host.docker.internal:9100,要么 mcp-tushare 入同一 network |
执行计划
Phase 1 — 验证 ✅(2026-05-18 完成)
| step | 内容 | 结果 |
|---|---|---|
| 1.1 | spawn-profile.sh 重启 hanliang730 容器 | ✅(hacks: apk add openssl,预 tag image) |
| 1.2 | /health 200 | ✅ |
| 1.3 | /wechat/qr HTTP 路由 | ❌ 不存在 → 弃 HTTP,走 docker exec |
| 1.4 | /wechat/status HTTP 路由 | ❌ 不存在 |
| 1.5 | 容器内 qr_login() 函数 | ✅ /opt/hermes/gateway/platforms/weixin.py:1041 |
| 1.6 | Hermes 单 process 多 profile | ❌ Hermes 是 sticky select,决定走方案 A(一容器一用户) |
Phase 2 — 桥脚本独立验证(L1 测试)✅ 完成(2026-05-18)
| step | 内容 | 结果 |
|---|---|---|
| 2.1 | 写 scripts/admin/weixin-qr-bridge.py(stateless start/poll/cancel,state.json 持久化) | ✅ |
| 2.2 | scp 到 profile dir(host path /home/twilight/hermes-profiles/<id>/weixin-qr-bridge.py) | ✅ |
| 2.3 | docker exec ... bridge start 返回 QR JSON | ✅ |
| 2.4 | 父进程退出后无子进程残留(设计去掉了 fork,stateless 每次单独 call) | ✅ |
| 2.5 | 手机扫 QR | ✅ |
| 2.6 | bridge poll status 流转 wait → confirmed | ✅ |
| 2.7 | 凭据落 /opt/data/weixin/accounts/<account>.json | ✅(注意:实际路径是 weixin/accounts/ 不是 .hermes/weixin/) |
| 2.8 | 重启容器(不是 hermes gateway restart,那个权限会冲突)→ 让 entrypoint 重做 chown + 读 .env | ✅ |
| 2.9 | gateway_state.json platforms.weixin.state=connected + 新 timestamp | ✅ |
| 2.10 | 发微信 "hello now" → Hermes 收到 → LLM 调用 → 回复 "你好!有什么我可以帮你的吗?" | ✅ |
Phase 2 出口已达:bridge 机制 + LLM 回复 全链路 verified。完整 audit trail 在 logs/agent.log:
04:25:11 [Weixin] inbound msg='hello now'
04:25:14 OpenAI client → openrouter.ai
04:25:29 API call: 9.6s latency, out=70 tokens
04:25:29 [Weixin] Sending response (14 chars)Phase 2 期间踩的 11 个坑 全部记录在 docs/err/2026-05-18-hermes-wechat-binding-and-llm-routing.md,含 root cause + 全局 fix 清单(A/B/C 三类)。
Phase 3 — backend 接桥脚本(L2 测试)
需要做。Phase 2 是手工 docker exec 跑通;Phase 3 让 backend code 自动调 bridge,把整个流程串到 StatusPage UI。
- [ ] 3.1 应用 err doc 的 A 类(必须)改动:
- A1 spawn-profile.sh 用 host path 传
docker run -v - A2 spawn-profile.sh 拷贝 bridge 到 profile dir
- A3 backend Dockerfile 加
openssl python3 - A4 profile
.envtemplate 加OPENAI_API_KEY+OPENAI_BASE_URL - A5 provision-worker
TIER_PROFILES默认 1cpu/1g(避免 OOM) - A6 Hermes image tag 兼容(
:latest或预 tag:0.13.0) - A7
service-runtime.service.tsgetWeixinQr/pollWeixinStatus改docker exec ... bridge start/poll - A8 bridge confirmed 后 backend recreate container(不是 restart)
- A1 spawn-profile.sh 用 host path 传
- [ ] 3.2 local dev stack(PR #98)跑端到端
- [ ] 3.3 ECS deploy backend,hanliang730 当 canary 重跑流程
Phase 3 出口:付费 → 自动 spawn → StatusPage 展示 QR → 用户扫 → READY。
Phase 4 — 回归 + 清理
- [ ] 4.1 PR #97 的 Phase B/C/E 与新流程冲突 reconcile(多半放弃 status-machine 改动,保留 schema additions)
- [ ] 4.2
_prisma_migrations旧 enum value(CREATED/PENDING_BIND/BIND_CONFIRMED)保留不写 - [ ] 4.3 runbook 加一节:「人工 rebind 微信」步骤(删容器、清
weixin/accounts/、StatusPage 重新点 bind) - [ ] 4.4 B 类(健壮性)改动:health-check 解耦 dashboard、bridge state.json 权限、MCP_URL fix
- [ ] 4.5 C 类(架构层)回到 NewAPI 路径:要么关 CF Bot Fight Mode for
llm.fsagent.cc,要么 NewAPI 加 SDK UA 白名单。期间 Hermes 直连 OpenRouter 作为短期方案。
一致性问题清单(已识别)
service-runtime.controller.ts /wechat-status?qrcode=query 过时:bridge 不需要 qrcode 参数。改 controller。provisioning.service.spawnAfterBind整段废弃。HermesInstance.weixinToken字段新流程下无人写。Phase 4 可考虑 nullable + 停用(凭据在 Hermes 容器自己的文件里)。- backend image 当前缺
openssl+python3:B5 修。
风险 / 未确认
qr_login子进程在 docker exec 主进程退出后会不会被 reap 杀掉?需要setsid或 nohup。hermes gateway restart实测:是否 graceful、需多久、会不会丢 kanban / cron 状态?- 同一个用户多次「重置并重新绑定」会不会留下垃圾文件 in
${HERMES_HOME}/weixin/? - 用户扫码超时(默认 480s)后,怎么把状态回退到 AWAITING_BIND 让他重试?
验证 / 回滚兜底
- 每次部署前 pg_dump 备份(runbook 已有)
- Phase 3 启动前 用
backend/scripts/dev/dry-run-migration.sh(PR #98)跑 enum migration - Phase 3 上 ECS 后 hanliang730 当 canary 一周再上其他用户
- bridge 异常时 用户能在 StatusPage 点「重置并重新绑定」(PR #97 Phase B 加的 canRecover 分支)触发 rebind → 删容器 + 清
.hermes/weixin/→ 重新 AWAITING_BIND