# mcp-server 计费系统问题分析和修复方案 > **分析日期**: 2026-03-09 > **优先级**: 🔴 高优先级(涉及用户资金安全) > **影响范围**: 计费模块、配额管理、余额扣款 --- ## 📊 执行摘要 mcp-server 实现了**两种独立的计费模式**: 1. **Agent 运行时长计费** - 基于 Pod 运行时间和资源使用 2. **模型 Token 计费** - 基于 LiteLLM 模型调用的 Token 消耗 经过深入分析,发现了 **8 个严重问题**,其中包括: - 🔥 潜在的重复扣款风险 - 💥 配额释放与 Pod 删除顺序错误 - 🔒 并发操作缺少保护 - 💾 数据双重存储不一致 --- ## 🏗️ 计费架构概览 ### 系统架构图 ``` ┌─────────────────────────────────────────────────────────────┐ │ 用户操作 │ ├─────────────────┬────────────────────────┬──────────────────┤ │ 创建 Agent │ Agent 调用模型 │ 删除 Agent │ └────────┬────────┴────────────┬───────────┴─────────┬────────┘ │ │ │ ▼ ▼ ▼ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ Agent Manager │ │ LiteLLM │ │ Agent Manager │ │ 部署 Pod │ │ 调用模型 │ │ 删除 Pod │ └────────┬───────┘ └────────┬───────┘ └────────┬────────┘ │ │ │ ▼ ▼ ▼ ┌────────────────────────────────────────────────────────────┐ │ mcp-server 计费模块 │ ├────────────────┬────────────────────────┬──────────────────┤ │ 创建计费记录 │ 接收 LiteLLM 回调 │ 结束计费记录 │ │ AgentBilling │ ModelBillingRecord │ 更新 end_time │ │ Record │ 实时扣款 Token │ 扣款+释放配额 │ └────────┬───────┴────────────┬───────────┴─────────┬────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 周期性计费任务(每小时) │ │ periodic_billing.py - 扫描运行中的 Agent │ │ 计算增量成本并扣款 (cost_increment) │ └────────┬────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Balance 表(用户余额) │ │ eu_balance - 统一扣款来源 │ └─────────────────────────────────────────────────────────────┘ ``` ### 数据模型 #### 1. AgentBillingRecord(Agent 计费记录) | 字段 | 类型 | 说明 | |------|------|------| | `user_id` | GUID | 用户ID | | `agent_name` | String | Agent 名称 | | `is_platform_agent` | Boolean | 是否为平台 Agent | | `start_time` | DateTime | 启动时间 | | `end_time` | DateTime | 停止时间(NULL = 运行中) | | `duration_seconds` | Integer | 运行时长(秒) | | `cpu_used` | String | CPU 配置(如 "100m") | | `memory_used` | String | 内存配置(如 "256Mi") | | `cost` | Numeric | 成本金额 | | `eu_consumed` | Integer | EU 消耗量 | #### 2. ModelBillingRecord(模型计费记录) | 字段 | 类型 | 说明 | |------|------|------| | `tenant_id` | GUID | 租户ID | | `litellm_call_id` | String | LiteLLM 调用ID(唯一) | | `model_name` | String | 模型名称 | | `input_tokens` | Integer | 输入 Token 数 | | `output_tokens` | Integer | 输出 Token 数 | | `total_tokens` | Integer | 总 Token 数 | | `total_cost` | Numeric | 总成本 | | `eu_consumed` | Numeric | EU 消耗量 | #### 3. Balance(用户余额) | 字段 | 类型 | 说明 | |------|------|------| | `user_id` | GUID | 用户ID(唯一) | | `eu_balance` | Numeric(15,2) | EU 余额 | --- ## 🔴 严重问题清单 ### 问题 1:双重计费风险 ⚠️⚠️⚠️ **严重程度**: 🔴 高 **影响**: 用户可能为同一服务支付两次费用 #### 问题描述 当用户启动 Agent 并通过 Agent 调用模型时,系统会从**同一个余额账户**扣款两次: ``` 用户启动 Agent(平台/自定义) ├─> Agent Pod 运行 → AgentBillingRecord 按时长计费 ($1.00/小时) │ └─> 每小时扣款:periodic_billing.py │ └─> Agent 调用 LiteLLM 模型 → ModelBillingRecord 按 Token 计费 ($0.02/1K tokens) └─> 实时扣款:billing_webhook.py (LiteLLM 回调) 结果:用户余额被扣款两次 ❌ ``` #### 代码位置 1. **Agent 运行时长计费** - 文件:`services/mcp-server/app/routes/user.py` - 行号:L3219-L3232 ```python billing_record = AgentBillingRecord( user_id=user_id, agent_name=req.name, start_time=datetime.utcnow(), # 开始计时 cpu_used=req.cpuRequest, memory_used=req.memoryRequest, ) ``` 2. **周期性扣款** - 文件:`services/mcp-server/app/periodic_billing.py` - 行号:L180-L185 ```python cost_increment = cost - previous_cost success, message = await deduct_balance( user_id, cost_increment, db, f"Agent周期计费: {record.agent_name}" ) ``` 3. **模型调用扣款** - 文件:`services/mcp-server/app/routes/billing_webhook.py` - 行号:L281-L284 ```python old_balance = Decimal(str(balance.eu_balance)) new_balance = old_balance - Decimal(str(eu_consumed)) balance.eu_balance = float(new_balance) # 再次扣款 ❌ ``` #### 实际影响评估 **如果是设计预期(类似 AWS 双重计费)**: - ✅ 合理的计费模式(基础设施费用 + 使用费用) - ❌ 缺少文档说明 - ❌ 前端未分开展示两种费用 **如果不是设计预期**: - ❌ 用户被过度收费 - ❌ 可能导致法律纠纷 #### 修复建议 **方案 A:明确双重计费机制**(推荐) ```markdown 1. 在用户文档中明确说明: - Agent 基础设施费用:按 CPU/内存/时长计费 - 模型调用费用:按 Token 消耗计费 - 总费用 = 基础设施费用 + 模型调用费用 2. 前端展示优化: - 计费明细页面分开显示两种费用 - 实时显示:当前 Agent 运行成本 + 模型调用成本 ``` **方案 B:合并计费** ```python # 只对实际使用的服务计费 # 如果 Agent 调用了模型 → 只按 Token 计费 # 如果 Agent 未调用模型 → 只按时长计费 ``` --- ### 问题 2:周期计费和回调扣款重复 🔥🔥 **严重程度**: 🔴 高 **影响**: 并发场景下可能重复扣款 #### 问题描述 `periodic_billing.py`(周期任务)和 `billing_webhook.py`(Agent Manager 回调)可能**同时**更新同一条 `AgentBillingRecord`,导致重复扣款。 #### 竞争条件示例 ``` 时间线:正常流程 00:00 - Agent 启动,创建 billing_record (cost=0) 00:30 - 周期任务运行,计算 cost=30, 扣款 30 00:35 - Agent Manager 回调,计算 cost=35, 扣款 35-30=5 ✅ 01:00 - 周期任务再次运行,计算 cost=60, 扣款 60-35=25 ✅ 总扣款:30 + 5 + 25 = 60 ✅ 时间线:竞争条件 00:30:00 - 周期任务开始执行 00:30:01 - 周期任务读取 record.cost = 0 00:30:02 - 周期任务计算 cost_increment = 30 - 0 = 30 00:30:03 - Agent Manager 回调开始执行 00:30:04 - 回调读取 record.cost = 0 (周期任务还未 commit) 00:30:05 - 回调计算 cost_increment = 30 - 0 = 30 00:30:06 - 周期任务执行 deduct_balance(30) 00:30:07 - 回调执行 deduct_balance(30) 00:30:08 - 周期任务 commit (record.cost = 30) 00:30:09 - 回调 commit (record.cost = 30) ❌ 覆盖 总扣款:30 + 30 = 60 ❌(实际应该扣 30) ``` #### 根本原因 1. **没有行锁保护** - 周期任务和回调都直接查询 `AgentBillingRecord` - 没有使用 `with_for_update()` 行锁 2. **更新操作不是原子的** - 读取 `record.cost` → 计算增量 → 扣款 → 更新 `record.cost` - 中间有多步操作,可能被打断 3. **没有分布式锁** - 周期任务和回调可能在不同进程/线程中执行 - PostgreSQL 行锁只能保护同一数据库连接内的事务 #### 代码位置 1. **周期计费** - 文件:`services/mcp-server/app/periodic_billing.py` - 行号:L122-L200 ```python result = await db.execute( select(AgentBillingRecord).where( AgentBillingRecord.end_time == None ) # ❌ 缺少 .with_for_update() ) ``` 2. **Agent Manager 回调** - 文件:`services/mcp-server/app/routes/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() ) ``` #### 修复建议 **方案 A:只用周期计费,回调只更新记录**(推荐) ```python # periodic_billing.py - 保持不变,继续扣款 # billing_webhook.py - 只更新记录,不扣款 if existing_record: existing_record.end_time = end_time existing_record.duration_seconds = duration_seconds existing_record.cost = new_cost # 只更新成本,不扣款 existing_record.tools_used = callback_data.toolsUsed existing_record.request_id = callback_data.requestId # ❌ 删除这里的 deduct_balance 调用 ``` **方案 B:只在回调时扣款,周期任务只更新记录** ```python # periodic_billing.py - 只更新记录,不扣款 record.duration_seconds = duration_seconds record.eu_consumed = eu_consumed record.cost = float(cost) # ❌ 删除 deduct_balance 调用 # billing_webhook.py - 保持不变,继续扣款 ``` **方案 C:使用分布式锁(Redis)** ```python import redis async def update_agent_billing_with_lock(agent_name, user_id): lock_key = f"billing_lock:{user_id}:{agent_name}" lock = redis.lock(lock_key, timeout=30) if lock.acquire(blocking=True): try: # 获取锁后执行扣款操作 ... finally: lock.release() ``` --- ### 问题 3:配额释放与 Pod 删除顺序错误 💥 **严重程度**: 🔴 高 **影响**: Pod 删除失败时,用户可以免费使用资源 #### 问题描述 删除 Agent 时,系统先在数据库中标记 Agent 已停止并释放配额,**然后**才调用 Agent Manager 删除 Pod。如果删除 Pod 失败: - 数据库显示 Agent 已停止 - 配额已释放 - Pod 仍在 K8s 中运行 - 周期计费找不到对应的计费记录(`end_time != None`) **结果:用户免费使用资源** ❌ #### 错误流程 ```python # 当前实现(错误的顺序) # 文件:services/mcp-server/app/routes/user.py # 行号:L3330-L3390 # 1. 标记结束 billing_record.end_time = datetime.utcnow() # 2. 计算成本并扣款 cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, duration) billing_record.cost = float(cost) success, message = await deduct_balance(user_id, cost, db) # 3. 释放配额 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) # 4. 提交数据库 await db.commit() # 5. 删除 Pod(可能失败!)❌ await client.delete_agent(agent_full_name) ``` #### 失败场景分析 ``` 场景:Agent Manager 服务宕机或网络问题 步骤 1-4:✅ 成功执行 - billing_record.end_time = "2026-03-09 10:00:00" - quota.cpu_used 从 5 降到 3 - 数据库已提交 步骤 5:❌ 失败 - client.delete_agent() 抛出异常 - Pod 仍在 K8s 中运行 后续影响: - 周期计费任务扫描运行中的 Agent - 查询条件:end_time == None - 找不到该 Agent 的计费记录(因为 end_time 已设置) - Agent 继续运行但不再计费 ❌ - 用户可以免费使用 CPU 和内存资源 ``` #### 代码位置 文件:`services/mcp-server/app/routes/user.py` 行号:L3313-L3402 ```python @router.delete("/custom-agents/{name}", response_model=SuccessResponse) async def delete_custom_agent( name: str, principal: dict = Depends(require_auth), db: AsyncSession = Depends(get_db) ): """删除自定义 Agent""" user_id = principal.get("user_id") # 查找计费记录 billing_result = await db.execute( select(AgentBillingRecord).where(...) ) billing_record = billing_result.scalar_one_or_none() try: # ❌ 错误:先更新数据库 billing_record.end_time = datetime.utcnow() await deduct_balance(user_id, cost, db, ...) quota.cpu_used -= cpu_released await db.commit() # ❌ 提交后无法回滚 # ❌ 然后才删除 Pod(可能失败) await client.delete_agent(agent_full_name) except AgentManagerError as e: # ❌ 此时数据库已提交,无法回滚 raise HTTPException(status_code=e.status_code, ...) ``` #### 修复建议 **正确顺序**: ```python @router.delete("/custom-agents/{name}", response_model=SuccessResponse) async def delete_custom_agent( name: str, principal: dict = Depends(require_auth), db: AsyncSession = Depends(get_db) ): """删除自定义 Agent""" user_id = principal.get("user_id") # 1. 先查找计费记录 billing_result = await db.execute( select(AgentBillingRecord).where(...) ) billing_record = billing_result.scalar_one_or_none() if not billing_record: raise HTTPException(404, "Agent 不存在") # 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=f"删除 Agent 失败: {e.message}" ) # 3. ✅ Pod 删除成功后,再更新数据库 billing_record.end_time = datetime.utcnow() duration = (billing_record.end_time - billing_record.start_time).total_seconds() billing_record.duration_seconds = int(duration) # 4. 计算成本并扣款 cost = calculate_agent_cost_by_resources(cpu_cores, memory_gb, int(duration)) billing_record.cost = float(cost) success, message = await deduct_balance( user_id, cost, db, f"自定义Agent使用: {name} (运行{int(duration)}秒)" ) if not success: logger.warning(f"扣款失败: {message}") # 5. 释放配额 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) # 6. 提交数据库 await db.commit() return SuccessResponse( message=f"自定义 Agent {name} 已删除", data={"quotaReleased": {"cpu": cpu_released, "memory": memory_released}} ) ``` --- ### 问题 4:配额更新缺少并发保护 🔒 **严重程度**: 🟡 中 **影响**: 并发操作时配额计算可能出错 #### 问题描述 创建 Agent 时使用了行锁(`with_for_update()`),但删除 Agent 时没有使用行锁,导致并发删除时可能出现配额计算错误。 #### 代码对比 **✅ 创建 Agent 时(有行锁)** 文件:`services/mcp-server/app/routes/user.py` 行号:L3148-L3155 ```python # ✅ 有行锁保护 quota_result = await db.execute( select(TenantCustomAgentQuota) .where(TenantCustomAgentQuota.tenant_id == user_id) .with_for_update() # ✅ 行锁 ) quota = quota_result.scalar_one_or_none() # 安全的更新操作 quota.cpu_used = cpu_used + cpu_request quota.memory_used = memory_used + memory_request quota.agent_count = (quota.agent_count or 0) + 1 ``` **❌ 删除 Agent 时(无行锁)** 文件:`services/mcp-server/app/routes/user.py` 行号:L3358-L3368 ```python # ❌ 没有行锁 quota_result = await db.execute( select(TenantCustomAgentQuota).where( TenantCustomAgentQuota.tenant_id == user_id ) # ❌ 缺少 .with_for_update() ) quota = quota_result.scalar_one_or_none() # 不安全的更新操作 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 线程 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 # 删除 Agent 时也使用行锁 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 = 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:余额数据双重存储 💾 **严重程度**: 🟡 中 **影响**: 数据不一致,可能显示错误余额 #### 问题描述 系统中同时存在两个余额字段: - `User.eu_balance` - 标记为废弃但未删除 - `Balance.eu_balance` - 当前使用的字段 代码只更新 `Balance` 表,导致 `User.eu_balance` 永远是旧数据。 #### 代码位置 文件:`services/mcp-server/models.py` 行号:L85-L88, L518-L527 ```python class User(BaseModel, Base): __tablename__ = "users" credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 # ❌ 废弃但未删除 eu_balance = Column(sa.Numeric(15, 2), default=0) # 注释:[DEPRECATED - 使用 Balance 表] balance = Column(sa.Numeric(12, 2), default=0) # [DEPRECATED] class Balance(BaseModel, Base): """用户余额模型(当前使用)""" __tablename__ = "balances" user_id = Column(GUID(), ForeignKey("users.id"), nullable=False, unique=True) eu_balance = Column(sa.Numeric(15, 2), nullable=False, default=0) ``` #### 潜在问题 1. **数据不一致** ```python # 所有代码都更新 Balance 表 balance.eu_balance = new_balance # 但 User.eu_balance 不会更新 # 如果有代码查询 User.eu_balance → 显示错误余额 ``` 2. **存储浪费** - `User.eu_balance` 占用空间但不使用 - 每个用户浪费 15 bytes 3. **维护困难** - 新开发者可能不知道哪个字段是最新的 - 可能误用废弃字段 #### 修复建议 **步骤 1:确认没有代码使用 `User.eu_balance`** ```bash # 搜索是否有代码读取该字段 cd services/mcp-server grep -r "user\.eu_balance" . grep -r "User\.eu_balance" . ``` **步骤 2:创建数据库迁移脚本** ```python # migrations/remove_deprecated_balance_fields.py from alembic import op import sqlalchemy as sa def upgrade(): # 删除废弃字段 op.drop_column('users', 'eu_balance') op.drop_column('users', 'balance') def downgrade(): # 回滚(如果需要) op.add_column('users', sa.Column('eu_balance', sa.Numeric(15, 2), default=0)) op.add_column('users', sa.Column('balance', sa.Numeric(12, 2), default=0)) ``` **步骤 3:更新模型** ```python class User(BaseModel, Base): __tablename__ = "users" credit_limit = Column(sa.Numeric(12, 2), default=0) # 授信额度 # ✅ 删除废弃字段 ``` --- ### 问题 6:计费记录 start_time 可能为空 🕐 **严重程度**: 🟡 中 **影响**: 部分 Agent 可能不计费 #### 问题描述 周期计费任务会跳过 `start_time` 为 `None` 的记录,导致这些 Agent 免费运行。 #### 代码位置 文件:`services/mcp-server/app/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 免费运行 ``` #### 根本原因 创建 `AgentBillingRecord` 时可能没有设置 `start_time`。 #### 已检查的代码位置 ✅ 以下位置都正确设置了 `start_time`: - `services/mcp-server/app/routes/user.py:1428` - 平台 Agent - `services/mcp-server/app/routes/user.py:2483` - 平台 Agent(use接口) - `services/mcp-server/app/routes/user.py:3232` - 自定义 Agent ⚠️ 潜在问题: - `billing_webhook.py` 创建兜底记录时可能没有 `start_time` #### 修复建议 **方案 A:确保所有创建路径都设置 start_time** ```python # billing_webhook.py - 创建兜底记录时 billing_record = AgentBillingRecord( user_id=callback_data.userId, agent_name=callback_data.agentName, start_time=start_time or datetime.utcnow(), # ✅ 确保有值 end_time=end_time or datetime.utcnow(), ... ) ``` **方案 B:数据库约束** ```python # models.py class AgentBillingRecord(BaseModel, Base): start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 ``` **方案 C:周期任务中补救** ```python # periodic_billing.py if record.start_time is None: # 使用创建时间作为开始时间 record.start_time = record.created_at logger.warning(f"修复 Agent {record.agent_name} 的 start_time") ``` --- ### 问题 7:余额不足时的处理不一致 💰 **严重程度**: 🟡 中 **影响**: 用户体验不一致,可能透支 #### 问题描述 不同场景下对余额不足的处理方式不一致: - 创建 Agent:只记录日志,Agent 继续运行 - 周期计费:只记录警告,继续计费 - 模型调用:只记录警告,不阻止调用 #### 代码位置 **1. 创建计费记录时** 文件:`services/mcp-server/app/billing.py` 行号:L352-L356 ```python success, message = await deduct_balance(tenant_id, cost, db) if not success: # ❌ 只是跳过,Agent 继续运行 pass ``` **2. 周期计费时** 文件:`services/mcp-server/app/periodic_billing.py` 行号:L185-L191 ```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}, " f"用户: {record.user_id}, Agent: {record.agent_name}" ) # ❌ 只记录警告,不停止 Agent ``` **3. 模型调用时** 文件:`services/mcp-server/app/routes/billing_webhook.py` 行号:L299-L305 ```python if new_balance < 0: logger.warning( f"⚠️ 用户余额不足: user_id={tenant_id}, " f"balance={new_balance:.4f}, " f"建议充值" ) # ❌ 只警告,继续记录,允许透支 ``` #### 行业最佳实践对比 | 场景 | AWS | 阿里云 | 当前系统 | |------|-----|--------|----------| | 创建资源 | ✅ 预检查余额,不足拒绝 | ✅ 预检查余额,不足拒绝 | ❌ 不检查,直接创建 | | 运行中透支 | ✅ 暂停/停止资源 | ✅ 暂停资源 | ❌ 继续运行 | | API 调用透支 | ✅ 拒绝请求 | ✅ 拒绝请求 | ❌ 允许透支 | #### 修复建议 **创建 Agent 前检查余额** ```python @router.post("/custom-agents/create-with-tools") async def create_custom_agent_with_tools(...): # ✅ 预检查余额 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( status_code=403, detail=f"余额不足,无法创建 Agent。" f"预估成本: ${estimated_cost:.2f}, " f"可用余额: ${available:.2f}," f"请先充值" ) # 继续创建 Agent ... ``` **周期计费余额不足时停止 Agent** ```python # periodic_billing.py success, message = await deduct_balance(user_id, cost_increment, db) if not success: logger.warning(f"周期计费扣款失败: {message}") # ✅ 检查是否透支 balance, credit_limit, available = await get_available_balance(user_id, db) if available < 0: # ✅ 余额不足,停止该用户所有 Agent logger.warning(f"用户 {user_id} 余额不足,将停止所有 Agent") stopped = await stop_user_agents(str(record.user_id), db) stats["stopped_agents"].extend(stopped) ``` **模型调用余额不足时拒绝** ```python # billing_webhook.py # 扣款前检查余额 old_balance = Decimal(str(balance.eu_balance)) if old_balance < eu_consumed: # ✅ 余额不足,不允许透支 logger.warning(f"用户 {tenant_id} 余额不足,拒绝模型调用") raise HTTPException( status_code=403, detail=f"余额不足,无法调用模型。" f"所需: {eu_consumed} EU, " f"可用: {old_balance} EU" ) # 余额充足,继续扣款 new_balance = old_balance - eu_consumed balance.eu_balance = float(new_balance) ``` --- ### 问题 8:配额数据"假用量" 📊 ✅ **严重程度**: 🟡 中 **影响**: 配额显示不准确,用户无法创建 Agent **修复状态**: ✅ 已完成(2026-03-09) #### 问题描述 历史原因导致配额表中记录了"假用量": - 配额显示 `cpu_used=5` - 但 K8s 中没有对应的 Pod 运行 - 用户无法创建新 Agent(配额不足) - 实际资源完全空闲 #### 根本原因 来自代码注释(`fix_fake_quota.py`): ```python """ 修复脚本:清理历史假用量数据 此脚本用于清理因旧接口(/api/user/tools/generate)产生的假用量数据。 旧接口会在数据库中创建Agent记录并扣除配额,但不实际部署到K8s, 导致quota表中记录了用量但实际没有Pod运行。 """ ``` #### 证据 项目中存在 3 个修复脚本: 1. **fix_agent_quotas.py** - 功能:修复 Agent 资源配置和配额记录 - 说明:配额使用量与实际 Agent 不匹配 2. **fix_fake_quota.py** - 功能:清理假用量数据 - 说明:统计 `type='custom'` 且 `status='active'` 的真实 Agent 3. **fix_channel_quota_records.py** - 功能:修复渠道配额记录 - 说明:渠道使用旧格式配额但缺少新表记录 #### 修复建议 **✅ 已实现方案:综合健康检查工具** 创建了 `check_billing_health.py` 提供完整的健康检查功能: ```bash # 完整健康检查(推荐) python check_billing_health.py # 只检查假用量 python check_billing_health.py --check-quota-only # 只检查余额状态 python check_billing_health.py --check-balance-only # 测试自动停止功能 python check_billing_health.py --test-auto-stop ``` **功能特性**: - ✅ 自动检测配额不一致(假用量) - ✅ 识别透支用户、余额不足用户、风险用户 - ✅ 生成健康评分报告(0-100分) - ✅ 提供自动修复建议 - ✅ 可集成到监控系统(Prometheus/Grafana) **输出示例**: ``` ============================================================ 🔍 开始检查配额一致性(假用量检测) ============================================================ ✅ 所有租户配额数据一致,无假用量问题 💰 开始检查用户余额状态 📊 余额状态汇总: 总用户数: 150 透支用户: 0 余额不足: 5 风险用户: 2 ============================================================ 📊 健康检查完成 ============================================================ 健康评分: 100/100 (HEALTHY) 报告保存: /app/logs/billing_health_report.json 💡 建议: 无需操作,系统健康 ``` **修复假用量流程**: ```bash # 1. 检测问题 python check_billing_health.py --check-quota-only # 2. 执行修复(会自动备份) python fix_fake_quota.py # 3. 验证修复 python check_billing_health.py --check-quota-only ``` **运维文档**: 详细使用说明请参阅:[计费系统运维工具使用指南](../Docs/计费系统运维工具使用指南.md) --- **原建议方案(归档)**: **临时方案:定期运行修复脚本** ✅ 已包含在健康检查工具中 ```bash # 每天运行一次修复脚本 0 2 * * * cd /app && python fix_fake_quota.py ``` **长期方案:修复根本原因** ```python # 废弃旧接口 /api/user/tools/generate @router.post("/user/tools/generate") @deprecated(reason="使用新接口 /custom-agents/create-with-tools") async def generate_tool_agent(...): raise HTTPException(410, "此接口已废弃,请使用新接口") ``` **预防方案:配额一致性检查** ```python # 创建后台任务,定期检查配额一致性 async def check_quota_consistency(): """检查配额与实际 Agent 是否一致""" async with AsyncSessionLocal() as db: # 查询所有租户配额 quotas = await db.execute(select(TenantCustomAgentQuota)) for quota in quotas.scalars(): # 统计实际运行的 Agent real_agents = await db.execute( select(Agent) .where(Agent.owner_id == quota.tenant_id) .where(Agent.type == "custom") .where(Agent.status == "active") ) real_cpu = sum(float(a.cpu or 0) for a in real_agents) real_memory = sum(float(a.memory or 0) for a in real_agents) # 如果不一致,发送告警 if abs(real_cpu - float(quota.cpu_used)) > 0.1: logger.warning( f"配额不一致: 租户={quota.tenant_id}, " f"配额显示={quota.cpu_used}核, " f"实际使用={real_cpu}核" ) ``` --- ## 📋 修复优先级矩阵 | 问题 | 严重程度 | 紧急程度 | 修复难度 | 优先级 | |------|---------|---------|---------|--------| | 问题1: 双重计费风险 | 🔴 高 | 🔴 高 | 🟡 中 | **P0** | | 问题2: 重复扣款 | 🔴 高 | 🔴 高 | 🔴 高 | **P0** | | 问题3: 删除顺序错误 | 🔴 高 | 🔴 高 | 🟢 低 | **P0** | | 问题4: 并发保护缺失 | 🟡 中 | 🟡 中 | 🟢 低 | **P1** | | 问题5: 数据双重存储 | 🟡 中 | 🟢 低 | 🟡 中 | **P2** | | 问题6: start_time 为空 | 🟡 中 | 🟡 中 | 🟢 低 | **P1** | | 问题7: 余额处理不一致 | 🟡 中 | 🟡 中 | 🟡 中 | **P1** | | 问题8: 假用量数据 | 🟡 中 | 🟢 低 | 🟢 低 | **P2** | --- ## 🎉 修复完成总结 所有 8 个问题已完成修复!系统现在具备: ✅ **核心功能修复** - 统一计费逻辑,消除重复扣款 - 正确的删除顺序,保证数据一致性 - 完善的并发保护机制 ✅ **用户保护机制** - 创建前余额检查 - 余额不足自动停止 Agent - 透支保护(授信额度) ✅ **运维工具完善** - 自动化健康检查工具 `check_billing_health.py` - 假用量检测(配额一致性检查) - 余额状态监控(透支/余额不足/风险用户识别) - 自动停止功能测试 - 健康评分报告生成 - 假用量自动修复工具 `fix_fake_quota.py` - 配额详情查询工具 `check_quota_data.py` - 详细的运维文档([计费系统运维工具使用指南](../Docs/计费系统运维工具使用指南.md)) ✅ **文档完善** - 计费机制说明文档 - 运维工具使用指南(包含使用方法、故障排查、最佳实践) - 故障排查手册 - 健康检查报告模板 --- ## 🚀 快速修复清单(归档) ### 第一阶段:核心问题修复(P0) #### ✅ 修复 1:统一计费扣款逻辑 **目标**:消除重复扣款风险 **方案**:周期计费保留,回调只更新记录 **文件**:`services/mcp-server/app/routes/billing_webhook.py` **修改**: ```python # 行号:L454-L490 # 将 billing_webhook.py 中的扣款逻辑改为只更新记录 if existing_record: # 更新现有记录 previous_cost = Decimal(str(existing_record.cost or 0)) 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:调整删除 Agent 的顺序 **目标**:确保 Pod 删除成功后再更新数据库 **文件**:`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: raise HTTPException(status_code=e.status_code, detail=str(e)) # 3. ✅ 删除成功后再更新数据库 billing_record.end_time = datetime.utcnow() await deduct_balance(user_id, cost, db) quota.cpu_used -= cpu_released await db.commit() ``` #### ✅ 修复 3:添加并发保护 **目标**:所有配额更新操作都加行锁 **文件**:`services/mcp-server/app/routes/user.py` **修改**: ```python # 行号:L3358 # 在删除 Agent 时添加行锁 quota_result = await db.execute( select(TenantCustomAgentQuota) .where(TenantCustomAgentQuota.tenant_id == user_id) .with_for_update() # ✅ 添加行锁 ) ``` ### 第二阶段:改进和优化(P1) #### ✅ 修复 4:确保 start_time 不为空 **文件**:`services/mcp-server/app/routes/billing_webhook.py` **修改**: ```python # 创建兜底记录时确保 start_time 有值 billing_record = AgentBillingRecord( ... start_time=start_time or datetime.utcnow(), # ✅ 确保有值 ... ) ``` **文件**:`services/mcp-server/models.py` **修改**: ```python # 数据库约束:start_time 不允许为空 class AgentBillingRecord(BaseModel, Base): start_time = Column(DateTime, nullable=False) # ✅ 不允许为空 ``` #### ✅ 修复 5:完善余额检查 **文件**:`services/mcp-server/app/routes/user.py` **修改**: ```python # 创建 Agent 前检查余额 @router.post("/custom-agents/create-with-tools") async def create_custom_agent_with_tools(...): # ✅ 预估成本 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:删除废弃字段 **文件**:`services/mcp-server/models.py` **修改**: ```python class User(BaseModel, Base): credit_limit = Column(sa.Numeric(12, 2), default=0) # ❌ 删除以下废弃字段 # eu_balance = Column(sa.Numeric(15, 2), default=0) # balance = Column(sa.Numeric(12, 2), default=0) ``` #### ✅ 修复 7:添加文档说明 **新建文件**:`Docs/计费机制说明.md` **内容**: ```markdown # 计费机制说明 ## 费用组成 Taiji AI PAD 采用双重计费模式: ### 1. Agent 基础设施费用 - 计费对象:Agent Pod 的运行时间和资源配置 - 计费公式:成本 = CPU 用量 × CPU 单价 + 内存用量 × 内存单价 × 运行时长 - 扣款时机:每小时自动扣款(周期性计费任务) - 示例:1 核 + 1GB 内存运行 1 小时 = $1.00 ### 2. 模型调用费用 - 计费对象:通过 LiteLLM 调用的模型 Token 消耗 - 计费公式:成本 = Token 数量 × 模型单价 - 扣款时机:每次调用后实时扣款 - 示例:gpt-4 调用 1000 tokens = $0.02 ### 总费用 **总费用 = Agent 基础设施费用 + 模型调用费用** 注意:如果您的 Agent 调用了模型,您需要同时支付两种费用。 ``` --- ## 🧪 测试计划 ### 单元测试 #### 测试 1:重复扣款保护 ```python async def test_no_duplicate_deduction(): """测试周期计费和回调不会重复扣款""" # 1. 创建 Agent 计费记录 record = await create_agent_billing_record(...) # 2. 模拟周期任务扣款 await periodic_billing.update_running_agent_billing(db) balance_after_periodic = await get_balance(user_id) # 3. 模拟 Agent Manager 回调 await billing_webhook.agent_callback(callback_data) balance_after_callback = await get_balance(user_id) # 4. 验证:两次扣款总和等于实际成本 total_deducted = initial_balance - balance_after_callback assert total_deducted == expected_cost assert total_deducted != expected_cost * 2 # 不是双倍 ``` #### 测试 2:删除顺序保护 ```python async def test_delete_agent_rollback_on_pod_failure(): """测试 Pod 删除失败时不会释放配额""" # 1. 创建 Agent await create_agent(...) initial_quota = await get_quota(user_id) # 2. Mock Agent Manager 删除失败 with mock.patch("client.delete_agent", side_effect=AgentManagerError): with pytest.raises(HTTPException): await delete_custom_agent(agent_name) # 3. 验证:配额没有被释放 current_quota = await get_quota(user_id) assert current_quota.cpu_used == initial_quota.cpu_used # 4. 验证:计费记录仍在运行中 record = await get_billing_record(agent_name) assert record.end_time is None ``` #### 测试 3:并发配额更新 ```python async def test_concurrent_quota_updates(): """测试并发删除 Agent 时配额计算正确""" # 1. 创建 2 个 Agent,每个 2 CPU await create_agent(name="agent1", cpu=2) await create_agent(name="agent2", cpu=2) initial_quota = await get_quota(user_id) assert initial_quota.cpu_used == 4 # 2. 并发删除两个 Agent await asyncio.gather( delete_custom_agent("agent1"), delete_custom_agent("agent2") ) # 3. 验证:配额正确减少 final_quota = await get_quota(user_id) assert final_quota.cpu_used == 0 # 不是 2 ``` ### 集成测试 #### 测试 4:端到端计费流程 ```python async def test_end_to_end_billing(): """测试完整的计费流程""" # 1. 充值 await recharge(user_id, amount=100) initial_balance = await get_balance(user_id) # 2. 创建 Agent await create_agent(cpu=1, memory=1) # 3. 运行 1 小时 await asyncio.sleep(3600) # 4. Agent 调用模型 await call_litellm_model(tokens=1000) # 5. 删除 Agent await delete_agent() # 6. 验证:总扣款 = Pod 费用 + Token 费用 final_balance = await get_balance(user_id) total_cost = initial_balance - final_balance expected_pod_cost = 1.0 # 1核1G运行1小时 expected_token_cost = 0.02 # 1000 tokens expected_total = expected_pod_cost + expected_token_cost assert abs(total_cost - expected_total) < 0.01 ``` ### 压力测试 #### 测试 5:高并发计费 ```python async def test_high_concurrency_billing(): """测试高并发场景下计费的正确性""" # 1. 模拟 100 个用户同时创建 Agent tasks = [] for i in range(100): tasks.append(create_agent(user_id=f"user_{i}", ...)) await asyncio.gather(*tasks) # 2. 模拟 1000 次并发模型调用 tasks = [] for i in range(1000): tasks.append(call_model(user_id=random.choice(users), ...)) await asyncio.gather(*tasks) # 3. 验证:所有计费记录都已创建 agent_records = await count_agent_billing_records() model_records = await count_model_billing_records() assert agent_records == 100 assert model_records == 1000 # 4. 验证:余额计算正确(无重复扣款) total_balance = await sum_all_balances() total_cost = await sum_all_costs() assert abs(total_balance - (initial_total - total_cost)) < 1.0 ``` --- ## 📈 监控和告警 ### 关键指标 #### 1. 扣款失败率 ```python # Prometheus metrics billing_deduction_failures = Counter( 'billing_deduction_failures_total', 'Total number of billing deduction failures', ['reason'] ) # 告警规则 rate(billing_deduction_failures_total[5m]) > 0.01 ``` #### 2. 配额不一致率 ```python quota_inconsistency = Gauge( 'quota_inconsistency_count', 'Number of tenants with quota inconsistency', ['tenant_id'] ) # 告警规则 quota_inconsistency_count > 0 ``` #### 3. 计费记录异常 ```python billing_record_anomalies = Counter( 'billing_record_anomalies_total', 'Billing records with anomalies', ['type'] # missing_start_time, negative_cost, etc. ) # 告警规则 billing_record_anomalies_total{type="missing_start_time"} > 0 ``` ### 日志监控 #### 关键日志模式 ```python # 1. 重复扣款检测 logger.warning("可能的重复扣款: user_id=%s, agent=%s, cost=%s", ...) # 2. 配额不一致检测 logger.error("配额不一致: quota.cpu_used=%s, actual=%s", ...) # 3. 余额不足但继续运行 logger.critical("余额不足但Agent继续运行: user_id=%s, balance=%s", ...) ``` ### 告警配置(Prometheus + Alertmanager) ```yaml groups: - name: billing_alerts rules: # 告警 1:扣款失败率过高 - alert: HighBillingFailureRate expr: rate(billing_deduction_failures_total[5m]) > 0.01 for: 5m labels: severity: critical annotations: summary: "扣款失败率过高" description: "过去5分钟扣款失败率 {{ $value | humanizePercentage }}" # 告警 2:配额不一致 - alert: QuotaInconsistency expr: quota_inconsistency_count > 0 for: 1m labels: severity: warning annotations: summary: "检测到配额不一致" description: "{{ $value }} 个租户存在配额不一致" # 告警 3:计费记录异常 - alert: BillingRecordAnomaly expr: increase(billing_record_anomalies_total[5m]) > 5 for: 1m labels: severity: warning annotations: summary: "计费记录异常增加" description: "异常类型: {{ $labels.type }}, 数量: {{ $value }}" ``` --- ## 📝 修复进度跟踪 | 修复项 | 优先级 | 状态 | 完成时间 | |--------|--------|------|----------| | 1. 统一计费扣款逻辑 | P0 | ✅ 已完成 | 2026-03-09 | | 2. 调整删除顺序 | P0 | ✅ 已完成 | 2026-03-09 | | 3. 添加并发保护 | P0 | ✅ 已完成 | 2026-03-09 | | 4. start_time 非空约束 | P1 | ✅ 已完成 | 2026-03-09 | | 5. 完善余额检查 | P1 | ✅ 已完成 | 2026-03-09 | | 6. 删除废弃字段 | P2 | ✅ 已完成 | 2026-03-09 | | 7. 添加计费文档 | P2 | ✅ 已完成 | 2026-03-09 | | 8. 配额一致性检查工具 | P2 | ✅ 已完成 | 2026-03-09 | **附加完成项**: - ✅ 创建综合健康检查工具 `check_billing_health.py` - ✅ 编写运维工具使用指南 `/Docs/计费系统运维工具使用指南.md` - ✅ 提供完整的故障排查流程 - ✅ 建立最佳实践指南 --- ## 🔗 相关文档 - [计费与资源平面对接文档](../Docs/项目文档/计费与资源平面对接文档.md) - [LiteLLM和AgentManager回调接口文档](../Docs/项目文档/LiteLLM和AgentManager回调接口文档.md) - [数据库迁移指南](../Docs/数据库迁移指南-postgres到taiji_prod.md) - **[计费系统运维工具使用指南](../Docs/计费系统运维工具使用指南.md)** ⭐️ 新增 --- ## 📞 联系方式 如有疑问或需要讨论,请联系: - 技术负责人:[待填写] - 邮件:[待填写] - Slack:#taiji-ai-pad-dev --- **文档版本**: v1.0.0 **最后更新**: 2026-03-09 **审核状态**: 待审核