Files
news/llm/client.py
T
simon ff911cf6f7 feat: Token Plan 迁移与 .env 热加载,并修复日报 AI 摘要为空
Token Plan 迁移 / 配置热加载:
- configs/llm_models.yaml: 各场景切到 Token Plan(deepseek-v4.1-flash / qwen3.6-flash)
- 新增 configs/runtime_env.py: .env 按 (mtime_ns, size) 热加载并同步 os.environ,
  统一 env_get 取值;llm / embedding / vectorstore / mcp / pipeline 改用 env_get
- configs/loader.py / scripts/run_scheduler.py 等配套调整
- 新增 tests/test_hot_reload.py

日报 AI 摘要为空修复(2026-09-25):
- 根因: 推理模型的 reasoning token 与正文共用 max_tokens, 预算 1500 被"思考"
  占满 -> text_tokens=0 / finish_reason=length, 摘要静默为空且不重试
- daily_report 场景新增 max_tokens(默认 4000, YAML 保存即热生效);
  LLMConfig 支持可选 max_tokens; 分块预算 800 -> 2000
- _llm_call 拆出 _call_once, 正文为空时自动加倍预算重试(上限 16000),
  用尽才降级返回空串; 网络异常重试语义不变
- docs/user-guide.md 新增 FAQ; continuation.md 记录本次排查
- 已重跑 2026-09-25 日报(report_id=357)补回 466 字摘要

测试: 相关用例 56 passed(test_hot_reload 12 passed);
      ruff 无新增问题; 3 个 crawler 既有失败与本改动无关
2026-09-25 11:13:37 +08:00

218 lines
7.1 KiB
Python

"""LLM 客户端抽象与工厂。
支持 DeepSeek 和 Qwen(百炼),两者均为 OpenAI 兼容接口,共用 openai SDK。
配置来源(优先级从高到低):
1. 代码 / CLI 显式参数(provider / model)
2. configs/llm_models.yaml 场景配置(scene 参数,见 configs/loader.py)
3. 环境变量 / .env(LLM_PROVIDER、DEEPSEEK_MODEL 等,向后兼容)
4. 代码内置默认值
环境变量(兜底):
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
from dataclasses import dataclass
from loguru import logger
from openai import AsyncOpenAI, OpenAI
from configs.loader import load_defaults, load_scene_config
from configs.runtime_env import env_get
# 默认基址
_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
DEFAULT_MAX_ATTEMPTS = 3
# 场景名 -> configs/llm_models.yaml 中 scenes 的 key
SCENE_EVENT_EXTRACTION = "event_extraction"
SCENE_DAILY_REPORT = "daily_report"
SCENE_STOCK_REPORT = "stock_report"
@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
max_attempts: int = DEFAULT_MAX_ATTEMPTS # 单次任务失败重试次数
# 单次输出预算(可选,场景配置 scenes.<scene>.max_tokens);None = 调用方用内置默认。
# 注意:推理模型(deepseek-v4.1-flash 等)的 reasoning token 与正文共用该预算。
max_tokens: int | None = None
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:
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def _first_env(keys: list[str | None]) -> str | None:
"""按顺序返回第一个非空的环境变量值。"""
for k in keys:
if not k:
continue
v = _read_env(k)
if v:
return v
return None
def _num(value: object) -> float | None:
"""把 YAML 数字/字符串安全转 float;非法或为空返回 None。"""
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def load_llm_config(
provider: str | None = None,
*,
model: str | None = None,
scene: str | None = None,
) -> LLMConfig:
"""按优先级构造 LLMConfig:显式参数 > YAML 场景 > 环境变量 > 内置默认。
scene 对应 configs/llm_models.yaml 中 scenes 的 key
(event_extraction / daily_report / stock_report),该场景未配置的字段
回退到环境变量,保持向后兼容。
"""
sc = load_scene_config(scene or "")
dflt = load_defaults()
p = (
provider
or sc.get("provider")
or _read_env("LLM_PROVIDER", "deepseek")
or "deepseek"
).lower()
# 各 provider 的 api_key / base_url / model 环境变量链
provider_envs: dict[str, tuple[list[str | None], list[str | None], list[str | None]]] = {
"deepseek": (
[sc.get("api_key_env"), "DEEPSEEK_API_KEY"],
[sc.get("base_url_env"), "DEEPSEEK_BASE_URL"],
["DEEPSEEK_MODEL", "LLM_MODEL"],
),
"qwen": (
[sc.get("api_key_env"), "QWEN_API_KEY", "DASHSCOPE_API_KEY"],
[sc.get("base_url_env"), "QWEN_BASE_URL"],
["QWEN_MODEL", "LLM_MODEL"],
),
}
if p == "deepseek":
key_envs, base_envs, model_envs = provider_envs["deepseek"]
default_base = _DEEPSEEK_DEFAULT_BASE
elif p in ("qwen", "dashscope"):
key_envs, base_envs, model_envs = provider_envs["qwen"]
default_base = _QWEN_DEFAULT_BASE
p = "qwen" # 内部统一用 qwen
else:
raise ValueError(f"未知 LLM provider: {p!r},仅支持 deepseek / qwen")
api_key = _first_env(key_envs) or ""
base_url = _first_env(base_envs) or default_base
# 模型优先级:显式参数 > YAML 场景 > 环境变量;模型必须显式配置,无内置兜底
m = model or sc.get("model") or _first_env(model_envs)
if not m:
env_hint = "/".join(v for v in model_envs if v)
raise ValueError(
f"未配置 LLM 模型(场景 {scene or 'default'}): "
f"请在 configs/llm_models.yaml 的 model 或 .env 设置 {env_hint}"
)
timeout = _pick_float(sc, dflt, "timeout_sec", "LLM_TIMEOUT_SEC", DEFAULT_TIMEOUT_SEC)
temperature = _pick_float(sc, dflt, "temperature", "LLM_TEMPERATURE", DEFAULT_TEMPERATURE)
max_attempts = _pick_int(sc, "max_attempts", DEFAULT_MAX_ATTEMPTS)
max_tokens = _pick_optional_int(sc, "max_tokens")
return LLMConfig(
provider=p,
model=m,
api_key=api_key,
base_url=base_url,
timeout_sec=timeout,
temperature=temperature,
max_attempts=max_attempts,
max_tokens=max_tokens,
)
def _pick_float(
sc: dict,
dflt: dict,
sc_key: str,
env_key: str,
default: float,
) -> float:
"""数值参数选择:YAML 场景 > 环境变量 > YAML defaults > 内置默认(零值合法)。"""
v = _num(sc.get(sc_key))
if v is not None:
return v
v = _num(_read_env(env_key))
if v is not None:
return v
v = _num(dflt.get(sc_key))
return v if v is not None else default
def _pick_int(sc: dict, sc_key: str, default: int) -> int:
v = _num(sc.get(sc_key))
return int(v) if v is not None else default
def _pick_optional_int(sc: dict, sc_key: str) -> int | None:
"""可选整数场景配置;未配置或非法时返回 None(调用方回退各自内置默认)。"""
v = _num(sc.get(sc_key))
return int(v) if v is not None else None
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,
)