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
+14 -17
View File
@@ -12,7 +12,6 @@
from __future__ import annotations
import json
import os
import subprocess
import time
from dataclasses import dataclass, field
@@ -21,6 +20,8 @@ from pathlib import Path
from loguru import logger
from configs.runtime_env import env_get, env_raw
from .timeutil import today_str
# 断点状态文件(按日期隔离,记录每步骤结果)
@@ -159,20 +160,16 @@ def _llm_scene_desc(scene: str) -> str | None:
try:
from configs.loader import load_scene_config
def _env(key: str) -> str | None:
v = os.environ.get(key)
return v.strip() if v else None
sc = load_scene_config(scene)
p = (sc.get("provider") or _env("LLM_PROVIDER") or "deepseek").lower()
p = (sc.get("provider") or env_get("LLM_PROVIDER") or "deepseek").lower()
if p in ("qwen", "dashscope"):
p = "qwen"
model = sc.get("model")
if not model:
if p == "qwen":
model = _env("QWEN_MODEL") or _env("LLM_MODEL")
model = env_get("QWEN_MODEL") or env_get("LLM_MODEL")
else:
model = _env("DEEPSEEK_MODEL") or _env("LLM_MODEL")
model = env_get("DEEPSEEK_MODEL") or env_get("LLM_MODEL")
if not model:
return None
return f"provider={p}, model={model}"
@@ -192,9 +189,9 @@ def _embedding_desc() -> str | None:
model = sc.get("model")
if not model:
if pt == "dashscope":
model = os.environ.get("DASHSCOPE_EMBEDDING_MODEL") or "text-embedding-v3"
model = env_get("DASHSCOPE_EMBEDDING_MODEL") or "text-embedding-v3"
else:
model = os.environ.get("LOCAL_EMBEDDING_MODEL") or "BAAI/bge-m3"
model = env_get("LOCAL_EMBEDDING_MODEL") or "BAAI/bge-m3"
return f"provider={pt}, model={model}"
except Exception as e: # noqa: BLE001
logger.debug("embedding 描述解析失败: {}", e)
@@ -281,14 +278,14 @@ def run_step(name: str, date_str: str) -> StepResult:
# 2. PIPELINE_STEP_TIMEOUT 环境变量 (全局兜底, 覆盖硬编码)
# 3. STEP_TIMEOUTS 硬编码字典 (代码内默认值)
# 4. 1800s (最终兜底)
import os
specific_key = f"TIMEOUT_{name.upper()}"
if specific_key in os.environ:
timeout = int(os.environ[specific_key])
elif "PIPELINE_STEP_TIMEOUT" in os.environ:
timeout = int(os.environ["PIPELINE_STEP_TIMEOUT"])
else:
timeout = STEP_TIMEOUTS.get(name, 1800)
timeout_raw = env_raw(specific_key) or env_raw("PIPELINE_STEP_TIMEOUT")
default_timeout = STEP_TIMEOUTS.get(name, 1800)
try:
timeout = int(timeout_raw) if timeout_raw else default_timeout
except ValueError:
logger.warning("超时配置 {!r} 非法,回退默认 {}s", timeout_raw, default_timeout)
timeout = default_timeout
started = datetime.now()
logger.info("步骤 {} 开始: {}", name, " ".join(full_cmd))
+119 -46
View File
@@ -10,7 +10,6 @@
from __future__ import annotations
import json
import os as _os
import re as _re
import subprocess
import time
@@ -22,14 +21,11 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from llm.client import LLMConfig
from dotenv import load_dotenv
from loguru import logger
from configs.runtime_env import env_get
from report_db.models import EventRow, ReportData # noqa: F401 - 供 _build_report_data 注解使用
# 确保 .env 已加载(模块级常量依赖环境变量)
load_dotenv()
# --------------------------------------------------------------------------- #
# 配置
# --------------------------------------------------------------------------- #
@@ -37,14 +33,64 @@ load_dotenv()
UPLOAD_HOST = "simon@doorcome.cn"
UPLOAD_BASE = "/var/www/html/echart/research"
CNINFO_DAYS_BACK = int(_os.environ.get("STOCK_REPORT_DAYS", "15")) # 与个股日报共用参数, 默认值保持一致
NEWS_DAYS_BACK = 1 # 新闻回溯天数
_MAX_HIGH_EVENTS = 20
# LLM 摘要调用重试参数(环境变量可覆盖)
_LLM_RETRY_TIMES = int(_os.environ.get("LLM_RETRY_TIMES", "3"))
_LLM_RETRY_BACKOFF_SEC = float(_os.environ.get("LLM_RETRY_BACKOFF_SEC", "2.0"))
# AI 摘要输出预算(token)。
# 推理模型(deepseek-v4.1-flash 等)的 reasoning token 与正文共用 max_tokens:
# 预算过小时"思考"会占满配额,正文为空(finish_reason=length、0 字符),
# 日报就会没有 AI 摘要。默认值需为 reasoning 预留余量。
DEFAULT_SUMMARY_MAX_TOKENS = 4000 # 单块 / 合并摘要默认预算
DEFAULT_SUMMARY_CHUNK_MAX_TOKENS = 2000 # 分块摘要默认预算
MAX_SUMMARY_MAX_TOKENS = 16000 # 正文为空时预算升级上限
_MAX_BUDGET_ESCALATIONS = 2 # 正文为空时最多升级预算次数
def _env_int(key: str, default: int) -> int:
"""读取整数环境变量(热加载 .env);非法值回退默认。"""
try:
return int(env_get(key, str(default)) or default)
except ValueError:
logger.warning("环境变量 {} 不是整数, 回退默认 {}", key, default)
return default
def _env_float(key: str, default: float) -> float:
"""读取浮点环境变量(热加载 .env);非法值回退默认。"""
try:
return float(env_get(key, str(default)) or default)
except ValueError:
logger.warning("环境变量 {} 不是数字, 回退默认 {}", key, default)
return default
def _cninfo_days_back() -> int:
"""公告/调研回溯天数(与个股日报共用 STOCK_REPORT_DAYS)。"""
return _env_int("STOCK_REPORT_DAYS", 15)
def _llm_retry_times() -> int:
return _env_int("LLM_RETRY_TIMES", 3)
def _llm_retry_backoff_sec() -> float:
return _env_float("LLM_RETRY_BACKOFF_SEC", 2.0)
def _summary_max_tokens(config: LLMConfig | None = None) -> int:
"""摘要输出预算:场景配置 max_tokens(热更新)> 代码内置默认。"""
value = getattr(config, "max_tokens", None)
try:
return int(value) if value else DEFAULT_SUMMARY_MAX_TOKENS
except (TypeError, ValueError):
logger.warning("场景 max_tokens 非法({!r}), 回退默认 {}", value, DEFAULT_SUMMARY_MAX_TOKENS)
return DEFAULT_SUMMARY_MAX_TOKENS
def _chunk_max_tokens(config: LLMConfig | None = None) -> int:
"""分块摘要预算:不超过单块/合并预算,避免小块调用过度消耗。"""
return min(_summary_max_tokens(config), DEFAULT_SUMMARY_CHUNK_MAX_TOKENS)
# 日报新闻回溯窗口(小时):07:00 生成当日日报时覆盖昨日全天至今晨的新闻
_NEWS_LOOKBACK_HOURS = 30
@@ -181,12 +227,14 @@ def _collect_news_events(day_str: str) -> dict[str, Any]:
}
def _collect_cninfo_events(today_str: str, days_back: int = CNINFO_DAYS_BACK) -> dict[str, Any]:
def _collect_cninfo_events(today_str: str, days_back: int | None = None) -> dict[str, Any]:
"""收集近 N 日 cninfo 公告/调研/互动(直接从 processed 数据读取,不依赖 M4 事件抽取)。
cninfo 公告/调研数据已结构化(stock_code/name/title/time/type),
无需经过 LLM 事件抽取即可直接用于日报。
"""
if days_back is None:
days_back = _cninfo_days_back()
today = datetime.strptime(today_str, "%Y%m%d")
since_str = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
wl_codes = _load_watchlist_codes()
@@ -514,7 +562,7 @@ def _generate_ai_summary(news: dict, cninfo: dict, day_str: str,
# 公告/调研
if cninfo["high"]:
lines.append(f"## 近 {CNINFO_DAYS_BACK} 日重要公告/调研 ({len(cninfo['high'])} 条)")
lines.append(f"## 近 {_cninfo_days_back()} 日重要公告/调研 ({len(cninfo['high'])} 条)")
for e in cninfo["high"][:8]:
ev = e.get("event", {})
lines.append(f"- [{ev.get('event_type', '公司公告')}] {e['title']}")
@@ -570,7 +618,7 @@ def _llm_summarize(client, config: LLMConfig, lines: list[str], day_str: str) ->
{chr(10).join(chunk)}
直接输出要点列表:"""
result = _llm_call(client, config, prompt, max_tokens=800)
result = _llm_call(client, config, prompt, max_tokens=_chunk_max_tokens(config))
if result:
partials.append(result)
logger.info("AI 摘要: 分块 {}/{} 完成 ({} 字)", i, len(chunks), len(result))
@@ -589,14 +637,14 @@ def _llm_summarize(client, config: LLMConfig, lines: list[str], day_str: str) ->
请合并为要点总结,每条一行以 "- " 开头,要求:
1. 前 3 条为影响最大的事件,说明为什么重要
2. 汇总近 {CNINFO_DAYS_BACK} 日公司公告/调研核心信息
2. 汇总近 {_cninfo_days_back()} 日公司公告/调研核心信息
3. 市场情绪基调(利好/利空/中性)
4. 值得持续关注的行业或主题
5. 纯要点,不要开场白/结束语
6. 总字数 500 字以内
直接输出要点列表:"""
return _llm_call(client, config, merge_prompt, max_tokens=1500)
return _llm_call(client, config, merge_prompt, max_tokens=_summary_max_tokens(config))
def _build_prompt(lines: list[str], day_str: str) -> str:
@@ -607,7 +655,7 @@ def _build_prompt(lines: list[str], day_str: str) -> str:
请用要点总结,每条一行,以 "- " 开头,要求:
1. 前 3 条为过去 24 小时影响最大的事件(优先参考新闻联播中的重大政策信号),说明为什么重要
2. 汇总近 {CNINFO_DAYS_BACK} 日重要公司公告/调研的核心信息
2. 汇总近 {_cninfo_days_back()} 日重要公司公告/调研的核心信息
3. 市场情绪基调(利好/利空/中性)
4. 值得持续关注的行业或主题
5. 纯要点,不要开场白/结束语/标题
@@ -616,45 +664,70 @@ def _build_prompt(lines: list[str], day_str: str) -> str:
直接输出要点列表:"""
def _llm_call(client, config: LLMConfig, prompt: str, max_tokens: int = 1500) -> str:
"""单次 LLM 调用(带重试),返回 strip 后的文本。
def _call_once(client, config: LLMConfig, prompt: str, max_tokens: int) -> tuple[str, str | None]:
"""单次 LLM 调用,返回 (正文, finish_reason)。异常由调用方处理。"""
resp = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
{"role": "user", "content": prompt},
],
temperature=config.temperature,
max_tokens=max_tokens,
)
choice = resp.choices[0]
content = (choice.message.content or "").strip()
return content, getattr(choice, "finish_reason", None)
config 为 llm.client.LLMConfig(daily_report 场景),提供 model / temperature。
失败按指数退避重试 `_LLM_RETRY_TIMES` 次(默认 3),全部失败则抛出最后一次异常。
若 finish_reason 为 'length' 则说明达到 max_tokens 上限被截断。
def _llm_call(client, config: LLMConfig, prompt: str, max_tokens: int | None = None) -> str:
"""单次 LLM 调用(带重试 + 空正文预算升级),返回 strip 后的文本。
config 为 llm.client.LLMConfig(daily_report 场景),提供 model / temperature /
max_tokens(未配置 max_tokens 时用 DEFAULT_SUMMARY_MAX_TOKENS)。
网络等异常按指数退避重试 `LLM_RETRY_TIMES` 次(默认 3),全部失败则抛出最后一次异常。
若 finish_reason 为 'length' 且正文为空(推理模型 reasoning 占满预算),
自动加倍预算重试,最多 `_MAX_BUDGET_ESCALATIONS` 次、上限 MAX_SUMMARY_MAX_TOKENS;
此时返回空字符串而非抛异常,由调用方降级。
"""
retry_times = _llm_retry_times()
backoff_sec = _llm_retry_backoff_sec()
budget = max_tokens or _summary_max_tokens(config)
last_exc: Exception | None = None
for attempt in range(_LLM_RETRY_TIMES):
escalations = 0
for attempt in range(retry_times):
try:
resp = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
{"role": "user", "content": prompt},
],
temperature=config.temperature,
max_tokens=max_tokens,
)
content = (resp.choices[0].message.content or "").strip()
finish = getattr(resp.choices[0], "finish_reason", None)
if finish == "length":
logger.warning(
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
max_tokens, len(content),
)
return content
content, finish = _call_once(client, config, prompt, budget)
except Exception as e:
last_exc = e
if attempt < _LLM_RETRY_TIMES - 1:
wait = _LLM_RETRY_BACKOFF_SEC * (2 ** attempt)
if attempt < retry_times - 1:
wait = backoff_sec * (2 ** attempt)
logger.warning(
"AI 摘要 LLM 调用失败(第 {}/{} 次): {}; {} 秒后重试",
attempt + 1, _LLM_RETRY_TIMES, e, round(wait, 2),
attempt + 1, retry_times, e, round(wait, 2),
)
time.sleep(wait)
logger.error("AI 摘要 LLM 调用重试 {} 次仍失败: {}", _LLM_RETRY_TIMES, last_exc)
assert last_exc is not None
raise last_exc
continue
if finish == "length":
logger.warning(
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
budget, len(content),
)
# 正文为空 = 推理占满预算;加倍预算重试(不改动调用方传入的显式预算以外逻辑)
if not content and escalations < _MAX_BUDGET_ESCALATIONS:
budget = min(budget * 2, MAX_SUMMARY_MAX_TOKENS)
escalations += 1
logger.warning("AI 摘要正文为空(推理占满预算), 提升 max_tokens 至 {} 重试", budget)
continue
return content
if last_exc is not None:
logger.error("AI 摘要 LLM 调用重试 {} 次仍失败: {}", retry_times, last_exc)
raise last_exc
logger.warning("AI 摘要 {} 次尝试仍未获得正文, 返回空(日报降级为无 AI 摘要)", retry_times)
return ""
# --------------------------------------------------------------------------- #
@@ -964,7 +1037,7 @@ def _render_html(news: dict, cninfo: dict, pipeline: dict,
news_table=news_table,
cninfo_high_count=len(cninfo["high"]),
cninfo_threshold=cninfo.get("hi_threshold", 4),
cninfo_days=CNINFO_DAYS_BACK,
cninfo_days=_cninfo_days_back(),
cninfo_table=cninfo_table,
raw_total=pipeline["raw_total"],
raw_total_24h=pipeline.get("raw_total_24h", pipeline["raw_total"]),
@@ -1065,7 +1138,7 @@ def generate_report(day_str: str | None = None, *, upload: bool = True) -> int |
# 收集数据
try:
news = _collect_news_events(day_str)
cninfo = _collect_cninfo_events(day_str, days_back=CNINFO_DAYS_BACK)
cninfo = _collect_cninfo_events(day_str, days_back=_cninfo_days_back())
pipeline = _collect_pipeline_stats(day_str)
xwlb = _collect_xwlb(day_str)
except Exception as e: