"""LLM 客户端抽象与工厂。 支持 DeepSeek 和 Qwen(百炼),两者均为 OpenAI 兼容接口,共用 openai SDK。 配置来源: - .env → API Key / Base URL(密钥和地址) - configs/system.yaml → provider / model / timeout / temperature(功能配置) """ import logging import os from dataclasses import dataclass from pathlib import Path import yaml from openai import AsyncOpenAI, OpenAI logger = logging.getLogger(__name__) # Provider 默认基址 _DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com" _QWEN_DEFAULT_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1" # 默认模型 _DEEPSEEK_DEFAULT_MODEL = "deepseek-chat" _QWEN_DEFAULT_MODEL = "qwen-plus" # 默认单次调用总尝试次数(含首次;system.yaml llm.max_attempts 未配置时兜底) _DEFAULT_MAX_ATTEMPTS = 3 def _load_system_config() -> dict: """加载 configs/system.yaml 中 llm 段配置。""" config_path = Path("configs/system.yaml") if config_path.exists(): try: with open(config_path, encoding="utf-8") as f: raw = yaml.safe_load(f) return raw.get("llm", {}) except Exception: logger.warning("加载 llm 配置失败,使用空配置") return {} @dataclass class LLMConfig: """LLM 调用配置(provider / model / api_key / base_url / 参数)。""" provider: str # "deepseek" / "qwen" model: str api_key: str base_url: str timeout_sec: float = 60.0 temperature: float = 0.1 max_tokens: int = 8192 max_attempts: int = _DEFAULT_MAX_ATTEMPTS # 单次调用总尝试次数(含首次) def __post_init__(self) -> None: if not self.api_key: raise ValueError(f"LLM provider={self.provider} 的 API key 为空") def load_llm_config( provider: str | None = None, *, model: str | None = None, ) -> LLMConfig: """根据配置文件构造 LLMConfig。 provider 为 None 时读 system.yaml llm.provider,默认 deepseek。 model 为 None 时读 system.yaml 中对应 provider 的 model。 Raises: ValueError: API key 未配置 """ config = _load_system_config() p = (provider or config.get("provider", "deepseek")).lower() if p == "deepseek": api_key = os.environ.get("DEEPSEEK_API_KEY", "") base = os.environ.get("DEEPSEEK_BASE_URL", _DEEPSEEK_DEFAULT_BASE) m = model or config.get("deepseek_model", _DEEPSEEK_DEFAULT_MODEL) elif p in ("qwen", "dashscope"): api_key = os.environ.get("QWEN_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or "" base = os.environ.get("QWEN_BASE_URL", _QWEN_DEFAULT_BASE) m = model or config.get("qwen_model", _QWEN_DEFAULT_MODEL) p = "qwen" else: raise ValueError(f"未知 LLM provider: {p!r},仅支持 deepseek / qwen") if not api_key: raise ValueError( f"LLM provider={p} 的 API key 未配置,请检查 .env 中的 " f"{'DEEPSEEK_API_KEY' if p == 'deepseek' else 'QWEN_API_KEY'}" ) timeout = float(config.get("timeout_sec", 60.0)) temperature = float(config.get("temperature", 0.1)) max_tokens = int(config.get("max_tokens", 8192)) max_attempts = int(config.get("max_attempts", _DEFAULT_MAX_ATTEMPTS)) return LLMConfig( provider=p, model=m, api_key=api_key, base_url=base, timeout_sec=timeout, temperature=temperature, max_tokens=max_tokens, max_attempts=max_attempts, ) def make_sync_client(config: LLMConfig) -> OpenAI: """构造同步 OpenAI 客户端(指向 DeepSeek/Qwen 兼容端点)。""" logger.info( "初始化同步 LLM 客户端: provider=%s model=%s base_url=%s", config.provider, config.model, config.base_url, ) return OpenAI( api_key=config.api_key, base_url=config.base_url, timeout=config.timeout_sec, ) def make_async_client(config: LLMConfig) -> AsyncOpenAI: """构造异步 OpenAI 客户端(用于批处理高并发)。""" logger.info( "初始化异步 LLM 客户端: provider=%s model=%s base_url=%s", config.provider, config.model, config.base_url, ) return AsyncOpenAI( api_key=config.api_key, base_url=config.base_url, timeout=config.timeout_sec, )