ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【Bug已解决】Bug: SummarizationMiddleware._find_safe_cutoff_point preserves orphan ToolMessages -> provi…

【Bug已解决】Bug: SummarizationMiddleware._find_safe_cutoff_point preserves orphan ToolMessages -> provi… 【Bug已解决】Bug: SummarizationMiddleware._find_safe_cutoff_point preserves orphan ToolMessages - provider 400 on next call一、现象长什么样SummarizationMiddleware在对话超长时把前面的历史压缩成摘要以腾出上下文的中间件在找安全截断点safe cutoff point时逻辑有缺陷保留了孤儿ToolMessage工具结果却把它前面配对的tool_use/tool_calls切掉了。Anthropic/OpenAI 等 API 要求ToolMessage必须对应一个存在的tool_use块工具结果得有来源。当中间件的截断把产生该工具的AIMessage含 tool_calls删了却留下ToolMessage时下一轮请求发给 provider 就会报400: tool_messages must be paired with tool_calls之类错误整个对话崩。现象很迷惑摘要生成得好好的、上下文也省了但下一次调用突然 400且错误指向某个工具消息没有对应调用——因为那个调用被摘要截断时删了工具消息却成了孤儿。二、背景带工具调用的对话消息序列是配对的AIMessage(tool_calls[call_1]) # 发起工具调用 ToolMessage(tool_call_idcall_1) # 工具结果 AIMessage(tool_calls[call_2]) ToolMessage(tool_call_idcall_2) ...SummarizationMiddleware压缩历史时要在某处截断旧的消息以放摘要。它找的safe cutoff应当保证被保留的ToolMessage一定有其tool_calls配对。但_find_safe_cutoff_point只看消息类型边界比如在某个AIMessage后截断没检查截断后留下的ToolMessage是否都有配对于是把AIMessage(tool_calls)切掉、留下ToolMessage→ 孤儿。三、根因根因两点截断不保证配对cutoff 点选择没考虑ToolMessage 必须有对应 tool_calls 在保留区内。孤儿 ToolMessage 残留被切掉的 tool_calls 对应的 ToolMessage 没一并删除。本质把截断当成纯按位置切消息忽略了工具消息的配对约束——ToolMessage 不能脱离其 tool_calls 单独存在。四、最小可运行复现下面缩略逻辑复现孤儿def bad_cutoff(messages): # 在某个 AIMessage 后截断没管 tool 配对 for i, m in enumerate(messages): if m.type ai and m.tool_calls: cut i break return messages[cut:] # 切掉了带 tool_calls 的 AI但后面的 ToolMessage 留下了 msgs [ AIMessage(tool_calls[call_1]), ToolMessage(tool_call_idcall_1), # 截断后成孤儿 AIMessage(content最终回答), ] kept bad_cutoff(msgs) # 含孤儿 ToolMessage - 下次调用 400修复截断时若保留某 ToolMessage必须确保对应 tool_calls 也在否则一并丢弃该 ToolMessage或保留整对。def safe_cutoff(messages): # 找到第一个无前置 tool_calls 的 AI 或普通消息作为起点丢弃其前的孤儿 ToolMessage kept [] for m in messages: if m.type tool and not any_has_call(messages_before_for(m)): continue # 孤儿丢弃 kept.append(m) return kept五、解决方案第一层最小直接修复最小修法_find_safe_cutoff_point在确定截断点时向后检查保留区内若有ToolMessage则其tool_call_id对应的tool_calls必须也在保留区否则把该ToolMessage一并丢弃或把整对前移。def find_safe_cutoff(messages): # 从后往前找第一个安全起点起点之前的 ToolMessage 都能配对 call_ids_in_kept set() for m in reversed(messages): if m.type ai: call_ids_in_kept.update(c[id] for c in getattr(m, tool_calls, [])) elif m.type tool: if m.tool_call_id not in call_ids_in_kept: # 这个 ToolMessage 是孤儿截断点必须在这之前 ... # 调整 cutoff return cutoff_index这一层保证截断后无孤儿 ToolMessage。六、解决方案第二层结构化改进把截断配对约束固化成策略对象作为单一事实来源明确 ToolMessage 必须配对。from dataclasses import dataclass dataclass(frozenTrue) class LangChainSummarizationMiddlewarePolicy: SummarizationMiddleware 截断配对策略的单一事实来源。 drop_orphan_tool_messages: bool True keep_tool_pairs_together: bool True def is_orphan(self, tool_msg, retained_messages) - bool: retained_ids set() for m in retained_messages: for c in getattr(m, tool_calls, []): retained_ids.add(c[id]) return tool_msg.tool_call_id not in retained_ids def validate(self) - None: if self.keep_tool_pairs_together and not self.drop_orphan_tool_messages: raise AssertionError(must drop orphans to keep pairs)中间件用policy.is_orphan在截断时剔除孤儿 ToolMessage。七、解决方案第三层断言 / CI 守护用 pytest 锁死无孤儿import pytest from policy import LangChainSummarizationMiddlewarePolicy as P class Msg: def __init__(self, type_, tool_callsNone, tool_call_idNone): self.type type_; self.tool_calls tool_calls or []; self.tool_call_id tool_call_id def test_orphan_detected(): p P() ai Msg(ai, tool_calls[{id: c1}]) tool Msg(tool, tool_call_idc1) # 只保留 tool没有 ai - 孤儿 assert p.is_orphan(tool, [tool]) is True def test_paired_not_orphan(): p P() ai Msg(ai, tool_calls[{id: c1}]) tool Msg(tool, tool_call_idc1) assert p.is_orphan(tool, [ai, tool]) is False def test_drop_requires_pair(): with pytest.raises(AssertionError): P(keep_tool_pairs_togetherTrue, drop_orphan_tool_messagesFalse).validate()CI 加一条SummarizationMiddleware单测构造带工具调用的长对话截断后断言保留区内无孤儿 ToolMessage且下一轮调用不 400。八、排查清单摘要后下次调用 400 tool message unpaired→ 截断留下孤儿 ToolMessage。cutoff 点是否考虑 tool 配对→ 必须保证 ToolMessage 有对应 tool_calls。孤儿 ToolMessage 是否丢弃→ 截断时一并删。是否整对保留/删除→ keep_tool_pairs_together。错误指向某个工具消息→ 它的调用被摘要删了。是否有无孤儿测试→ 必须有。九、小结SummarizationMiddleware找截断点时未保证工具消息配对留下孤儿ToolMessage其tool_calls被切掉导致下一轮请求被 provider 以 400 拒绝。根因是把截断当成纯位置切分忽略了 ToolMessage 必须配对的约束。第一层在截断时剔除/保留整对工具消息第二层用LangChainSummarizationMiddlewarePolicy把配对约束固化成单一事实来源第三层用 pytest 守护无孤儿。对话压缩的通用原则截断历史时必须维护工具消息的配对约束孤儿 ToolMessage 必须被剔除否则下游 API 直接拒绝。
返回列表