docs/plans/2026-05-30-001-feat-voice-pack-tts-pool-lb-plan.md
一个计划两块,号池负载均衡排最前。先做 TTS 号池容量感知负载均衡(Redis 在途计数 + 容量感知路由 + 429 反哺 + 监控),让「一账号 10 app × 10 并发 = 100 并发」的理论容量真正用满;再做 Voice Pack 音色系统(服务端 voice_packs 表 + admin CRUD API + 角色卡冻结快照 + 合成读快照)。两块解耦:Voice Pack 只 pin 一个 tts model id,那个 model 的 upstreams 即号池。
号池侧:上游按 app_id 限并发(典型 10),扩额贵,绕法是一账号开多 app 凑并发。但现有 routeTts 的 createKeyRotator(apps/server/src/services/.../router.ts:429)是无状态盲轮转,每请求新建、纯按 config 顺序遍历;upstream 主循环(router.ts:559)也是固定顺序,永远先打满第一个再降级。结果是 100 并发理论容量用不满,且单号越界触发 429。这层不是负载均衡,是 failover 顺序表。
音色侧:当前音色是全局 localStorage 状态(packages/stage-ui/src/stores/modules/speech.ts:32-35),不绑角色卡、不是快照。voice catalog per-model,上游变化时已选音色会被动漂移。用户还被迫理解 provider 拓扑。
详见 origin 需求文档(docs/brainstorms/2026-05-30-voice-pack-requirements.md)的 Problem Frame 与 Key Decisions。
并发计数放 Redis,按多副本设计。 代码已有多副本假设(apps/server/src/.../gauges/active-sessions.ts:23-26 明确 cluster-wide gauge、必须 avg() 不 sum();otel/index.ts:76-90 选 ObservableGauge 避免多副本重复计数)。进程内内存会让各副本各算、号池超卖。复用 flux meter 的 Lua 原子模式(flux-meter.ts:23-31 INCRBY+EXPIRE+条件 DECRBY),TTL 兜底防崩溃副本永久占槽。(see origin: docs/brainstorms/2026-05-30-voice-pack-requirements.md R1/R2)
建模:一个 app_id = 一个 upstream。 volcengine 的 appid 取自 ctx.adapterParams.appid(adapters/tts/volcengine.ts:49-51,upstream 级),token 走 keys[].ciphertext。要表达 10 个 app_id 就配 10 个 upstreams[](每个一个 appid + 它的 token)。这样 appid 已在路由层可见(upstream.adapterParams.appid),并发计数 key 自然是 pool:inflight:<appid>,且直接落在 routeTts 已有的 upstream 遍历上。否决「appid 下沉到 key 级」方案:要改 adapter 取值位置且注入面更大。
容量感知做在 routeTts 路由层,不加 route gate。 「哪个 app_id 还有余量」是路由决策不是请求准入。现有 ttsMeter.assertCanAfford(计费余额)和 ttsGuard(配置存在性,config-guard.ts:12)都不是并发 gate,不复用它们做并发。全池满 → 走现有 exhaustion fail-fast(router.ts:591-616 mapUpstreamError),不静默降级。
429 是安全网不是主信号。 fallbackHttpCodes 默认含 429(router.ts:549),现状是上游回 429 才被动切号。主动层(Redis 计数)在派发前就跳过满号;429 仍作兜底,且收到 429 时把该 appid 标记短 TTL「已满」反哺主动层,避免继续往坏号派(对应 origin R6 坏号退避)。
监控复用现有 OTel metrics,零新基建。 counter 加进 GatewayMetrics(otel/index.ts:202-244,已有 fallbackCount/upstreamErrors/keyExhaustedCount,router 里已打点);池水位 gauge 仿 gauges/active-sessions.ts:42-102 把数据源从 Postgres 换 Redis。Langfuse 是 trace 不碰。
Voice Pack library 是服务端 voice_packs 表(复数),管理员策展。 本轮只装云提供商音色 = provider + model + voice + 参数覆盖,覆盖标准 voice 与云端克隆 model id 两类。参数覆盖是 pack 身份的一部分(同 voice 不同参数 = 不同 pack)。未来参考音频落 voice_pack_reference 子表(FK → voice_packs),voice_packs 永远是唯一身份/市场/计费实体,本轮不建子表只记形状。
绑定冻结解析后的值,不存表外键。 角色卡冻结 provider/model/voice/params/tier + pin 的 tts model id 进 extensions.airi.modules.speech.voicePack(扩 airi-card.ts:173-215 现有 speech 快照点)。存外键会让改表连带改卡,回到漂移。resolveAiriExtension(airi-card.ts:161)处理字段缺失,不加 backward-compat guard。
tier 本轮是展示 + 数据。 voice_packs 一列,复用 tts-billing-tiers.md 的 lite/standard/pro/premium 命名,冻进快照。本轮只有单 meter(FLUX_PER_1K_CHARS_TTS),四档拆分属 billing 独立线,「按最高档取价」暂无真实差价效果。
flowchart TB
REQ[TTS 合成请求进入 routeTts] --> RESOLVE[解析目标 tts model 的 upstreams]
RESOLVE --> QUERY["读 Redis 各 upstream.adapterParams.appid 在途计数
pool:inflight:<appid>"]
QUERY --> RANK[按剩余并发余量排序 upstreams]
RANK --> PICK{有 upstream 还有余量?}
PICK -->|否| EXHAUST["fail-fast: mapUpstreamError
带 triedKeys/triedUpstreams 上下文"]
PICK -->|是| ACQUIRE["Lua 原子: 检查容量 + INCR 占槽"]
ACQUIRE --> SEND[dispatchOneTtsUpstream → adapter.send]
SEND --> RESULT{结果}
RESULT -->|成功| RELEASE_OK["finally: DECR 释放槽"]
RESULT -->|429/失败| MARK["标记该 appid 短 TTL 已满 (熔断)
+ DECR 释放槽"]
MARK --> PICK
RELEASE_OK --> DONE[返回音频]
flowchart LR
TABLE["voice_packs 表 (货架)
provider/model/voice/params/tier/enabled"] -->|admin CRUD| TABLE
TABLE -->|"列 enabled"| MARKET[最小绑定入口]
MARKET -->|绑定: 冻结解析后的值| CARD["角色卡 extensions.airi.modules.speech.voicePack
(买走的快照, 不存表外键)"]
CARD -->|合成读快照| SYNTH[映射 tts model id]
SYNTH --> POOL[经号池 LB 挑号合成]
TABLE -.改表/禁用.-> TABLE
CARD -.不受改表影响.-> CARD
apps/server/src/services/tts/concurrency-ledger.ts(新建,命名待 review,避免 manager/pool 泛词;候选 concurrency-ledger / inflight-slots)apps/server/src/services/tts/concurrency-ledger.test.ts(新建)flux-meter.ts:23-31 的 Lua 原子脚本:tryAcquire(appId, maxConcurrency) = Lua 内 GET pool:inflight:<appId>,未超上限则 INCR + EXPIRE(短 TTL 防泄漏) 返回成功,超上限返回失败;release(appId) = DECR(下限 0)。另出 markSaturated(appId, ttl)(429 熔断用,set 一个 pool:saturated:<appId> 短 TTL flag)与 currentInflight(appId) 读数。复用现有 Redis client injeca(与 createFluxMeter 同源,app.ts:616 附近)。apps/server/src/services/.../flux-meter.ts:23-31(Lua INCRBY+EXPIRE+条件 DECRBY)、:106(redis.eval 用法)、:18(TTL survives 注释思路)。routeTts 的固定顺序 upstream 遍历换成按 appid 剩余并发余量挑选,并在派发前后占/放槽。apps/server/src/services/.../router.ts(改 routeTts upstream 选择 :559、dispatchOneTtsUpstream 槽位获取/释放 :413-534)apps/server/src/services/.../router.test.ts(新增/扩展号池路由用例)upstream.adapterParams.appid 的在途数与 saturated flag,过滤掉满号/熔断号,按剩余余量排序后遍历(替代 :559 的 for i in 0..length 固定顺序)。进入 adapter.send(:450)前 tryAcquire;失败(该号刚好满)则跳到下一个候选。槽释放放 dispatchOneTtsUpstream 已有的 finally(:527-529,与 key.plaintext.fill(0) 同块)。所有候选都满/耗尽 → 现有 exhaustion 路径 mapUpstreamError(:591-616)fail-fast。key 级 createKeyRotator 不动(appid 在 upstream 级)。router.ts:559(upstream 遍历)、:527-529(finally 释放点)、:591-616(exhaustion fail-fast)、mapUpstreamError(error-mapping.ts:62)。apps/server/src/services/.../router.ts(429 fallback 分支 :506-524 处调用 markSaturated)apps/server/src/services/.../router.test.ts(扩展)fallbackHttpCodes.includes(rawStatus))命中 429 时,除继续切号外,调用 U1 的 markSaturated(appid, shortTtl)。U2 的候选过滤已读 saturated flag,自然跳过。区分 429(并发/限流,熔断该号)与其它 fallback 码(如 500/502,按现有逻辑切号但不必熔断),避免把临时网络错误误判成号满。router.ts:506-524(fallback 分支与状态判断)、fallbackHttpCodes(:549)。apps/server/src/.../otel/index.ts(GatewayMetrics 接口 :202-244 加字段 + 实例化 :442-458)apps/server/src/.../gauges/tts-pool.ts(新建,仿 active-sessions gauge)apps/server/src/app.ts(注册 gauge,仿 :695-712 registerActiveSessionsGauge)apps/server/src/.../gauges/tts-pool.test.ts(新建)GatewayMetrics 加 poolSaturationCount(429-as-full 次数)、slotAcquireFailCount(主动层判满拒派次数),打点位置复用 router 现有 counter 打点处(U2/U3 内)。gauge:池水位 ObservableGauge,回调读 Redis 各 pool:inflight:<appid>,仿 gauges/active-sessions.ts:42-102(10s 缓存 + in-flight 去重 + 失败不 observe 让 staleness 报警)。给 gauge 加 app_id label。otel/index.ts:202-244(GatewayMetrics 定义与打点)、gauges/active-sessions.ts:42-102(cluster-wide ObservableGauge 模板)、app.ts:695-712(注册)。voice_packs 表 + 迁移voice_packs 表存云提供商音色定义。id、name、provider、model、voice_id、params(jsonb:pitch/rate/volume 等)、tier(picklist lite/standard/pro/premium)、enabled(bool 软禁用)、created_at/updated_at。参数覆盖入 jsonb(同 voice 不同参数 = 不同行 = 不同 pack)。待确认(实现期): apps/server 的迁移工具与既有表定义位置(cloud-sync 设计提到 characters 表,沿用同一 ORM/迁移机制);确认后按现有 migration 约定落表。characters 表同机制);列命名贴近域、复数表名(voice_packs)。apps/server/src/services/domain/voice-packs/(新建域服务 + valibot schema)*.test.tscreate/update/disable/list/listEnabled,injeca 注入 DB(仅 DB 边界用 DI,不建 pass-through service)。复用现有 admin router-config 服务的组织方式(services/domain/admin/router-config)。apps/server/src/services/adapters/config-kv.ts:25-153(valibot 用法)、services/domain/admin/router-config(域服务 + injeca)。apps/server/src/routes/admin/voice-packs/(新建路由)*.test.tsapps/server/src/routes/admin/config/),路由调 U6 域服务。入参 valibot 校验(外部边界)。列表接口供最小绑定入口读 enabled pack。apps/server/src/routes/admin/config/router/index.ts(admin 路由 + body schema)、injeca 服务注入(app.ts:648 adminRouterConfig)。packages/stage-ui/src/stores/modules/airi-card.ts(扩 AiriExtension.modules.speech 加 voicePack 子对象 :19-74;写入 :173-215;读取 resolveAiriExtension :161)packages/stage-ui/src/stores/modules/airi-card.test.ts(新增/扩展)voicePack 快照 = { packId, name, provider, model, voiceId, params, tier, ttsModelId }(解析后的值,非表外键)。绑定写入这个对象;resolveAiriExtension 对缺失返回 undefined 分支(不加 backward-compat guard,缺失即「未绑定 Voice Pack」走旧 speech 字段)。注意与整卡 LWW 云同步(docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md)对齐:快照随整卡同步,schema 改动不破坏 LWW。airi-card.ts:161-215(speech 快照读写)、resolveAiriExtension(:161 缺失兼容)、packages/ccc/src/export/types/extensions.ts:1(开放 extensions)。packages/stage-pages/src/pages/settings/modules/speech.vue(加 Voice Pack 选择 → 调绑定)packages/stage-ui/src/stores/modules/(绑定动作:读 listEnabled API → 冻结快照入卡,复用 speech store / airi-card store)packages/i18n(注意 @ 转义 {'@'})*.test.tsVoiceCardManySelect / speech.vue 现有结构(stage-pages 共享页,双端生效),列 enabled pack(带 tier badge 展示,复用属性 chips 落点 voice-card.vue:167-180)。选中即调 U8 的绑定动作冻结快照。本轮不做独立市场浏览页。tier 仅展示。packages/stage-pages/src/pages/settings/modules/speech.vue(共享页 + <route lang="yaml"> layout: settings)、components/menu/voice-card.vue(badge/chips)、use-modules-list.ts:59-64(模块入口)。packages/stage-ui/src/stores/modules/speech.ts(读快照构造合成请求;参数 → generateSSML prosody :298-338 或 adapter options)apps/server/src/routes/openai/v1/index.ts:489-614 handleTTS,参数透传/校验)*.test.tsvoicePack 快照,用 ttsModelId 作为 model(经服务端 routeTts → 号池 LB)。参数覆盖:SSML-capable provider 走 generateSSML(pitch/rate/volume);其它走 adapter speed/extraOptions。目标后端无法应用某参数时 fail-fast 报带上下文错误(可 grep),不静默丢参数(符合禁止静默降级)。speech.ts:280-296(合成入口)、:298-338(generateSSML prosody)、adapters/tts/types.ts:12-30(TtsInput speed/extraOptions)、errorMessageFrom(speech.ts:113)。avg() 不 sum();新增 gauge 沿用此约束并在注释写明。voice_pack_reference 子表,本轮只记形状不建表。characters 表同源),再落表。adapterParams.appid(volcengine.ts:49);若实际有 provider 把 app 凭据放别处,需在 U2 路由前确认 appid 提取统一。tts-billing-tiers.md 不在本分支:tier 命名来源文档当前在 main worktree 未提交,引用时同步。apps/server/src/services/.../router.ts:413-617 — routeTts、dispatchOneTtsUpstream、createKeyRotator、upstream 遍历(号池 LB 改造点)、exhaustion fail-fast。apps/server/src/services/.../flux-meter.ts:18, 23-31, 106 — Lua INCRBY+EXPIRE+条件 DECRBY + redis.eval(并发账本复用模式)。apps/server/src/.../gauges/active-sessions.ts:23-26, 42-102 — cluster-wide ObservableGauge 模板 + 多副本 avg 约束。apps/server/src/.../otel/index.ts:76-90, 202-244, 442-458 — GatewayMetrics、ObservableGauge 选型。apps/server/src/services/adapters/tts/volcengine.ts:49-55, 90 — appid 取自 adapterParams、token 走 keyPlaintext。apps/server/src/services/adapters/config-kv.ts:33-40, 57-61, 83-87, 118, 134 — keyEntry/ttsUpstream/ttsModel schema、FLUX_PER_1K_CHARS_TTS、DEFAULT_TTS_VOICES。apps/server/src/routes/openai/v1/index.ts:489-642, 738, 759 — handleTTS、/audio/voices、ttsGuard、/speech 路由挂载。apps/server/src/app.ts:616-632, 695-712 — ttsMeter(Redis)、registerActiveSessionsGauge。apps/server/railway.toml:1-11 — Railway 部署(无 replicas 字段,副本数控制台侧)。packages/stage-ui/src/stores/modules/airi-card.ts:19-74, 161-215 — speech 快照读写(冻结快照落点)。packages/stage-ui/src/stores/modules/speech.ts:32-35, 280-296, 298-338 — 全局 voice 状态、合成入口、generateSSML。packages/ccc/src/export/types/extensions.ts:1 — 开放 extensions。docs/ai/context/tts-billing-tiers.md — tier 命名来源。docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md — 整卡 LWW 云同步对齐。