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:
+59
-16
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user