"""LLM 客户端抽象与工厂。 支持 DeepSeek 和 Qwen(百炼),两者均为 OpenAI 兼容接口,共用 openai SDK。 环境变量: LLM_PROVIDER = deepseek | qwen (默认 deepseek) DeepSeek: DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL / DEEPSEEK_MODEL Qwen: QWEN_API_KEY / QWEN_BASE_URL / QWEN_MODEL (QWEN_API_KEY -> DASHSCOPE_API_KEY 兜底) 模型必须显式配置(provider 对应的 *_MODEL 或 LLM_MODEL),不再提供内置默认模型。 LLM_TEMPERATURE / LLM_TIMEOUT_SEC """ from __future__ import annotations import os from dataclasses import dataclass from loguru import logger from openai import AsyncOpenAI, OpenAI # 默认基址 _DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com" _QWEN_DEFAULT_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1" # 抽取任务默认参数 DEFAULT_TIMEOUT_SEC = 60.0 DEFAULT_TEMPERATURE = 0.1 @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 = DEFAULT_TIMEOUT_SEC temperature: float = DEFAULT_TEMPERATURE def __post_init__(self) -> None: if not self.api_key: raise ValueError(f"LLM provider={self.provider} 的 API key 为空") def _read_env(key: str, default: str | None = None) -> str | None: val = os.environ.get(key) if val is None or val.strip() == "": return default return val.strip() def load_llm_config( provider: str | None = None, *, model: str | None = None, ) -> LLMConfig: """根据环境变量构造 LLMConfig。 provider 为 None 时读 LLM_PROVIDER 环境变量,默认 deepseek。 model 为 None 时读 LLM_MODEL 或 provider 默认。 """ p = (provider or _read_env("LLM_PROVIDER", "deepseek") or "deepseek").lower() if p == "deepseek": api_key = _read_env("DEEPSEEK_API_KEY") or "" base = _read_env("DEEPSEEK_BASE_URL", _DEEPSEEK_DEFAULT_BASE) or _DEEPSEEK_DEFAULT_BASE # DEEPSEEK_MODEL → LLM_MODEL;模型必须显式配置,不提供内置默认 m = model or _read_env("DEEPSEEK_MODEL") or _read_env("LLM_MODEL") if not m: raise ValueError("未配置 LLM 模型: 请设置 DEEPSEEK_MODEL 或 LLM_MODEL") elif p in ("qwen", "dashscope"): api_key = _read_env("QWEN_API_KEY") or _read_env("DASHSCOPE_API_KEY") or "" base = _read_env("QWEN_BASE_URL", _QWEN_DEFAULT_BASE) or _QWEN_DEFAULT_BASE # QWEN_MODEL → LLM_MODEL;模型必须显式配置,不提供内置默认 m = model or _read_env("QWEN_MODEL") or _read_env("LLM_MODEL") if not m: raise ValueError("未配置 LLM 模型: 请设置 QWEN_MODEL 或 LLM_MODEL") p = "qwen" # 内部统一用 qwen else: raise ValueError(f"未知 LLM provider: {p!r},仅支持 deepseek / qwen") timeout = float(_read_env("LLM_TIMEOUT_SEC", str(DEFAULT_TIMEOUT_SEC)) or DEFAULT_TIMEOUT_SEC) temperature = float(_read_env("LLM_TEMPERATURE", str(DEFAULT_TEMPERATURE)) or DEFAULT_TEMPERATURE) return LLMConfig( provider=p, model=m, api_key=api_key, base_url=base, timeout_sec=timeout, temperature=temperature, ) def make_sync_client(config: LLMConfig) -> OpenAI: """构造同步 OpenAI 客户端(指向 DeepSeek/Qwen 兼容端点)。""" logger.debug( "初始化同步 LLM 客户端: provider={} model={} base_url={}", 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.debug( "初始化异步 LLM 客户端: provider={} model={} base_url={}", config.provider, config.model, config.base_url, ) return AsyncOpenAI( api_key=config.api_key, base_url=config.base_url, timeout=config.timeout_sec, )