forked from xiaohei/taiji-AI-PAD
1552 lines
46 KiB
Markdown
1552 lines
46 KiB
Markdown
# 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 <user_id>
|
||
```
|
||
|
||
**功能特性**:
|
||
- ✅ 自动检测配额不一致(假用量)
|
||
- ✅ 识别透支用户、余额不足用户、风险用户
|
||
- ✅ 生成健康评分报告(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
|
||
**审核状态**: 待审核
|