From 31d84340103d51c651698e020de936849dc257c0 Mon Sep 17 00:00:00 2001 From: Yuzhiran Dev Date: Mon, 1 Jun 2026 13:23:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20call=5Fllm=20429=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=20fallback=20+=20=E7=A7=BB=E9=99=A4=20compliance=20?= =?UTF-8?q?=E7=A1=AC=E7=BC=96=E7=A0=81=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nvidia_client.py 新增 _get_provider_fallback_list() 从 DB 读提供商顺序 - call_llm 循环尝试提供商列表,HTTP 429 自动跳过到下一个 - 移除 compliance_optimizer.py 中硬编码 provider="opencode-go" - 新增 logging 模块引用 --- platform/backend/app/core/nvidia_client.py | 135 ++++++++++++--------- scripts/compliance_optimizer.py | 2 +- 2 files changed, 77 insertions(+), 60 deletions(-) diff --git a/platform/backend/app/core/nvidia_client.py b/platform/backend/app/core/nvidia_client.py index aa50666..f184540 100644 --- a/platform/backend/app/core/nvidia_client.py +++ b/platform/backend/app/core/nvidia_client.py @@ -6,9 +6,12 @@ Unified LLM Client import os import requests import json +import logging from typing import Optional, Dict, Any, List from pathlib import Path +logger = logging.getLogger(__name__) + from dotenv import load_dotenv env_path = Path(__file__).resolve().parents[2] / ".env" @@ -88,6 +91,20 @@ def _get_db_defaults(provider: Optional[str] = None) -> dict: pass return {"temperature": 0.20, "max_tokens": 2048, "system_prompt": "你是一个专业的内容创作助手。"} +def _get_provider_fallback_list() -> List[str]: + """返回提供商尝试顺序列表(活跃优先,其余按配置顺序)""" + try: + from ..database import SessionLocal + from ..models import LLMConfig + db = SessionLocal() + configs = db.query(LLMConfig).order_by(LLMConfig.is_active.desc(), LLMConfig.id).all() + db.close() + providers = [c.provider for c in configs if c.provider] + return providers + except Exception: + pass + return ["opencode-go", "nvidia"] + def call_llm( prompt: str, model: Optional[str] = None, @@ -105,66 +122,66 @@ def call_llm( temperature = temperature if temperature is not None else defaults["temperature"] max_tokens = max_tokens if max_tokens is not None else defaults["max_tokens"] system_prompt = system_prompt if system_prompt is not None else defaults["system_prompt"] - cfg = _get_provider_config(provider) - endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions" - headers = { - "Authorization": f"Bearer {cfg['api_key']}", - "Content-Type": "application/json" - } - payload = { - "model": model or cfg["model"], - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": prompt} - ], - "temperature": temperature, - "max_tokens": max_tokens, - "top_p": top_p, - "frequency_penalty": frequency_penalty, - "presence_penalty": presence_penalty, - "stream": stream, - } - if additional_params: - payload.update(additional_params) - try: - resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream) - if resp.status_code != 200: - raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}") - if stream: - content_parts = [] - reasoning_parts = [] - for line in resp.iter_lines(): - if not line: continue - if line.startswith(b'data: '): - data = line[6:] - if data == b'[DONE]': break - try: - chunk = json.loads(data) - delta = chunk['choices'][0]['delta'] - if delta.get('content'): - content_parts.append(delta['content']) - if delta.get('reasoning_content'): - reasoning_parts.append(delta['reasoning_content']) - except Exception: continue - if content_parts: - return "".join(content_parts) - return "".join(reasoning_parts) - else: - data = resp.json() - msg = data["choices"][0]["message"] - # 优先取 content(推理模型如 deepseek 的最终答案在此字段) - # 如果 content 为空但 reasoning_content 有值(说明 max_tokens 不够没输出完),取其末尾作为近似答案 - content = msg.get('content') or '' - if not content.strip(): - rc = msg.get('reasoning_content', '') - if rc: - # 取 reasoning 末尾最可能包含答案的句子 - parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()] - content = parts[-1] if parts else rc - return content.strip() if content else '' - except requests.RequestException as e: - raise LLMError(f"Request failed: {e}") + providers_to_try = [provider] if provider else _get_provider_fallback_list() + last_error = None + for p in providers_to_try: + try: + cfg = _get_provider_config(p) + endpoint = f"{cfg['base_url'].rstrip('/')}/chat/completions" + headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"} + payload = { + "model": model or cfg["model"], + "messages": [{"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}], + "temperature": temperature, "max_tokens": max_tokens, "top_p": top_p, + "frequency_penalty": frequency_penalty, "presence_penalty": presence_penalty, + "stream": stream, + } + if additional_params: + payload.update(additional_params) + + resp = requests.post(endpoint, json=payload, headers=headers, timeout=120, stream=stream) + if resp.status_code == 429: + logger.warning(f"[LLM] {p} 429 配额超限 → 尝试下一个") + last_error = LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}") + continue + if resp.status_code != 200: + raise LLMError(f"HTTP {resp.status_code}: {resp.text[:200]}") + + if stream: + content_parts, reasoning_parts = [], [] + for line in resp.iter_lines(): + if not line: continue + if line.startswith(b'data: '): + data = line[6:] + if data == b'[DONE]': break + try: + chunk = json.loads(data) + delta = chunk['choices'][0]['delta'] + if delta.get('content'): content_parts.append(delta['content']) + if delta.get('reasoning_content'): reasoning_parts.append(delta['reasoning_content']) + except Exception: continue + return "".join(content_parts) or "".join(reasoning_parts) + else: + data = resp.json() + msg = data["choices"][0]["message"] + content = msg.get('content') or '' + if not content.strip(): + rc = msg.get('reasoning_content', '') + if rc: + parts = [p.strip() for p in rc.replace('\n', '。').split('。') if p.strip()] + content = parts[-1] if parts else rc + return content.strip() or '' + except LLMError as e: + if '429' in str(e): + logger.warning(f"[LLM] {p} 429 配额超限 → 尝试下一个") + last_error = e; continue + raise + except Exception as e: + logger.warning(f"[LLM] {p} 调用异常: {e} → 尝试下一个") + last_error = e; continue + + raise last_error or LLMError("所有 LLM 提供商均不可用") def expand_content_with_llm( topic: dict, diff --git a/scripts/compliance_optimizer.py b/scripts/compliance_optimizer.py index 54854c8..551be62 100644 --- a/scripts/compliance_optimizer.py +++ b/scripts/compliance_optimizer.py @@ -182,7 +182,7 @@ def polish_with_llm(html: str, platform: str, remaining_issues: Optional[List[Di prompt = get_prompt("compliance_fix", issues_desc=issues_desc, html=html) else: prompt = get_prompt("compliance_polish", html=html) - polished = call_llm(prompt, provider="opencode-go", temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt) + polished = call_llm(prompt, provider=None, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt) polished = clean_html_content(polished) polished = strip_ai_preface(polished) polished = strip_thinking_html(polished)