Files
gaokao-volunteer-system/data/llm/client.py
Hermes Agent 11fbb591c3
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(llm): 多模型 fallback 支持
LLMClient 改为供应商链模式:
- 主供应商 + GAOKAO_LLM_FALLBACK_MODELS/PROVIDERS/API_KEYS/BASE_URLS
- 按顺序尝试, 第一个成功即返回
- 全部失败时抛出聚合错误(含供应商数量和最后错误)

配置层:
- Settings 新增 4 个 fallback 字段
- load_settings() 读取对应环境变量

新增测试(4 个):
- test_fallback_config_parsed: 验证 fallback 链解析正确
- test_fallback_no_config: 无 fallback 时只有主供应商
- test_fallback_falls_through_to_second_provider: 主失败自动切换
- test_fallback_all_fail_raises: 全部失败抛聚合错误

部署示例:
.env.docker.example / .env.payment.example 补充注释掉的 fallback 模板

验证: data/llm/tests 16 passed, admin/tests 40 passed
2026-06-28 13:34:17 +08:00

250 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LLM 客户端:统一 OpenAI-compatible 接口调用。"""
from __future__ import annotations
import json
import urllib.request
import urllib.error
from dataclasses import dataclass, field
from typing import Any
from admin.config import Settings
class LLMError(Exception):
"""LLM 调用失败。"""
@dataclass(frozen=True)
class LLMResponse:
"""LLM 响应。"""
content: str
usage: dict[str, int] = field(default_factory=dict)
model: str = ""
raw: dict[str, Any] = field(default_factory=dict)
class LLMClient:
"""统一 LLM 客户端,通过 OpenAI-compatible API 调用。
支持:
- openai: https://api.openai.com/v1
- dashscope: https://dashscope.aliyuncs.com/compatible-mode/v1
- anthropic: 通过兼容层或直接 API
"""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._timeout = settings.llm_timeout_seconds
self._max_tokens = settings.llm_max_tokens
# 构建供应商链:主供应商 + fallback 供应商列表
self._providers: list[dict[str, str]] = []
if settings.llm_provider != "none" and settings.llm_api_key:
self._providers.append({
"provider": settings.llm_provider,
"api_key": settings.llm_api_key,
"base_url": settings.llm_base_url.rstrip("/"),
"model": settings.llm_model,
})
# 解析 fallback 配置
fb_models = [
s.strip()
for s in (settings.llm_fallback_models or "").split(",")
if s.strip()
]
fb_providers = [
s.strip()
for s in (settings.llm_fallback_providers or "").split(",")
if s.strip()
]
fb_keys = [
s.strip()
for s in (settings.llm_fallback_api_keys or "").split(",")
if s.strip()
]
fb_urls = [
s.strip()
for s in (settings.llm_fallback_base_urls or "").split(",")
if s.strip()
]
for i, model in enumerate(fb_models):
provider = (
fb_providers[i]
if i < len(fb_providers)
else (self._providers[0]["provider"] if self._providers else "openai")
)
api_key = (
fb_keys[i]
if i < len(fb_keys)
else (self._providers[0]["api_key"] if self._providers else "")
)
base_url = (
fb_urls[i].rstrip("/")
if i < len(fb_urls)
else (
self._providers[0]["base_url"]
if self._providers
else "https://api.openai.com/v1"
)
)
if api_key:
self._providers.append({
"provider": provider,
"api_key": api_key,
"base_url": base_url,
"model": model,
})
# 兼容旧接口
self._provider = self._providers[0]["provider"] if self._providers else "none"
self._api_key = self._providers[0]["api_key"] if self._providers else ""
self._base_url = self._providers[0]["base_url"] if self._providers else ""
self._model = self._providers[0]["model"] if self._providers else ""
@property
def is_configured(self) -> bool:
"""LLM 是否已配置可用。"""
return len(self._providers) > 0
@property
def provider_count(self) -> int:
"""已配置的供应商数量(含主+fallback"""
return len(self._providers)
def chat(
self,
messages: list[dict[str, str]],
*,
temperature: float = 0.7,
max_tokens: int | None = None,
) -> LLMResponse:
"""调用 chat completions API支持多供应商 fallback。
按供应商链顺序依次尝试,第一个成功即返回。
全部失败时抛出最后一个错误。
Args:
messages: OpenAI 格式的消息列表。
temperature: 采样温度。
max_tokens: 最大生成 token 数,默认使用 Settings 配置。
Returns:
LLMResponse。
Raises:
LLMError: 全部供应商都失败。
"""
if not self.is_configured:
raise LLMError(
f"LLM 未配置 (provider={self._provider})。"
"请设置 GAOKAO_LLM_PROVIDER 和 GAOKAO_LLM_API_KEY。"
)
last_error: LLMError | None = None
for idx, prov in enumerate(self._providers):
try:
return self._call_single_provider(
provider=prov,
messages=messages,
temperature=temperature,
max_tokens=max_tokens or self._max_tokens,
)
except LLMError as e:
last_error = e
prov_name = prov["provider"]
model_name = prov["model"]
# 如果还有下一个供应商,继续尝试
if idx < len(self._providers) - 1:
continue
# 最后一个也失败了
raise LLMError(
f"全部 {len(self._providers)} 个 LLM 供应商均失败。"
f"最后错误 ({prov_name}/{model_name}): {e}"
) from e
# 理论上不会到达这里
raise last_error or LLMError("未知 LLM 错误")
def _call_single_provider(
self,
*,
provider: dict[str, str],
messages: list[dict[str, str]],
temperature: float,
max_tokens: int,
) -> LLMResponse:
"""调用单个供应商的 API。"""
payload: dict[str, Any] = {
"model": provider["model"],
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
url = f"{provider['base_url']}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {provider['api_key']}",
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
body = resp.read().decode("utf-8")
result = json.loads(body)
except urllib.error.HTTPError as e:
raw_body = e.read()
error_body = (
raw_body.decode("utf-8", "replace")
if isinstance(raw_body, bytes)
else str(raw_body)
)
raise LLMError(f"LLM API HTTP {e.code}: {error_body[:500]}") from e
except urllib.error.URLError as e:
raise LLMError(f"LLM API 连接失败: {e}") from e
except json.JSONDecodeError as e:
raise LLMError(f"LLM API 响应解析失败: {e}") from e
choices = result.get("choices", [])
if not choices:
raise LLMError(f"LLM API 返回空 choices: {result}")
content = choices[0].get("message", {}).get("content", "")
if not content:
raise LLMError(f"LLM API 返回空 content: {result}")
usage = result.get("usage", {})
model = result.get("model", provider["model"])
return LLMResponse(
content=content,
usage=usage,
model=model,
raw=result,
)
def chat_with_system(
self,
system_prompt: str,
user_prompt: str,
*,
temperature: float = 0.7,
max_tokens: int | None = None,
) -> LLMResponse:
"""便捷方法system + user 两条消息。"""
return self.chat(
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=temperature,
max_tokens=max_tokens,
)