主题
Hermes Instance Lifecycle Rebuild
日期: 2026-05-16 目标: 用状态机驱动 Instance 生命周期,消除 docker restart 死循环,QR 与容器解耦,容器 immutable。
背景
旧架构:支付 → spawn 空容器 → 等扫码 → 写文件 → docker restart → iLink 限流 → --restart unless-stopped 立即拉起 → 二次限流 → 死循环。
根本错误:
- 容器生命周期被扫码流程绑架
docker restart被当作业务重试机制- 没有状态机,只有布尔
gateway_running - 容器 mutable — 运行后改 env 再 restart
目标架构
Frontend → NestJS API → Instance State Machine → Provision Worker → spawn-profile.sh → Docker
↑
poll ProvisionTask(DB-backed queue)状态机:
CREATED → PENDING_BIND → BIND_CONFIRMED → PROVISIONING → READY → STOPPED → DESTROYED
↓
FAILED核心原则:
- 容器 immutable:env 一次注入,从不
docker restart - QR 不依赖容器:iLink QR API 由 NestJS 直接调
- 重置 = destroy + recreate:换号、绑错、重绑都走
stop-profile.sh --destroy+spawn-profile.sh - provision 异步:confirmed 不阻塞 HTTP,enqueue 后 worker 跑
阶段划分
Phase 1 — 状态机 + Schema 改造(破坏性 migration)
改 backend/prisma/schema.prisma:
prisma
enum InstanceStatus {
CREATED // 新建,未做任何事
PENDING_BIND // 等用户扫码
BIND_CONFIRMED // 扫码完成,待 spawn
PROVISIONING // spawn-profile.sh 跑中
READY // 容器健康,可用
STOPPED // 主动停止(订阅到期/退款)
FAILED // spawn 失败,需人工或 retry
DESTROYED // 容器删除 + profile dir 清理
}
model HermesInstance {
// 现有字段保留
// 新增:
weixinChannel String? // ilink_bot_id,扫码后写入
weixinToken String? // ilink bot_token
profileName String? // = instanceId(spawn-profile 用)
lastError String? // 最近一次失败信息
spawnedAt DateTime?
destroyedAt DateTime?
}Migration: prisma migrate dev --name instance_state_machine
⚠ 老数据:所有现存 PROVISIONING/READY 记录手动设为 DESTROYED(ECS 上 stop-profile.sh --destroy 清干净),让新流程从 0 开始。
Phase 2 — provisionPaidOrder 拆分(不 spawn 容器)
改 backend/src/modules/provisioning/provisioning.service.ts:
typescript
async provisionPaidOrder(orderId: string) {
// 1. 创建 Subscription + Instance(status=PENDING_BIND)
// 2. 发 NewAPI LLM key → 写入 instance.llmApiKey
// 3. 返回,不调用 spawnHermesProfile
}
// 新方法:扫码完成后调
async spawnAfterBind(instanceId: string) {
// 1. instance.status = PROVISIONING
// 2. 创建 ProvisionTask(orderId, status=PENDING) ← 入队
// 3. 返回 — worker 异步处理
}删除:provisionPaidOrder 内的 spawnHermesProfile 调用块(line 165-194)。
Phase 3 — Provision Worker(DB 队列,零新依赖)
新文件 backend/src/modules/provisioning/provision-worker.service.ts:
typescript
@Injectable()
export class ProvisionWorkerService implements OnModuleInit {
// 每 5s 扫 ProvisionTask(status=PENDING),取一条跑
// 跑完成功 → status=SUCCEEDED + Instance.status=READY
// 跑失败 → status=FAILED + Instance.status=FAILED + lastError
// 不无限重试:attempts >= 3 锁死,需人工
}跑的内容:
- 读 Instance → 拿
weixinChannel、llmApiKey、profileName - 调
spawn-profile.sh <profileName> container LLM_API_KEY=xxx WEIXIN_HOME_CHANNEL=xxx WEIXIN_TOKEN=xxx - 解析输出 → 写回
externalRef、hostPort、baseUrl、spawnedAt - healthcheck(
curl http://127.0.0.1:<hostPort>/api/status)→ 等gateway_state=running Instance.status = READY
注册到 provisioning.module.ts。
Phase 4 — service-runtime 重构
改 backend/src/modules/service-runtime/service-runtime.service.ts:
typescript
// 删除:saveWeixinChannel, restartContainer
// 改 pollWeixinStatus:
async pollWeixinStatus(qrcode: string, instanceId: string) {
const { data } = await axios.get(...);
if (data.status === 'confirmed') {
const channelId = String(data.ilink_bot_id ?? '');
const token = String(data.bot_token ?? '');
await this.prisma.hermesInstance.update({
where: { id: instanceId },
data: {
weixinChannel: channelId,
weixinToken: token,
status: 'BIND_CONFIRMED',
},
});
await this.provisioning.spawnAfterBind(instanceId); // 入队
return { status: 'confirmed', channelId };
}
return { status: data.status };
}改 service-runtime.controller.ts:33:
typescript
// wechat-qr 允许 PENDING_BIND
if (!['PENDING_BIND', 'BIND_CONFIRMED'].includes(sub.instance?.status ?? '')) {
return { error: 'instance_not_pending_bind' };
}新增端点:
typescript
@Get('/services/instance/:subscriptionId/provision-status')
// 前端轮询 — 返回 instance.status + lastErrorPhase 5 — spawn-profile.sh 接收 WeChat 参数
改 scripts/admin/spawn-profile.sh:
bash
# 新参数(KEY=VALUE 形式已支持)
# WEIXIN_HOME_CHANNEL=<ilink_bot_id>
# WEIXIN_TOKEN=<bot_token>
# 写 .env 时注入(line 122-142 替换):
cat > "$PROFILE_DIR/.env" <<EOF
...
WEIXIN_HOME_CHANNEL=${WEIXIN_HOME_CHANNEL:-}
WEIXIN_TOKEN=${WEIXIN_TOKEN:-}
GATEWAY_ALLOW_ALL_USERS=true
SEARXNG_URL=${SEARXNG_URL}
...
EOF
# Docker run 改 restart policy(line 205):
--restart on-failure:3不再需要 ~/.hermes/.env 双写。一次 docker run,永不 restart。
Phase 6 — 重置/换号路径(destroy + recreate)
新端点 POST /services/instance/:subscriptionId/rebind:
typescript
// 1. stop-profile.sh <profileName> --destroy
// 2. Instance: status=DESTROYED → 重置为 PENDING_BIND,清 weixinChannel/weixinToken/externalRef/hostPort
// 3. 前端重新拉 QRstop-profile.sh 已经存在,直接 exec。
Phase 7 — 前端 StatusPage 状态机驱动
改 frontend/src/pages/StatusPage.tsx:
新增 phase:
typescript
type WeixinState =
| { phase: 'idle' }
| { phase: 'qr'; qrcode: string; scanUrl: string }
| { phase: 'confirmed' }
| { phase: 'provisioning' } // 新:等 spawn worker
| { phase: 'ready' } // 新:容器健康
| { phase: 'failed'; error: string } // 新:spawn 失败
| { phase: 'error'; message: string };confirmed 后切到 provisioning,轮询 /provision-status,看到 READY 切 ready。失败显示 lastError + 提供 "重试绑定" 按钮(调 /rebind)。
老数据迁移
ECS 上一次性脚本:
bash
# 1. 停所有当前 Hermes 容器
docker ps -a --filter "name=hermes-" --format '{{.Names}}' | xargs -r docker stop
docker ps -a --filter "name=hermes-" --format '{{.Names}}' | xargs -r docker rm -f
# 2. 清 profile 目录
rm -rf /home/twilight/hermes-profiles/*
# 3. DB 更新所有 Instance.status = DESTROYED
# 通过 prisma migrate + 一条 SQL 脚本老用户重新进入 StatusPage → 点 "重新绑定" → 走新流程。
Tier 差异化(199 标准 vs 999 专业)
决策:取消 SHARED 概念,两 tier 都跑独立容器。差异落在配额 + 资源 + 优先级,不做共享容器池。
理由:
- 现状
provisioning.service.ts:166已经写死tier='container',SHARED 是假的 - iLink 物理限制:1 bot_id = 1 微信账号,共享容器不可能多用户绑微信
- 简化架构,省掉一条分叉
差异表:
| 维度 | 标准版 ¥199 | 专业版 ¥999 |
|---|---|---|
| 容器 | 独立 | 独立 |
| CPU | 0.25 | 1.0 |
| Memory | 256m | 1g |
| NewAPI 月配额 | 2M tokens | 无上限 |
Hermes max_turns | 60 | 120 |
| Provision Worker 优先级 | 低(FIFO) | 高(队列优先) |
| WeChat 支持 | ✅ | ✅ |
| WebSearch | ✅ | ✅ |
落地点:
Phase 1 schema
Plan.instanceKind字段保留(前端展示用),provisioning 不再读- 老数据:
UPDATE HermesInstance SET kind='DEDICATED' WHERE kind='SHARED' InstanceKind枚举可保留 SHARED 值,但永不创建新行
Phase 2 provisioning
- 删除
provisioning.service.ts:90-106的findFirst SHARED复用块 - 永远走
tx.hermesInstance.create({ kind: 'DEDICATED', ... })
- 删除
Phase 3 worker — 优先级队列
typescript// 拉任务时按 plan.priceCnyFen 倒序 const next = await prisma.provisionTask.findFirst({ where: { status: 'PENDING' }, orderBy: [ { order: { plan: { priceCnyFen: 'desc' } } }, // 高价先跑 { createdAt: 'asc' }, // 同价 FIFO ], });Phase 5 spawn-profile.sh — 接收 tier 参数
bash# 新参数 # TIER_CPUS=<float> 默认 0.25 # TIER_MEMORY=<size> 默认 256m # MAX_TURNS=<int> 默认 60 TIER_CPUS="${TIER_CPUS:-0.25}" TIER_MEMORY="${TIER_MEMORY:-256m}" MAX_TURNS="${MAX_TURNS:-60}" # docker run 用:--cpus $TIER_CPUS --memory $TIER_MEMORY # config.yaml 改 sed: max_turns: ${MAX_TURNS}config.yaml.template加{{MAX_TURNS}}占位符,spawn 时 sed 替换。Phase 3 worker — 透传 tier
typescriptconst plan = order.plan; const isProf = plan.priceCnyFen >= 99900; const args = [ ..., `TIER_CPUS=${isProf ? '1.0' : '0.25'}`, `TIER_MEMORY=${isProf ? '1g' : '256m'}`, `MAX_TURNS=${isProf ? '120' : '60'}`, ];NewAPI 配额
- 已有:
provisioning.service.ts:153调newApi.createUserToken(name, monthlyQuotaTokens) - 标准版
monthlyQuotaTokens=2_000_000→ NewAPI 自动限流 - 专业版
monthlyQuotaTokens=null→ NewAPI 无限 - 无需改动,已就位
- 已有:
不做的事
- ❌ 不引入 Redis / BullMQ —— 用 ProvisionTask 表当队列足够
- ❌ 不做 multi-profile 共享容器 —— 风控 + 隔离风险太高
- ❌ 不改 NewAPI key 流程 —— 已稳定,保留
- ❌ 不动 SearXNG 集成 —— 已经在 spawn-profile.sh 中通过 .env 注入
- ❌ 不做真共享池 —— Tier 差异化纯靠资源/配额/优先级,无架构分叉
验收清单
- [ ] 新用户支付 → 5s 内能拿到 QR(无需等容器启动)
- [ ] 扫码确认 → 30s 内 Instance 进入 READY
- [ ] 全程零
docker restart调用 - [ ] iLink 限流不再出现(连续扫码 3 次不触发)
- [ ] 微信内发消息 → Hermes 回复(含 WebSearch 通过 SearXNG)
- [ ] 重绑场景:rebind → 新 QR → 新容器,旧容器干净销毁
- [ ] spawn 失败 → Instance.status=FAILED,前端显示 lastError,可重试
- [ ] 标准版容器
docker inspect显示CpuPeriod/CpuQuota对应 0.25 CPU;专业版 1.0 CPU - [ ] 专业版用户的 ProvisionTask 排队时优先于同时入队的标准版
- [ ] 标准版用户连续调用 NewAPI 超过 2M tokens 后返回 429(配额限流)
落地顺序
- Phase 1 schema + migration(开发环境先验)
- Phase 5 spawn-profile.sh 改造(独立可测)
- Phase 2 + 3 provisioning 拆分 + worker
- Phase 4 service-runtime 重构
- Phase 6 rebind 端点
- Phase 7 前端
- ECS 老数据清理 + 上线
每 phase 独立 PR,CI 通过再 merge。
风险
- iLink QR 拉取频率:前端轮询 3s 一次,扫码超时若用户长时间不扫,iLink 可能限流 QR 接口。需测临界值。
- spawn-profile.sh 跑时间:当前 ~10-30s(docker pull + container start + healthcheck)。worker 间隔 5s 扫,最差延迟 5s,可接受。
- Worker 单点:单容器跑 NestJS 时 worker 单实例。多 NestJS 副本时需 advisory lock(pg_advisory_lock)。当前架构单副本,暂不处理。