428 lines
15 KiB
Python
428 lines
15 KiB
Python
"""
|
||
Token 使用统计服务
|
||
|
||
负责记录、查询和分析 LLM 调用的 token 使用情况
|
||
数据持久化到 data/token_usage 目录,按月份组织
|
||
采用双层存储:
|
||
1. JSONL 文件 - 详细记录(按月存储)
|
||
2. 索引文件 - 快速聚合统计(按 API URL、日期等维度)
|
||
"""
|
||
import json
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import List, Dict, Optional, Any
|
||
from datetime import datetime
|
||
from collections import defaultdict
|
||
|
||
try:
|
||
from backend.models.internal import TokenUsageRecord, TokenUsageStatus
|
||
from backend.core.config import settings
|
||
except ImportError:
|
||
from models.internal import TokenUsageRecord, TokenUsageStatus
|
||
from core.config import settings
|
||
|
||
|
||
class TokenUsageService:
|
||
"""
|
||
Token 使用统计服务
|
||
|
||
功能:
|
||
- 记录每次 LLM 调用的 token 使用情况
|
||
- 按月份、日期、角色、聊天、API URL 维度统计
|
||
- 支持中断和失败标记
|
||
- 数据持久化到文件系统(JSONL + 索引)
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.token_usage_dir = settings.DATA_PATH / "token_usage"
|
||
self.token_usage_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ✅ 索引文件目录 - 用于快速聚合查询
|
||
self.index_dir = self.token_usage_dir / "indexes"
|
||
self.index_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _get_month_file(self, year: int, month: int) -> Path:
|
||
"""获取指定月份的统计文件路径"""
|
||
month_dir = self.token_usage_dir / f"{year}"
|
||
month_dir.mkdir(parents=True, exist_ok=True)
|
||
return month_dir / f"{month:02d}.jsonl"
|
||
|
||
def _load_month_records(self, year: int, month: int) -> List[TokenUsageRecord]:
|
||
"""加载指定月份的所有记录"""
|
||
file_path = self._get_month_file(year, month)
|
||
|
||
if not file_path.exists():
|
||
return []
|
||
|
||
records = []
|
||
try:
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
if line.strip():
|
||
data = json.loads(line)
|
||
records.append(TokenUsageRecord(**data))
|
||
except Exception as e:
|
||
print(f"[TokenUsage] 加载记录失败: {e}")
|
||
|
||
return records
|
||
|
||
def _save_record(self, record: TokenUsageRecord):
|
||
"""保存单条记录到对应的月份文件"""
|
||
dt = datetime.fromtimestamp(record.timestamp)
|
||
file_path = self._get_month_file(dt.year, dt.month)
|
||
|
||
try:
|
||
with open(file_path, 'a', encoding='utf-8') as f:
|
||
f.write(json.dumps(record.model_dump(), ensure_ascii=False) + '\n')
|
||
|
||
# ✅ 同时更新索引文件(用于快速查询)
|
||
self._update_indexes(record)
|
||
except Exception as e:
|
||
print(f"[TokenUsage] 保存记录失败: {e}")
|
||
|
||
def _update_indexes(self, record: TokenUsageRecord):
|
||
"""
|
||
更新索引文件 - 实现高效的按维度聚合查询
|
||
|
||
索引结构:
|
||
- indexes/api_urls.json - 按 API URL 聚合
|
||
- indexes/daily/{year}-{month}.json - 按日聚合
|
||
"""
|
||
dt = datetime.fromtimestamp(record.timestamp)
|
||
|
||
# 1. 更新 API URL 索引
|
||
if record.apiUrl:
|
||
api_url_index = self.index_dir / "api_urls.json"
|
||
self._update_api_url_index(api_url_index, record)
|
||
|
||
# 2. 更新每日索引
|
||
daily_index = self.index_dir / "daily" / f"{dt.year}-{dt.month:02d}.json"
|
||
daily_index.parent.mkdir(parents=True, exist_ok=True)
|
||
self._update_daily_index(daily_index, record)
|
||
|
||
def _update_api_url_index(self, index_file: Path, record: TokenUsageRecord):
|
||
"""更新 API URL 索引文件"""
|
||
index_data = {}
|
||
|
||
# 加载现有索引
|
||
if index_file.exists():
|
||
try:
|
||
with open(index_file, 'r', encoding='utf-8') as f:
|
||
index_data = json.load(f)
|
||
except:
|
||
index_data = {}
|
||
|
||
# 更新统计
|
||
api_url = record.apiUrl
|
||
if api_url not in index_data:
|
||
index_data[api_url] = {
|
||
"totalPromptTokens": 0,
|
||
"totalCompletionTokens": 0,
|
||
"totalTokens": 0,
|
||
"count": 0,
|
||
"firstUsed": record.timestamp,
|
||
"lastUsed": record.timestamp
|
||
}
|
||
|
||
stats = index_data[api_url]
|
||
stats["totalPromptTokens"] += record.promptTokens
|
||
stats["totalCompletionTokens"] += record.completionTokens
|
||
stats["totalTokens"] += record.totalTokens
|
||
stats["count"] += 1
|
||
stats["lastUsed"] = max(stats["lastUsed"], record.timestamp)
|
||
stats["firstUsed"] = min(stats["firstUsed"], record.timestamp)
|
||
|
||
# 保存索引
|
||
with open(index_file, 'w', encoding='utf-8') as f:
|
||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||
|
||
def _update_daily_index(self, index_file: Path, record: TokenUsageRecord):
|
||
"""更新每日索引文件"""
|
||
dt = datetime.fromtimestamp(record.timestamp)
|
||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||
|
||
index_data = {}
|
||
|
||
# 加载现有索引
|
||
if index_file.exists():
|
||
try:
|
||
with open(index_file, 'r', encoding='utf-8') as f:
|
||
index_data = json.load(f)
|
||
except:
|
||
index_data = {}
|
||
|
||
# 更新统计
|
||
if day_key not in index_data:
|
||
index_data[day_key] = {
|
||
"promptTokens": 0,
|
||
"completionTokens": 0,
|
||
"totalTokens": 0,
|
||
"count": 0
|
||
}
|
||
|
||
stats = index_data[day_key]
|
||
stats["promptTokens"] += record.promptTokens
|
||
stats["completionTokens"] += record.completionTokens
|
||
stats["totalTokens"] += record.totalTokens
|
||
stats["count"] += 1
|
||
|
||
# 保存索引
|
||
with open(index_file, 'w', encoding='utf-8') as f:
|
||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||
|
||
async def record_usage(
|
||
self,
|
||
chat_id: str,
|
||
role_name: str,
|
||
chat_name: str,
|
||
prompt_tokens: int,
|
||
completion_tokens: int,
|
||
total_tokens: int,
|
||
status: TokenUsageStatus = TokenUsageStatus.COMPLETED,
|
||
message_id: Optional[str] = None,
|
||
floor: Optional[int] = None,
|
||
error_message: Optional[str] = None,
|
||
duration: Optional[float] = None,
|
||
model: Optional[str] = None,
|
||
api_provider: Optional[str] = None,
|
||
api_url: Optional[str] = None
|
||
) -> TokenUsageRecord:
|
||
"""
|
||
记录一次 LLM 调用的 token 使用情况
|
||
|
||
Args:
|
||
chat_id: 聊天ID
|
||
role_name: 角色名称
|
||
chat_name: 聊天名称
|
||
prompt_tokens: 输入 token 数
|
||
completion_tokens: 输出 token 数
|
||
total_tokens: 总 token 数
|
||
status: 请求状态
|
||
message_id: 关联的消息ID
|
||
floor: 楼层号
|
||
error_message: 错误信息
|
||
duration: 请求耗时
|
||
model: 使用的模型
|
||
api_provider: API 提供商
|
||
api_url: API URL地址
|
||
|
||
Returns:
|
||
TokenUsageRecord: 创建的记录
|
||
"""
|
||
record = TokenUsageRecord(
|
||
id=str(uuid.uuid4()),
|
||
chatId=chat_id,
|
||
roleName=role_name,
|
||
chatName=chat_name,
|
||
messageId=message_id,
|
||
floor=floor,
|
||
promptTokens=prompt_tokens,
|
||
completionTokens=completion_tokens,
|
||
totalTokens=total_tokens,
|
||
status=status,
|
||
errorMessage=error_message,
|
||
duration=duration,
|
||
model=model,
|
||
apiProvider=api_provider,
|
||
apiUrl=api_url
|
||
)
|
||
|
||
self._save_record(record)
|
||
return record
|
||
|
||
async def get_stats_by_month(
|
||
self,
|
||
year: int,
|
||
month: int,
|
||
role_name: Optional[str] = None,
|
||
chat_name: Optional[str] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
获取指定月份的统计数据
|
||
|
||
Args:
|
||
year: 年份
|
||
month: 月份
|
||
role_name: 角色名称(可选,用于过滤)
|
||
chat_name: 聊天名称(可选,用于过滤)
|
||
|
||
Returns:
|
||
统计数据字典
|
||
"""
|
||
records = self._load_month_records(year, month)
|
||
|
||
# 过滤
|
||
if role_name:
|
||
records = [r for r in records if r.roleName == role_name]
|
||
if chat_name:
|
||
records = [r for r in records if r.chatName == chat_name]
|
||
|
||
# 统计
|
||
total_prompt = sum(r.promptTokens for r in records)
|
||
total_completion = sum(r.completionTokens for r in records)
|
||
total_tokens = sum(r.totalTokens for r in records)
|
||
|
||
completed_count = sum(1 for r in records if r.status == TokenUsageStatus.COMPLETED)
|
||
interrupted_count = sum(1 for r in records if r.status == TokenUsageStatus.INTERRUPTED)
|
||
failed_count = sum(1 for r in records if r.status == TokenUsageStatus.FAILED)
|
||
|
||
# 按日期分组
|
||
daily_stats = defaultdict(lambda: {
|
||
"promptTokens": 0,
|
||
"completionTokens": 0,
|
||
"totalTokens": 0,
|
||
"count": 0
|
||
})
|
||
|
||
for r in records:
|
||
dt = datetime.fromtimestamp(r.timestamp)
|
||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||
daily_stats[day_key]["promptTokens"] += r.promptTokens
|
||
daily_stats[day_key]["completionTokens"] += r.completionTokens
|
||
daily_stats[day_key]["totalTokens"] += r.totalTokens
|
||
daily_stats[day_key]["count"] += 1
|
||
|
||
# 按角色分组
|
||
role_stats = defaultdict(lambda: {
|
||
"promptTokens": 0,
|
||
"completionTokens": 0,
|
||
"totalTokens": 0,
|
||
"count": 0
|
||
})
|
||
|
||
for r in records:
|
||
role_stats[r.roleName]["promptTokens"] += r.promptTokens
|
||
role_stats[r.roleName]["completionTokens"] += r.completionTokens
|
||
role_stats[r.roleName]["totalTokens"] += r.totalTokens
|
||
role_stats[r.roleName]["count"] += 1
|
||
|
||
# 按聊天分组
|
||
chat_stats = defaultdict(lambda: {
|
||
"promptTokens": 0,
|
||
"completionTokens": 0,
|
||
"totalTokens": 0,
|
||
"count": 0
|
||
})
|
||
|
||
for r in records:
|
||
chat_key = f"{r.roleName}/{r.chatName}"
|
||
chat_stats[chat_key]["promptTokens"] += r.promptTokens
|
||
chat_stats[chat_key]["completionTokens"] += r.completionTokens
|
||
chat_stats[chat_key]["totalTokens"] += r.totalTokens
|
||
chat_stats[chat_key]["count"] += 1
|
||
|
||
# ✅ 按 API URL 分组
|
||
api_url_stats = defaultdict(lambda: {
|
||
"promptTokens": 0,
|
||
"completionTokens": 0,
|
||
"totalTokens": 0,
|
||
"count": 0
|
||
})
|
||
|
||
for r in records:
|
||
if r.apiUrl:
|
||
api_url_stats[r.apiUrl]["promptTokens"] += r.promptTokens
|
||
api_url_stats[r.apiUrl]["completionTokens"] += r.completionTokens
|
||
api_url_stats[r.apiUrl]["totalTokens"] += r.totalTokens
|
||
api_url_stats[r.apiUrl]["count"] += 1
|
||
|
||
return {
|
||
"year": year,
|
||
"month": month,
|
||
"totalRecords": len(records),
|
||
"totalPromptTokens": total_prompt,
|
||
"totalCompletionTokens": total_completion,
|
||
"totalTokens": total_tokens,
|
||
"completedCount": completed_count,
|
||
"interruptedCount": interrupted_count,
|
||
"failedCount": failed_count,
|
||
"dailyStats": dict(daily_stats),
|
||
"roleStats": dict(role_stats),
|
||
"chatStats": dict(chat_stats),
|
||
"apiUrlStats": dict(api_url_stats), # ✅ 新增
|
||
"records": [r.model_dump() for r in records[:100]] # 最近100条记录
|
||
}
|
||
|
||
async def list_months(self) -> List[Dict[str, int]]:
|
||
"""列出所有有数据的月份"""
|
||
months = []
|
||
|
||
if not self.token_usage_dir.exists():
|
||
return months
|
||
|
||
for year_dir in sorted(self.token_usage_dir.iterdir()):
|
||
if year_dir.is_dir() and year_dir.name.isdigit():
|
||
year = int(year_dir.name)
|
||
for month_file in sorted(year_dir.glob("*.jsonl")):
|
||
month = int(month_file.stem)
|
||
months.append({"year": year, "month": month})
|
||
|
||
return months
|
||
|
||
async def get_api_url_stats(self) -> Dict[str, Any]:
|
||
"""
|
||
✅ 获取按 API URL 分组的统计数据(从索引文件快速读取)
|
||
|
||
Returns:
|
||
{api_url: {totalPromptTokens, totalCompletionTokens, totalTokens, count, firstUsed, lastUsed}}
|
||
"""
|
||
api_url_index = self.index_dir / "api_urls.json"
|
||
|
||
if not api_url_index.exists():
|
||
return {}
|
||
|
||
try:
|
||
with open(api_url_index, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
print(f"[TokenUsage] 读取 API URL 索引失败: {e}")
|
||
return {}
|
||
|
||
async def get_daily_stats(self, year: int, month: int) -> Dict[str, Any]:
|
||
"""
|
||
✅ 获取指定月份的每日统计数据(从索引文件快速读取)
|
||
|
||
Args:
|
||
year: 年份
|
||
month: 月份
|
||
|
||
Returns:
|
||
{day_key: {promptTokens, completionTokens, totalTokens, count}}
|
||
"""
|
||
daily_index = self.index_dir / "daily" / f"{year}-{month:02d}.json"
|
||
|
||
if not daily_index.exists():
|
||
return {}
|
||
|
||
try:
|
||
with open(daily_index, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
print(f"[TokenUsage] 读取每日索引失败: {e}")
|
||
return {}
|
||
|
||
async def get_available_roles(self, year: int, month: int) -> List[str]:
|
||
"""获取指定月份有数据的角色列表"""
|
||
records = self._load_month_records(year, month)
|
||
roles = set(r.roleName for r in records)
|
||
return sorted(list(roles))
|
||
|
||
async def get_available_chats(
|
||
self,
|
||
year: int,
|
||
month: int,
|
||
role_name: Optional[str] = None
|
||
) -> List[str]:
|
||
"""获取指定月份有数据的聊天列表"""
|
||
records = self._load_month_records(year, month)
|
||
|
||
if role_name:
|
||
records = [r for r in records if r.roleName == role_name]
|
||
|
||
chats = set(f"{r.roleName}/{r.chatName}" for r in records)
|
||
return sorted(list(chats))
|
||
|
||
|
||
# 全局实例
|
||
token_usage_service = TokenUsageService()
|