mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 20:14:16 +08:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user