Files
intl_news/llm/client.py
T
simon c72a5ed13a feat: AI 模型按场景独立配置(llm_scenes)+ 去重多来源合并
- system.yaml 新增 llm_scenes(translation / daily_report,含用途/方法/模型要求说明)
- load_llm_config(scene=...) 场景覆盖;日报摘要 temperature 0.3 硬编码 → 配置
- M3 去重:唯一篇记录 source_ids(跨源重复合并,首个来源为 source_id)
- source_ids 经翻译透传至 events,日报事件 source 多来源拼接展示(≤3 个)
- 已部署 pi5:merge 实证 10 源合并;日报 report_id=224 正常入库
2026-08-12 09:56:55 +08:00

152 lines
5.1 KiB
Python

"""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(scene: str | None = None) -> dict:
"""加载 llm 配置;scene 指定时与 llm_scenes.{scene} 合并(场景覆盖默认段)。
Args:
scene: 场景名(translation / daily_report)。未配置该场景时回退 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)
base = raw.get("llm", {})
if scene:
scene_cfg = (raw.get("llm_scenes", {}) or {}).get(scene, {})
if not scene_cfg:
logger.warning(
"system.yaml 中不存在 llm_scenes.%s,使用 llm 默认段", scene
)
return {**base, **scene_cfg}
return base
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,
scene: str | None = None,
) -> LLMConfig:
"""根据配置文件构造 LLMConfig。
provider 为 None 时读 system.yaml(或场景段)llm.provider,默认 deepseek。
model 为 None 时优先读场景段的 model 键,其次 system.yaml 对应 provider 的 model。
scene 指定时,llm_scenes.{scene} 覆盖 llm 默认段的各参数(按场景独立配置模型)。
Raises:
ValueError: API key 未配置
"""
config = _load_system_config(scene=scene)
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("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("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,
)