23 KiB
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 调用模型时,系统会从同一个余额账户扣款两次
验证结果:✅ 这是设计上的双重收费,符合业务模型
理由: 根据用户提供的定价信息,系统确实应该收取两部分费用:
-
VM计算费用: 0.5 EU/hour - 按 Agent Pod 的运行时间计费
- 代码位置:
periodic_billing.py:L180-L185 - 计费逻辑: 每小时扫描运行中的 Agent,计算增量成本并扣款
- 代码位置:
-
模型推理费用: 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回调)可能同时更新同一条记录,导致重复扣款
验证结果:🔴 确实存在并发竞争风险
代码验证:
- 周期计费(
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}"
)
- 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}"
)
问题分析: 两个函数都:
- 读取
record.cost(旧值) - 计算增量
cost_increment = new_cost - old_cost - 扣款
- 更新
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 时没有使用行锁
验证结果:🔴 部分正确,实际上创建和删除都没有行锁
代码验证:
- 检查整个 user.py 中 with_for_update 的使用:
grep "with_for_update" services/mcp-server/app/routes/user.py
结果:只有 3 处使用(L1302, L2307, L2552),都是在平台 Agent 的配额查询中。
- 创建自定义 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
- 删除自定义 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(当前使用)
验证结果:✅ 已标注废弃,代码未实际使用
代码验证:
- 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消耗
- 搜索是否有代码使用 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不检查、周期计费只警告、模型调用允许透支
验证结果:🟡 部分正确,已有停止机制,但创建时未预检查
代码验证:
-
创建 Agent 时(无预检查)
- 未找到余额预检查代码
- Agent 可以在余额不足时创建
-
周期计费时(
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)
好消息:周期计费已经实现了余额不足停止机制!
- 模型调用时(
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 运行
验证结果:✅ 确实存在历史遗留问题,已准备修复脚本
代码验证:
-
修复脚本存在:
/services/mcp-server/fix_fake_quota.py✅/services/mcp-server/fix_agent_quotas.py✅/services/mcp-server/fix_channel_quota_records.py✅
-
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)- 这是设计上的双重收费
最紧急的修复:
- 🔥 消除重复扣款风险(问题2)
- 🔥 调整删除顺序(问题3)
- 🔒 添加并发保护(问题4)
文档版本: v1.0.0
最后更新: 2026-03-09
审核状态: 待审核