From 0ee3aa8f1f4a25d8ca6ecb9d70553b5f22c079ab Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Mon, 17 Aug 2026 17:47:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=88=86=E6=9E=90=E7=B1=BB=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=94=BE=E5=BC=80=20max=5Ftokens=20=E9=99=90=E5=88=B6?= =?UTF-8?q?=20=E2=80=94=20=E4=BF=AE=E5=A4=8D=E6=8E=A8=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=AD=A3=E6=96=87=200=20=E5=AD=97=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(实测钉死): 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 无新增。 --- backend/app/services/ai_provider.py | 35 +++++++++++++------ .../app/services/concept_rotation_analyzer.py | 10 +++++- backend/app/services/financial_analyzer.py | 9 ++++- backend/app/services/market_recap.py | 9 ++++- backend/app/services/stock_analyzer.py | 11 +++++- backend/tests/test_ai_provider.py | 18 ++++++++++ 6 files changed, 77 insertions(+), 15 deletions(-) diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index a7cd3a6..7b5f9ae 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -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") diff --git a/backend/app/services/concept_rotation_analyzer.py b/backend/app/services/concept_rotation_analyzer.py index d01683e..9dbbdc5 100644 --- a/backend/app/services/concept_rotation_analyzer.py +++ b/backend/app/services/concept_rotation_analyzer.py @@ -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) diff --git a/backend/app/services/financial_analyzer.py b/backend/app/services/financial_analyzer.py index cd5c597..494f53f 100644 --- a/backend/app/services/financial_analyzer.py +++ b/backend/app/services/financial_analyzer.py @@ -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) diff --git a/backend/app/services/market_recap.py b/backend/app/services/market_recap.py index 333a3b1..5be1ff6 100644 --- a/backend/app/services/market_recap.py +++ b/backend/app/services/market_recap.py @@ -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) diff --git a/backend/app/services/stock_analyzer.py b/backend/app/services/stock_analyzer.py index 0c2af8e..949054e 100644 --- a/backend/app/services/stock_analyzer.py +++ b/backend/app/services/stock_analyzer.py @@ -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) diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 75930fb..79adf43 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -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",