From f249f5099420b5d31d13199d8d284b7e23f5a133 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 7 Mar 2025 20:41:12 +0800 Subject: [PATCH] =?UTF-8?q?v0.5.10=20=E5=9C=A8=E6=A0=B9=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=BB=9F=E8=AE=A1=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llm_statistics.txt | 74 ++++++++++++++ src/plugins/chat/__init__.py | 8 ++ src/plugins/models/utils_model.py | 98 +++++++++++++++++- src/plugins/utils/statistic.py | 162 ++++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 llm_statistics.txt create mode 100644 src/plugins/utils/statistic.py diff --git a/llm_statistics.txt b/llm_statistics.txt new file mode 100644 index 000000000..338158ef8 --- /dev/null +++ b/llm_statistics.txt @@ -0,0 +1,74 @@ +LLM请求统计报告 (生成时间: 2025-03-07 20:38:57) +================================================== + +所有时间统计 +====== +总请求数: 858 +总Token数: 285415 +总花费: ¥0.3309 + +按模型统计: +- Pro/Qwen/Qwen2-VL-7B-Instruct: 67次 (花费: ¥0.0272) +- Pro/Qwen/Qwen2.5-7B-Instruct: 646次 (花费: ¥0.0718) +- Pro/deepseek-ai/DeepSeek-V3: 9次 (花费: ¥0.0193) +- Qwen/QwQ-32B: 29次 (花费: ¥0.1246) +- Qwen/Qwen2.5-32B-Instruct: 55次 (花费: ¥0.0771) +- deepseek-ai/DeepSeek-R1-Distill-Qwen-32B: 3次 (花费: ¥0.0067) +- deepseek-ai/DeepSeek-V2.5: 49次 (花费: ¥0.0043) + +按请求类型统计: +- chat: 858次 (花费: ¥0.3309) + +最近7天统计 +====== +总请求数: 858 +总Token数: 285415 +总花费: ¥0.3309 + +按模型统计: +- Pro/Qwen/Qwen2-VL-7B-Instruct: 67次 (花费: ¥0.0272) +- Pro/Qwen/Qwen2.5-7B-Instruct: 646次 (花费: ¥0.0718) +- Pro/deepseek-ai/DeepSeek-V3: 9次 (花费: ¥0.0193) +- Qwen/QwQ-32B: 29次 (花费: ¥0.1246) +- Qwen/Qwen2.5-32B-Instruct: 55次 (花费: ¥0.0771) +- deepseek-ai/DeepSeek-R1-Distill-Qwen-32B: 3次 (花费: ¥0.0067) +- deepseek-ai/DeepSeek-V2.5: 49次 (花费: ¥0.0043) + +按请求类型统计: +- chat: 858次 (花费: ¥0.3309) + +最近24小时统计 +======== +总请求数: 858 +总Token数: 285415 +总花费: ¥0.3309 + +按模型统计: +- Pro/Qwen/Qwen2-VL-7B-Instruct: 67次 (花费: ¥0.0272) +- Pro/Qwen/Qwen2.5-7B-Instruct: 646次 (花费: ¥0.0718) +- Pro/deepseek-ai/DeepSeek-V3: 9次 (花费: ¥0.0193) +- Qwen/QwQ-32B: 29次 (花费: ¥0.1246) +- Qwen/Qwen2.5-32B-Instruct: 55次 (花费: ¥0.0771) +- deepseek-ai/DeepSeek-R1-Distill-Qwen-32B: 3次 (花费: ¥0.0067) +- deepseek-ai/DeepSeek-V2.5: 49次 (花费: ¥0.0043) + +按请求类型统计: +- chat: 858次 (花费: ¥0.3309) + +最近1小时统计 +======= +总请求数: 858 +总Token数: 285415 +总花费: ¥0.3309 + +按模型统计: +- Pro/Qwen/Qwen2-VL-7B-Instruct: 67次 (花费: ¥0.0272) +- Pro/Qwen/Qwen2.5-7B-Instruct: 646次 (花费: ¥0.0718) +- Pro/deepseek-ai/DeepSeek-V3: 9次 (花费: ¥0.0193) +- Qwen/QwQ-32B: 29次 (花费: ¥0.1246) +- Qwen/Qwen2.5-32B-Instruct: 55次 (花费: ¥0.0771) +- deepseek-ai/DeepSeek-R1-Distill-Qwen-32B: 3次 (花费: ¥0.0067) +- deepseek-ai/DeepSeek-V2.5: 49次 (花费: ¥0.0043) + +按请求类型统计: +- chat: 858次 (花费: ¥0.3309) \ No newline at end of file diff --git a/src/plugins/chat/__init__.py b/src/plugins/chat/__init__.py index 22f3059b5..f7da8ba96 100644 --- a/src/plugins/chat/__init__.py +++ b/src/plugins/chat/__init__.py @@ -14,6 +14,10 @@ from nonebot.rule import to_me from .bot import chat_bot from .emoji_manager import emoji_manager import time +from ..utils.statistic import LLMStatistics + +# 创建LLM统计实例 +llm_stats = LLMStatistics("llm_statistics.txt") # 添加标志变量 _message_manager_started = False @@ -57,6 +61,10 @@ scheduler = require("nonebot_plugin_apscheduler").scheduler @driver.on_startup async def start_background_tasks(): """启动后台任务""" + # 启动LLM统计 + llm_stats.start() + print("\033[1;32m[初始化]\033[0m LLM统计功能已启动") + # 只启动表情包管理任务 asyncio.create_task(emoji_manager.start_periodic_check(interval_MINS=global_config.EMOJI_CHECK_INTERVAL)) await bot_schedule.initialize() diff --git a/src/plugins/models/utils_model.py b/src/plugins/models/utils_model.py index 7bfc966f6..a471bd72d 100644 --- a/src/plugins/models/utils_model.py +++ b/src/plugins/models/utils_model.py @@ -8,6 +8,8 @@ from nonebot import get_driver from loguru import logger from ..chat.config import global_config from ..chat.utils_image import compress_base64_image_by_scale +from datetime import datetime +from ...common.database import Database driver = get_driver() config = driver.config @@ -24,6 +26,75 @@ class LLM_request: raise ValueError(f"配置错误:找不到对应的配置项 - {str(e)}") from e self.model_name = model["name"] self.params = kwargs + + self.pri_in = model.get("pri_in", 0) + self.pri_out = model.get("pri_out", 0) + + # 获取数据库实例 + self.db = Database.get_instance() + self._init_database() + + def _init_database(self): + """初始化数据库集合""" + try: + # 创建llm_usage集合的索引 + self.db.db.llm_usage.create_index([("timestamp", 1)]) + self.db.db.llm_usage.create_index([("model_name", 1)]) + self.db.db.llm_usage.create_index([("user_id", 1)]) + self.db.db.llm_usage.create_index([("request_type", 1)]) + except Exception as e: + logger.error(f"创建数据库索引失败: {e}") + + def _record_usage(self, prompt_tokens: int, completion_tokens: int, total_tokens: int, + user_id: str = "system", request_type: str = "chat", + endpoint: str = "/chat/completions"): + """记录模型使用情况到数据库 + Args: + prompt_tokens: 输入token数 + completion_tokens: 输出token数 + total_tokens: 总token数 + user_id: 用户ID,默认为system + request_type: 请求类型(chat/embedding/image等) + endpoint: API端点 + """ + try: + usage_data = { + "model_name": self.model_name, + "user_id": user_id, + "request_type": request_type, + "endpoint": endpoint, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "cost": self._calculate_cost(prompt_tokens, completion_tokens), + "status": "success", + "timestamp": datetime.now() + } + self.db.db.llm_usage.insert_one(usage_data) + logger.info( + f"Token使用情况 - 模型: {self.model_name}, " + f"用户: {user_id}, 类型: {request_type}, " + f"提示词: {prompt_tokens}, 完成: {completion_tokens}, " + f"总计: {total_tokens}" + ) + except Exception as e: + logger.error(f"记录token使用情况失败: {e}") + + def _calculate_cost(self, prompt_tokens: int, completion_tokens: int) -> float: + """计算API调用成本 + 使用模型的pri_in和pri_out价格计算输入和输出的成本 + + Args: + prompt_tokens: 输入token数量 + completion_tokens: 输出token数量 + + Returns: + float: 总成本(元) + """ + # 使用模型的pri_in和pri_out计算成本 + input_cost = (prompt_tokens / 1000000) * self.pri_in + output_cost = (completion_tokens / 1000000) * self.pri_out + return round(input_cost + output_cost, 6) async def _execute_request( self, @@ -33,6 +104,8 @@ class LLM_request: payload: dict = None, retry_policy: dict = None, response_handler: callable = None, + user_id: str = "system", + request_type: str = "chat" ): """统一请求执行入口 Args: @@ -40,10 +113,10 @@ class LLM_request: prompt: prompt文本 image_base64: 图片的base64编码 payload: 请求体数据 - is_async: 是否异步 retry_policy: 自定义重试策略 - (示例: {"max_retries":3, "base_wait":15, "retry_codes":[429,500]}) response_handler: 自定义响应处理器 + user_id: 用户ID + request_type: 请求类型 """ # 合并重试策略 default_retry = { @@ -105,7 +178,7 @@ class LLM_request: result = await response.json() # 使用自定义处理器或默认处理 - return response_handler(result) if response_handler else self._default_response_handler(result) + return response_handler(result) if response_handler else self._default_response_handler(result, user_id, request_type, endpoint) except Exception as e: if retry < policy["max_retries"] - 1: @@ -145,7 +218,8 @@ class LLM_request: **self.params } - def _default_response_handler(self, result: dict) -> Tuple: + def _default_response_handler(self, result: dict, user_id: str = "system", + request_type: str = "chat", endpoint: str = "/chat/completions") -> Tuple: """默认响应解析""" if "choices" in result and result["choices"]: message = result["choices"][0]["message"] @@ -157,6 +231,21 @@ class LLM_request: if not reasoning_content: reasoning_content = reasoning + # 记录token使用情况 + usage = result.get("usage", {}) + if usage: + prompt_tokens = usage.get("prompt_tokens", 0) + completion_tokens = usage.get("completion_tokens", 0) + total_tokens = usage.get("total_tokens", 0) + self._record_usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=total_tokens, + user_id=user_id, + request_type=request_type, + endpoint=endpoint + ) + return content, reasoning_content return "没有返回结果", "" @@ -244,3 +333,4 @@ class LLM_request: response_handler=embedding_handler ) return embedding + diff --git a/src/plugins/utils/statistic.py b/src/plugins/utils/statistic.py new file mode 100644 index 000000000..093ace539 --- /dev/null +++ b/src/plugins/utils/statistic.py @@ -0,0 +1,162 @@ +from typing import Dict, List, Any +import time +import threading +import json +from datetime import datetime, timedelta +from collections import defaultdict +from ...common.database import Database + +class LLMStatistics: + def __init__(self, output_file: str = "llm_statistics.txt"): + """初始化LLM统计类 + + Args: + output_file: 统计结果输出文件路径 + """ + self.db = Database.get_instance() + self.output_file = output_file + self.running = False + self.stats_thread = None + + def start(self): + """启动统计线程""" + if not self.running: + self.running = True + self.stats_thread = threading.Thread(target=self._stats_loop) + self.stats_thread.daemon = True + self.stats_thread.start() + + def stop(self): + """停止统计线程""" + self.running = False + if self.stats_thread: + self.stats_thread.join() + + def _collect_statistics_for_period(self, start_time: datetime) -> Dict[str, Any]: + """收集指定时间段的LLM请求统计数据 + + Args: + start_time: 统计开始时间 + """ + stats = { + "total_requests": 0, + "requests_by_type": defaultdict(int), + "requests_by_user": defaultdict(int), + "requests_by_model": defaultdict(int), + "average_tokens": 0, + "total_tokens": 0, + "total_cost": 0.0, + "costs_by_user": defaultdict(float), + "costs_by_type": defaultdict(float), + "costs_by_model": defaultdict(float) + } + + cursor = self.db.db.llm_usage.find({ + "timestamp": {"$gte": start_time} + }) + + total_requests = 0 + + for doc in cursor: + stats["total_requests"] += 1 + request_type = doc.get("request_type", "unknown") + user_id = str(doc.get("user_id", "unknown")) + model_name = doc.get("model_name", "unknown") + + stats["requests_by_type"][request_type] += 1 + stats["requests_by_user"][user_id] += 1 + stats["requests_by_model"][model_name] += 1 + + prompt_tokens = doc.get("prompt_tokens", 0) + completion_tokens = doc.get("completion_tokens", 0) + stats["total_tokens"] += prompt_tokens + completion_tokens + + cost = doc.get("cost", 0.0) + stats["total_cost"] += cost + stats["costs_by_user"][user_id] += cost + stats["costs_by_type"][request_type] += cost + stats["costs_by_model"][model_name] += cost + + total_requests += 1 + + if total_requests > 0: + stats["average_tokens"] = stats["total_tokens"] / total_requests + + return stats + + def _collect_all_statistics(self) -> Dict[str, Dict[str, Any]]: + """收集所有时间范围的统计数据""" + now = datetime.now() + + return { + "all_time": self._collect_statistics_for_period(datetime.min), + "last_7_days": self._collect_statistics_for_period(now - timedelta(days=7)), + "last_24_hours": self._collect_statistics_for_period(now - timedelta(days=1)), + "last_hour": self._collect_statistics_for_period(now - timedelta(hours=1)) + } + + def _format_stats_section(self, stats: Dict[str, Any], title: str) -> str: + """格式化统计部分的输出 + + Args: + stats: 统计数据 + title: 部分标题 + """ + output = [] + output.append(f"\n{title}") + output.append("=" * len(title)) + + output.append(f"总请求数: {stats['total_requests']}") + if stats['total_requests'] > 0: + output.append(f"总Token数: {stats['total_tokens']}") + output.append(f"总花费: ¥{stats['total_cost']:.4f}") + + output.append("\n按模型统计:") + for model_name, count in sorted(stats["requests_by_model"].items()): + cost = stats["costs_by_model"][model_name] + output.append(f"- {model_name}: {count}次 (花费: ¥{cost:.4f})") + + output.append("\n按请求类型统计:") + for req_type, count in sorted(stats["requests_by_type"].items()): + cost = stats["costs_by_type"][req_type] + output.append(f"- {req_type}: {count}次 (花费: ¥{cost:.4f})") + + return "\n".join(output) + + def _save_statistics(self, all_stats: Dict[str, Dict[str, Any]]): + """将统计结果保存到文件""" + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + output = [] + output.append(f"LLM请求统计报告 (生成时间: {current_time})") + output.append("=" * 50) + + # 添加各个时间段的统计 + sections = [ + ("所有时间统计", "all_time"), + ("最近7天统计", "last_7_days"), + ("最近24小时统计", "last_24_hours"), + ("最近1小时统计", "last_hour") + ] + + for title, key in sections: + output.append(self._format_stats_section(all_stats[key], title)) + + # 写入文件 + with open(self.output_file, "w", encoding="utf-8") as f: + f.write("\n".join(output)) + + def _stats_loop(self): + """统计循环,每1分钟运行一次""" + while self.running: + try: + all_stats = self._collect_all_statistics() + self._save_statistics(all_stats) + except Exception as e: + print(f"\033[1;31m[错误]\033[0m 统计数据处理失败: {e}") + + # 等待1分钟 + for _ in range(60): + if not self.running: + break + time.sleep(1)