Files
SillyTavern_replica/backend/services/token_usage_service.py

428 lines
15 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.
"""
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()