46 KiB
mcp-server 计费系统问题分析和修复方案
分析日期: 2026-03-09
优先级: 🔴 高优先级(涉及用户资金安全)
影响范围: 计费模块、配额管理、余额扣款
📊 执行摘要
mcp-server 实现了两种独立的计费模式:
- Agent 运行时长计费 - 基于 Pod 运行时间和资源使用
- 模型 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 回调)
结果:用户余额被扣款两次 ❌
代码位置
-
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, ) - 文件:
-
周期性扣款
- 文件:
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}" ) - 文件:
-
模型调用扣款
- 文件:
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)
根本原因
-
没有行锁保护
- 周期任务和回调都直接查询
AgentBillingRecord - 没有使用
with_for_update()行锁
- 周期任务和回调都直接查询
-
更新操作不是原子的
- 读取
record.cost→ 计算增量 → 扣款 → 更新record.cost - 中间有多步操作,可能被打断
- 读取
-
没有分布式锁
- 周期任务和回调可能在不同进程/线程中执行
- PostgreSQL 行锁只能保护同一数据库连接内的事务
代码位置
-
周期计费
- 文件:
services/mcp-server/app/periodic_billing.py - 行号:L122-L200
result = await db.execute( select(AgentBillingRecord).where( AgentBillingRecord.end_time == None ) # ❌ 缺少 .with_for_update() ) - 文件:
-
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)
潜在问题
-
数据不一致
# 所有代码都更新 Balance 表 balance.eu_balance = new_balance # 但 User.eu_balance 不会更新 # 如果有代码查询 User.eu_balance → 显示错误余额 -
存储浪费
User.eu_balance占用空间但不使用- 每个用户浪费 15 bytes
-
维护困难
- 新开发者可能不知道哪个字段是最新的
- 可能误用废弃字段
修复建议
步骤 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- 平台 Agentservices/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 个修复脚本:
-
fix_agent_quotas.py
- 功能:修复 Agent 资源配置和配额记录
- 说明:配额使用量与实际 Agent 不匹配
-
fix_fake_quota.py
- 功能:清理假用量数据
- 说明:统计
type='custom'且status='active'的真实 Agent
-
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
审核状态: 待审核