Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions backend/package/yuxi/models/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
178 changes: 178 additions & 0 deletions backend/test/unit/models/test_chat_invalid_tool_call_sanitize.py
Original file line number Diff line number Diff line change
@@ -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())
Original file line number Diff line number Diff line change
@@ -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`)都净化。