PetAgent/token_optimization_plan.md

14 KiB
Raw Permalink Blame History

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 handleVoiceMessagehandleRealtimeMessage 新增 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_startbreak 只退出事件循环,不断开 WebSocket前端可能还在发音频数据
  3. voice_chat 的 session_started 时做了 token 检查,但那只检查一次,不检查后续轮次

问题 C文字聊天 token 不限制

现象:文字聊天聊了半天,后台 100 token 始终不变化。

根因分析

  1. chat_router.py WebSocket 端点L263-274扣费逻辑存在charge_tokens 失败时不发送 error 到前端
  2. 前端 sendMessagecase 'error' 处理:收到 insufficient_tokens 后调用 fail() → 显示 "消息发送失败",但消息已经生成并返回了AI 回复已经通过 message_chunk 流式发出)
  3. fallback_amount=1,如果 tokens_used 没有正确返回,每次只扣 1 token
  4. 前端 sendMessage 没有 token_update case无法接收 token 更新通知

二、优化方案

优化 1usage 扣费失败时终止会话优先级P0

文件backend/api/voice_chat_router.py 位置receive_events_loop 函数,usage 事件的 else 分支L1150-1166

改动内容

在扣费失败且余额为 0 时,增加 break 退出事件循环:

# 当前代码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

改动内容

# 当前代码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 处理:

// 在 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 发送消息前增加检查:

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 回复请求

优化 4voice_chat asr_start break 后的清理优先级P1

文件backend/api/voice_chat_router.py 位置receive_events_loop 函数,asr_start 事件处理L1096-1111

改动内容

break 后,事件循环退出,但 WebSocket 仍连接。需确保前端收到错误后主动断开。当前修改 M2 已发送 error 消息,前端 handleVoiceMessagecase 'error'insufficient_tokens 分支会调用 disconnectVoiceWebSocket()这部分已通过 M3 修复。

但需确认:handleVoiceMessagetoken_update case 的 warning 处理不会干扰断开逻辑。当前 M3 的 token_update 只是 console.log不会断开连接这是正确的。

无需额外代码修改,仅需验证。

验证标准

  • 语音聊天 token 耗尽后,前端自动断开 voice WebSocket
  • 不会出现"连接还在但无法使用"的僵死状态

优化 5voice_chat 的 token_charged 跨轮次重置优先级P2

文件backend/api/voice_chat_router.py 位置websocket_voice_chat 函数中 start_session 消息处理L392-402

改动内容

当前 start_session 已重置 token_charged=FalseL400。但实时通话的 start_callL625也已重置。无需修改。

但需确认:同一 WebSocket 连接中,用户多次交互(多轮对话)时,token_charged 不应在 usage 事件之间被错误地设为 True 后跳过后续扣费。

分析:火山引擎端到端模型的 usage event 在每轮对话结束时发送一次。token_charged=True 会跳过同一会话内的重复扣费。这意味着:如果一轮对话产生 2 个 usage event第二个会被跳过。

建议:在每次收到 asr_start 时重置 token_charged=False,确保每轮对话都会触发扣费。

# 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 余额递减,不是只扣一次

优化 6REST API 端点的 token 检查优先级P2

文件backend/api/chat_router.py 位置send_message_endpoint 函数L53-111

改动内容

当前 REST API 端点在发送前检查 tokenL69-78但扣费失败后仍然返回响应L95-106。需在扣费失败时返回 402

# 当前代码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 → 发送 errorbreak前端断开
  │
  ├─→ [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 到达后确认扣费