Skip to content

fix(models): 净化 invalid_tool_call 与孤儿 ToolMessage,规避 DeepSeek 等接口报错 - #1006

Merged
xerrors merged 5 commits into
xerrors:mainfrom
zgpnuaa:feat/invalid-tool-call-cleanup
Sep 11, 2026
Merged

fix(models): 净化 invalid_tool_call 与孤儿 ToolMessage,规避 DeepSeek 等接口报错#1006
xerrors merged 5 commits into
xerrors:mainfrom
zgpnuaa:feat/invalid-tool-call-cleanup

Conversation

@zgpnuaa

@zgpnuaa zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

现象

用 DeepSeek 等模型时,偶尔报两类错误导致整轮失败:

  1. unknown variant invalid_tool_call —— 模型生成无效工具调用(JSON 参数截断/格式错)时,LangChain 记录 invalid_tool_call,序列化后以该变体发往模型,DeepSeek 不认识。
  2. role='tool' must be a response to a preceding message with 'tool_calls' —— 孤儿 ToolMessagetool_call_id 已无对应 tool_calls)。

机理

现有的 normalize_tool_call_chunks 只处理了「流式续片空串归一化」这一半,没有「清空 invalid_tool_calls 属性」「把 content 里的 invalid_tool_call block 转文本」「删除孤儿 ToolMessage」这三步。

改进方法

在消息发送前做三层净化:

  1. invalid_tool_calls 属性清空;
  2. content 数组里的 invalid_tool_call block 转成文本 [工具调用失败] name: error
  3. 删除 tool_call_id 不在任何合法 tool_calls 里的孤儿 ToolMessage

通过 _InvalidToolCallFilterMixin 混入各类 ChatModel,重写 _generate/_agenerate/_stream/_astream 四个入口,统一在发送前净化。

效果

DeepSeek 等接口不再因 invalid_tool_call 变体或孤儿 ToolMessage 报错;单次工具调用失败降级为文本提示,不再拖垮整轮。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Codex Review:

预 review 基于 2fe64a994fe1

发现 1 个需修复的问题。

[P1] 清空解析字段后,原始无效 tool_call 会被重新序列化。 chat.py:42–45 只清空 invalid_tool_calls,没有处理 additional_kwargs["tool_calls"]。从 OpenAI 响应解析出的消息通常同时保留这两份表示。当该条消息只有无效调用时,清空后 langchain_openai_convert_message_to_dict 会回退使用 additional_kwargs,把同一条截断参数的调用再次发出;而下面的过滤又删除了对应 ToolMessage,形成仍有 tool_calls、却没有工具响应的请求。

已用实际 LangChain 消息和安装的 OpenAI 序列化函数复现:原始调用参数为 {"query":,经过本 PR 净化后,wire payload 仍包含该 malformed tool_call,配对 ToolMessage 则已消失。请在真正的发送边界一致处理原始/解析表示,并避免原地修改共享 state 消息;回归测试需断言最终 wire payload,覆盖纯 invalid、valid/invalid 混合及流式入口。

验证:执行了从该 head 提取净化函数、构造真实 AIMessage(additional_kwargs={"tool_calls": ...}) 并调用 _convert_message_to_dict 的最小复现。PR 未新增回归测试;本次未调用真实 provider、未运行该 head 全量单测或 Compose/E2E。请补充模型输入行为变更的 tracked decision。

- additional_kwargs["tool_calls"] 收敛为合法 id 子集,阻断 langchain_openai 回退重发
- 净化返回新消息对象,不再原地修改共享 graph state/checkpoint 消息
- wire payload 回归测试 6 个(纯无效/混合/不改入参/孤儿/内容块/流式入口)
- tracked decision
@zgpnuaa

zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

感谢 review,复现准确,已修复(最新 head 010a49b9)。

[P1] 已修复:净化同步收敛 additional_kwargs 原始表示

确认根因:langchain_openai.chat_models.base._convert_message_to_dicttool_callsinvalid_tool_calls 都为空时走 elif "tool_calls" in message.additional_kwargs,用 OpenAI 响应的原始 wire 表示序列化——所以只清空解析字段,反而让截断参数的调用被原样重发,而配对的 ToolMessage 已被过滤删除,形成「有 tool_calls、无工具响应」。

修复:

  • additional_kwargs["tool_calls"] 收敛为「id 在合法 tool_calls 内」的子集(无合法调用时即为空列表,直接阻断回退路径);
  • 净化改为返回新消息对象model_copy),不再原地修改入参——这些对象来自共享 graph state / checkpoint;
  • content 里的 invalid_tool_call block 转文本、孤儿 ToolMessage 删除保持不变。

回归测试(断言最终 wire payload)

新增 backend/test/unit/models/test_chat_invalid_tool_call_sanitize.py,用 _convert_message_to_dict 断言真正发给供应商的载荷:

  • 纯 invalid:wire 上不出现该调用;valid/invalid 混合:wire 只保留 call-good 及其原始参数;
  • 不原地修改入参(原消息 invalid_tool_callsadditional_kwargs 不变);
  • 孤儿 ToolMessage 随无效调用一起删除;
  • content 的 invalid_tool_call block 转文本;
  • 流式入口_stream / _astream)同样在发送前净化。

tracked decision:docs/develop-guides/decisions/implemented/2026-09-10-model-input-invalid-tool-call-sanitize.md

验证:该文件 6 passed;test/unit/models + test_model_provider_service.py 共 31 passed;ruff check/format/select-I 通过。未调用真实 provider。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Codex Review:

仅供参考:本轮预 review 基于 010a49b9e40a,不是正式批准或合并结论。

复查上次 P1:原始无效 tool_call 被重新序列化的问题已修复。 当前实现同步过滤 additional_kwargs["tool_calls"],并通过 model_copy(update=...) 避免原地修改共享消息;补充的 wire payload 回归测试覆盖纯无效、混合调用、孤儿 ToolMessage、content block、输入不变及流式入口。tracked decision 也已补充。

实际验证:在当前 head 独立快照运行 PYTHONPATH=package:server python -m pytest test/unit/models/test_chat_invalid_tool_call_sanitize.py -q --disable-warnings --tb=short,6 passed。本轮未发现新的可确认缺陷。

范围限制:wire payload 验证使用实际 langchain_openai 转换函数;流式包装入口使用 fake model。未调用真实 DeepSeek/Anthropic/Gemini,也未跑真实 provider E2E,因此本结论只确认上次缺陷及现有测试覆盖的行为,不代表所有供应商已验证。

@xerrors

xerrors commented Sep 10, 2026

Copy link
Copy Markdown
Owner

经过 Codex 质量评审,建议调整实现方案后再合入。这个问题值得修复,但目前通过动态 mixin 包装所有供应商、覆盖四个调用入口并全局删除孤儿 ToolMessage,范围偏大,也可能掩盖消息链路本身的问题。

建议采用更小、更明确的方案:

  1. 复用已有协议适配入口。 将必要的输入转换放到 ChatCompletionsAdapter._get_request_payload。已检查当前容器依赖,普通 Chat Completions 的同步、异步、流式和非流式路径都会经过这里,无需新增动态 _wrap_model 和四个方法覆盖,也不必在缺少故障证据时同时改动 Anthropic、Gemini。
  2. 区分两类故障。 本地序列化验证显示,AIMessage.invalid_tool_calls 属性会被转换成 type: "function",并不是直接发成 invalid_tool_call 变体。应先用脱敏失败样本确认 content 内部 block 泄漏与参数解析失败各自的实际载荷,再针对性转换;保留原始 checkpoint,并在需要降级时给模型明确的失败反馈。
  3. 不要通过全历史 ID 集合清洗工具响应。 ID 存在不代表调用与响应顺序合法;当前算法会保留“响应在前、调用在后”等非法序列。直接删除孤儿响应也可能丢掉实际执行结果。建议追踪历史裁剪、恢复或消息组装的具体根因,在对应入口修复,无法安全恢复时显式报错。
  4. 补齐实际请求验证。 通过真实 adapter 捕获 HTTP 请求体,覆盖上述四条调用路径、合法工具调用及响应不变、原始消息不变,以及能复现原始错误的负向案例。现有转换函数测试有价值,但 fake model 的入口测试不足以证明所有供应商真实发送路径都已修复。

建议本 PR 先收窄为有明确复现的 Chat Completions 协议兼容修复;孤儿工具消息按根因单独处理。这样实现更简单,行为边界和验证也更清晰。

评审范围:PR diff、现有适配器和容器内依赖源码;已执行本地消息序列化验证,未向真实供应商发请求,未运行完整回归测试。

按作者建议收窄:移除动态 _InvalidToolCallFilterMixin/_wrap_model 四入口包装,
移除孤儿 ToolMessage 全局删除,改在 ChatCompletionsAdapter._get_request_payload
的 wire 边界处理。

根因修正(实测 langchain_openai 1.6.0 序列化):
- invalid_tool_calls 属性转成 type:"function"(不是 invalid_tool_call 变体),
  真正问题是 arguments 截断导致参数解析失败;
- content 里的 invalid_tool_call block 已被 _convert_from_v1_to_chat_completions 丢弃。

修复:按 id 移除 wire 里来自 invalid_tool_calls 的截断 function,并在 content 追加
text 失败反馈。只影响 OpenAI 兼容协议(ChatCompletionsAdapter),不改 Anthropic/Gemini,
原始 checkpoint 不动。真实 adapter + mock HTTP 覆盖四条路径。
@zgpnuaa

zgpnuaa commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

感谢 review,四点都成立,已按此收窄(最新 head 26766448)。

采纳:收窄到 ChatCompletionsAdapter._get_request_payload

移除动态 _InvalidToolCallFilterMixin/_wrap_model 四入口包装、移除孤儿 ToolMessage 的全局 id 集合清洗,改在 ChatCompletionsAdapter._get_request_payload 的 wire 边界处理——普通 Chat Completions 的同步/异步/流式/非流式四条路径都经过这里,无需再包装 Anthropic/Gemini。

根因修正(实测 langchain_openai 1.6.0 的真实序列化)

你说得对,我上一版的根因假设错了。实测 _convert_message_to_dict + _convert_from_v1_to_chat_completions

  1. AIMessage.invalid_tool_calls_lc_invalid_tool_call_to_openai_tool_call 转成 type:"function"不是 invalid_tool_call 变体;真正的问题是 arguments 为截断 JSON,导致参数解析失败。
  2. content 数组里的 {"type":"invalid_tool_call"} block 在 _convert_from_v1_to_chat_completions 里已被丢弃,不会泄漏到 wire。

所以上一版「清空属性 + 收敛 additional_kwargs + content block 转文本」里,前两项是在修一个不存在的变体问题,第三项是空转(上游已丢弃)。已全部移除。

修复:截断 function 降级为明确失败反馈

_get_request_payload 里用原始消息的 invalid_tool_calls id 对账 wire 载荷,按 id 移除这些截断 tool_calls,并在 content 追加 [工具调用失败] name: error 文本反馈——给模型明确的失败反馈,而不是发出畸形参数。原始 checkpoint(LangChain 消息对象)不动;合法调用与其参数不受影响。

孤儿 ToolMessage 未在本 PR 处理,待按历史裁剪/恢复/消息组装的根因另行定位。

验证(真实 adapter + mock HTTP,断言最终请求体)

test_chat_invalid_tool_call_sanitize.py 7 passed:

  • 纯无效调用:tool_calls 移除、content 追加失败反馈;
  • 有效/无效混合:wire 只保留 call-good、追加失败反馈;
  • 无无效调用:wire 原样不变;
  • 四条路径(invoke/ainvoke/stream/astream)都净化。

全量 unit 1936 passed / 52 skippedruff check + ruff format --check + verify_engineering_contracts.py 通过。

未调用真实 DeepSeek;wire 载荷用真实 langchain_openai 序列化 + mock HTTP 捕获验证。若需要真实 provider E2E 或把孤儿 ToolMessage 根因一并定位,我再补。

Refactor _sanitize_wire_invalid_tool_calls function to handle invalid tool calls and provide feedback in wire messages.
@xerrors
xerrors merged commit 9820648 into xerrors:main Sep 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants