diff --git a/backend/package/yuxi/models/chat.py b/backend/package/yuxi/models/chat.py index 0b7af4525..1c7a8e99d 100644 --- a/backend/package/yuxi/models/chat.py +++ b/backend/package/yuxi/models/chat.py @@ -162,8 +162,11 @@ def _standardize_content(self, message: AIMessage, raw: dict) -> None: def _get_request_payload(self, input_, *, stop=None, **kwargs): """支持推理的供应商在工具续答时接收完整原文。""" payload = super()._get_request_payload(input_, stop=stop, **kwargs) - if self.preserve_reasoning and "messages" in payload: - originals = self._convert_input(input_).to_messages() + if "messages" not in payload: + return payload + originals = self._convert_input(input_).to_messages() + _sanitize_wire_invalid_tool_calls(payload["messages"], originals) + if self.preserve_reasoning: for original, wire in zip(originals, payload["messages"], strict=True): if isinstance(original, AIMessage): content = wire.get("content") @@ -249,5 +252,40 @@ def select_model(model_spec: str, **kwargs) -> LangChainChatAdapter: ) +def _sanitize_wire_invalid_tool_calls(messages: list[dict], originals: list) -> None: + """把 wire 消息里来自 invalid_tool_calls 的截断 function 转成失败反馈。 + + langchain_openai 把 AIMessage.invalid_tool_calls 序列化成 type:"function"、 + arguments 为截断 JSON 的 tool_call,DeepSeek 等接口因此报参数解析失败。这里在 + 发送边界按 tool_call id 移除这些截断调用,并在 content 里给模型明确的 text 反馈, + 而不是发出畸形参数。原始 checkpoint(LangChain 消息对象)不动。 + """ + for wire, original in zip(messages, originals, strict=True): + if not isinstance(original, AIMessage): + continue + invalid = list(original.invalid_tool_calls or []) + if not invalid: + continue + invalid_ids = {call.get("id") for call in invalid if call.get("id")} + tool_calls = wire.get("tool_calls") + if isinstance(tool_calls, list): + kept = [call for call in tool_calls if call.get("id") not in invalid_ids] + if kept: + wire["tool_calls"] = kept + else: + wire.pop("tool_calls", None) + feedback = ";".join( + f"[工具调用失败] {call.get('name') or 'unknown'}: {call.get('error') or 'arguments malformed or truncated'}" + for call in invalid + ) + content = wire.get("content") + if isinstance(content, list): + content.append({"type": "text", "text": feedback}) + elif content: + wire["content"] = [{"type": "text", "text": content}, {"type": "text", "text": feedback}] + else: + wire["content"] = [{"type": "text", "text": feedback}] + + if __name__ == "__main__": pass diff --git a/backend/test/unit/models/test_chat_invalid_tool_call_sanitize.py b/backend/test/unit/models/test_chat_invalid_tool_call_sanitize.py new file mode 100644 index 000000000..dbc700e30 --- /dev/null +++ b/backend/test/unit/models/test_chat_invalid_tool_call_sanitize.py @@ -0,0 +1,178 @@ +"""invalid_tool_call 窄方案:在 ChatCompletionsAdapter 发送边界净化 wire 载荷。 + +langchain_openai 把 AIMessage.invalid_tool_calls 序列化成 type:"function"、arguments +为截断 JSON 的 tool_call,DeepSeek 等接口因此报参数解析失败。窄方案在 +ChatCompletionsAdapter._get_request_payload 里按 id 移除这些截断调用,并在 content +里给模型明确的 text 反馈,覆盖同步/异步/流式/非流式四条路径;不改 Anthropic/Gemini, +不删孤儿 ToolMessage(按根因单独处理),原始 checkpoint 消息对象不动。 +""" + +from __future__ import annotations + +import json +import os + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, InvalidToolCall +from pydantic import SecretStr + +os.environ.setdefault("OPENAI_API_KEY", "test-key") + +from yuxi.models.chat import ChatCompletionsAdapter, _sanitize_wire_invalid_tool_calls + + +def _adapter() -> ChatCompletionsAdapter: + return ChatCompletionsAdapter( + model="deepseek-chat", + api_key=SecretStr("test-key"), + base_url="http://test.local/v1", + ) + + +def _invalid_call() -> InvalidToolCall: + return InvalidToolCall(name="kbs_search", args='{"query":', id="call-bad", error="Unterminated string") + + +def _messages_with_invalid() -> list: + return [ + HumanMessage(content="hi"), + AIMessage(content="", invalid_tool_calls=[_invalid_call()]), + ] + + +# ---- 单元:wire 净化函数 ---- + + +def test_pure_invalid_call_is_removed_and_feedback_added(): + wire = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"type": "function", "id": "call-bad", "function": {"name": "kbs_search", "arguments": '{"query":'}} + ], + }, + ] + originals = [ + HumanMessage(content="hi"), + AIMessage(content="", invalid_tool_calls=[_invalid_call()]), + ] + + _sanitize_wire_invalid_tool_calls(wire, originals) + + assert "tool_calls" not in wire[1] + assert wire[1]["content"] == [{"type": "text", "text": "[工具调用失败] kbs_search: Unterminated string"}] + + +def test_mixed_keeps_valid_call_and_adds_feedback(): + wire = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "id": "call-good", + "function": {"name": "kbs_search", "arguments": '{"query": "ok"}'}, + }, + {"type": "function", "id": "call-bad", "function": {"name": "kbs_search", "arguments": '{"query":'}}, + ], + }, + ] + originals = [ + AIMessage( + content="", + tool_calls=[{"id": "call-good", "name": "kbs_search", "args": {"query": "ok"}, "type": "tool_call"}], + invalid_tool_calls=[_invalid_call()], + ), + ] + + _sanitize_wire_invalid_tool_calls(wire, originals) + + assert [call["id"] for call in wire[0]["tool_calls"]] == ["call-good"] + assert wire[0]["content"] == [{"type": "text", "text": "[工具调用失败] kbs_search: Unterminated string"}] + + +def test_no_invalid_call_leaves_wire_unchanged(): + wire = [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "type": "function", + "id": "call-good", + "function": {"name": "kbs_search", "arguments": '{"query": "ok"}'}, + } + ], + } + ] + originals = [ + AIMessage( + content="", + tool_calls=[{"id": "call-good", "name": "kbs_search", "args": {"query": "ok"}, "type": "tool_call"}], + ), + ] + snapshot = json.loads(json.dumps(wire)) + + _sanitize_wire_invalid_tool_calls(wire, originals) + + assert wire == snapshot + + +# ---- 集成:真实 adapter + mock HTTP,捕获请求体 ---- + + +def _assert_body_has_no_invalid_tool_call(request) -> None: + body = json.loads(request.content) + assistant = body["messages"][1] + # 截断 function 已移除,换成 text 反馈 + assert "tool_calls" not in assistant + assert assistant["content"] == [{"type": "text", "text": "[工具调用失败] kbs_search: Unterminated string"}] + + +def _ok_response() -> dict: + return { + "choices": [{"message": {"role": "assistant", "content": "ok"}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + + +def _stream_response() -> str: + return 'data: {"choices":[{"delta":{"content":"ok"},"index":0}]}\n\ndata: [DONE]\n\n' + + +def test_invoke_sanitizes_request_body(httpx_mock): + httpx_mock.add_response(url="http://test.local/v1/chat/completions", json=_ok_response()) + + _adapter().invoke(_messages_with_invalid()) + + _assert_body_has_no_invalid_tool_call(httpx_mock.get_request()) + + +@pytest.mark.asyncio +async def test_ainvoke_sanitizes_request_body(httpx_mock): + httpx_mock.add_response(url="http://test.local/v1/chat/completions", json=_ok_response()) + + await _adapter().ainvoke(_messages_with_invalid()) + + _assert_body_has_no_invalid_tool_call(httpx_mock.get_request()) + + +def test_stream_sanitizes_request_body(httpx_mock): + httpx_mock.add_response(url="http://test.local/v1/chat/completions", text=_stream_response()) + + list(_adapter().stream(_messages_with_invalid())) + + _assert_body_has_no_invalid_tool_call(httpx_mock.get_request()) + + +@pytest.mark.asyncio +async def test_astream_sanitizes_request_body(httpx_mock): + httpx_mock.add_response(url="http://test.local/v1/chat/completions", text=_stream_response()) + + async for _ in _adapter().astream(_messages_with_invalid()): + pass + + _assert_body_has_no_invalid_tool_call(httpx_mock.get_request()) diff --git a/docs/develop-guides/decisions/implemented/2026-09-10-model-input-invalid-tool-call-sanitize.md b/docs/develop-guides/decisions/implemented/2026-09-10-model-input-invalid-tool-call-sanitize.md new file mode 100644 index 000000000..41836c410 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-09-10-model-input-invalid-tool-call-sanitize.md @@ -0,0 +1,45 @@ +# 模型输入净化:invalid_tool_call 的截断 function 在发送边界降级为失败反馈 + +状态:implemented +类型:bug-fix +Owner:backend/package/yuxi/models/chat.py + +## 问题 + +模型生成无效工具调用(JSON 参数截断/格式错)时,LangChain 把它记在 `AIMessage.invalid_tool_calls`。`langchain_openai` 序列化时把它转成 `type:"function"`、`arguments` 为截断 JSON 的 `tool_call`,DeepSeek 等接口因参数不合法报错并中断整轮。 + +## 根因澄清(对上一版假设的修正) + +初版把现象归为「`invalid_tool_calls` 序列化成 `invalid_tool_call` 变体」,并为此清空解析字段、收敛 `additional_kwargs`、删孤儿 `ToolMessage`、动态包装四个入口。实测 `langchain_openai` 1.6.0 的真实序列化推翻了该假设: + +- `AIMessage.invalid_tool_calls` 经 `_lc_invalid_tool_call_to_openai_tool_call` 转成 `type:"function"`,**不是** `invalid_tool_call` 变体;真正的问题是 `arguments` 截断导致参数解析失败。 +- content 数组里的 `{"type":"invalid_tool_call"}` block 在 `_convert_from_v1_to_chat_completions` 里已被丢弃,不会泄漏到 wire。 +- 孤儿 `ToolMessage` 的根因在历史裁剪/恢复/消息组装,不是发送边界能安全修复的。 + +## 决策 + +收窄为**只在 Chat Completions 发送边界**处理「截断 function」:`ChatCompletionsAdapter._get_request_payload` 里用原始消息(`invalid_tool_calls` 的 id)对账 wire 载荷,按 id 移除这些截断 `tool_calls`,并在 content 里追加 text 反馈「`[工具调用失败] name: error`」——给模型明确的失败反馈,而不是发出畸形参数。 + +- 只影响 `ChatCompletionsAdapter`(OpenAI 兼容协议);Anthropic/Gemini 有自己的消息格式,不处理。 +- 不改消息对象(原始 checkpoint 不动),净化只落在序列化后的 wire 载荷。 +- 同步/异步/流式/非流式四条路径都经 `_get_request_payload`,一处覆盖。 +- 不删孤儿 `ToolMessage`:id 存在不代表调用与响应顺序合法,按根因另行处理。 + +## 替代方案 + +- 动态 `_InvalidToolCallFilterMixin` 包装四个入口:覆盖所有供应商、范围过大,且掩盖消息链路本身的问题;拒绝。 +- 在 `_convert_message_to_dict` 打补丁:第三方内部实现,升级即失效;拒绝。 +- 全历史 id 集合清洗工具响应:会把「响应在前、调用在后」等非法序列保留,或误删实际执行结果;拒绝。 + +## 后果 + +发送给 DeepSeek 等接口的载荷不再含参数截断的 `function` 调用,改为明确的文本失败反馈。`invalid_tool_calls` 属性仍在 checkpoint 里原样保留(可观测、可追溯),仅在 wire 边界降级。非 OpenAI 兼容供应商不受影响。 + +## 验证 + +`backend/test/unit/models/test_chat_invalid_tool_call_sanitize.py`(真实 adapter + mock HTTP,断言最终请求体): + +- 纯无效调用:`tool_calls` 移除,content 追加失败反馈; +- 有效/无效混合:wire 只保留 `call-good`,追加失败反馈; +- 无无效调用:wire 原样不变; +- 四条路径(`invoke`/`ainvoke`/`stream`/`astream`)都净化。