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
+19 -15
View File
@@ -18,7 +18,8 @@
# LLM_MODEL);若全部缺失则直接报错,绝不静默使用内置默认模型。
# · api_key_env / base_url_env 为可选字段,填写存放 API Key / 服务地址的
# 环境变量名;API Key 一律放 .env,禁止写入本文件(安全规范)。
# · 修改后无需重启常驻服务即可生效(每次调用重新读取;如需热更新缓存可重启)。
# · 修改后无需重启常驻服务即可生效:configs/loader.py 以 (mtime, size) 失效缓存,
# 保存后下一次调用即读到新值;.env 的改动由 configs/runtime_env.py 在约 2s 内热更新。
# =============================================================================
# ---- 全局默认参数(各场景可覆盖;低于 .env,高于代码内置默认)----
@@ -49,9 +50,9 @@ scenes:
# 建议模型: deepseek-v4-flash(生产实测) / deepseek-chat / qwen-plus / qwen-max
event_extraction:
provider: qwen # 建议 deepseek | qwen;留空则回退 .env 的 LLM_PROVIDER
model: qwen3.7-flash # 留空则回退 .env(DEEPSEEK_MODEL → LLM_MODEL)
api_key_env: DASHSCOPE_API_KEY # 例如: DEEPSEEK_API_KEY / QWEN_API_KEY / DASHSCOPE_API_KEY
base_url_env: QWEN_BASE_URL # 例如: DEEPSEEK_BASE_URL / QWEN_BASE_URL
model: qwen3.6-flash # Token Plan 模型;注意 Token Plan 无 qwen3.7-flash
api_key_env: QWEN_API_KEY # Token Plan 计费账号(sk-sp-…);勿用 DASHSCOPE_API_KEY
base_url_env: QWEN_BASE_URL # .env 指向 token-plan.*.maas.aliyuncs.com
temperature: 0.1
timeout_sec: 60
max_attempts: 3 # 单篇解析失败的最大重试次数
@@ -66,20 +67,23 @@ scenes:
# 使用方式:无需手动触发,定时任务自动执行;失败自动降级(日报留空,不影响入库)。
# 对模型的要求:
# · OpenAI 兼容 chat 接口(不需要 JSON 输出);
# · 输出长度 ≥ 1500 tokens(max_tokens=1500,输出超长会被截断并记 WARNING);
# · 输出长度 ≥ max_tokens 配置值(见下,输出超长会被截断并记 WARNING);
# · 中文摘要能力强、要点化输出稳定(每条一行,以 "- " 开头);
# · 上下文窗口 ≥ 8K tokens(素材按 3000 字符/块分块,多块先分段再合并);
# · temperature 0.3 左右,兼顾稳定与表达;网络失败按指数退避重试 3 次。
# · 输出长度需求:分段摘要约 800 tokens、合并摘要约 1500 tokens(代码内置,
# 不在本文件配置),模型应能稳定输出 1500+ tokens 的中文要点。
# · max_tokens 说明:推理模型(deepseek-v4.1-flash 等)的 reasoning token 与
# 正文共用该预算;预算过小时"思考"会占满配额导致正文为空
# (finish_reason=length、0 字符,日报因此没有 AI 摘要)。代码兜底见
# scheduler/reporter.py: 正文为空时自动加倍预算重试(最多 2 次,上限 16000)。
# 建议模型: deepseek-v4-flash(生产实测) / deepseek-chat / qwen-plus
daily_report:
provider: # 建议 deepseek | qwen;留空则回退 .env 的 LLM_PROVIDER
model:
api_key_env:
base_url_env:
provider: qwen # Token Plan 计费账号
model: deepseek-v4.1-flash
api_key_env: QWEN_API_KEY
base_url_env: QWEN_BASE_URL
temperature: 0.3
timeout_sec: 60
max_tokens: 4000 # 单块/合并摘要输出预算(需为 reasoning token 预留余量)
# ------------------------------------------------------------------------- #
# 场景 3: 个股 AI 要点分析
@@ -97,10 +101,10 @@ scenes:
# · 输出长度需求:约 500 tokens(代码内置,不在本文件配置)。
# 建议模型: deepseek-v4-flash(生产实测) / deepseek-chat / qwen-plus
stock_report:
provider: # 建议 deepseek | qwen;留空则回退 .env 的 LLM_PROVIDER
model:
api_key_env:
base_url_env:
provider: qwen # Token Plan(个股日报当前禁用,配置好以防将来启用时漏计费)
model: qwen3.6-flash
api_key_env: QWEN_API_KEY
base_url_env: QWEN_BASE_URL
temperature: 0.3
timeout_sec: 60
+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()
+188
View File
@@ -0,0 +1,188 @@
"""运行期配置热加载:改 ``.env`` 后无需重启进程即生效。
为什么需要它
------------
常驻进程(``scripts/run_scheduler.py``、``mcp_server``)启动时把 ``.env`` 读进
``os.environ``,之后再改 ``.env`` 不会生效——子进程虽然会 ``load_dotenv()``,但
它继承的是父进程那份旧环境,而 python-dotenv 默认不覆盖已存在的键,于是
"改了配置却没反应"。
做法
----
- :func:`ensure_env_loaded`:先比对 ``.env`` 的 ``(mtime_ns, size)``。文件没变时
只做一次 ``stat``;变了才重新解析并同步到 ``os.environ``。
- 删除语义:上一轮由 ``.env`` 带入、这一轮已从文件里删掉的键会被清除,
保证"文件即事实源",而不是只能加不能减。
- :func:`env_get`:先热加载再读取,供各模块统一取配置(空字符串视为未设置)。
- :func:`start_env_watcher`:守护线程周期性刷新,照顾那些仍直接读
``os.environ`` 的历史代码路径。
优先级(从高到低)
------------------
显式参数 / CLI > configs/llm_models.yaml 场景 > 进程环境(shell / systemd)
> .env 文件 > 内置默认值
其中「进程环境」与「.env」的关系是:
- 进程环境里**显式设置且与文件不同**的键优先,热加载不会覆盖它
(例如 ``LLM_PROVIDER=qwen python -m a_share_cli`` 这种一次性覆盖);
- 其余键由本模块托管,跟随 ``.env`` 文件变化即时更新;
- 从 ``.env`` 里删掉的托管键,会同步从进程环境移除。
测试或部署可用 ``A_SHARE_ENV_FILE`` 指定其它 ``.env`` 路径。
"""
from __future__ import annotations
import os
import threading
import time
from pathlib import Path
from dotenv import dotenv_values
from loguru import logger
#: 指定替代的 .env 路径(测试 / 多环境部署用)
ENV_FILE_OVERRIDE = "A_SHARE_ENV_FILE"
#: watcher 轮询间隔(秒)
WATCH_INTERVAL_SEC = 2.0
_lock = threading.Lock()
_signature: tuple[int, int] | None = None
#: 由本模块写入 os.environ 的键 -> 写入值;用于识别"外部显式覆盖"
_managed: dict[str, str] = {}
def dotenv_path() -> Path:
"""返回当前使用的 ``.env`` 路径。"""
override = os.environ.get(ENV_FILE_OVERRIDE)
if override:
return Path(override).expanduser()
return Path(__file__).resolve().parents[1] / ".env"
def file_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)
def _release_all() -> None:
"""撤下所有仍由本模块托管的键(.env 消失时)。"""
for key, managed_value in list(_managed.items()):
if os.environ.get(key) == managed_value:
os.environ.pop(key, None)
del _managed[key]
def _apply(values: dict[str, str]) -> None:
"""把文件值同步到 ``os.environ``,尊重外部显式覆盖。"""
# 1) 已从文件移除的托管键 → 同步删除
for key in list(_managed):
if key in values:
continue
if os.environ.get(key) == _managed[key]:
os.environ.pop(key, None)
del _managed[key]
# 2) 应用文件中的键
for key, value in values.items():
current = os.environ.get(key)
if current is None or current == value:
# 未设置,或与文件一致 → 交给文件托管(后续可热更新)
os.environ[key] = value
_managed[key] = value
elif _managed.get(key) == current:
# 当前值正是本模块上一轮写入的 → 跟随文件热更新
os.environ[key] = value
_managed[key] = value
else:
# 进程环境里显式设置且与文件不同 → 外部优先,不接管
_managed.pop(key, None)
def ensure_env_loaded(force: bool = False) -> bool:
"""确保 ``os.environ`` 与 ``.env`` 文件一致。
Args:
force: 忽略签名缓存,强制重新解析(首次加载 / 测试用)。
Returns:
本次是否真的重新加载了文件。
"""
global _signature
path = dotenv_path()
sig = file_signature(path)
with _lock:
if not force and sig == _signature:
return False
if sig is None:
removed = len(_managed)
_release_all()
_signature = None
if removed:
logger.warning("{} 不可读,已回退 {} 个环境变量", path, removed)
return True
values = {k: v for k, v in dotenv_values(path).items() if v is not None}
_apply(values)
_signature = sig
logger.debug("已加载/热更新 {}({} 项)", path, len(values))
return True
def env_get(key: str, default: str | None = None) -> str | None:
"""读取配置项(读取前自动热加载 ``.env``);空字符串视为未设置。"""
ensure_env_loaded()
value = os.environ.get(key)
if value is None or value.strip() == "":
return default
return value.strip()
def env_raw(key: str, default: str | None = None) -> str | None:
"""读取配置项原始值(读取前自动热加载 ``.env``)。
与 :func:`env_get` 的区别:**不把空字符串当作未设置**。
用于"显式留空表示禁用"这类开关,例如 ``STOCK_REPORT_TIME=``。
"""
ensure_env_loaded()
value = os.environ.get(key)
if value is None:
return default
return value.strip()
def start_env_watcher(interval: float = WATCH_INTERVAL_SEC) -> threading.Thread:
"""启动守护线程:周期性检查 ``.env``,变了就热更新 ``os.environ``。
只对**常驻进程**有意义;短命的一次性脚本按需读取即可。
"""
def _loop() -> None:
while True:
try:
ensure_env_loaded()
except Exception: # noqa: BLE001 - 热加载失败不应拖垮主进程
logger.exception("热加载 .env 失败")
time.sleep(interval)
thread = threading.Thread(target=_loop, name="env-watcher", daemon=True)
thread.start()
logger.info("已启动 .env 热加载监听(每 {:.0f}s 检查一次)", interval)
return thread
def reset_cache() -> None:
"""仅供测试:撤下托管键并清空签名缓存。"""
global _signature
with _lock:
_release_all()
_signature = None