fix(ai): 分析类调用放开 max_tokens 限制 — 修复推理模型正文 0 字失败

根因(实测钉死): deepseek-v4-pro 等推理模型把思考 token 计入
max_tokens 预算。个股分析真实调用(62KB prompt, max_tokens=4500):
usage completion=4500 全是 reasoning, finish=length, 正文 0 字,
流"正常"结束 → 前端兜底报「分析未返回内容」→ 表现为"经常失败"
(推理长度随机, 逼近 4500 即失败)。与 180s 超时无关(实际 65s 结束)。

改动:
- ai_provider: generate/stream 的 max_tokens 支持 None=不传该参数,
  输出上限交给服务端默认; _openai_kwargs None 时省略; codex 提示词
  None 时跳过长度约束行
- 四个分析器(个股/复盘/财务/概念轮动)改传 max_tokens=None;
  新增空正文守卫: 流结束但 0 个 delta → 明确报错+日志(原先静默,
  backend.log 无痕迹无法排查)。策略标题生成(max_tokens=8)等小任务
  保持原限制

对照实测(真实 62KB prompt): 不传 → finish=stop, 推理 3311 token
+正文 2407 字, 59s; max_tokens=16000 → 同样成功但推理撒欢 6239
token/93s — 不限制即最优。

验证: 新增 kwargs/codex 单测, ai_provider 21 通过, 相关 174 通过;
线上接口复测: 修复前 0 delta 直接 done, 修复后 1682 delta/2467 字
正常流式; ruff 相对 HEAD 无新增。
This commit is contained in:
shy3130
2026-08-17 17:47:46 +08:00
parent 6f0786abac
commit 0ee3aa8f1f
6 changed files with 77 additions and 15 deletions
+24 -11
View File
@@ -216,10 +216,15 @@ async def generate_ai_text(
messages: Sequence[Message],
*,
temperature: float | None = 0.3,
max_tokens: int = 3000,
max_tokens: int | None = 3000,
timeout: float = 180.0,
) -> str:
"""Return a complete AI response from the currently configured provider."""
"""Return a complete AI response from the currently configured provider.
max_tokens=None 表示不传该参数(输出上限交给服务端默认) — 推理型模型
(如 deepseek reasoner 系)的思考 token 计入 max_tokens 预算, 显式限制
会挤占正文甚至全部吃光(正文 0 字 + finish=length), 长分析类调用应放开。
"""
if is_codex_cli_provider():
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
@@ -234,13 +239,15 @@ async def stream_ai_text(
messages: Sequence[Message],
*,
temperature: float | None = 0.5,
max_tokens: int = 4000,
max_tokens: int | None = 4000,
timeout: float = 180.0,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
max_tokens=None 表示不限制输出(同 generate_ai_text 的说明)。
"""
if is_codex_cli_provider():
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
@@ -259,7 +266,7 @@ async def _run_openai_once(
messages: Sequence[Message],
*,
temperature: float | None,
max_tokens: int,
max_tokens: int | None,
timeout: float,
) -> str:
ai_key = secrets_store.get_ai_key()
@@ -295,7 +302,7 @@ async def _stream_openai(
messages: Sequence[Message],
*,
temperature: float | None,
max_tokens: int,
max_tokens: int | None,
timeout: float,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
@@ -402,9 +409,15 @@ def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None:
return None
def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict:
"""Build OpenAI create() kwargs; optional parameters are omitted when empty."""
kwargs: dict = {"max_tokens": max_tokens}
def _openai_kwargs(*, temperature: float | None, max_tokens: int | None) -> dict:
"""Build OpenAI create() kwargs; optional parameters are omitted when empty.
max_tokens=None 时不传 — 由服务端默认上限管理(推理模型的思考 token 也
计入该参数预算, 限制会挤占正文, 见 stream_ai_text 文档)。
"""
kwargs: dict = {}
if max_tokens is not None:
kwargs["max_tokens"] = max_tokens
if temperature is not None:
kwargs["temperature"] = temperature
if current_ai_provider() == OPENAI_PROVIDER:
@@ -507,7 +520,7 @@ def _compact_error_text(text: str) -> str:
async def _run_codex_cli(
messages: Sequence[Message],
*,
max_tokens: int,
max_tokens: int | None,
timeout: float,
) -> str:
prompt = _codex_prompt(messages, max_tokens=max_tokens)
@@ -640,14 +653,14 @@ def _make_writable_and_retry(
raise exc_info[1] from None
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int | None) -> str:
parts = [
"You are Tick Stock Panel's local AI provider.",
"This is a text-generation task. The working directory is intentionally empty.",
"Use only the user-provided prompt content below; do not inspect or modify local files.",
"Return only the final requested content; do not include execution logs.",
]
if max_tokens > 0:
if max_tokens:
parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
for message in messages:
role = message.get("role", "user")
@@ -365,18 +365,26 @@ async def analyze_rotation_stream(
return
user_prompt = _build_user_prompt(signals, overview, days, dates, focus, kind)
got_content = False
async for delta in stream_ai_text(
[
{"role": "system", "content": _build_system_prompt(kind)},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4000,
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
max_tokens=None,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI %s rotation analyze failed: %s", kind, e)
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False)
return
if not got_content:
logger.warning("AI %s rotation analyze ended with empty content", kind)
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
+8 -1
View File
@@ -178,14 +178,17 @@ async def analyze_financials_stream(
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(fins, symbol, focus)
got_content = False
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.4,
max_tokens=4000,
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
max_tokens=None,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
@@ -193,4 +196,8 @@ async def analyze_financials_stream(
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
if not got_content:
logger.warning("AI financial analysis ended with empty content for %s", symbol)
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
+8 -1
View File
@@ -301,14 +301,17 @@ async def recap_market_stream(
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus)
got_content = False
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
# 不限制输出(推理模型思考 token 计入预算, 见 ai_provider.stream_ai_text)
max_tokens=None,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
@@ -316,6 +319,10 @@ async def recap_market_stream(
yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
return
if not got_content:
logger.warning("AI market recap ended with empty content for %s", as_of_str)
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
+10 -1
View File
@@ -315,14 +315,18 @@ async def analyze_stock_stream(
kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus,
asset_type=repo.resolve_asset_type(symbol))
got_content = False
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
# 不限制输出: 推理模型(deepseek reasoner 系)思考 token 计入 max_tokens
# 预算, 固定上限会把正文挤光(实测 4500 全被推理吃掉 → 正文 0 字)。
max_tokens=None,
):
got_content = True
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
@@ -330,4 +334,9 @@ async def analyze_stock_stream(
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
if not got_content:
# 流正常结束但一个正文块都没有(典型: 输出上限被思考吃光后静默截断)
logger.warning("AI stock analysis ended with empty content for %s", symbol)
yield json.dumps({"type": "error", "message": "AI 未返回正文(输出被截断), 请重试"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
+18
View File
@@ -197,6 +197,24 @@ def test_openai_kwargs_include_configured_reasoning_effort(monkeypatch):
assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000)
def test_openai_kwargs_none_max_tokens_omits_limit():
"""max_tokens=None → 不传上限(推理模型思考 token 计入预算, 分析类调用放开)。"""
kwargs = ai_provider._openai_kwargs(temperature=0.5, max_tokens=None)
assert "max_tokens" not in kwargs
assert kwargs.get("temperature") == 0.5
# 显式数值仍正常下发(策略标题生成等小任务依赖)
assert ai_provider._openai_kwargs(temperature=None, max_tokens=8) == {"max_tokens": 8}
def test_codex_prompt_none_max_tokens_skips_length_hint():
prompt = ai_provider._codex_prompt([{"role": "user", "content": "hi"}], max_tokens=None)
assert "Keep the final answer" not in prompt
bounded = ai_provider._codex_prompt([{"role": "user", "content": "hi"}], max_tokens=300)
assert "Keep the final answer" in bounded
def test_ai_settings_keep_provider_models_separate(monkeypatch):
stored = {
"ai_provider": "openai_compat",