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 既有失败与本改动无关
This commit is contained in:
2026-09-25 11:13:37 +08:00
parent 2eaea2ee81
commit ff911cf6f7
19 changed files with 1024 additions and 200 deletions
+59 -16
View File
@@ -8,33 +8,75 @@
3. 环境变量 / .env(LLM_PROVIDER、DEEPSEEK_MODEL 等,向后兼容)
4. 代码内置默认值
热加载: 缓存以 ``(mtime_ns, size)`` 为准 —— 改完 YAML 保存后,下一次读取即生效,
常驻进程(调度器 / MCP server)无需重启。
说明:API Key 一律放 .env,本文件只保存环境变量名(api_key_env),禁止写密钥。
"""
from __future__ import annotations
from functools import lru_cache
import os
import threading
from pathlib import Path
from loguru import logger
DEFAULT_CONFIG_PATH = Path("configs/llm_models.yaml")
#: 指定替代的模型配置文件路径(测试 / 多环境部署用)
MODELS_CONFIG_OVERRIDE = "A_SHARE_MODELS_CONFIG"
#: 默认模型配置文件(绝对路径,不依赖当前工作目录)
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "configs" / "llm_models.yaml"
_lock = threading.Lock()
_cache: dict[Path, tuple[tuple[int, int] | None, dict]] = {}
def config_path() -> Path:
"""返回当前使用的 ``llm_models.yaml`` 路径。"""
override = os.environ.get(MODELS_CONFIG_OVERRIDE)
if override:
return Path(override).expanduser()
return DEFAULT_CONFIG_PATH
def _signature(path: Path) -> tuple[int, int] | None:
"""返回 ``(mtime_ns, size)``;文件不存在时返回 None。"""
try:
st = path.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
@lru_cache(maxsize=8)
def _load_yaml(path: Path) -> dict:
"""读取 YAML 文件为 dict;文件缺失或解析失败返回空 dict(走兜底配置)。"""
"""读取 YAML 为 dict;文件缺失或解析失败返回空 dict(走兜底配置)。
按 ``(mtime_ns, size)`` 失效缓存:文件一旦变化,下次调用即重新解析。
"""
sig = _signature(path)
with _lock:
cached = _cache.get(path)
if cached is not None and cached[0] == sig:
return cached[1]
if not path.is_file():
logger.debug("配置文件不存在,使用内置/环境变量兜底: {}", path)
return {}
try:
import yaml
data: dict = {}
else:
try:
import yaml
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as e: # noqa: BLE001 - YAML 语法错误等
logger.error("解析 {} 失败: {}", path, e)
return {}
return data if isinstance(data, dict) else {}
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
data = raw if isinstance(raw, dict) else {}
except Exception as e: # noqa: BLE001 - YAML 语法错误等
logger.error("解析 {} 失败: {}", path, e)
data = {}
with _lock:
_cache[path] = (sig, data)
return data
def load_scene_config(scene: str) -> dict:
@@ -45,7 +87,7 @@ def load_scene_config(scene: str) -> dict:
"""
if not scene:
return {}
data = _load_yaml(DEFAULT_CONFIG_PATH)
data = _load_yaml(config_path())
scenes = data.get("scenes") or {}
cfg = scenes.get(scene)
if cfg is None:
@@ -59,11 +101,12 @@ def load_scene_config(scene: str) -> dict:
def load_defaults() -> dict:
"""读取 llm_models.yaml 顶层 defaults(全局默认参数)。"""
data = _load_yaml(DEFAULT_CONFIG_PATH)
data = _load_yaml(config_path())
d = data.get("defaults") or {}
return d if isinstance(d, dict) else {}
def clear_cache() -> None:
"""清空 YAML 缓存(测试或热更新配置时使用)。"""
_load_yaml.cache_clear()
"""清空 YAML 缓存(测试或强制重载时使用;正常热加载无需调用)。"""
with _lock:
_cache.clear()