From c23020038e61302afb1369d629f9f8e33a893ed5 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 11 Mar 2026 03:49:26 +0800 Subject: [PATCH] feat: add TTL cache and stale-cache fallback to Groq AI analyzer --- src/analysis/ai_analyzer.py | 62 +++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/analysis/ai_analyzer.py b/src/analysis/ai_analyzer.py index de5d8028..ec57e9db 100644 --- a/src/analysis/ai_analyzer.py +++ b/src/analysis/ai_analyzer.py @@ -1,4 +1,6 @@ +import hashlib import os +import threading import time import requests from loguru import logger @@ -9,17 +11,57 @@ MODELS = [ "llama-3.1-8b-instant", ] +# ── 本地缓存 ────────────────────────────────────────────── +# key: sha1(city_name + weather_insights[:200]) +# value: {"result": str, "t": float} +_ai_cache: dict = {} +_ai_cache_lock = threading.Lock() + +# 全局 429 冷却期:触发限流后暂停一段时间内的所有 Groq 请求 +_rate_limit_until: float = 0.0 +_rate_limit_lock = threading.Lock() + def get_ai_analysis(weather_insights: str, city_name: str, temp_symbol: str) -> str: """ 通过 Groq API (LLaMA 3.3 70B) 对天气态势进行极速交易分析 - 内置自动重试 + 模型降级机制 + 内置自动重试 + 模型降级机制 + 本地 TTL 缓存 + 全局 429 冷却期 """ api_key = os.getenv("GROQ_API_KEY") if not api_key: logger.warning("GROQ_API_KEY 未配置,跳过 AI 分析") return "" + # ── 缓存配置 ───────────────────────────────────────── + cache_ttl = int(os.getenv("GROQ_CACHE_TTL_SEC", "1200")) # 默认 20 分钟 + rl_cooldown = int(os.getenv("GROQ_RATE_LIMIT_COOLDOWN_SEC", "600")) # 默认 10 分钟 + + # 缓存 key:城市名 + 天气摘要前 200 字符(同城市、同数据不重复打 API) + cache_raw = f"{city_name}:{weather_insights[:200]}" + cache_key = hashlib.sha1(cache_raw.encode("utf-8")).hexdigest()[:16] + + now = time.time() + + # ── 命中缓存则直接返回 ──────────────────────────────── + with _ai_cache_lock: + cached = _ai_cache.get(cache_key) + if cached and now - cached["t"] < cache_ttl: + logger.debug(f"Groq AI cache hit city={city_name} age={int(now - cached['t'])}s") + return cached["result"] + + # ── 全局 429 冷却期检查 ─────────────────────────────── + with _rate_limit_lock: + global _rate_limit_until + if now < _rate_limit_until: + remaining = int(_rate_limit_until - now) + logger.warning(f"Groq 冷却期中,还需等待 {remaining}s,跳过本次请求") + # 如果有旧缓存,返回旧结果(过期但总比没有好) + with _ai_cache_lock: + stale = _ai_cache.get(cache_key) + if stale: + return stale["result"] + "\n(AI 分析来自缓存,数据可能略旧)" + return "\n⚠️ Groq AI 限流中,请稍后再试" + url = "https://api.groq.com/openai/v1/chat/completions" headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} @@ -103,6 +145,9 @@ P4 **预报背景**(最低优先级): if model != MODELS[0]: logger.info(f"Groq 降级到备用模型 {model} 成功") + # ── 写入缓存 ───────────────────────────────── + with _ai_cache_lock: + _ai_cache[cache_key] = {"result": content, "t": time.time()} return content except requests.exceptions.HTTPError as e: @@ -115,6 +160,13 @@ P4 **预报背景**(最低优先级): logger.warning( f"Groq {model} 失败 (HTTP {status}): {error_body}. 尝试下一个..." ) + if status == 429: + # 触发限流:设置全局冷却期,后续请求不再尝试 + with _rate_limit_lock: + global _rate_limit_until + _rate_limit_until = time.time() + rl_cooldown + logger.warning(f"Groq 触发限流,设置 {rl_cooldown}s 全局冷却期") + break # 不再尝试其他模型,直接走 stale cache 逻辑 if status in (500, 502, 503) and attempt == 0: time.sleep(1.5) continue @@ -125,4 +177,10 @@ P4 **预报背景**(最低优先级): break logger.error("所有 Groq 模型均不可用") - return "\n⚠️ Groq AI 暂时不可用,请稍后再试" + # ── 有旧缓存则返回旧结果 ────────────────────────────── + with _ai_cache_lock: + stale = _ai_cache.get(cache_key) + if stale: + logger.info(f"Groq 不可用,返回旧缓存结果 city={city_name} age={int(time.time()-stale['t'])}s") + return stale["result"] + "\n(⚠️ AI 分析来自上次缓存)" + return ""