From f9c4a0ac96e54ea4677b0ed81b1e7603c2ceec1a Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:01:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(assistant):=20=E4=B8=8A=E6=B8=B8=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E7=A9=BA=E4=B8=B2=20reasoning=5Fcontent=20=E6=97=B6?= =?UTF-8?q?=E4=B9=9F=E4=BF=9D=E7=95=99=E8=AF=A5=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按字段存在性 (is not None) 判断, 区分"上游未返回"与"返回空串"; 流式 round_end、chat_service 下一轮请求、非流式工具循环三处均补齐, 并加回归用例。 Signed-off-by: kevin9327 <5299031+kevin9327@users.noreply.github.com> --- backend/app/custom/assistant/chat_service.py | 6 ++-- backend/app/custom/assistant/streaming.py | 6 +++- backend/app/services/ai_provider.py | 2 +- .../tests/test_assistant_reasoning_content.py | 28 ++++++++++++++----- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/backend/app/custom/assistant/chat_service.py b/backend/app/custom/assistant/chat_service.py index 99eb915e..32e60874 100644 --- a/backend/app/custom/assistant/chat_service.py +++ b/backend/app/custom/assistant/chat_service.py @@ -268,14 +268,14 @@ async def chat_stream( while True: round_text: list[str] = [] tool_calls: list[dict[str, Any]] = [] - reasoning_content = "" + reasoning_content: str | None = None async for event in stream_round(req_messages, schemas, temperature=0.3, timeout=240.0): if event["type"] == "text": round_text.append(event["delta"]) await queue.put({"type": "delta", "content": event["delta"]}) elif event["type"] == "round_end": tool_calls = event.get("tool_calls") or [] - reasoning_content = event.get("reasoning_content") or "" + reasoning_content = event.get("reasoning_content") if not tool_calls: if not round_text and not text_seen: @@ -322,7 +322,7 @@ async def chat_stream( assistant_message["content"] = "".join(round_text) text_seen = True # DeepSeek 思考模式要求工具轮的 reasoning_content 原样回传, 否则下一轮 400。 - if reasoning_content: + if reasoning_content is not None: assistant_message["reasoning_content"] = reasoning_content req_messages.append(assistant_message) diff --git a/backend/app/custom/assistant/streaming.py b/backend/app/custom/assistant/streaming.py index dfa4dbfc..ca2584a8 100644 --- a/backend/app/custom/assistant/streaming.py +++ b/backend/app/custom/assistant/streaming.py @@ -125,6 +125,8 @@ async def stream_openai_round( content_seen = False reasoning_seen = False reasoning_parts: list[str] = [] + # 上游显式返回 reasoning_content (哪怕是空串) 时也要在 round_end 里回传该字段。 + reasoning_field_seen = False finish_reason = "" calls: dict[int, dict[str, Any]] = {} ordered: list[dict[str, Any]] = [] @@ -141,6 +143,8 @@ async def stream_openai_round( if delta is None: continue reasoning = getattr(delta, "reasoning_content", None) + if reasoning is not None: + reasoning_field_seen = True if reasoning: reasoning_seen = True reasoning_parts.append(str(reasoning)) @@ -191,7 +195,7 @@ async def stream_openai_round( "finish_reason": finish_reason, } # DeepSeek 思考模式: 带 tools 的后续请求缺了这一轮的 reasoning_content 会被 400。 - if reasoning_parts: + if reasoning_field_seen: end["reasoning_content"] = "".join(reasoning_parts) yield end return diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index fd1e61fe..bfa9158a 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -426,7 +426,7 @@ async def generate_ai_text_with_tools( assistant["content"] = message.content # DeepSeek 思考模式要求工具轮的 reasoning_content 原样回传, 否则下一轮 400。 reasoning = getattr(message, "reasoning_content", None) - if reasoning: + if reasoning is not None: assistant["reasoning_content"] = reasoning req_messages.append(assistant) diff --git a/backend/tests/test_assistant_reasoning_content.py b/backend/tests/test_assistant_reasoning_content.py index d64b7b2e..d04ce875 100644 --- a/backend/tests/test_assistant_reasoning_content.py +++ b/backend/tests/test_assistant_reasoning_content.py @@ -83,11 +83,24 @@ async def test_stream_round_end_without_reasoning_has_no_key(monkeypatch: pytest assert "reasoning_content" not in events[-1] -async def test_chat_service_sends_reasoning_content_back(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_stream_round_end_keeps_empty_reasoning_content(monkeypatch: pytest.MonkeyPatch) -> None: + # 上游显式返回空串时也要保留字段, 与"未返回该字段"区分开。 + events = await _events(monkeypatch, [ + _chunk({"reasoning_content": ""}), + _chunk({"tool_calls": [{"index": 0, "id": "call_1", "type": "function", + "function": {"name": "list_strategies", "arguments": "{}"}}]}, + finish_reason="tool_calls"), + ]) + + assert events[-1]["reasoning_content"] == "" + + +@pytest.mark.parametrize("reasoning", ["先看看有哪些策略", ""]) +async def test_chat_service_sends_reasoning_content_back(monkeypatch: pytest.MonkeyPatch, reasoning: str) -> None: captured: list[list[dict[str, Any]]] = [] script = [ {"tool_calls": [{"id": "call_1", "name": "list_strategies", "arguments": "{}"}], - "reasoning_content": "先看看有哪些策略"}, + "reasoning_content": reasoning}, {"text": "你有 3 个策略"}, ] @@ -97,7 +110,7 @@ async def test_chat_service_sends_reasoning_content_back(monkeypatch: pytest.Mon if step.get("text"): yield {"type": "text", "delta": step["text"]} end = {"type": "round_end", "tool_calls": step.get("tool_calls", []), "finish_reason": "stop"} - if step.get("reasoning_content"): + if "reasoning_content" in step: end["reasoning_content"] = step["reasoning_content"] yield end @@ -115,15 +128,16 @@ async def test_chat_service_sends_reasoning_content_back(monkeypatch: pytest.Mon assert [json.loads(line) for line in lines if '"error"' in line] == [] assistant_msg = captured[1][-2] assert assistant_msg["role"] == "assistant" - assert assistant_msg["reasoning_content"] == "先看看有哪些策略" + assert assistant_msg["reasoning_content"] == reasoning -async def test_tool_loop_sends_reasoning_content_back(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize("reasoning", ["先跑一次回测", ""]) +async def test_tool_loop_sends_reasoning_content_back(monkeypatch: pytest.MonkeyPatch, reasoning: str) -> None: replies = [ ChatCompletionMessage.model_validate({ "role": "assistant", "content": None, - "reasoning_content": "先跑一次回测", + "reasoning_content": reasoning, "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "run_backtest", "arguments": "{}"}}], }), @@ -147,5 +161,5 @@ async def test_tool_loop_sends_reasoning_content_back(monkeypatch: pytest.Monkey execute_tool=fake_execute, ) - assert messages[1]["reasoning_content"] == "先跑一次回测" + assert messages[1]["reasoning_content"] == reasoning assert "reasoning_content" not in messages[3]