Skip to content

付费 → 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 HTMLtarget 流程必须改:用 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❌ 本地只有 :latestspawn-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 节点~30GB0(但要钱)

短期(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 回答        │                                                              │                       │
  │◄──────────────────────────────────────────────────────────────────────────────────                      │

核心原则

  1. 付款 → 立即 spawn 容器。容器是 QR 的来源,spawn 完成才进入 AWAITING_BIND。
  2. twilight-backend 不直接调 iLink。所有 iLink 交互在 Hermes 容器内部由 gateway/platforms/weixin.py 执行。
  3. weixin-qr-bridge.py 是中介:跑在 Hermes 容器内,包装 qr_login() 为「fire-and-forget + 状态文件」模式;twilight-backend 用 docker exec 调它。
  4. 绑定凭据由 Hermes 持有:成功后 ${HERMES_HOME}/weixin/<accountId>.json 是 source of truth;twilight 只在 HermesInstance.weixinChannel 缓存 accountId 用于展示。
  5. 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.tstimer polling,发现 PENDING task → 跑 spawn-profile.sh → 健康检查 → 写回 Instance
service-runtime/service-runtime.service.tsdocker exec 调 bridge script,把 QR / status 转给前端;polling confirmed → update weixinChannel
newapi/newapi.service.tsLLM 网关 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 run hermes-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 时
FAILEDspawn 超过 MAX_ATTEMPTSworker
ERRORNewAPI 发 token 失败 / bridge 异常 / 其它 fatalprovisioning service
DESTROYED / DECOMMISSIONED取消 / 退订退订流程

作废CREATED / PENDING_BIND / BIND_CONFIRMED(旧设计遗留;migration 时只停写,保留枚举值避免破坏旧行)。


现状 → 目标的 gap

A 类:删旧代码(破坏性)

#文件内容
A1backend/src/modules/service-runtime/service-runtime.service.ts:9-83ILINK_BASE 常量、getWeixinQr / pollWeixinStatus 对 iLink 的直接调用、onBindConfirmed 整段(不再走 callback 路径)
A2scripts/admin/spawn-profile.sh:22-23, 146-148WEIXIN_HOME_CHANNEL / WEIXIN_TOKEN 入参 + .env 写入对应行
A3backend/src/modules/provisioning/provisioning.service.ts:191-194spawnHermesProfilePublic 移除 weixin 参数
A4backend/src/modules/provisioning/provision-worker.service.ts:147-148runSpawn 移除 weixin 入参
A5profile/template-stock-research-pro/.env.example + secrets.schema.json + README.md移除 WEIXIN_* 描述
A6backend/src/modules/provisioning/provisioning.service.ts spawnAfterBind整段删除(bind 后不再触发 spawn,spawn 已经先于 bind)

B 类:加新代码

#文件内容
B1backend/prisma/schema.prisma InstanceStatus enumPROVISIONING_INITIALAWAITING_BIND
B2new migrationALTER TYPE InstanceStatus ADD VALUE 'PROVISIONING_INITIAL'、同样加 AWAITING_BIND
B3scripts/admin/weixin-qr-bridge.py (新文件)qr_login 的 fork-and-state-file 包装
B4scripts/admin/spawn-profile.sh拷贝 bridge 到 profile dir
B5backend/Dockerfileapk add openssl python3 py3-pip(运行 spawn-profile.sh + bridge 需要)
B6backend/src/modules/provisioning/provisioning.service.tsprovisionPaidOrder 末尾创建 ProvisionTask(spawn 在付款时触发)
B7backend/src/modules/provisioning/provision-worker.service.ts runSpawn写回 status='AWAITING_BIND'
B8backend/src/modules/service-runtime/service-runtime.service.tsgetWeixinQr(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'
B9frontend/src/pages/StatusPage.tsxcanBind = instanceStatus==='AWAITING_BIND';instanceLabel 加 PROVISIONING_INITIAL / AWAITING_BIND
B10frontend/src/pages/PurchasePage.tsxmarkPaidDev 按钮在生产 build 的显示;error fallback 「重试」改成 reload 当前订单状态
B11deploy/Dockerfile 或 backend image确保 hermes-agent 镜像在 ECS 本地有 :0.13.0 tag(CI 或部署脚本一次 tag)

C 类:未验证的依赖(依然要验)

#假设怎么验
C1bridge fork 子进程能在 docker exec 退出后继续跑在容器内试一次:python3 -c "import os; pid=os.fork(); ..." → 看父子是否独立
C2容器内 import gateway.platforms.weixin 在 docker exec 环境下能 workdocker exec ... python3 -c "from gateway.platforms.weixin import qr_login; print('ok')"
C3hermes gateway restart 不丢现有 session(kanban / cron / 等)重启前后看 gateway_state.json
C4Hermes 容器能从 NewAPI 拿响应docker exec ... curl -H "Authorization: Bearer $LLM_API_KEY" https://llm.fsagent.cc/v1/models
C5MCP_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.1spawn-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.6Hermes 单 process 多 profile❌ Hermes 是 sticky select,决定走方案 A(一容器一用户)

Phase 2 — 桥脚本独立验证(L1 测试)✅ 完成(2026-05-18)

step内容结果
2.1scripts/admin/weixin-qr-bridge.py(stateless start/poll/cancel,state.json 持久化)
2.2scp 到 profile dir(host path /home/twilight/hermes-profiles/<id>/weixin-qr-bridge.py
2.3docker exec ... bridge start 返回 QR JSON
2.4父进程退出后无子进程残留(设计去掉了 fork,stateless 每次单独 call)
2.5手机扫 QR
2.6bridge 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.9gateway_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 .env template 加 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.ts getWeixinQr/pollWeixinStatusdocker exec ... bridge start/poll
    • A8 bridge confirmed 后 backend recreate container(不是 restart)
  • [ ] 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

团队内部文档