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
250 lines
8.0 KiB
Python
250 lines
8.0 KiB
Python
"""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,
|
||
)
|