apps/server/docs/ai-context/flux-meter.md
Flux 是整数计费单位。但部分服务(TTS 字符、STT 秒、embedding token 等)的单价远小于 1 Flux:例如 TTS 当前定价 FLUX_PER_1K_CHARS_TTS = 2,意味着 1 Flux ≈ 500 字符。
最初的实现采用 max(MIN_CHARGE_TTS, ceil(chars/1000 * rate)),每个 TTS 请求都被向上取整为至少 1 Flux。这在前端把一整轮 Agent 回复切成 N 个短句分发的场景下极不公平:100 字的回复被切 10 段 = 10 Flux,而单次发完只要 1 Flux。
实现一层通用的"债务账本",把不到 1 Flux 的零头存在 Redis,跨请求累计,攒够整数 Flux 才下扣。
考虑过让前端给每轮对话发一个 turnId,服务端按 turn 聚合后结算。否决理由:
finalize 信号、处理崩溃路径债务账本不依赖任何业务边界:每次精确扣,欠账恒定 < 1 Flux(< unitsPerFlux 个单位),TTL 到期抹零,对账简单。
5 分钟聚合也能解决"短请求被高估"问题,但需要 cron / 懒扣双机制处理窗口边界,且窗口跨越用户会话时语义诡异。债务账本没有"窗口"概念,纯量化累计。
┌──────────┐ units ┌─────────────┐
│ route │──────────▶│ FluxMeter │
│(handleX) │ │ accumulate()│
└──────────┘ └─────┬───────┘
│ Lua: INCRBY + 阈值判断 + DECRBY
▼
┌──────────┐
│ Redis │ flux-meter:{name}:{userId}:debt
└─────┬────┘
│ 跨阈值 → fluxToDebit > 0
▼
┌──────────────────────┐
│ BillingService │
│ consumeFluxForLLM() │ ← 走原有 debitFlux + Stream
└──────────────────────┘
local debt = redis.call('INCRBY', key, units)
redis.call('EXPIRE', key, ttl)
if debt >= unitsPerFlux then
local flux = math.floor(debt / unitsPerFlux)
redis.call('DECRBY', key, flux * unitsPerFlux)
return {flux, debt - flux*unitsPerFlux}
end
return {0, debt}
INCRBY/DECRBY 的组合在 Redis 单线程模型下天然原子;多服务实例并发请求同一用户安全。
packages/server/src/services/billing/flux-meter.ts
createFluxMeter(redis, billingService, { name, resolveRuntime }) → meter 实例
resolveRuntime: () => Promise<{ unitsPerFlux, debtTtlSeconds }> 每次调用都执行,不做进程内缓存。多实例部署下任一实例改配置,其它实例下一次请求立即生效。createApp 启动阶段挂,只会在首个 TTS 请求时抛错,配合 route-level configGuard 产生 per-request 503,不会连带 chat/auth/stripe 一起挂。meter.assertCanAfford(userId, newUnits, currentBalance) — 请求前余额校验,不足直接 throw 402meter.accumulate({ userId, units, currentBalance, requestId, metadata }) — 累加并按需结算,返回 { fluxDebited, debtAfter, balanceAfter }。billing debit 抛错时,已结算的 units 会被 INCRBY 回滚到债务账本,保证不漏账。meter.peekDebt(userId) — 读当前未结算字符数(运维/调试用)任何"消耗单位 < 1 Flux"的服务都应该走债务账本,不要重复实现"单请求最低消费"。
| 服务 | name | unitsPerFlux 来源 |
|---|---|---|
| TTS | tts | 1000 / FLUX_PER_1K_CHARS_TTS(在 app.ts 装配时计算) |
| 服务 | name | unitsPerFlux 示意 |
|---|---|---|
| STT 转录 | stt | 60 秒 = 1 Flux |
| Embedding | embedding | 10000 token = 1 Flux |
| 自营小模型 chat | llm-mini | 视定价而定 |
不适用:每次调用本身就 ≥ 1 Flux 的服务(如图像生成)。直接 consumeFluxForLLM 即可,套一层 meter 反而降低可读性。
services/config-kv.ts 加费率/TTL 配置项app.ts 用 injeca.provide 注册新 meter,注入对应路由 / 服务assertCanAfford,调上游成功后 accumulateunitsPerFlux - 1 个单位(< 1 Flux),TTL 到期抹零。这部分给用户。
__keyevent@0__:expired 在过期时强制结算到下一个整 Flux。当前不做,量化损失太小。flux_transaction 一条记录可能对应多次请求,description 为 <name>_request(如 tts_request,和 llm_request 保持同一命名风格)。具体哪几个 requestId 贡献了这次扣费,靠 OTel span / request_log 反查。assertCanAfford 按"当前累计 + 这次 units"算,无法预知后续请求。极端情况下用户余额从够到不够之间会有几次请求成功(最多欠 < 1 Flux),可接受。EXPIRE,一个长期活跃用户的债务永远不会过期,会一直滚到下次跨阈值。这是期望行为。flux_transaction 已够用)