| |
| """ |
| 后台任务调度器模块。 |
| 使用 APScheduler 设置和管理周期性任务,例如: |
| - 清理旧日志文件。 |
| - 每日重置 API Key 的使用计数 (RPD, TPD)。 |
| - 定期生成并记录使用情况报告。 |
| - 定期刷新 Key 分数缓存。 |
| - 定期清理内存数据库中的旧上下文记录 (如果使用内存数据库)。 |
| """ |
| import logging |
| import asyncio |
| from typing import TYPE_CHECKING, List, Dict, Any |
| from apscheduler.schedulers.asyncio import AsyncIOScheduler |
| from apscheduler.executors.asyncio import AsyncIOExecutor |
|
|
| |
| from app import config |
| from app.config import ( |
| USAGE_REPORT_INTERVAL_MINUTES, |
| MEMORY_CONTEXT_CLEANUP_INTERVAL_SECONDS, |
| CACHE_REFRESH_INTERVAL_SECONDS, |
| REPORT_LOG_LEVEL_INT |
| ) |
| |
| from app.core.context.store import ContextStore |
| |
| from app.core.database.utils import IS_MEMORY_DB |
| |
| from app.handlers.log_config import cleanup_old_logs |
| |
| from app.core.reporting.daily_reset import reset_daily_counts |
| from app.core.reporting.reporter import report_usage |
| |
| from app.core.keys.checker import _refresh_all_key_scores |
|
|
| |
| if TYPE_CHECKING: |
| from app.core.keys.manager import APIKeyManager |
|
|
| logger = logging.getLogger('my_logger') |
|
|
| |
| COLOR_TITLE = "\033[1;94m" |
| COLOR_SEPARATOR = "\033[0;90m" |
| COLOR_SECTION_HEADER = "\033[1;96m" |
| COLOR_POSITIVE = "\033[1;92m" |
| COLOR_WARNING = "\033[1;93m" |
| COLOR_ERROR = "\033[1;91m" |
| COLOR_INFO = "\033[0;37m" |
| COLOR_RESET = "\033[0m" |
| SEPARATOR_LINE = f"{COLOR_SEPARATOR}{'=' * 60}{COLOR_RESET}" |
|
|
| |
| |
| scheduler = AsyncIOScheduler( |
| executors={ |
| 'default': {'type': 'threadpool', 'max_workers': 20}, |
| 'asyncio': AsyncIOExecutor() |
| } |
| ) |
|
|
| |
| |
|
|
| |
| |
|
|
| def _format_key_usage_summary(models_summary: List[Dict[str, Any]]) -> List[str]: |
| """(内部辅助函数) 格式化报告中的 Key 使用情况聚合部分。""" |
| lines = [f"\n{SEPARATOR_LINE}\n{COLOR_SECTION_HEADER} Key 使用情况聚合 {COLOR_RESET}\n{SEPARATOR_LINE}"] |
| if not models_summary: |
| lines.append(f" {COLOR_WARNING}暂无 Key 使用数据。{COLOR_RESET}") |
| else: |
| |
| for model_summary in models_summary: |
| |
| lines.append(f" {COLOR_POSITIVE}模型: {model_summary.get('model_name', 'N/A')} (Key 数量: {model_summary.get('key_count', 0)}){COLOR_RESET}") |
| lines.append(f" 今日总 RPD: {model_summary.get('total_rpd_today', 0):,} | 今日总 TPD 输入: {model_summary.get('total_tpd_input_today', 0):,}") |
| lines.append(" 状态分布:") |
| status_distribution = model_summary.get("status_distribution", []) |
| if not status_distribution: |
| lines.append(f" {COLOR_WARNING}无状态数据。{COLOR_RESET}") |
| else: |
| |
| for status_info in status_distribution: |
| lines.append(f" - 数量: {status_info.get('count', 0)}, 状态: {status_info.get('status', 'N/A')}") |
| return lines |
|
|
| def _format_overall_stats(report_data: Dict[str, Any]) -> List[str]: |
| """(内部辅助函数) 格式化报告中的总体统计、预测、缓存使用情况和 Key 筛选跟踪部分。""" |
| |
| overall_stats = report_data.get("overall_stats", {}) |
| cache_stats = report_data.get("cache_stats", {}) |
| key_selection_stats = report_data.get("key_selection_stats", {}) |
|
|
| lines = [f"\n{SEPARATOR_LINE}\n{COLOR_SECTION_HEADER} 总体统计与预测 {COLOR_RESET}\n{SEPARATOR_LINE}"] |
| |
| lines.append(f" 活跃 Key 数量: {overall_stats.get('active_keys_count', 0)}") |
| lines.append(f" 启动时无效 Key 数量: {overall_stats.get('invalid_keys_at_startup', 0)}") |
| |
| lines.append(" RPD 容量估算:") |
| rpd_capacity_estimations = overall_stats.get("rpd_capacity_estimation", []) |
| if not rpd_capacity_estimations: |
| lines.append(f" {COLOR_WARNING}无 RPD 容量估算数据。{COLOR_RESET}") |
| else: |
| for estimation in rpd_capacity_estimations: |
| if "capacity" in estimation: |
| lines.append(f" - 基于模型 {estimation.get('based_on', 'N/A')}: 限制 {estimation.get('limit', 'N/A')}, 容量 {estimation.get('capacity', 0):,}") |
| else: |
| lines.append(f" - 基于模型 {estimation.get('based_on', 'N/A')}: {estimation.get('message', 'N/A')}") |
| |
| lines.append(" TPD 输入容量估算:") |
| tpd_input_capacity_estimations = overall_stats.get("tpd_input_capacity_estimation", []) |
| if not tpd_input_capacity_estimations: |
| lines.append(f" {COLOR_WARNING}无 TPD 输入容量估算数据。{COLOR_RESET}") |
| else: |
| for estimation in tpd_input_capacity_estimations: |
| if "capacity" in estimation: |
| lines.append(f" - 基于模型 {estimation.get('based_on', 'N/A')}: 限制 {estimation.get('limit', 'N/A')}, 容量 {estimation.get('capacity', 0):,}") |
| else: |
| lines.append(f" - 基于模型 {estimation.get('based_on', 'N/A')}: {estimation.get('message', 'N/A')}") |
| |
| lines.append(f" 今日已用 RPD: {overall_stats.get('current_rpd_today', 0):,}") |
| lines.append(f" 今日已用 TPD 输入: {overall_stats.get('current_tpd_input_today', 0):,}") |
| lines.append(f" 预估今日总 RPD: {overall_stats.get('estimated_rpd_today', 'N/A')} (基于已过去 {overall_stats.get('estimation_fraction_of_day', 0):.1%} 时间)") |
| lines.append(f" 预估今日总 TPD 输入: {overall_stats.get('estimated_tpd_input_today', 'N/A')} (基于已过去 {overall_stats.get('estimation_fraction_of_day', 0):.1%} 时间)") |
| |
| historical_average_usage = overall_stats.get("historical_average_usage", {}) |
| lines.append(f" 历史平均每日 RPD ({historical_average_usage.get('days_included', 0)} 天): {historical_average_usage.get('avg_daily_rpd', 'N/A')}") |
|
|
| |
| lines.append(f"\n{COLOR_SECTION_HEADER} 缓存使用情况 {COLOR_RESET}") |
| lines.append(f" 缓存命中次数: {cache_stats.get('hit_count', 0):,}") |
| lines.append(f" 缓存未命中次数: {cache_stats.get('miss_count', 0):,}") |
| lines.append(f" 节省的总 Token 数: {cache_stats.get('total_tokens_saved', 0):,}") |
| lines.append(f" 缓存命中率: {cache_stats.get('hit_rate', 'N/A')}") |
|
|
| |
| lines.append(f"\n{COLOR_SECTION_HEADER} Key 筛选原因统计 {COLOR_RESET}") |
| total_by_reason = key_selection_stats.get('total_by_reason', {}) |
| if not total_by_reason: |
| lines.append(f" {COLOR_WARNING}无 Key 筛选原因数据。{COLOR_RESET}") |
| else: |
| lines.append(" 总计 (按原因):") |
| |
| for reason, count in sorted(total_by_reason.items(), key=lambda item: item[1], reverse=True): |
| lines.append(f" - {reason}: {count:,} 次") |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| return lines |
|
|
| def _format_key_suggestion(suggestion: str) -> List[str]: |
| """(内部辅助函数) 格式化报告中的 Key 数量建议部分,根据建议内容添加颜色。""" |
| lines = [f"\n{SEPARATOR_LINE}\n{COLOR_SECTION_HEADER} Key 数量建议 {COLOR_RESET}\n{SEPARATOR_LINE}"] |
| |
| if "强烈建议" in suggestion or "接近或达到上限" in suggestion: |
| lines.append(f" {COLOR_ERROR}{suggestion}{COLOR_RESET}") |
| elif "建议增加" in suggestion or "用量较高" in suggestion: |
| lines.append(f" {COLOR_WARNING}{suggestion}{COLOR_RESET}") |
| elif "可以考虑减少" in suggestion: |
| lines.append(f" {COLOR_INFO}{suggestion}{COLOR_RESET}") |
| else: |
| lines.append(f" {COLOR_POSITIVE}{suggestion}{COLOR_RESET}") |
| return lines |
|
|
| def _format_top_ips(top_ips: Dict[str, Any]) -> List[str]: |
| """(内部辅助函数) 格式化报告中的 Top 5 IP 地址统计部分。""" |
| lines = [f"\n{SEPARATOR_LINE}\n{COLOR_SECTION_HEADER} Top 5 IP 地址统计 {COLOR_RESET}\n{SEPARATOR_LINE}"] |
|
|
| |
| lines.append(f"\n {COLOR_INFO}Top 请求 IP:{COLOR_RESET}") |
| |
| for period in ["today", "week", "month"]: |
| lines.append(f" {period.capitalize()}:") |
| ip_list = top_ips.get("requests", {}).get(period, []) |
| if not ip_list: |
| lines.append(f" {COLOR_WARNING}无数据。{COLOR_RESET}") |
| else: |
| |
| for ip_info in ip_list: |
| lines.append(f" - {ip_info.get('ip', 'N/A')}: {ip_info.get('count', 0):,} 次请求") |
|
|
| |
| lines.append(f"\n {COLOR_INFO}Top Token IP (输入):{COLOR_RESET}") |
| |
| for period in ["today", "week", "month"]: |
| lines.append(f" {period.capitalize()}:") |
| ip_list = top_ips.get("tokens", {}).get(period, []) |
| if not ip_list: |
| lines.append(f" {COLOR_WARNING}无数据。{COLOR_RESET}") |
| else: |
| |
| for ip_info in ip_list: |
| lines.append(f" - {ip_info.get('ip', 'N/A')}: {ip_info.get('tokens', 0):,} Tokens") |
| return lines |
|
|
|
|
| |
| def log_usage_report(key_manager: 'APIKeyManager'): |
| """ |
| (同步函数) 生成使用情况报告并将其记录到日志。 |
| 此函数由 APScheduler 定期调用。 |
| |
| Args: |
| key_manager (APIKeyManager): APIKeyManager 的实例。 |
| """ |
| logger.info("尝试执行 log_usage_report 任务...") |
| logger.info("正在记录周期性使用情况报告到终端...") |
| try: |
| |
| |
| report_data = report_usage(key_manager) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| report_lines = [f"{COLOR_TITLE}--- API 使用情况报告 ({report_data.get('timestamp', 'N/A')}) ---{COLOR_RESET}"] |
| report_lines.extend(_format_key_usage_summary(report_data.get("key_usage_summary", {}).get("models", []))) |
| report_lines.extend(_format_overall_stats(report_data)) |
| report_lines.extend(_format_key_suggestion(report_data.get("key_suggestion", "无建议。"))) |
| report_lines.extend(_format_top_ips(report_data.get("top_ips", {}))) |
|
|
| |
| full_report_string = "\n".join(report_lines) |
| |
| logger.log(REPORT_LOG_LEVEL_INT, full_report_string) |
|
|
| logger.info("周期性使用情况报告已记录到终端。") |
|
|
| except Exception as e: |
| logger.error(f"记录周期性使用情况报告到终端失败: {e}", exc_info=True) |
|
|
|
|
| |
|
|
| def _add_memory_context_cleanup_job(context_store_manager: ContextStore): |
| """ |
| (内部辅助函数) 向调度器添加用于清理内存上下文的定时任务。 |
| 仅在 CONTEXT_STORAGE_MODE 为 'memory' 时添加。 |
| |
| Args: |
| context_store_manager (ContextStore): ContextStore 的实例。 |
| """ |
| if config.CONTEXT_STORAGE_MODE == "memory": |
| cleanup_interval = config.MEMORY_CONTEXT_CLEANUP_INTERVAL_SECONDS |
| if cleanup_interval <= 0: |
| logger.info("内存上下文清理任务的间隔配置为非正数,任务将不被添加。") |
| return |
|
|
| scheduler.add_job( |
| context_store_manager.perform_memory_cleanup, |
| 'interval', |
| seconds=cleanup_interval, |
| id='memory_context_cleanup', |
| name='内存上下文清理 (ContextStore)', |
| replace_existing=True, |
| executor='asyncio' |
| ) |
| logger.info(f"内存上下文清理任务 (ContextStore) 已添加,运行间隔: {cleanup_interval} 秒。") |
| else: |
| logger.info("非内存上下文存储模式,跳过添加内存上下文清理任务 (ContextStore)。") |
|
|
|
|
| def setup_scheduler(key_manager: 'APIKeyManager', context_store_manager: ContextStore): |
| """ |
| 设置 APScheduler,添加所有需要的后台定时任务。 |
| |
| Args: |
| key_manager (APIKeyManager): APIKeyManager 的实例。 |
| context_store_manager (ContextStore): ContextStore 的实例。 |
| """ |
|
|
| logger.info("正在设置后台任务调度器...") |
| |
| |
| |
| scheduler.add_job(cleanup_old_logs, 'cron', hour=3, minute=0, args=[30], id='log_cleanup', name='日志清理', replace_existing=True) |
|
|
| |
| |
| |
| scheduler.add_job(reset_daily_counts, 'cron', hour=0, minute=0, timezone='America/Los_Angeles', id='daily_reset', name='每日限制重置', replace_existing=True, executor='asyncio') |
|
|
| |
| |
| scheduler.add_job(log_usage_report, 'interval', minutes=USAGE_REPORT_INTERVAL_MINUTES, args=[key_manager], id='usage_report', name='使用报告', replace_existing=True) |
|
|
| |
| |
| |
| scheduler.add_job(_refresh_all_key_scores, 'interval', seconds=CACHE_REFRESH_INTERVAL_SECONDS, args=[key_manager], id='key_score_update', name='Key 得分更新', replace_existing=True, executor='asyncio') |
|
|
| |
| _add_memory_context_cleanup_job(context_store_manager) |
|
|
| |
| job_names = [job.name for job in scheduler.get_jobs()] |
| logger.info(f"后台任务已调度: {', '.join(job_names)}") |
|
|
| def start_scheduler(): |
| """ |
| 启动后台任务调度器。 |
| 如果调度器已在运行,则不执行任何操作。 |
| """ |
| try: |
| if not scheduler.running: |
| scheduler.start() |
| logger.info("后台调度器已启动。") |
| else: |
| logger.info("后台调度器已在运行。") |
| except Exception as e: |
| logger.error(f"启动后台调度器失败: {e}", exc_info=True) |
|
|
| def shutdown_scheduler(): |
| """ |
| 关闭后台任务调度器。 |
| 通常在应用关闭时调用,以确保任务被优雅地停止。 |
| """ |
| if scheduler.running: |
| logger.info("正在关闭后台调度器...") |
| scheduler.shutdown() |
| logger.info("后台调度器已关闭。") |
|
|