fix: normalize system messages before model calls

This commit is contained in:
吉鑫升 committed 2026-08-17 10:12:43 +08:00
1 parent a28ad2140e
commit 94cf37a2ae
9 files changed
+236 -52

No files matched your search

@@ -12,7 +12,7 @@ from typing import Any, override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import SystemMessage from langchain_core.messages import HumanMessage
from deerflow.runtime.references import ( from deerflow.runtime.references import (
build_latest_reference_prompt, build_latest_reference_prompt,
@@ -105,7 +105,10 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
) )
patched_messages = [ patched_messages = [
*messages, *messages,
SystemMessage(content=prompt), HumanMessage(
content=prompt,
additional_kwargs={"hide_from_ui": True, "lc_source": "rag_citation"},
),
] ]
return request.override(messages=patched_messages) return request.override(messages=patched_messages)
@@ -14,13 +14,13 @@ from typing import override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.messages import HumanMessage
class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]): class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
"""达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。 """达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。
软约束(注入 SystemMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型 软约束(注入隐藏 HumanMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。 无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。
""" """
@@ -33,8 +33,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
human_count = sum(1 for m in messages if isinstance(m, HumanMessage)) human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
return human_count >= self._max_rounds return human_count >= self._max_rounds
def _reminder(self) -> SystemMessage: def _reminder(self) -> HumanMessage:
return SystemMessage( return HumanMessage(
content=( content=(
f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。" f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。"
"现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。" "现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。"
@@ -42,7 +42,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
"word_count_target 默认 800、target_audience 默认「普通读者」、" "word_count_target 默认 800、target_audience 默认「普通读者」、"
"writing_mode 默认 loose、material_source 默认 knowledge_base、" "writing_mode 默认 loose、material_source 默认 knowledge_base、"
"article_type 不确定就留空。" "article_type 不确定就留空。"
) ),
additional_kwargs={"hide_from_ui": True, "lc_source": "writing_setup_round_cap"},
) )
def _patch(self, request: ModelRequest) -> ModelRequest: def _patch(self, request: ModelRequest) -> ModelRequest:
@@ -0,0 +1,82 @@
"""Utilities for provider-specific chat message ordering constraints."""
from __future__ import annotations
from typing import Any
from langchain_core.messages import BaseMessage, SystemMessage
_SYSTEM_SEPARATOR = "\n\n---\n\n"
def normalize_chat_payload_messages(payload: dict[str, Any]) -> None:
"""Ensure payload ``system`` messages are represented once at the beginning.
Several OpenAI-compatible gateways reject requests when a transient
``system`` message appears after user/assistant/tool history. LangChain and
DeerFlow middleware can legitimately create those transient messages, so the
provider boundary folds every system payload into a single leading message
while preserving all non-system messages in their original order.
"""
messages = payload.get("messages")
if not isinstance(messages, list):
return
normalized = _normalize_payload_message_list(messages)
if normalized is not messages:
payload["messages"] = normalized
def normalize_langchain_messages(messages: list[BaseMessage]) -> list[BaseMessage]:
"""Return LangChain messages with all system context merged at the front."""
system_messages = [message for message in messages if isinstance(message, SystemMessage)]
if not system_messages:
return messages
first_non_system_index = next((idx for idx, message in enumerate(messages) if not isinstance(message, SystemMessage)), len(messages))
if len(system_messages) == first_non_system_index:
return messages
merged = system_messages[0].model_copy(
update={
"content": _merge_contents([message.content for message in system_messages]),
}
)
return [merged, *(message for message in messages if not isinstance(message, SystemMessage))]
def _normalize_payload_message_list(messages: list[Any]) -> list[Any]:
system_messages = [message for message in messages if isinstance(message, dict) and message.get("role") == "system"]
if not system_messages:
return messages
first_non_system_index = next(
(
idx
for idx, message in enumerate(messages)
if not (isinstance(message, dict) and message.get("role") == "system")
),
len(messages),
)
if len(system_messages) == first_non_system_index:
return messages
merged_system = dict(system_messages[0])
merged_system["content"] = _merge_contents([message.get("content") for message in system_messages])
return [merged_system, *(message for message in messages if not (isinstance(message, dict) and message.get("role") == "system"))]
def _merge_contents(contents: list[Any]) -> Any:
if all(isinstance(content, str) for content in contents):
return _SYSTEM_SEPARATOR.join(content for content in contents if content)
blocks: list[Any] = []
for idx, content in enumerate(contents):
if idx > 0:
blocks.append({"type": "text", "text": _SYSTEM_SEPARATOR})
if isinstance(content, list):
blocks.extend(content)
elif content is not None:
blocks.append({"type": "text", "text": str(content)})
return blocks
@@ -6,10 +6,12 @@ import uuid
from collections.abc import Iterator from collections.abc import Iterator
import httpx import httpx
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
from langchain_core.outputs import ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_langchain_messages
def _fix_messages(messages: list) -> list: def _fix_messages(messages: list) -> list:
"""Sanitize incoming messages for MindIE compatibility. """Sanitize incoming messages for MindIE compatibility.
@@ -55,7 +57,7 @@ def _fix_messages(messages: list) -> list:
fixed.append(msg.model_copy(update={"content": text})) fixed.append(msg.model_copy(update={"content": text}))
return fixed return normalize_langchain_messages(fixed)
def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]: def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]:
@@ -9,10 +9,12 @@ on all assistant messages when thinking mode is enabled.
from typing import Any from typing import Any
from langchain_core.language_models import LanguageModelInput from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage from langchain_core.messages import AIMessage
from langchain_deepseek import ChatDeepSeek from langchain_deepseek import ChatDeepSeek
from deerflow.models.message_ordering import normalize_chat_payload_messages
class PatchedChatDeepSeek(ChatDeepSeek): class PatchedChatDeepSeek(ChatDeepSeek):
"""ChatDeepSeek with proper reasoning_content preservation. """ChatDeepSeek with proper reasoning_content preservation.
@@ -65,9 +67,10 @@ class PatchedChatDeepSeek(ChatDeepSeek):
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
reasoning_content = ai_msg.additional_kwargs.get("reasoning_content") reasoning_content = ai_msg.additional_kwargs.get("reasoning_content")
if reasoning_content is not None: if reasoning_content is not None:
payload_messages[idx]["reasoning_content"] = reasoning_content payload_messages[idx]["reasoning_content"] = reasoning_content
return payload normalize_chat_payload_messages(payload)
return payload
@@ -20,11 +20,13 @@ from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import ( from langchain_openai.chat_models.base import (
_convert_delta_to_message_chunk, _convert_delta_to_message_chunk,
_create_usage_metadata, _create_usage_metadata,
) )
from deerflow.models.message_ordering import normalize_chat_payload_messages
_THINK_TAG_RE = re.compile(r"<think>\s*(.*?)\s*</think>", re.DOTALL) _THINK_TAG_RE = re.compile(r"<think>\s*(.*?)\s*</think>", re.DOTALL)
@@ -112,9 +114,10 @@ class PatchedChatMiniMax(ChatOpenAI):
**extra_body, **extra_body,
"reasoning_split": True, "reasoning_split": True,
} }
else: else:
payload["extra_body"] = {"reasoning_split": True} payload["extra_body"] = {"reasoning_split": True}
return payload normalize_chat_payload_messages(payload)
return payload
def _convert_chunk_to_generation_chunk( def _convert_chunk_to_generation_chunk(
self, self,
@@ -29,9 +29,11 @@ from typing import Any
from langchain_core.language_models import LanguageModelInput from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages
class PatchedChatOpenAI(ChatOpenAI): class PatchedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway. """ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
@@ -87,15 +89,16 @@ class PatchedChatOpenAI(ChatOpenAI):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_tool_call_signatures(payload_msg, orig_msg) _restore_tool_call_signatures(payload_msg, orig_msg)
_restore_reasoning_content(payload_msg, orig_msg) _restore_reasoning_content(payload_msg, orig_msg)
else: else:
# Fallback: match assistant-role entries positionally against AIMessages. # Fallback: match assistant-role entries positionally against AIMessages.
ai_messages = [m for m in original_messages if isinstance(m, AIMessage)] ai_messages = [m for m in original_messages if isinstance(m, AIMessage)]
assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"] assistant_payloads = [(i, m) for i, m in enumerate(payload_messages) if m.get("role") == "assistant"]
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages): for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg) _restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg) _restore_reasoning_content(payload_msg, ai_msg)
return payload normalize_chat_payload_messages(payload)
return payload
def _convert_chunk_to_generation_chunk( def _convert_chunk_to_generation_chunk(
self, self,
@@ -32,9 +32,11 @@ from langchain_core.messages import (
) )
from langchain_core.messages.tool import tool_call_chunk from langchain_core.messages.tool import tool_call_chunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata from langchain_openai.chat_models.base import _create_usage_metadata
from deerflow.models.message_ordering import normalize_chat_payload_messages
def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None: def _normalize_vllm_chat_template_kwargs(payload: dict[str, Any]) -> None:
"""Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``. """Map DeerFlow's legacy ``thinking`` toggle to vLLM/Qwen's ``enable_thinking``.
@@ -182,13 +184,14 @@ class VllmChatModel(ChatOpenAI):
for payload_msg, orig_msg in zip(payload_messages, original_messages): for payload_msg, orig_msg in zip(payload_messages, original_messages):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage): if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_reasoning_field(payload_msg, orig_msg) _restore_reasoning_field(payload_msg, orig_msg)
else: else:
ai_messages = [message for message in original_messages if isinstance(message, AIMessage)] ai_messages = [message for message in original_messages if isinstance(message, AIMessage)]
assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"] assistant_payloads = [message for message in payload_messages if message.get("role") == "assistant"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages): for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg) _restore_reasoning_field(payload_msg, ai_msg)
return payload normalize_chat_payload_messages(payload)
return payload
def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult: def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult:
"""Preserve vLLM reasoning on non-streaming responses.""" """Preserve vLLM reasoning on non-streaming responses."""
@@ -0,0 +1,84 @@
import pytest
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
from deerflow.models.message_ordering import normalize_chat_payload_messages, normalize_langchain_messages
from deerflow.models.patched_openai import PatchedChatOpenAI
def _multi_turn_payload_with_late_systems(turns: int):
messages = [{"role": "system", "content": "base system"}]
for idx in range(turns):
messages.append({"role": "user", "content": f"user {idx}"})
messages.append({"role": "assistant", "content": f"assistant {idx}"})
if idx >= 9 and (idx + 1) % 10 == 0:
messages.append({"role": "system", "content": f"late summary after {idx + 1} turns"})
messages.append({"role": "system", "content": "tail forced instruction"})
return {"messages": messages}
@pytest.mark.parametrize("turns", [12, 25, 50])
def test_normalize_chat_payload_messages_merges_late_systems_after_many_turns(turns):
payload = _multi_turn_payload_with_late_systems(turns)
normalize_chat_payload_messages(payload)
roles = [message["role"] for message in payload["messages"]]
assert roles[0] == "system"
assert roles.count("system") == 1
assert roles[1:5] == ["user", "assistant", "user", "assistant"]
assert "base system" in payload["messages"][0]["content"]
assert "late summary after 10 turns" in payload["messages"][0]["content"]
assert "tail forced instruction" in payload["messages"][0]["content"]
def test_normalize_langchain_messages_moves_late_systems_before_history():
messages = [SystemMessage(content="base system")]
for idx in range(12):
messages.append(HumanMessage(content=f"user {idx}"))
messages.append(AIMessage(content=f"assistant {idx}"))
if idx == 10:
messages.append(SystemMessage(content="late compaction summary"))
normalized = normalize_langchain_messages(messages)
assert isinstance(normalized[0], SystemMessage)
assert sum(isinstance(message, SystemMessage) for message in normalized) == 1
assert normalized[0].content == "base system\n\n---\n\nlate compaction summary"
assert isinstance(normalized[1], HumanMessage)
assert normalized[1].content == "user 0"
@pytest.mark.parametrize("turns", [12, 25, 50])
def test_patched_openai_payload_has_only_leading_system_after_many_turns(turns):
model = PatchedChatOpenAI(
model="test-model",
api_key="test-key",
base_url="http://127.0.0.1:9/v1",
)
messages = [SystemMessage(content="base system")]
for idx in range(turns):
messages.append(HumanMessage(content=f"user {idx}"))
messages.append(AIMessage(content=f"assistant {idx}"))
if idx >= 9 and (idx + 1) % 10 == 0:
messages.append(SystemMessage(content=f"late system after {idx + 1} turns"))
messages.append(SystemMessage(content="tail system"))
payload = model._get_request_payload(messages)
roles = [message["role"] for message in payload["messages"]]
assert roles[0] == "system"
assert roles.count("system") == 1
assert "base system" in payload["messages"][0]["content"]
assert "late system after 10 turns" in payload["messages"][0]["content"]
assert "tail system" in payload["messages"][0]["content"]
def test_writing_round_cap_reminder_is_not_late_system_message():
middleware = WritingSetupRoundCapMiddleware(max_rounds=3)
reminder = middleware._reminder()
assert isinstance(reminder, HumanMessage)
assert not isinstance(reminder, SystemMessage)
assert reminder.additional_kwargs["hide_from_ui"] is True