fix(llm): call_llm 429 自动 fallback + 移除 compliance 硬编码 provider

- nvidia_client.py 新增 _get_provider_fallback_list() 从 DB 读提供商顺序
- call_llm 循环尝试提供商列表,HTTP 429 自动跳过到下一个
- 移除 compliance_optimizer.py 中硬编码 provider="opencode-go"
- 新增 logging 模块引用
This commit is contained in:
Yuzhiran Dev
2026-06-01 13:23:35 +08:00
parent 09c467e56d
commit 31d8434010
2 changed files with 77 additions and 60 deletions
+76 -59
View File
@@ -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,
+1 -1
View File
@@ -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)