Files
taiji-AI-PAD/plans/计费系统问题分析和修复方案.md
T
2026-03-10 06:40:38 +00:00

46 KiB
Raw Blame History

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
    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
    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
    old_balance = Decimal(str(balance.eu_balance))
    new_balance = old_balance - Decimal(str(eu_consumed))
    balance.eu_balance = float(new_balance)  # 再次扣款 ❌
    

实际影响评估

如果是设计预期(类似 AWS 双重计费):

  • ✅ 合理的计费模式(基础设施费用 + 使用费用)
  • ❌ 缺少文档说明
  • ❌ 前端未分开展示两种费用

如果不是设计预期:

  • ❌ 用户被过度收费
  • ❌ 可能导致法律纠纷

修复建议

方案 A:明确双重计费机制(推荐)

1. 在用户文档中明确说明:
   - Agent 基础设施费用:按 CPU/内存/时长计费
   - 模型调用费用:按 Token 消耗计费
   - 总费用 = 基础设施费用 + 模型调用费用

2. 前端展示优化:
   - 计费明细页面分开显示两种费用
   - 实时显示:当前 Agent 运行成本 + 模型调用成本

方案 B:合并计费

# 只对实际使用的服务计费
# 如果 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
    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
    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:只用周期计费,回调只更新记录(推荐)

# 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:只在回调时扣款,周期任务只更新记录

# periodic_billing.py - 只更新记录,不扣款
record.duration_seconds = duration_seconds
record.eu_consumed = eu_consumed
record.cost = float(cost)
# ❌ 删除 deduct_balance 调用

# billing_webhook.py - 保持不变,继续扣款

方案 C:使用分布式锁(Redis)

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)

结果:用户免费使用资源 ❌

错误流程

# 当前实现(错误的顺序)
# 文件: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

@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, ...)

修复建议

正确顺序:

@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

# ✅ 有行锁保护
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

# ❌ 没有行锁
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

修复建议

# 删除 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

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. 数据不一致

    # 所有代码都更新 Balance 表
    balance.eu_balance = new_balance
    
    # 但 User.eu_balance 不会更新
    # 如果有代码查询 User.eu_balance → 显示错误余额
    
  2. 存储浪费

    • User.eu_balance 占用空间但不使用
    • 每个用户浪费 15 bytes
  3. 维护困难

    • 新开发者可能不知道哪个字段是最新的
    • 可能误用废弃字段

修复建议

步骤 1:确认没有代码使用 User.eu_balance

# 搜索是否有代码读取该字段
cd services/mcp-server
grep -r "user\.eu_balance" .
grep -r "User\.eu_balance" .

步骤 2:创建数据库迁移脚本

# 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:更新模型

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

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

# 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:数据库约束

# models.py
class AgentBillingRecord(BaseModel, Base):
    start_time = Column(DateTime, nullable=False)  # ✅ 不允许为空

方案 C:周期任务中补救

# 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

success, message = await deduct_balance(tenant_id, cost, db)
if not success:
    # ❌ 只是跳过,Agent 继续运行
    pass

2. 周期计费时

文件:services/mcp-server/app/periodic_billing.py 行号:L185-L191

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

if new_balance < 0:
    logger.warning(
        f"⚠️ 用户余额不足: user_id={tenant_id}, "
        f"balance={new_balance:.4f}, "
        f"建议充值"
    )
    # ❌ 只警告,继续记录,允许透支

行业最佳实践对比

场景 AWS 阿里云 当前系统
创建资源 ✅ 预检查余额,不足拒绝 ✅ 预检查余额,不足拒绝 ❌ 不检查,直接创建
运行中透支 ✅ 暂停/停止资源 ✅ 暂停资源 ❌ 继续运行
API 调用透支 ✅ 拒绝请求 ✅ 拒绝请求 ❌ 允许透支

修复建议

创建 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(
            status_code=403,
            detail=f"余额不足,无法创建 Agent。"
                   f"预估成本: ${estimated_cost:.2f}, "
                   f"可用余额: ${available:.2f},"
                   f"请先充值"
        )
    
    # 继续创建 Agent
    ...

周期计费余额不足时停止 Agent

# 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)

模型调用余额不足时拒绝

# 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):

"""
修复脚本:清理历史假用量数据

此脚本用于清理因旧接口(/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 提供完整的健康检查功能:

# 完整健康检查(推荐)
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 <user_id>

功能特性:

  • ✅ 自动检测配额不一致(假用量)
  • ✅ 识别透支用户、余额不足用户、风险用户
  • ✅ 生成健康评分报告(0-100分)
  • ✅ 提供自动修复建议
  • ✅ 可集成到监控系统(Prometheus/Grafana)

输出示例:

============================================================
🔍 开始检查配额一致性(假用量检测)
============================================================
✅ 所有租户配额数据一致,无假用量问题

💰 开始检查用户余额状态
📊 余额状态汇总:
   总用户数: 150
   透支用户: 0
   余额不足: 5
   风险用户: 2

============================================================
📊 健康检查完成
============================================================
健康评分: 100/100 (HEALTHY)
报告保存: /app/logs/billing_health_report.json

💡 建议:
   无需操作,系统健康

修复假用量流程:

# 1. 检测问题
python check_billing_health.py --check-quota-only

# 2. 执行修复(会自动备份)
python fix_fake_quota.py

# 3. 验证修复
python check_billing_health.py --check-quota-only

运维文档: 详细使用说明请参阅:计费系统运维工具使用指南


原建议方案(归档):

临时方案:定期运行修复脚本 ✅ 已包含在健康检查工具中

# 每天运行一次修复脚本
0 2 * * * cd /app && python fix_fake_quota.py

长期方案:修复根本原因

# 废弃旧接口 /api/user/tools/generate
@router.post("/user/tools/generate")
@deprecated(reason="使用新接口 /custom-agents/create-with-tools")
async def generate_tool_agent(...):
    raise HTTPException(410, "此接口已废弃,请使用新接口")

预防方案:配额一致性检查

# 创建后台任务,定期检查配额一致性
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
  • 详细的运维文档(计费系统运维工具使用指南)

✅ 文档完善

  • 计费机制说明文档
  • 运维工具使用指南(包含使用方法、故障排查、最佳实践)
  • 故障排查手册
  • 健康检查报告模板

🚀 快速修复清单(归档)

第一阶段:核心问题修复(P0)

✅ 修复 1:统一计费扣款逻辑

目标:消除重复扣款风险

方案:周期计费保留,回调只更新记录

文件:services/mcp-server/app/routes/billing_webhook.py

修改:

# 行号: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

修改:

# 行号: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

修改:

# 行号: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

修改:

# 创建兜底记录时确保 start_time 有值
billing_record = AgentBillingRecord(
    ...
    start_time=start_time or datetime.utcnow(),  # ✅ 确保有值
    ...
)

文件:services/mcp-server/models.py

修改:

# 数据库约束:start_time 不允许为空
class AgentBillingRecord(BaseModel, Base):
    start_time = Column(DateTime, nullable=False)  # ✅ 不允许为空

✅ 修复 5:完善余额检查

文件:services/mcp-server/app/routes/user.py

修改:

# 创建 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

修改:

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

内容:

# 计费机制说明

## 费用组成

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:重复扣款保护

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:删除顺序保护

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:并发配额更新

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:端到端计费流程

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:高并发计费

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. 扣款失败率

# 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. 配额不一致率

quota_inconsistency = Gauge(
    'quota_inconsistency_count',
    'Number of tenants with quota inconsistency',
    ['tenant_id']
)

# 告警规则
quota_inconsistency_count > 0

3. 计费记录异常

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

日志监控

关键日志模式

# 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)

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
  • ✅ 提供完整的故障排查流程
  • ✅ 建立最佳实践指南

🔗 相关文档


📞 联系方式

如有疑问或需要讨论,请联系:

  • 技术负责人:[待填写]
  • 邮件:[待填写]
  • Slack:#taiji-ai-pad-dev

文档版本: v1.0.0
最后更新: 2026-03-09
审核状态: 待审核