# Token 计费系统优化计划 ## 一、问题现状 ### 1.1 已修复的问题(前置修改 + 本轮优化) | 编号 | 文件 | 修改内容 | 状态 | |------|------|----------|------| | M1 | `voice_chat_router.py` | usage 扣费失败时重置 `token_charged=False` 并发送 `token_update` | 已完成 | | M2 | `voice_chat_router.py` | `asr_start` 事件新增 token 耗尽检查 + break | 已完成 | | M3 | `chatStore.js` | `handleVoiceMessage` 和 `handleRealtimeMessage` 新增 `token_update` case | 已完成 | | O1 | `voice_chat_router.py` | usage 扣费失败 + 余额=0 时 break 退出事件循环 | 已完成 | | O2 | `chat_router.py` | 文字聊天 WebSocket 扣费失败 + 余额=0 时发 error 到前端 | 已完成 | | O3 | `chatStore.js` | `sendMessage` 新增 `_tokenExhausted` 检查 + `token_update` case | 已完成 | | O4 | `voice_chat_router.py` | `asr_start` 时重置 `token_charged=False`,每轮对话触发扣费 | 已完成 | ### 1.2 仍存在的问题 #### 问题 A:实时通话 token 卡住不动 **现象**:后台 500 token → 实时聊天几分钟后降至 300 → 继续聊十几分钟,token 一直停在 300。火山引擎实际消费 7.8 元。 **根因分析**: 1. `receive_events_loop` 中 `_charge_user_tokens` 扣费失败时不会 break,火山引擎继续工作但本地不扣费 2. `token_charged` 在首次扣费成功后设为 True,跳过后续所有 usage 扣费(设计上是防重复,但如果火山引擎一轮对话只有一次 usage event,只会扣一次费) 3. 火山引擎 usage event 通常在会话结束时才发送,不是持续的,所以"卡住不动"是因为只收到一次 usage 4. **扣费失败无前端通知**:`_charge_user_tokens` 返回失败时,旧代码只 logger.warning,不发送任何消息到前端 #### 问题 B:按住说话在 token 耗尽时仍可用 **现象**:后台剩 100 token,使用按住说话还能得到 AI 文字回复,token 不变。 **根因分析**: 1. 修改 M2 已在 `asr_start` 加了 token 检查,**本轮对话已修复** 2. 但 `asr_start` 的 `break` 只退出事件循环,不断开 WebSocket,前端可能还在发音频数据 3. voice_chat 的 `session_started` 时做了 token 检查,但那只检查一次,不检查后续轮次 #### 问题 C:文字聊天 token 不限制 **现象**:文字聊天聊了半天,后台 100 token 始终不变化。 **根因分析**: 1. `chat_router.py` WebSocket 端点(L263-274)扣费逻辑存在,但 `charge_tokens` 失败时**不发送 error 到前端** 2. 前端 `sendMessage` 的 `case 'error'` 处理:收到 `insufficient_tokens` 后调用 `fail()` → 显示 "消息发送失败",但**消息已经生成并返回了**(AI 回复已经通过 `message_chunk` 流式发出) 3. `fallback_amount=1`,如果 `tokens_used` 没有正确返回,每次只扣 1 token 4. 前端 `sendMessage` 没有 `token_update` case,无法接收 token 更新通知 --- ## 二、优化方案 ### 优化 1:usage 扣费失败时终止会话(优先级:P0) **文件**:`backend/api/voice_chat_router.py` **位置**:`receive_events_loop` 函数,`usage` 事件的 else 分支(L1150-1166) **改动内容**: 在扣费失败且余额为 0 时,增加 `break` 退出事件循环: ```python # 当前代码(L1150-1166): else: session_state['token_charged'] = False logger.warning( f"Failed to charge tokens for user {user_id} in {charge_mode}: " f"{charge_result.get('message')}, token_charged reset" ) try: await websocket.send_text(json.dumps({ "type": "token_update", "remaining_tokens": charge_result.get("available_tokens", 0), "consumed": 0, "mode": charge_mode, "warning": charge_result.get("message", "扣费失败"), "timestamp": datetime.now().isoformat() })) except Exception: pass # 优化后:在 try/except 块之后增加: remaining = charge_result.get("available_tokens", 0) if remaining <= 0: logger.warning( f"[VOICE_TOKEN_EXHAUSTED] user_id={user_id} mode={charge_mode} " f"remaining={remaining}, breaking event loop" ) break ``` **预期效果**:余额为 0 时,事件循环立即退出,`_persist_voice_session_history` 在 finally 中执行,火山引擎的后续事件不再被转发。 **验证标准**: - [ ] 给测试用户设置 1 个 token,发起实时通话 - [ ] 首次 usage 扣费后,观察后端日志出现 `[VOICE_TOKEN_EXHAUSTED]` - [ ] 前端收到 `token_update` 消息,显示 warning - [ ] 事件循环退出后,前端 `asr_start` 检查触发 break(双重保护) --- ### 优化 2:文字聊天扣费失败时通知前端(优先级:P0) **文件**:`backend/api/chat_router.py` **位置**:`websocket_chat_endpoint` 函数,stats chunk 扣费逻辑(L270-274) **改动内容**: ```python # 当前代码(L270-275): if not charge_result.get("success"): logger.warning( f"Failed to charge tokens for user {user_id} in text_chat: " f"{charge_result.get('message')}" ) remaining_tokens = charge_result.get("available_tokens") # 优化后: if not charge_result.get("success"): logger.warning( f"Failed to charge tokens for user {user_id} in text_chat: " f"{charge_result.get('message')}" ) # 余额耗尽时通知前端 remaining = charge_result.get("available_tokens", 0) if remaining <= 0: await websocket.send_text(json.dumps({ "type": "error", "error": "insufficient_tokens", "message": "Token不足,请充值", "available_tokens": remaining, "timestamp": datetime.now().isoformat() })) remaining_tokens = charge_result.get("available_tokens") ``` **预期效果**:文字聊天余额耗尽时,前端收到 `insufficient_tokens` 错误,停止后续发送。 **验证标准**: - [ ] 设置用户 token 为 1,发送一条文字消息 - [ ] 后端日志显示扣费失败 - [ ] 前端收到 error 消息 - [ ] 再发送消息时,前端在发送前检查 token 状态(需配合前端优化 4) --- ### 优化 3:前端文字聊天发送前检查 token(优先级:P1) **文件**:`frontend/src/stores/chatStore.js` **位置**:`sendMessage` 函数(L108 附近) **改动内容**: 在 `sendMessage` 中增加本地 token 状态检查,并增加 `token_update` 处理: ```javascript // 在 sendMessage 函数开头增加: // 当前代码 L126 之前插入: // 如果之前收到过 token 耗尽通知,阻止发送 // (通过模块级变量追踪) let _tokenExhausted = false // 模块级变量 // 在 switch(data.type) 中增加 case: case 'token_update': { const remaining = data.remaining_tokens ?? data.data?.remaining_tokens ?? null if (remaining !== null && remaining <= 0) { _tokenExhausted = true } } break case 'error': if (data.error === 'insufficient_tokens') { _tokenExhausted = true } fail(new Error(data.message || data.error || '聊天失败')) break ``` 在 `socket.onopen` 发送消息前增加检查: ```javascript socket.onopen = () => { if (_tokenExhausted) { fail(new Error('Token已耗尽,请充值')) return } socket.send(JSON.stringify({ type: 'chat_message', content, pet_id: petId, background_id: bgId, conversation_id: conversationId })) } ``` **预期效果**:前端在 token 耗尽后自动阻止发送新消息。 **验证标准**: - [ ] token 耗尽后,文字聊天按钮显示禁用或发送时提示 - [ ] 不再产生无效的 AI 回复请求 --- ### 优化 4:voice_chat asr_start break 后的清理(优先级:P1) **文件**:`backend/api/voice_chat_router.py` **位置**:`receive_events_loop` 函数,`asr_start` 事件处理(L1096-1111) **改动内容**: `break` 后,事件循环退出,但 WebSocket 仍连接。需确保前端收到错误后主动断开。当前修改 M2 已发送 error 消息,前端 `handleVoiceMessage` 的 `case 'error'` 中 `insufficient_tokens` 分支会调用 `disconnectVoiceWebSocket()`。**这部分已通过 M3 修复。** 但需确认:`handleVoiceMessage` 中 `token_update` case 的 warning 处理不会干扰断开逻辑。当前 M3 的 `token_update` 只是 console.log,不会断开连接,这是正确的。 **无需额外代码修改,仅需验证。** **验证标准**: - [ ] 语音聊天 token 耗尽后,前端自动断开 voice WebSocket - [ ] 不会出现"连接还在但无法使用"的僵死状态 --- ### 优化 5:voice_chat 的 token_charged 跨轮次重置(优先级:P2) **文件**:`backend/api/voice_chat_router.py` **位置**:`websocket_voice_chat` 函数中 `start_session` 消息处理(L392-402) **改动内容**: 当前 `start_session` 已重置 `token_charged=False`(L400)。但实时通话的 `start_call`(L625)也已重置。**无需修改。** 但需确认:同一 WebSocket 连接中,用户多次交互(多轮对话)时,`token_charged` 不应在 usage 事件之间被错误地设为 True 后跳过后续扣费。 **分析**:火山引擎端到端模型的 usage event 在每轮对话结束时发送一次。`token_charged=True` 会跳过同一会话内的重复扣费。这意味着:如果一轮对话产生 2 个 usage event,第二个会被跳过。 **建议**:在每次收到 `asr_start` 时重置 `token_charged=False`,确保每轮对话都会触发扣费。 ```python # voice_chat_router.py receive_events_loop 中 asr_start 处理(L1096-1111)增加: if event.event_type == 'asr_start': logger.debug(f"ASR started: {event.data}") session_state['token_charged'] = False # 新增:每轮对话重置扣费标记 _tc = await _check_user_has_tokens(user_id) if not _tc.get("has_tokens"): # ...现有逻辑... ``` **预期效果**:每轮对话都会触发 usage 扣费,不会因 `token_charged=True` 跳过。 **验证标准**: - [ ] 设置用户 100 token,发起语音聊天 - [ ] 进行 3 轮对话,观察后端日志出现 3 次 `[VOICE_CHARGE_OK]` - [ ] token 余额递减,不是只扣一次 --- ### 优化 6:REST API 端点的 token 检查(优先级:P2) **文件**:`backend/api/chat_router.py` **位置**:`send_message_endpoint` 函数(L53-111) **改动内容**: 当前 REST API 端点在发送前检查 token(L69-78),但扣费失败后仍然返回响应(L95-106)。需在扣费失败时返回 402: ```python # 当前代码(L95-106): if not charge_result.get("success"): logger.warning(...) # 当前代码继续返回响应 # 优化后: if not charge_result.get("success"): logger.warning(...) # 如果余额为0,返回 402 if charge_result.get("available_tokens", 0) <= 0: return SendMessageResponse( response=result["response"], conversation_id=result["conversation_id"], tokens_used=result["tokens_used"], remaining_tokens=0 ) ``` **预期效果**:REST API 扣费失败时前端可感知余额为 0。 --- ## 三、实施顺序 ``` 第 1 阶段(P0,立即执行): 优化 1 → 优化 2 验证:实时通话和文字聊天的 token 耗尽终止 第 2 阶段(P1,本周内): 优化 3 → 优化 4 验证:前端主动阻止 + WebSocket 清理 第 3 阶段(P2,下周): 优化 5 → 优化 6 验证:跨轮次扣费 + REST API 一致性 ``` --- ## 四、架构层面的长期建议 ### 4.1 当前架构的根本问题 当前扣费是**事后结算**模式:火山引擎先消费,usage event 返回后才扣费。这导致: - 本地 token 余额与实际消费不同步 - 无法在用户耗尽 token 时实时停止火山引擎的消费 - 火山引擎的费用与本地 token 扣费可能不一致 ### 4.2 建议的架构改进 ``` 短期(可立即做): ├── asr_start 时检查 token → 耗尽则 break + 通知前端 ├── usage 扣费失败时 break → 防止继续消费 └── 前端 token 耗尽时禁用发送按钮 中期(需要火山引擎 API 支持): ├── 在火山引擎 session 配置中设置 max_tokens ├── 或在 receive_events_loop 中监控 usage 累计值 └── 超过 token 预算时主动关闭 session 长期(架构优化): ├── 预扣费模式:开始对话前先扣费,结束后按实际用量多退少补 ├── 或 token 预算模式:每次对话设定 token 上限 └── 与火山引擎的计费对齐:建立本地 token 与火山引擎费用的映射关系 ``` ### 4.3 建议的 token 扣费流程(新架构) ``` 用户发起语音对话 │ ├─→ [asr_start] 检查 token 余额 │ ├─ 余额 > 0 → 继续 │ └─ 余额 = 0 → 发送 error,break,前端断开 │ ├─→ [asr_result] 记录用户消息 │ ├─→ [chat_response] AI 生成回复 │ ├─→ [usage] 收到实际用量 │ ├─ 扣费成功 → 发送 token_update,设置 token_charged=True │ └─ 扣费失败 → 发送 token_update(warning),break │ └─→ [下一轮 asr_start] 重置 token_charged=False,重新检查 ``` --- ## 五、文件修改清单 | 优先级 | 文件 | 修改位置 | 改动描述 | 状态 | |--------|------|----------|----------|------| | P0 | `backend/api/voice_chat_router.py` | `receive_events_loop` usage else 分支 | 扣费失败 + 余额=0 时 break | 已完成 | | P0 | `backend/api/chat_router.py` | `websocket_chat_endpoint` stats chunk | 扣费失败 + 余额=0 时发 error | 已完成 | | P1 | `frontend/src/stores/chatStore.js` | `sendMessage` 函数 | 增加 token 耗尽检查 + token_update case | 已完成 | | P1 | `backend/api/voice_chat_router.py` | `receive_events_loop` asr_start | 每轮对话重置 token_charged | 已完成 | | P2 | `backend/api/chat_router.py` | REST `/send` 端点 | 已有 pre-check 返回 402,无需额外修改 | 已有 | --- ## 六、风险评估 | 风险 | 影响 | 缓解措施 | |------|------|----------| | break 过于激进导致会话中断 | 用户体验下降 | break 前发送明确的 error 消息,前端优雅处理 | | token_charged 重置导致多扣费 | 用户投诉 | 在 ChatLog 中记录每次扣费,可追溯 | | 前端 _tokenExhausted 状态未同步 | 用户无法发送 | 定期刷新 token 状态,或在页面加载时重新检查 | | 火山引擎 usage event 延迟 | 扣费不及时 | 在 asr_start 时预检查,usage 到达后确认扣费 |