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",