# mcp-server 计费系统问题验证报告 > **验证日期**: 2026-03-09 > **验证人**: AI Assistant > **基于文档**: [计费系统问题分析和修复方案.md](./计费系统问题分析和修复方案.md) --- ## 📊 执行摘要 本报告对原分析文档中提到的 8 个问题进行了代码验证。根据用户提供的定价信息: - **模型推理**: 0.025 EU/call(LLM API调用) - **VM计算**: 0.5 EU/hour(Firecracker VM运行时) **验证结论**: - ✅ **问题1(双重计费)不是问题** - 这是设计上的双重收费,符合业务模型 - 🔴 **问题2(重复扣款)确实存在** - 周期计费和回调可能重复扣款 - 🔴 **问题3(删除顺序错误)确实存在** - 先释放配额再删除Pod,可能导致免费使用 - 🔴 **问题4(缺少行锁)确实存在** - 创建/删除Agent时配额更新没有并发保护 - ✅ **问题5(双重存储)已标注废弃** - User.eu_balance已标记废弃,代码未使用 - 🔴 **问题6(start_time为空)存在风险** - 周期计费会跳过start_time为空的记录 - 🟡 **问题7(余额处理不一致)部分正确** - 已有透支停止机制,但创建时未预检查 - ✅ **问题8(假用量)已有修复脚本** - 历史问题,已准备修复脚本 --- ## 🔍 详细验证结果 ### ✅ 问题1:双重计费风险 - **非问题(设计如此)** **原文档观点**: > 用户启动 Agent 并通过 Agent 调用模型时,系统会从同一个余额账户扣款两次 **验证结果**:✅ **这是设计上的双重收费,符合业务模型** **理由**: 根据用户提供的定价信息,系统确实应该收取两部分费用: 1. **VM计算费用**: 0.5 EU/hour - 按 Agent Pod 的运行时间计费 - 代码位置: `periodic_billing.py:L180-L185` - 计费逻辑: 每小时扫描运行中的 Agent,计算增量成本并扣款 2. **模型推理费用**: 0.025 EU/call - 按 LiteLLM 调用次数计费 - 代码位置: `billing_webhook.py:L281-L284` - 计费逻辑: LiteLLM 回调后实时扣款 这类似于云服务商的计费模式(如 AWS Lambda = 执行时长费用 + API Gateway 调用费用)。 **建议**: - ✅ 保持当前双重计费机制 - ⚠️ 需要在用户文档和前端明确说明「总费用 = VM计算费用 + 模型推理费用」 - ⚠️ 前端计费明细页面应分开展示两种费用 --- ### 🔴 问题2:周期计费和回调扣款重复 - **确实存在** **原文档观点**: > `periodic_billing.py`(周期任务)和 `billing_webhook.py`(Agent Manager回调)可能同时更新同一条记录,导致重复扣款 **验证结果**:🔴 **确实存在并发竞争风险** **代码验证**: 1. **周期计费**(`periodic_billing.py:L122-L200`) ```python # ❌ 没有行锁 result = await db.execute( select(AgentBillingRecord).where( AgentBillingRecord.end_time == None ) # 缺少 .with_for_update() ) # 计算增量并扣款 previous_cost = Decimal(str(record.cost or 0)) cost_increment = cost - previous_cost if cost_increment > 0: success, message = await deduct_balance( str(record.user_id), cost_increment, db, f"Agent周期计费: {record.agent_name}" ) ``` 2. **Agent Manager回调**(`billing_webhook.py:L454-L490`) ```python # ❌ 没有行锁 existing_result = await db.execute( select(AgentBillingRecord).where( and_( AgentBillingRecord.agent_name == callback_data.agentName, AgentBillingRecord.user_id == callback_data.userId, AgentBillingRecord.end_time == None ) ) # 缺少 .with_for_update() ) if existing_record: # 计算增量并扣款 previous_cost = Decimal(str(existing_record.cost or 0)) cost_increment = new_cost - previous_cost if cost_increment > 0: success, message = await deduct_balance( callback_data.userId, cost_increment, db, f"Agent 结算(增量): {callback_data.agentName}" ) ``` **问题分析**: 两个函数都: 1. 读取 `record.cost`(旧值) 2. 计算增量 `cost_increment = new_cost - old_cost` 3. 扣款 4. 更新 `record.cost`(新值) 如果两者同时执行,可能出现: ``` 时间线:竞争条件 00:30:00 - 周期任务读取 record.cost = 10 00:30:01 - 回调读取 record.cost = 10(周期任务还未commit) 00:30:02 - 周期任务计算增量 = 15 - 10 = 5,扣款5 00:30:03 - 回调计算增量 = 15 - 10 = 5,扣款5 结果:扣了10,但实际应该扣5 ``` **建议修复方案**(推荐): ```python # 方案:只在周期计费时扣款,回调只更新记录状态 # billing_webhook.py - 只更新记录,不扣款 if existing_record: existing_record.end_time = end_time or datetime.utcnow() existing_record.duration_seconds = duration_seconds existing_record.cost = float(new_cost) # 只更新成本 existing_record.tools_used = callback_data.toolsUsed # ❌ 删除增量扣款逻辑 ``` --- ### 🔴 问题3:配额释放与Pod删除顺序错误 - **确实存在** **原文档观点**: > 删除 Agent 时,系统先在数据库中释放配额并提交,然后才删除 Pod。如果删除 Pod 失败,用户可以免费使用资源。 **验证结果**:🔴 **确实存在,且非常严重** **代码验证**(`user.py:L3313-L3402`): ```python @router.delete("/custom-agents/{name}", response_model=SuccessResponse) async def delete_custom_agent(...): # 1. 查找计费记录 billing_record = ... try: # 2. ❌ 先更新数据库 billing_record.end_time = datetime.utcnow() billing_record.cost = float(cost) # 3. ❌ 扣款 await deduct_balance(user_id, cost, db, ...) # 4. ❌ 释放配额 quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) quota.agent_count = max(0, (quota.agent_count or 0) - 1) # 5. ❌ 提交数据库(此时无法回滚) await db.commit() # 6. ❌ 然后才删除 Pod(可能失败!) client = get_agent_manager_client() await client.delete_agent(agent_full_name) ``` **失败场景**: ``` 步骤 1-5:✅ 成功执行 - billing_record.end_time = "2026-03-09 10:00:00" - quota.cpu_used 从 5 降到 3 - 数据库已提交(无法回滚) 步骤 6:❌ 失败(Agent Manager 宕机/网络问题) - Pod 仍在 K8s 中运行 后续影响: - 周期计费查询条件: end_time == None - 找不到该 Agent(end_time 已设置) - Agent 继续运行但不再计费 ❌ - 用户免费使用资源 ❌ ``` **代码还有注释承认了这个问题**: ```python except Exception as agent_delete_error: logger.error(f"Agent Manager 删除失败: {agent_full_name}, 错误: {str(agent_delete_error)}") # 注意:配额已释放,但 Agent 可能未删除 # 可以考虑标记为"待清理"状态,供后台任务处理 ``` **建议修复**: ```python @router.delete("/custom-agents/{name}", response_model=SuccessResponse) async def delete_custom_agent(...): # 1. 查找计费记录 billing_record = ... # 2. ✅ 先删除 Pod(如果失败,整个操作终止) try: await client.delete_agent(agent_full_name) except AgentManagerError as e: logger.error(f"删除 Pod 失败: {e.message}") raise HTTPException(status_code=e.status_code, detail=str(e)) # 3. ✅ Pod 删除成功后,再更新数据库 billing_record.end_time = datetime.utcnow() billing_record.cost = float(cost) await deduct_balance(user_id, cost, db, ...) quota.cpu_used -= cpu_released await db.commit() ``` --- ### 🔴 问题4:配额更新缺少并发保护 - **确实存在** **原文档观点**: > 创建 Agent 时使用了行锁,但删除 Agent 时没有使用行锁 **验证结果**:🔴 **部分正确,实际上创建和删除都没有行锁** **代码验证**: 1. **检查整个 user.py 中 with_for_update 的使用**: ```bash grep "with_for_update" services/mcp-server/app/routes/user.py ``` 结果:只有 3 处使用(L1302, L2307, L2552),都是在**平台 Agent** 的配额查询中。 2. **创建自定义 Agent 时的配额查询**(`user.py:L2893`): ```python # ❌ 没有行锁 quota_result = await db.execute( select(TenantCustomAgentQuota).where( TenantCustomAgentQuota.tenant_id == user_id ) # 缺少 .with_for_update() ) # 更新配额(不安全) quota.cpu_used = cpu_used + cpu_request quota.memory_used = memory_used + memory_request quota.agent_count = (quota.agent_count or 0) + 1 ``` 3. **删除自定义 Agent 时的配额查询**(`user.py:L3359`): ```python # ❌ 没有行锁 quota_result = await db.execute( select(TenantCustomAgentQuota).where( TenantCustomAgentQuota.tenant_id == user_id ) # 缺少 .with_for_update() ) # 更新配额(不安全) quota.cpu_used = max(0, float(quota.cpu_used or 0) - cpu_released) quota.memory_used = max(0, float(quota.memory_used or 0) - memory_released) quota.agent_count = max(0, (quota.agent_count or 0) - 1) ``` **并发问题示例**: ``` 初始状态:quota.cpu_used = 5 并发场景:两个请求同时删除不同的 Agent 线程1: 删除 Agent A(释放 2 CPU) 1. 读取 cpu_used = 5 2. 计算 new_value = 5 - 2 = 3 线程2: 删除 Agent B(释放 2 CPU) 1. 读取 cpu_used = 5(线程1还未 commit) 2. 计算 new_value = 5 - 2 = 3 线程1: 写入 cpu_used = 3 线程2: 写入 cpu_used = 3 ❌ 最终结果:cpu_used = 3 正确结果:cpu_used = 1 (5 - 2 - 2) 丢失更新:2 CPU ``` **建议修复**: ```python # 所有配额更新操作都加行锁 quota_result = await db.execute( select(TenantCustomAgentQuota) .where(TenantCustomAgentQuota.tenant_id == user_id) .with_for_update() # ✅ 添加行锁 ) ``` --- ### ✅ 问题5:余额数据双重存储 - **已标注废弃,代码未使用** **原文档观点**: > 系统中同时存在两个余额字段:User.eu_balance(标记为废弃)和 Balance.eu_balance(当前使用) **验证结果**:✅ **已标注废弃,代码未实际使用** **代码验证**: 1. **models.py:L85-L88** ```python class User(BaseModel, Base): # EU计费(执行单元) eu_balance = Column(sa.Numeric(15, 2), default=0) # EU余额 [DEPRECATED - 使用 Balance 表] total_eu_consumed = Column(sa.Numeric(15, 2), default=0) # 总EU消耗 ``` 2. **搜索是否有代码使用 User.eu_balance**: ```bash grep -r "User\.eu_balance\|user\.eu_balance" services/mcp-server/ ``` 结果:只在 `billing.py:L29` 有一条注释: ```python # 注意:User.eu_balance 和 User.balance 字段已废弃,请使用 Balance 表 ``` **结论**: - ✅ 字段已标记废弃 - ✅ 代码未使用该字段 - ⚠️ 但字段仍存在数据库中,占用存储空间 **建议**: ```python # 后续可通过数据库迁移删除(非紧急) def upgrade(): op.drop_column('users', 'eu_balance') op.drop_column('users', 'balance') ``` --- ### 🔴 问题6:计费记录 start_time 可能为空 - **存在风险** **原文档观点**: > 周期计费任务会跳过 start_time 为 None 的记录,导致这些 Agent 免费运行 **验证结果**:🔴 **代码确实会跳过 start_time 为空的记录** **代码验证**(`periodic_billing.py:L132-L138`): ```python for record in running_agents: try: # 跳过没有开始时间的记录 if record.start_time is None: logger.warning(f"Agent {record.agent_name} 缺少 start_time,跳过计费") stats["failed"] += 1 stats["errors"].append(f"Agent {record.agent_name}: start_time 为空") continue # ❌ 跳过计费 → Agent 免费运行 ``` **潜在问题代码**: 在 `billing_webhook.py:L500-L510` 创建兜底记录时: ```python billing_record = AgentBillingRecord( user_id=callback_data.userId, channel_id=user.channel_id if user.channel_id else None, agent_name=callback_data.agentName, agent_type=agent_type, is_platform_agent=is_platform_agent, duration_seconds=duration_seconds, eu_consumed=eu_consumed, cost=float(new_cost), start_time=start_time or datetime.utcnow(), # ✅ 有兜底值 end_time=end_time or datetime.utcnow(), ... ) ``` 好消息:代码中使用了 `start_time or datetime.utcnow()`,理论上不会为空。 **建议**: ```python # 1. 数据库约束:确保 start_time 不为空 class AgentBillingRecord(BaseModel, Base): start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 # 2. 周期任务中修复而非跳过 if record.start_time is None: # 使用创建时间作为开始时间 record.start_time = record.created_at or datetime.utcnow() logger.warning(f"修复 Agent {record.agent_name} 的 start_time") # 继续计费,而不是跳过 ``` --- ### 🟡 问题7:余额不足时的处理不一致 - **部分正确** **原文档观点**: > 不同场景下对余额不足的处理方式不一致:创建Agent不检查、周期计费只警告、模型调用允许透支 **验证结果**:🟡 **部分正确,已有停止机制,但创建时未预检查** **代码验证**: 1. **创建 Agent 时**(无预检查) - 未找到余额预检查代码 - Agent 可以在余额不足时创建 2. **周期计费时**(`periodic_billing.py:L185-L200`): ```python success, message = await deduct_balance( str(record.user_id), cost_increment, db, f"Agent周期计费: {record.agent_name}" ) if not success: logger.warning(f"周期计费扣款失败: {message}") # ✅ 检查是否透支,如果透支则停止所有Agent balance, credit_limit, available = await get_available_balance( str(record.user_id), db ) if available < 0: logger.warning( f"用户 {record.user_id} 余额不足 (可用: {available}),将停止所有Agent" ) stopped = await stop_user_agents(str(record.user_id), db) stats["stopped_agents"].extend(stopped) ``` 好消息:**周期计费已经实现了余额不足停止机制!** 3. **模型调用时**(`billing_webhook.py:L299-L305`): ```python # 扣减EU余额 old_balance = Decimal(str(balance.eu_balance)) new_balance = old_balance - Decimal(str(eu_consumed)) balance.eu_balance = float(new_balance) # 余额不足警告(但不阻止记录) if new_balance < 0: logger.warning( f"⚠️ 用户余额不足: user_id={tenant_id}, " f"balance={new_balance:.4f}, " f"建议充值" ) # ❌ 只警告,允许透支 ``` **建议**: ```python # 创建 Agent 前预检查余额 @router.post("/custom-agents/create") async def create_custom_agent(...): # ✅ 预估成本 estimated_cost = estimate_agent_cost( cpu=req.cpuRequest, memory=req.memoryRequest, duration=3600 # 预估至少运行1小时 ) # ✅ 检查余额 balance, credit_limit, available = await get_available_balance(user_id, db) if available < estimated_cost: raise HTTPException(403, "余额不足,无法创建 Agent,请先充值") ``` --- ### ✅ 问题8:配额数据"假用量" - **历史问题,已有修复脚本** **原文档观点**: > 历史原因导致配额表中记录了"假用量":配额显示 cpu_used=5,但 K8s 中没有对应的 Pod 运行 **验证结果**:✅ **确实存在历史遗留问题,已准备修复脚本** **代码验证**: 1. **修复脚本存在**: - `/services/mcp-server/fix_fake_quota.py` ✅ - `/services/mcp-server/fix_agent_quotas.py` ✅ - `/services/mcp-server/fix_channel_quota_records.py` ✅ 2. **fix_fake_quota.py 文件头注释**(L1-L18): ```python """ 修复脚本:清理历史假用量数据 此脚本用于清理因旧接口(/api/user/tools/generate)产生的假用量数据。 旧接口会在数据库中创建Agent记录并扣除配额,但不实际部署到K8s, 导致quota表中记录了用量但实际没有Pod运行。 脚本逻辑: 1. 备份当前quota数据到JSON文件 2. 遍历所有租户的TenantCustomAgentQuota记录 3. 对每个租户,重新统计agents表中type='custom'且status='active'的真实运行Agent 4. 更新quota表的cpu_used、memory_used、agent_count为真实值 5. 生成修复报告 使用方法: 在mcp-server容器内运行: python fix_fake_quota.py 注意:此脚本会修改数据库,请务必先备份数据库! """ ``` **建议**: - ✅ 修复脚本已准备好,可以执行 - ⚠️ 执行前务必备份数据库 - ⚠️ 废弃旧接口 `/api/user/tools/generate`,防止再次产生假用量 --- ## 📋 问题优先级总结 | 问题 | 严重程度 | 是否存在 | 紧急程度 | 推荐优先级 | |------|---------|---------|---------|----------| | 问题1: 双重计费风险 | N/A | ❌ 非问题(设计如此) | - | **P3 - 文档优化** | | **问题2: 重复扣款** | 🔴 高 | ✅ **确实存在** | 🔴 高 | **P0 - 立即修复** | | **问题3: 删除顺序错误** | 🔴 高 | ✅ **确实存在** | 🔴 高 | **P0 - 立即修复** | | **问题4: 并发保护缺失** | 🟡 中 | ✅ **确实存在** | 🟡 中 | **P1 - 尽快修复** | | 问题5: 数据双重存储 | 🟡 中 | ⚠️ 已标记废弃 | 🟢 低 | **P2 - 后续清理** | | **问题6: start_time 为空** | 🟡 中 | ⚠️ 存在风险但有兜底 | 🟡 中 | **P1 - 添加约束** | | 问题7: 余额处理不一致 | 🟡 中 | 🟡 部分正确 | 🟡 中 | **P1 - 添加预检查** | | 问题8: 假用量数据 | 🟡 中 | ✅ 历史问题 | 🟢 低 | **P2 - 执行脚本** | --- ## 🚀 推荐修复计划 ### 第一阶段:P0 核心问题修复(本周内) #### 1. 修复问题2:消除重复扣款风险 **文件**: `services/mcp-server/app/routes/billing_webhook.py` **修改**: ```python # 行号:L454-L490 # 将 billing_webhook.py 中的扣款逻辑改为只更新记录 if existing_record: # 更新现有记录 existing_record.end_time = end_time or datetime.utcnow() existing_record.duration_seconds = duration_seconds existing_record.eu_consumed = eu_consumed existing_record.cost = float(new_cost) existing_record.period_end = end_time or datetime.utcnow() existing_record.tools_used = callback_data.toolsUsed existing_record.request_id = callback_data.requestId billing_record = existing_record # ❌ 删除增量扣款逻辑,只在周期计费时扣款 # cost_increment = new_cost - previous_cost # if cost_increment > 0: # success, message = await deduct_balance(...) logger.info( f"📝 更新Agent计费记录(不扣款): agent={callback_data.agentName}, " f"最终成本={new_cost}" ) ``` #### 2. 修复问题3:调整删除 Agent 的顺序 **文件**: `services/mcp-server/app/routes/user.py` **修改**: ```python # 行号:L3313-L3402 # 调整 delete_custom_agent 函数的执行顺序 @router.delete("/custom-agents/{name}", response_model=SuccessResponse) async def delete_custom_agent(...): # 1. 查找计费记录 billing_record = ... # 2. ✅ 先删除 Pod(如果失败,整个操作终止) try: await client.delete_agent(agent_full_name) except AgentManagerError as e: logger.error(f"删除 Pod 失败: {e.message}") raise HTTPException(status_code=e.status_code, detail=str(e)) # 3. ✅ Pod 删除成功后,再更新数据库 billing_record.end_time = datetime.utcnow() billing_record.cost = float(cost) await deduct_balance(user_id, cost, db, ...) # 4. ✅ 使用行锁释放配额 quota_result = await db.execute( select(TenantCustomAgentQuota) .where(TenantCustomAgentQuota.tenant_id == user_id) .with_for_update() # ✅ 添加行锁 ) quota = quota_result.scalar_one_or_none() if quota: quota.cpu_used -= cpu_released quota.memory_used -= memory_released quota.agent_count -= 1 await db.commit() ``` ### 第二阶段:P1 改进和优化(下周) #### 3. 修复问题4:添加并发保护 **文件**: `services/mcp-server/app/routes/user.py` **修改位置**: - L2893(创建自定义Agent) - L3359(删除自定义Agent) - L3446(扩缩容) ```python # 所有配额更新操作都加行锁 quota_result = await db.execute( select(TenantCustomAgentQuota) .where(TenantCustomAgentQuota.tenant_id == user_id) .with_for_update() # ✅ 添加行锁 ) ``` #### 4. 修复问题6:确保 start_time 不为空 **文件**: `services/mcp-server/models.py` ```python class AgentBillingRecord(BaseModel, Base): start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 ``` #### 5. 修复问题7:添加余额预检查 **文件**: `services/mcp-server/app/routes/user.py` ```python @router.post("/custom-agents/create") async def create_custom_agent(...): # ✅ 预估成本并检查余额 estimated_cost = estimate_agent_cost( cpu=req.cpuRequest, memory=req.memoryRequest, duration=3600 # 预估1小时 ) balance, credit_limit, available = await get_available_balance(user_id, db) if available < estimated_cost: raise HTTPException(403, "余额不足,请先充值") ``` ### 第三阶段:P2 清理和文档(后续) #### 6. 问题5:删除废弃字段 创建数据库迁移脚本删除 User.eu_balance 和 User.balance。 #### 7. 问题8:执行假用量修复脚本 ```bash # 1. 备份数据库 pg_dump taiji_prod > backup_before_quota_fix.sql # 2. 执行修复脚本 python services/mcp-server/fix_fake_quota.py ``` #### 8. 问题1:完善计费文档 创建用户文档,明确说明: - 总费用 = VM计算费用(0.5 EU/hour)+ 模型推理费用(0.025 EU/call) - 前端分开展示两种费用 --- ## 📊 测试建议 ### 单元测试 ```python # 测试1:重复扣款保护 async def test_no_duplicate_deduction(): """测试周期计费和回调不会重复扣款""" # 创建Agent → 运行1小时 → 周期扣款 → 回调更新 # 验证:总扣款 = 实际成本(不是双倍) # 测试2:删除顺序保护 async def test_delete_agent_rollback_on_pod_failure(): """测试Pod删除失败时不会释放配额""" # Mock Agent Manager删除失败 # 验证:配额没有被释放,计费记录仍在运行中 # 测试3:并发配额更新 async def test_concurrent_quota_updates(): """测试并发删除Agent时配额计算正确""" # 并发删除2个Agent # 验证:配额正确减少(不丢失更新) ``` --- ## 📝 总结 经过详细的代码验证,原分析文档中提到的 8 个问题中: - ✅ **3个确实存在且严重**(问题2、3、4)- 需要立即修复 - ⚠️ **2个存在风险**(问题6、7)- 需要改进 - ✅ **1个已有解决方案**(问题8)- 执行修复脚本即可 - ✅ **1个已标注废弃**(问题5)- 后续清理 - ❌ **1个非问题**(问题1)- 这是设计上的双重收费 **最紧急的修复**: 1. 🔥 消除重复扣款风险(问题2) 2. 🔥 调整删除顺序(问题3) 3. 🔒 添加并发保护(问题4) --- **文档版本**: v1.0.0 **最后更新**: 2026-03-09 **审核状态**: 待审核