184 lines
6.4 KiB
Python
184 lines
6.4 KiB
Python
"""成本优化服务(P1)"""
|
||
|
||
from typing import Any, Dict, Optional
|
||
|
||
from sqlalchemy import and_, func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..models import AICallLog, CostTracking, ModelRequirementBinding, ModelVersion, TruthNode
|
||
|
||
|
||
class CostOptimizationService:
|
||
"""面向宪章维度的成本优化建议。"""
|
||
|
||
def __init__(self, db: AsyncSession):
|
||
self.db = db
|
||
|
||
async def analyze_cost_optimization(
|
||
self,
|
||
truth_node_id: int,
|
||
tenant_id: int,
|
||
period: str = "weekly",
|
||
project_id: Optional[int] = None,
|
||
) -> Dict[str, Any]:
|
||
if project_id is not None:
|
||
await self.validate_truth_node_in_project(
|
||
truth_node_id=truth_node_id,
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
)
|
||
tracking = await self._get_tracking(truth_node_id, tenant_id, period)
|
||
if not tracking:
|
||
return {
|
||
"truth_node_id": truth_node_id,
|
||
"period": period,
|
||
"monthly_cost": 0.0,
|
||
"recommendations": [],
|
||
"total_potential_savings_percent": 0,
|
||
}
|
||
|
||
monthly_cost = float(tracking.actual_cost_usd or 0.0) * self._period_multiplier(period)
|
||
recs = []
|
||
recs.extend(await self._recommend_model_switch(truth_node_id, tenant_id))
|
||
recs.extend(await self._recommend_cache_and_batching(truth_node_id, tenant_id))
|
||
|
||
total_pct = sum(int(item.get("estimated_savings_percent", 0)) for item in recs)
|
||
total_pct = min(total_pct, 60)
|
||
|
||
result = {
|
||
"truth_node_id": truth_node_id,
|
||
"period": period,
|
||
"monthly_cost": round(monthly_cost, 2),
|
||
"recommendations": recs,
|
||
"total_potential_savings_percent": total_pct,
|
||
"estimated_monthly_savings_usd": round(monthly_cost * total_pct / 100, 2),
|
||
}
|
||
tracking.optimization_suggestions = result
|
||
await self.db.commit()
|
||
return result
|
||
|
||
async def validate_truth_node_in_project(
|
||
self, truth_node_id: int, tenant_id: int, project_id: int
|
||
) -> TruthNode:
|
||
result = await self.db.execute(
|
||
select(TruthNode).where(
|
||
and_(
|
||
TruthNode.id == truth_node_id,
|
||
TruthNode.tenant_id == tenant_id,
|
||
)
|
||
)
|
||
)
|
||
truth_node = result.scalar_one_or_none()
|
||
if not truth_node:
|
||
raise ValueError(f"宪章 {truth_node_id} 不存在")
|
||
if truth_node.project_id != project_id:
|
||
raise ValueError(
|
||
f"宪章 {truth_node_id} 不属于项目 {project_id},拒绝跨项目分析"
|
||
)
|
||
return truth_node
|
||
|
||
async def _get_tracking(
|
||
self, truth_node_id: int, tenant_id: int, period: str
|
||
) -> Optional[CostTracking]:
|
||
result = await self.db.execute(
|
||
select(CostTracking).where(
|
||
and_(
|
||
CostTracking.truth_node_id == truth_node_id,
|
||
CostTracking.tenant_id == tenant_id,
|
||
CostTracking.period == period,
|
||
)
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _recommend_model_switch(
|
||
self, truth_node_id: int, tenant_id: int
|
||
) -> list[Dict[str, Any]]:
|
||
result = await self.db.execute(
|
||
select(ModelRequirementBinding).where(
|
||
and_(
|
||
ModelRequirementBinding.truth_node_id == truth_node_id,
|
||
ModelRequirementBinding.tenant_id == tenant_id,
|
||
)
|
||
)
|
||
)
|
||
bindings = result.scalars().all()
|
||
if not bindings:
|
||
return []
|
||
|
||
current = bindings[0]
|
||
model = await self._get_model(current.model_version_id)
|
||
if not model or not model.cost_per_1k_tokens:
|
||
return []
|
||
|
||
cheaper = await self._find_cheaper_compatible_model(float(model.cost_per_1k_tokens))
|
||
if not cheaper:
|
||
return []
|
||
|
||
return [
|
||
{
|
||
"type": "model_switch",
|
||
"priority": "high",
|
||
"description": f"可从 {model.name} 切换到 {cheaper.name}(更低 token 成本)",
|
||
"estimated_savings_percent": 25,
|
||
}
|
||
]
|
||
|
||
async def _recommend_cache_and_batching(
|
||
self, truth_node_id: int, tenant_id: int
|
||
) -> list[Dict[str, Any]]:
|
||
# 当前日志没有强关联 truth_node 字段,按租户统计调用频次近似分析。
|
||
call_count_res = await self.db.execute(
|
||
select(func.count(AICallLog.id)).where(AICallLog.tenant_id == str(tenant_id))
|
||
)
|
||
call_count = int(call_count_res.scalar() or 0)
|
||
|
||
recs: list[Dict[str, Any]] = [
|
||
{
|
||
"type": "cache",
|
||
"priority": "high",
|
||
"description": "启用/提升相似请求缓存命中率(去重 prompt + TTL 分层)",
|
||
"estimated_savings_percent": 15,
|
||
}
|
||
]
|
||
if call_count > 200:
|
||
recs.append(
|
||
{
|
||
"type": "batching",
|
||
"priority": "medium",
|
||
"description": "批量聚合低优先级推理请求,减少并发和重复上下文开销",
|
||
"estimated_savings_percent": 10,
|
||
}
|
||
)
|
||
return recs
|
||
|
||
async def _get_model(self, model_version_id: int) -> Optional[ModelVersion]:
|
||
result = await self.db.execute(
|
||
select(ModelVersion).where(ModelVersion.id == model_version_id)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
async def _find_cheaper_compatible_model(
|
||
self, current_cost_per_1k_tokens: float
|
||
) -> Optional[ModelVersion]:
|
||
result = await self.db.execute(
|
||
select(ModelVersion)
|
||
.where(ModelVersion.is_active == True) # noqa: E712
|
||
.order_by(ModelVersion.cost_per_1k_tokens.asc())
|
||
)
|
||
candidates = result.scalars().all()
|
||
for m in candidates:
|
||
cost = float(m.cost_per_1k_tokens or 0.0)
|
||
if cost > 0 and cost < current_cost_per_1k_tokens:
|
||
return m
|
||
return None
|
||
|
||
def _period_multiplier(self, period: str) -> int:
|
||
if period == "daily":
|
||
return 30
|
||
if period == "weekly":
|
||
return 4
|
||
if period == "monthly":
|
||
return 1
|
||
return 1
|