Files
taiji-AI-PAD/plans/计费系统问题验证报告.md
T
2026-03-10 06:40:38 +00:00

23 KiB
Raw Blame History

mcp-server 计费系统问题验证报告

验证日期: 2026-03-09
验证人: AI Assistant
基于文档: 计费系统问题分析和修复方案.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)
# ❌ 没有行锁
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}"
    )
  1. Agent Manager回调(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()
)

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

建议修复方案(推荐):

# 方案:只在周期计费时扣款,回调只更新记录状态

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

@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 继续运行但不再计费 ❌
- 用户免费使用资源 ❌

代码还有注释承认了这个问题:

except Exception as agent_delete_error:
    logger.error(f"Agent Manager 删除失败: {agent_full_name}, 错误: {str(agent_delete_error)}")
    # 注意:配额已释放,但 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, ...)
    quota.cpu_used -= cpu_released
    await db.commit()

🔴 问题4:配额更新缺少并发保护 - 确实存在

原文档观点:

创建 Agent 时使用了行锁,但删除 Agent 时没有使用行锁

验证结果:🔴 部分正确,实际上创建和删除都没有行锁

代码验证:

  1. 检查整个 user.py 中 with_for_update 的使用:
grep "with_for_update" services/mcp-server/app/routes/user.py

结果:只有 3 处使用(L1302, L2307, L2552),都是在平台 Agent 的配额查询中。

  1. 创建自定义 Agent 时的配额查询(user.py:L2893):
# ❌ 没有行锁
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
  1. 删除自定义 Agent 时的配额查询(user.py:L3359):
# ❌ 没有行锁
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

建议修复:

# 所有配额更新操作都加行锁
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
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消耗
  1. 搜索是否有代码使用 User.eu_balance:
grep -r "User\.eu_balance\|user\.eu_balance" services/mcp-server/

结果:只在 billing.py:L29 有一条注释:

# 注意:User.eu_balance 和 User.balance 字段已废弃,请使用 Balance 表

结论:

  • ✅ 字段已标记废弃
  • ✅ 代码未使用该字段
  • ⚠️ 但字段仍存在数据库中,占用存储空间

建议:

# 后续可通过数据库迁移删除(非紧急)
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):

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 创建兜底记录时:

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(),理论上不会为空。

建议:

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

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)

好消息:周期计费已经实现了余额不足停止机制!

  1. 模型调用时(billing_webhook.py:L299-L305):
# 扣减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"建议充值"
    )
    # ❌ 只警告,允许透支

建议:

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

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

此脚本用于清理因旧接口(/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

修改:

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

修改:

# 行号: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(扩缩容)
# 所有配额更新操作都加行锁
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

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

5. 修复问题7:添加余额预检查

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

@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:执行假用量修复脚本

# 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)
  • 前端分开展示两种费用

📊 测试建议

单元测试

# 测试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
审核状态: 待审核