Files

556 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LLM 成本追踪器 + 增强降级策略 (MVP-3 / D34)
职责
----
1. 记录每次 LLM 调用的 token / 费用 / 延迟,提供按时段聚合的统计
2. 实现"硬熔断"降级策略: 当达到日预算 / 错误率阈值时自动禁用 LLM
3. 提供成本仪表盘所需的导出接口(供 ``api/llm.py`` 暴露给前端)
设计要点
--------
* **职责分离**:成本追踪本身只负责"记账 + 状态机",不直接决定能否调用 LLM;
调用方 (例如检测管道) 在每次调用前调用 ``can_call()``,根据结果决定降级
* **滑动窗口**:使用按 UTC 日历日的桶 (``deque``) 保存最近 N 天的统计,
避免长时间运行内存无限增长
* **熔断**:达到错误率阈值 (默认连续 ``error_threshold`` 次调用失败率 >50%)
自动进入 ``CIRCUIT_OPEN`` 状态,``cooldown_seconds`` 后自动半开尝试恢复
* **Token 估算**:未拿到真实 usage 时按"图像 + prompt 长度"做粗估,
保证账单不会因为 provider 不返回 usage 而归零
接入方式
--------
::
tracker = LLMCostTracker(daily_budget_usd=5.0)
if not tracker.can_call():
return None # 降级处理
result = await llm_service.analyze_with_fallback(req)
tracker.record(result, request=req)
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Deque, Dict, List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 常量 / 数据结构
# ---------------------------------------------------------------------------
# 不同 provider 的近似定价 (USD per 1M tokens),仅供估算
DEFAULT_PRICING: Dict[str, Dict[str, float]] = {
# OpenAI GPT-4o
"openai": {"prompt": 5.0, "completion": 15.0, "image": 0.005},
# 豆包 / Qwen / GLM 国内 VLM 大致价位(仅估算)
"qwen": {"prompt": 0.8, "completion": 2.0, "image": 0.001},
"glm": {"prompt": 1.0, "completion": 3.0, "image": 0.001},
"doubao": {"prompt": 0.8, "completion": 2.0, "image": 0.001},
# mock provider 不计费
"mock": {"prompt": 0.0, "completion": 0.0, "image": 0.0},
# 兜底
"default": {"prompt": 1.0, "completion": 3.0, "image": 0.002},
}
class CircuitState:
CLOSED = "closed" # 正常
OPEN = "open" # 熔断
HALF_OPEN = "half_open" # 试探恢复
@dataclass
class CallRecord:
"""单次 LLM 调用记录。"""
timestamp: float
provider: str
model: str
success: bool
confirmed: Optional[bool]
latency_ms: float
prompt_tokens: int
completion_tokens: int
image_count: int
cost_usd: float
error: Optional[str] = None
@dataclass
class _DailyBucket:
"""按 UTC 日历日聚合的桶。"""
date: str
call_count: int = 0
success_count: int = 0
failure_count: int = 0
total_prompt_tokens: int = 0
total_completion_tokens: int = 0
total_image_count: int = 0
total_cost_usd: float = 0.0
total_latency_ms: float = 0.0
def add(self, record: CallRecord) -> None:
self.call_count += 1
if record.success:
self.success_count += 1
else:
self.failure_count += 1
self.total_prompt_tokens += record.prompt_tokens
self.total_completion_tokens += record.completion_tokens
self.total_image_count += record.image_count
self.total_cost_usd += record.cost_usd
self.total_latency_ms += record.latency_ms
def to_dict(self) -> Dict[str, Any]:
return {
"date": self.date,
"call_count": self.call_count,
"success_count": self.success_count,
"failure_count": self.failure_count,
"prompt_tokens": self.total_prompt_tokens,
"completion_tokens": self.total_completion_tokens,
"image_count": self.total_image_count,
"cost_usd": round(self.total_cost_usd, 6),
"avg_latency_ms": round(
self.total_latency_ms / self.call_count, 2
)
if self.call_count
else 0.0,
}
# ---------------------------------------------------------------------------
# LLMCostTracker
# ---------------------------------------------------------------------------
class LLMCostTracker:
"""LLM 成本追踪器 + 熔断降级。
Args:
daily_budget_usd: 日预算,0 表示不限
history_days: 保留多少天的日级统计
recent_window: 用于错误率计算的最近调用窗口
error_rate_threshold: 触发熔断的错误率阈值 [0, 1]
cooldown_seconds: 熔断进入半开状态的冷却时间
pricing_overrides: 自定义价格表 (覆盖默认)
"""
def __init__(
self,
daily_budget_usd: float = 0.0,
history_days: int = 7,
recent_window: int = 20,
error_rate_threshold: float = 0.5,
cooldown_seconds: float = 60.0,
pricing_overrides: Optional[Dict[str, Dict[str, float]]] = None,
) -> None:
if daily_budget_usd < 0:
raise ValueError("daily_budget_usd 必须 >= 0")
if history_days < 1:
raise ValueError("history_days 必须 >= 1")
if recent_window < 1:
raise ValueError("recent_window 必须 >= 1")
if not 0.0 < error_rate_threshold <= 1.0:
raise ValueError("error_rate_threshold 必须在 (0, 1]")
if cooldown_seconds < 0:
raise ValueError("cooldown_seconds 必须 >= 0")
self.daily_budget_usd = daily_budget_usd
self.history_days = history_days
self.recent_window = recent_window
self.error_rate_threshold = error_rate_threshold
self.cooldown_seconds = cooldown_seconds
# 合并定价
self._pricing: Dict[str, Dict[str, float]] = {
k: dict(v) for k, v in DEFAULT_PRICING.items()
}
if pricing_overrides:
for provider, table in pricing_overrides.items():
self._pricing.setdefault(provider, {})
self._pricing[provider].update(table)
# 状态
self._buckets: Deque[_DailyBucket] = deque(maxlen=history_days)
self._recent: Deque[bool] = deque(maxlen=recent_window)
self._records: Deque[CallRecord] = deque(maxlen=200)
self._circuit_state = CircuitState.CLOSED
self._circuit_opened_at: Optional[float] = None
self._manually_disabled = False
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
# 准入检查
# ------------------------------------------------------------------
def can_call(self, now: Optional[float] = None) -> bool:
"""同步判断当前是否允许调用 LLM。
触发拒绝的条件按优先级:
1. 手动禁用
2. 日预算超限
3. 熔断器处于 OPEN 且未到冷却结束
"""
if self._manually_disabled:
return False
if self._budget_exhausted():
return False
ts = now if now is not None else time.time()
if self._circuit_state == CircuitState.OPEN:
if (
self._circuit_opened_at is not None
and (ts - self._circuit_opened_at) >= self.cooldown_seconds
):
# 进入半开,允许一次试探
self._circuit_state = CircuitState.HALF_OPEN
logger.info("LLMCostTracker 熔断器进入 HALF_OPEN")
return True
return False
return True
def reason_for_block(self, now: Optional[float] = None) -> Optional[str]:
"""返回当前不可调用的原因 (调试用),None 表示可以调用。"""
if self._manually_disabled:
return "manually_disabled"
if self._budget_exhausted():
return f"daily_budget_exceeded({self._today_cost():.4f}/{self.daily_budget_usd})"
ts = now if now is not None else time.time()
if self._circuit_state == CircuitState.OPEN:
if (
self._circuit_opened_at is not None
and (ts - self._circuit_opened_at) < self.cooldown_seconds
):
return "circuit_open"
return None
# ------------------------------------------------------------------
# 记录
# ------------------------------------------------------------------
def record(
self,
result: Any,
prompt_tokens: Optional[int] = None,
completion_tokens: Optional[int] = None,
image_count: int = 1,
provider_override: Optional[str] = None,
) -> CallRecord:
"""记录一次 LLM 调用。
``result`` 可以是 ``LLMAnalysisResult`` 实例或鸭子类型 (含 confirmed
/ provider / model / latency_ms / error / metadata)。
"""
provider = provider_override or getattr(result, "provider", "default")
model = getattr(result, "model", "")
success = getattr(result, "error", None) is None and getattr(
result, "confirmed", None
) is not None
latency_ms = float(getattr(result, "latency_ms", 0.0) or 0.0)
usage = self._extract_usage(result)
prompt_tokens = (
int(prompt_tokens)
if prompt_tokens is not None
else int(usage.get("prompt_tokens") or 0)
)
completion_tokens = (
int(completion_tokens)
if completion_tokens is not None
else int(usage.get("completion_tokens") or 0)
)
# token 估算兜底
if prompt_tokens == 0 and completion_tokens == 0 and provider != "mock":
prompt_tokens = self._estimate_prompt_tokens(image_count)
completion_tokens = self._estimate_completion_tokens(result)
cost = self._calc_cost(provider, prompt_tokens, completion_tokens, image_count)
record = CallRecord(
timestamp=time.time(),
provider=provider,
model=model,
success=success,
confirmed=getattr(result, "confirmed", None),
latency_ms=latency_ms,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
image_count=image_count,
cost_usd=cost,
error=getattr(result, "error", None),
)
self._append_record(record)
self._update_circuit(record)
return record
# ------------------------------------------------------------------
# 控制
# ------------------------------------------------------------------
def disable(self) -> None:
"""手动禁用 LLM 调用 (运维场景)。"""
self._manually_disabled = True
logger.warning("LLMCostTracker 已被手动禁用")
def enable(self) -> None:
"""手动重新启用。"""
self._manually_disabled = False
self._circuit_state = CircuitState.CLOSED
self._circuit_opened_at = None
logger.info("LLMCostTracker 已重新启用")
def reset(self) -> None:
"""清空全部统计 (谨慎使用)。"""
self._buckets.clear()
self._recent.clear()
self._records.clear()
self._circuit_state = CircuitState.CLOSED
self._circuit_opened_at = None
# ------------------------------------------------------------------
# 导出
# ------------------------------------------------------------------
@property
def is_enabled(self) -> bool:
return not self._manually_disabled
@property
def circuit_state(self) -> str:
return self._circuit_state
def daily_summary(self) -> List[Dict[str, Any]]:
return [bucket.to_dict() for bucket in self._buckets]
def today_summary(self) -> Dict[str, Any]:
bucket = self._today_bucket(create=False)
if bucket is None:
return {
"date": _today_str(),
"call_count": 0,
"cost_usd": 0.0,
"budget_usd": self.daily_budget_usd,
"budget_used_ratio": 0.0,
}
data = bucket.to_dict()
data["budget_usd"] = self.daily_budget_usd
data["budget_used_ratio"] = (
data["cost_usd"] / self.daily_budget_usd
if self.daily_budget_usd > 0
else 0.0
)
return data
def recent_records(self, limit: int = 20) -> List[Dict[str, Any]]:
records = list(self._records)[-limit:]
return [
{
"timestamp": r.timestamp,
"provider": r.provider,
"model": r.model,
"success": r.success,
"confirmed": r.confirmed,
"latency_ms": round(r.latency_ms, 2),
"prompt_tokens": r.prompt_tokens,
"completion_tokens": r.completion_tokens,
"image_count": r.image_count,
"cost_usd": round(r.cost_usd, 6),
"error": r.error,
}
for r in reversed(records)
]
def stats(self) -> Dict[str, Any]:
recent_total = len(self._recent)
recent_failed = sum(1 for ok in self._recent if not ok)
recent_error_rate = (
recent_failed / recent_total if recent_total > 0 else 0.0
)
return {
"enabled": self.is_enabled,
"circuit_state": self._circuit_state,
"circuit_opened_at": self._circuit_opened_at,
"block_reason": self.reason_for_block(),
"today": self.today_summary(),
"history_days": self.history_days,
"recent_window": self.recent_window,
"recent_error_rate": round(recent_error_rate, 4),
"error_rate_threshold": self.error_rate_threshold,
}
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _append_record(self, record: CallRecord) -> None:
bucket = self._today_bucket(create=True)
assert bucket is not None
bucket.add(record)
self._recent.append(record.success)
self._records.append(record)
def _today_bucket(self, *, create: bool) -> Optional[_DailyBucket]:
today = _today_str()
if self._buckets and self._buckets[-1].date == today:
return self._buckets[-1]
if not create:
for b in self._buckets:
if b.date == today:
return b
return None
bucket = _DailyBucket(date=today)
self._buckets.append(bucket)
return bucket
def _today_cost(self) -> float:
bucket = self._today_bucket(create=False)
return bucket.total_cost_usd if bucket else 0.0
def _budget_exhausted(self) -> bool:
if self.daily_budget_usd <= 0:
return False
return self._today_cost() >= self.daily_budget_usd
def _update_circuit(self, record: CallRecord) -> None:
# HALF_OPEN: 一次结果决定回到 CLOSED 还是 OPEN
if self._circuit_state == CircuitState.HALF_OPEN:
if record.success:
self._circuit_state = CircuitState.CLOSED
self._circuit_opened_at = None
logger.info("LLMCostTracker 熔断器恢复 CLOSED")
else:
self._circuit_state = CircuitState.OPEN
self._circuit_opened_at = time.time()
logger.warning("LLMCostTracker 半开试探失败,回到 OPEN")
return
# CLOSED: 检查最近窗口的错误率
if (
self._circuit_state == CircuitState.CLOSED
and len(self._recent) >= max(3, self.recent_window // 2)
):
failures = sum(1 for ok in self._recent if not ok)
error_rate = failures / len(self._recent)
if error_rate >= self.error_rate_threshold:
self._circuit_state = CircuitState.OPEN
self._circuit_opened_at = time.time()
logger.warning(
"LLMCostTracker 触发熔断: 最近 %d 次调用错误率 %.2f >= %.2f",
len(self._recent),
error_rate,
self.error_rate_threshold,
)
def _calc_cost(
self,
provider: str,
prompt_tokens: int,
completion_tokens: int,
image_count: int,
) -> float:
rates = self._pricing.get(provider) or self._pricing["default"]
cost = (
prompt_tokens / 1_000_000.0 * rates.get("prompt", 0.0)
+ completion_tokens / 1_000_000.0 * rates.get("completion", 0.0)
+ image_count * rates.get("image", 0.0)
)
return max(0.0, cost)
@staticmethod
def _estimate_prompt_tokens(image_count: int) -> int:
# 经验值:每张图 ~ 200 tokenprompt 模板 ~ 120 token
return 120 + 200 * max(0, image_count)
@staticmethod
def _estimate_completion_tokens(result: Any) -> int:
reasoning = getattr(result, "reasoning", "") or ""
# 粗略 1.3 字符 = 1 token
return max(16, int(len(reasoning) / 1.3))
@staticmethod
def _extract_usage(result: Any) -> Dict[str, Any]:
"""从结果对象上尽力获取 usage (provider 可能放 metadata 里)。"""
usage = getattr(result, "usage", None)
if isinstance(usage, dict):
return usage
metadata = getattr(result, "metadata", None)
if isinstance(metadata, dict):
md_usage = metadata.get("usage")
if isinstance(md_usage, dict):
return md_usage
return {}
# ---------------------------------------------------------------------------
# 全局单例
# ---------------------------------------------------------------------------
_global_tracker: Optional[LLMCostTracker] = None
def init_global_tracker(
daily_budget_usd: float = 0.0,
history_days: int = 7,
recent_window: int = 20,
error_rate_threshold: float = 0.5,
cooldown_seconds: float = 60.0,
) -> LLMCostTracker:
"""初始化并返回全局成本追踪器。"""
global _global_tracker
_global_tracker = LLMCostTracker(
daily_budget_usd=daily_budget_usd,
history_days=history_days,
recent_window=recent_window,
error_rate_threshold=error_rate_threshold,
cooldown_seconds=cooldown_seconds,
)
return _global_tracker
def get_global_tracker() -> Optional[LLMCostTracker]:
return _global_tracker
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
def _today_str() -> str:
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
__all__ = [
"LLMCostTracker",
"CallRecord",
"CircuitState",
"DEFAULT_PRICING",
"init_global_tracker",
"get_global_tracker",
]