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.middleware import AgentMiddleware
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 (
build_latest_reference_prompt,
@@ -105,7 +105,10 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]):
)
patched_messages = [
*messages,
SystemMessage(content=prompt),
HumanMessage(
content=prompt,
additional_kwargs={"hide_from_ui": True, "lc_source": "rag_citation"},
),
]
return request.override(messages=patched_messages)
@@ -14,13 +14,13 @@ from typing import override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
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]):
"""达到澄清轮数上限时,强制模型调用 ``setup_writing`` 提交。
软约束(注入 SystemMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
软约束(注入隐藏 HumanMessage)+ 硬约束(``tool_choice`` 锁定工具)双保险:即使个别模型
无视提示,``tool_choice`` 也会逼它产出一次 ``setup_writing`` 调用。
"""
@@ -33,8 +33,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
human_count = sum(1 for m in messages if isinstance(m, HumanMessage))
return human_count >= self._max_rounds
def _reminder(self) -> SystemMessage:
return SystemMessage(
def _reminder(self) -> HumanMessage:
return HumanMessage(
content=(
f"【系统强制】意图澄清已达 {self._max_rounds} 轮上限。"
"现在必须立即调用 setup_writing 提交写作配置,禁止再向用户提任何问题。"
@@ -42,7 +42,8 @@ class WritingSetupRoundCapMiddleware(AgentMiddleware[AgentState]):
"word_count_target 默认 800、target_audience 默认「普通读者」、"
"writing_mode 默认 loose、material_source 默认 knowledge_base、"
"article_type 不确定就留空。"
)
),
additional_kwargs={"hide_from_ui": True, "lc_source": "writing_setup_round_cap"},
)
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
import httpx
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
from langchain_core.outputs import ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage
from langchain_core.outputs import ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_langchain_messages
def _fix_messages(messages: list) -> list:
"""Sanitize incoming messages for MindIE compatibility.
@@ -55,7 +57,7 @@ def _fix_messages(messages: list) -> list:
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]]:
@@ -9,10 +9,12 @@ on all assistant messages when thinking mode is enabled.
from typing import Any
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage
from langchain_deepseek import ChatDeepSeek
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage
from langchain_deepseek import ChatDeepSeek
from deerflow.models.message_ordering import normalize_chat_payload_messages
class PatchedChatDeepSeek(ChatDeepSeek):
"""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)]
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):
reasoning_content = ai_msg.additional_kwargs.get("reasoning_content")
if reasoning_content is not None:
payload_messages[idx]["reasoning_content"] = reasoning_content
return payload
for (idx, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
reasoning_content = ai_msg.additional_kwargs.get("reasoning_content")
if reasoning_content is not None:
payload_messages[idx]["reasoning_content"] = reasoning_content
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.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import (
_convert_delta_to_message_chunk,
_create_usage_metadata,
)
from langchain_openai.chat_models.base import (
_convert_delta_to_message_chunk,
_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)
@@ -112,9 +114,10 @@ class PatchedChatMiniMax(ChatOpenAI):
**extra_body,
"reasoning_split": True,
}
else:
payload["extra_body"] = {"reasoning_split": True}
return payload
else:
payload["extra_body"] = {"reasoning_split": True}
normalize_chat_payload_messages(payload)
return payload
def _convert_chunk_to_generation_chunk(
self,
@@ -29,9 +29,11 @@ from typing import Any
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import AIMessage, AIMessageChunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from deerflow.models.message_ordering import normalize_chat_payload_messages
class PatchedChatOpenAI(ChatOpenAI):
"""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):
_restore_tool_call_signatures(payload_msg, orig_msg)
_restore_reasoning_content(payload_msg, orig_msg)
else:
# Fallback: match assistant-role entries positionally against AIMessages.
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"]
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg)
return payload
else:
# Fallback: match assistant-role entries positionally against AIMessages.
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"]
for (_, payload_msg), ai_msg in zip(assistant_payloads, ai_messages):
_restore_tool_call_signatures(payload_msg, ai_msg)
_restore_reasoning_content(payload_msg, ai_msg)
normalize_chat_payload_messages(payload)
return payload
def _convert_chunk_to_generation_chunk(
self,
@@ -32,9 +32,11 @@ from langchain_core.messages import (
)
from langchain_core.messages.tool import tool_call_chunk
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_openai import ChatOpenAI
from langchain_openai.chat_models.base import _create_usage_metadata
from langchain_openai import ChatOpenAI
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:
"""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):
if payload_msg.get("role") == "assistant" and isinstance(orig_msg, AIMessage):
_restore_reasoning_field(payload_msg, orig_msg)
else:
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"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg)
return payload
else:
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"]
for payload_msg, ai_msg in zip(assistant_payloads, ai_messages):
_restore_reasoning_field(payload_msg, ai_msg)
normalize_chat_payload_messages(payload)
return payload
def _create_chat_result(self, response: dict | openai.BaseModel, generation_info: dict | None = None) -> ChatResult:
"""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