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], messages: Sequence[Message],
*, *,
temperature: float | None = 0.3, temperature: float | None = 0.3,
max_tokens: int = 3000, max_tokens: int | None = 3000,
timeout: float = 180.0, timeout: float = 180.0,
) -> str: ) -> 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(): if is_codex_cli_provider():
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once( return await _run_openai_once(
@@ -234,13 +239,15 @@ async def stream_ai_text(
messages: Sequence[Message], messages: Sequence[Message],
*, *,
temperature: float | None = 0.5, temperature: float | None = 0.5,
max_tokens: int = 4000, max_tokens: int | None = 4000,
timeout: float = 180.0, timeout: float = 180.0,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider. """Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits. yields one complete chunk after the command exits.
max_tokens=None 表示不限制输出(同 generate_ai_text 的说明)。
""" """
if is_codex_cli_provider(): if is_codex_cli_provider():
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0)) 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], messages: Sequence[Message],
*, *,
temperature: float | None, temperature: float | None,
max_tokens: int, max_tokens: int | None,
timeout: float, timeout: float,
) -> str: ) -> str:
ai_key = secrets_store.get_ai_key() ai_key = secrets_store.get_ai_key()
@@ -295,7 +302,7 @@ async def _stream_openai(
messages: Sequence[Message], messages: Sequence[Message],
*, *,
temperature: float | None, temperature: float | None,
max_tokens: int, max_tokens: int | None,
timeout: float, timeout: float,
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key() ai_key = secrets_store.get_ai_key()
@@ -402,9 +409,15 @@ def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None:
return None return None
def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict: def _openai_kwargs(*, temperature: float | None, max_tokens: int | None) -> dict:
"""Build OpenAI create() kwargs; optional parameters are omitted when empty.""" """Build OpenAI create() kwargs; optional parameters are omitted when empty.
kwargs: dict = {"max_tokens": max_tokens}
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: if temperature is not None:
kwargs["temperature"] = temperature kwargs["temperature"] = temperature
if current_ai_provider() == OPENAI_PROVIDER: if current_ai_provider() == OPENAI_PROVIDER:
@@ -507,7 +520,7 @@ def _compact_error_text(text: str) -> str:
async def _run_codex_cli( async def _run_codex_cli(
messages: Sequence[Message], messages: Sequence[Message],
*, *,
max_tokens: int, max_tokens: int | None,
timeout: float, timeout: float,
) -> str: ) -> str:
prompt = _codex_prompt(messages, max_tokens=max_tokens) prompt = _codex_prompt(messages, max_tokens=max_tokens)
@@ -640,14 +653,14 @@ def _make_writable_and_retry(
raise exc_info[1] from None 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 = [ parts = [
"You are Tick Stock Panel's local AI provider.", "You are Tick Stock Panel's local AI provider.",
"This is a text-generation task. The working directory is intentionally empty.", "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.", "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.", "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.") parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
for message in messages: for message in messages:
role = message.get("role", "user") role = message.get("role", "user")
@@ -365,18 +365,26 @@ async def analyze_rotation_stream(
return return
user_prompt = _build_user_prompt(signals, overview, days, dates, focus, kind) user_prompt = _build_user_prompt(signals, overview, days, dates, focus, kind)
got_content = False
async for delta in stream_ai_text( async for delta in stream_ai_text(
[ [
{"role": "system", "content": _build_system_prompt(kind)}, {"role": "system", "content": _build_system_prompt(kind)},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
], ],
temperature=0.5, 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) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.exception("AI %s rotation analyze failed: %s", kind, e) logger.exception("AI %s rotation analyze failed: %s", kind, e)
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False) 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) 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 from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(fins, symbol, focus) user_prompt = _build_user_prompt(fins, symbol, focus)
got_content = False
async for delta in stream_ai_text( async for delta in stream_ai_text(
[ [
{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
], ],
temperature=0.4, 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) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001 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) yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return 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) 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 from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus) user_prompt = _build_user_prompt(overview, news or [], focus)
got_content = False
async for delta in stream_ai_text( async for delta in stream_ai_text(
[ [
{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
], ],
temperature=0.5, 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) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001 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) yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
return 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) 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) kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus, user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus,
asset_type=repo.resolve_asset_type(symbol)) asset_type=repo.resolve_asset_type(symbol))
got_content = False
async for delta in stream_ai_text( async for delta in stream_ai_text(
[ [
{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}, {"role": "user", "content": user_prompt},
], ],
temperature=0.5, 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) yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001 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) yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return 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) 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) 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): def test_ai_settings_keep_provider_models_separate(monkeypatch):
stored = { stored = {
"ai_provider": "openai_compat", "ai_provider": "openai_compat",