Tool 配对完整性ReAct Agent 最隐蔽的 Bug专栏信息《从零到一构建跨平台 AI 助手WeClaw 实战指南》专栏本文是模块八第 7 篇深入剖析 tool_call/tool_result 配对问题的诊断与修复。作者与项目作者简介翁勇刚 WENG YONGGANG新概念龙虾-WeClaw 开发团队负责人一群专注于跨平台 AI 应用的实践者理念“再复杂的技术也能用代码讲清楚”项目地址https://github.com/wyg5208/weclaw.git官网地址https://weclaw.link作者 CSDNhttps://blog.csdn.net/yweng18摘要本文结构概览本文从一个同一个 tool_call_id 在 7 个 ReAct 步骤中反复报告缺失结果的诡异现象出发层层剥茧揭示根因——消息截断在 ReAct 中间状态发生导致 assistant 的 tool_calls 和 tool_results 被分离。然后对比两种修复策略占位消息 vs 剥离最终实现原子批量写入和统一 stub 策略。背景LLM API 要求每条 assistant 消息中的 tool_call 都必须有对应的 tool_result。当上下文截断破坏了这种配对时模型要么报错、要么重复执行。核心问题ReAct 循环中消息截断可能在 assistant(tool_calls) 和 tool(tool_result) 之间发生如何保证配对完整性解决方案原子批量写入 孤儿剥离 统一 stub 策略关键成果彻底消除孤儿消息问题ReAct 循环中不再出现缺失结果警告压缩和截断两条路径采用一致的 stub 策略适合读者ReAct Agent 开发者尤其是遇到工具调用异常问题的团队阅读时长约 12 分钟关键词ReAct、Tool 配对、孤儿消息、原子写入、消息截断一、诡异现象同一个工具被遗忘了 7 次1.1 日志中的异常[Agent] Step 1: calling search_web, read_file... [Validator] WARNING: tool_call call_abc missing result [Validator] FIX: added placeholder for call_abc [Agent] Step 2: calling analyze_data... [Validator] WARNING: tool_call call_abc missing result [Validator] FIX: added placeholder for call_abc [Agent] Step 3: calling write_report... [Validator] WARNING: tool_call call_abc missing result [Validator] FIX: added placeholder for call_abc ... (重复到 Step 7)问题call_abc这个 tool_call 在 Step 1 时明明有对应的 tool_result为什么后续每个步骤都说它缺失结果1.2 更诡异的是占位消息在累积Step 1: 添加了 1 个占位消息 Step 2: 添加了 2 个占位消息上次的 新的 Step 3: 添加了 3 个占位消息 ... Step 7: 添加了 7 个占位消息消息列表越来越长但问题始终没解决二、根因分析截断发生在 ReAct 中间状态2.1 ReAct 循环的消息写入时序Step 1 的消息序列 [msg_N] assistant: tool_calls: [call_abc(search_web), call_def(read_file)] [msg_N1] tool: call_abc → 搜索结果... [msg_N2] tool: call_def → 文件内容... Step 2 开始时 [msg_N] assistant: tool_calls: [call_abc, call_def] [msg_N1] tool: call_abc → 搜索结果... [msg_N2] tool: call_def → 文件内容... [msg_N3] assistant: 分析结果如下... ← Step 1 的回复2.2 截断发生的时机当消息数超过限制时截断函数_enforce_limit()被调用。在 ReAct 循环中这个函数可能在工具结果尚未全部写入时被调用中间状态Step 1 执行中 [msg_N] assistant: tool_calls: [call_abc, call_def] ← 已写入 [msg_N1] tool: call_abc → 搜索结果... ← 已写入 [msg_N2] ← 还没写入 此时触发截断 → msg_N1 被保留但 msg_N2 不存在 → call_def 的 tool_result 丢失 → 验证器报告缺失结果2.3 占位消息为什么越积越多# 旧方案添加占位消息有 Bugdefvalidate_and_fix(messages):formsginmessages:ifmsg[role]assistant:fortcinmsg.get(tool_calls,[]):tc_idtc[id]ifnothas_result(messages,tc_id):# 添加占位消息messages.append({role:tool,tool_call_id:tc_id,content:[结果已省略]})# 问题占位消息添加在 messages 的副本中# 原始消息列表未修改 → 下次调用 validate 时又检测到缺失根因validate_and_fix操作的是消息列表的副本占位消息被添加到副本中返回给 LLM但源数据数据库中的消息没有被修复。下一次 ReAct 步骤加载消息时又从数据库加载了无占位的原始消息于是问题再次出现。三、两种修复策略的对比[图片: 占位累积 vs 剥离策略 | 生成方式: 文生图 PROMPT: “Timeline diagram comparing two orphan tool_call handling strategies: Left side shows placeholder messages accumulating over 7 steps with growing red blocks getting larger each step, Right side shows clean stripping approach with stable green blocks of consistent size, technical timeline style with step numbers 1-7, clean white background”]3.1 策略 A占位消息旧方案# 为每个孤儿 tool_call 添加占位 tool_resultplaceholder{role:tool,tool_call_id:orphan_id,content:[Result omitted due to context compression]}messages.append(placeholder)问题副本操作不修复源头 → 累积泄漏每个 ReAct 步骤都添加新占位 → 消息列表膨胀7 个步骤 × 2 个孤儿 14 条冗余占位消息3.2 策略 B剥离孤儿新方案# 从 assistant 消息中移除没有结果的 tool_calldefstrip_orphan_tool_calls(messages):剥离孤儿 tool_call从 assistant 消息中移除无结果的调用resultlist(messages)# 浅拷贝# 收集所有有结果的 tool_call_idresult_ids{m[tool_call_id]forminresultifm.get(role)tool}# 遍历 assistant 消息移除孤儿 tool_callformsginresult:ifmsg.get(role)assistantandtool_callsinmsg:msg[tool_calls][tcfortcinmsg[tool_calls]iftc[id]inresult_ids]# 如果所有 tool_calls 都被移除了转为纯文本消息ifnotmsg[tool_calls]:delmsg[tool_calls]returnresult优势直接修改源数据中的 assistant 消息不添加额外消息 → 列表不膨胀一次剥离永久生效3.3 选择剥离策略的理由维度占位消息剥离孤儿消息膨胀累积增长稳定修复持久性仅当次有效永久修复API 兼容性好保留 tool_call 结构好移除无效调用信息保留中占位文本无信息量低完全移除实现复杂度低中四、原子批量写入4.1 问题分步写入的中间状态旧方案中ReAct 循环的消息是分步写入的# 旧方案分步写入asyncdefhandle_tool_calls(self,tool_calls):# 先写 assistant 消息awaitself.add_assistant_message(tool_callstool_calls)# 然后逐个执行工具并写入结果fortcintool_calls:resultawaitexecute_tool(tc)awaitself.add_tool_message(tc[id],result)# ⚠️ 此时可能触发截断检查# assistant(tool_calls) 已写入但后续 tool_result 尚未写入4.2 原子批量写入# 新方案原子批量写入asyncdefhandle_tool_calls(self,tool_calls):# 收集所有消息batch[]batch.append({role:assistant,tool_calls:tool_calls})fortcintool_calls:resultawaitexecute_tool(tc)batch.append({role:tool,tool_call_id:tc[id],content:result})# 一次性写入所有消息原子操作awaitself.add_message_batch(batch)4.3 add_message_batch 实现asyncdefadd_message_batch(self,messages):原子性批量添加消息 确保 assistant(tool_calls) tool(results) 作为一个整体写入 不会在中间状态被截断函数打断 # 先全部添加到内存列表self._messages.extend(messages)# 然后批量写入数据库awaitself._db.batch_insert(self.session_id,messages)# 最后检查是否需要截断此时所有消息都已完整ifself._needs_truncation():self._enforce_limit()五、统一 Stub 策略5.1 两条路径的一致性问题上下文管理有两条可能产生孤儿的路径截断路径_enforce_limit()截断消息时可能切断配对压缩路径ContextEngine.compress()压缩旧消息时可能丢失 tool_result两条路径需要使用一致的孤儿处理策略# 统一 stub 策略ORPHAN_STUB_CONTENT[Result unavailable due to context management]defensure_tool_pair_integrity(messages):确保所有 tool_call 都有对应的 tool_result 对于缺失结果的 tool_call添加 stub result而非剥离 注意这与 validate_and_fix 的剥离策略互补—— 剥离用于截断路径stub 用于压缩路径 result_ids{m[tool_call_id]forminmessagesifm.get(role)tool}stubs[]formsginmessages:ifmsg.get(role)assistant:fortcinmsg.get(tool_calls,[]):iftc[id]notinresult_ids:stubs.append({role:tool,tool_call_id:tc[id],content:ORPHAN_STUB_CONTENT})result_ids.add(tc[id])# 避免重复添加returnmessagesstubs5.2 为什么压缩路径用 stub 而非剥离截断路径孤儿是临时状态剥离更安全不增加消息数压缩路径摘要是永久替换stub 更安全保留 tool_call 结构模型知道之前调用了什么工具六、Pre-scan 与 Consecutive Loop 的协调陷阱6.1 验证流程的两个阶段defvalidate_message_structure(self,messages):验证消息结构完整性# Phase 1: Pre-scan预扫描# 快速检测是否有孤儿 tool_callconsumed_idsset()formsginmessages:ifmsg.get(role)tool:consumed_ids.add(msg[tool_call_id])# Phase 2: Consecutive Loop连续遍历# 逐对检查 assistant → tool 配对i0whileilen(messages):msgmessages[i]ifmsg.get(role)assistantandtool_callsinmsg:assistant_posi# 记录 assistant 的真实位置fortcinmsg[tool_calls]:# 向后查找对应的 tool_resultfoundFalseforjinrange(i1,min(i10,len(messages))):ifmessages[j].get(tool_call_id)tc[id]:foundTruebreakifnotfoundandtc[id]notinconsumed_ids:# 孤儿需要处理self._handle_orphan(messages,assistant_pos,tc)i16.2 陷阱_assistant_pos记录真实位置旧代码使用i - 1来定位 assistant 消息# 旧代码有 Bugassistant_msgmessages[i-1]# 假设 tool 消息前面一定是 assistant# 问题如果中间有 extras额外插入的消息i-1 可能不是 assistant新代码显式记录 assistant 的位置# 新代码修复assistant_posi# 在处理 assistant 消息时记录位置# ...# 后续使用 assistant_pos 定位 assistant 消息assistant_msgmessages[assistant_pos]七、总结与展望7.1 核心要点回顾孤儿消息的根因是中间状态截断ReAct 循环的分步写入导致配对断裂占位消息会累积副本操作不修复源头每个步骤都添加新占位原子批量写入治本assistant tool_results 作为整体写入两条路径需要一致策略截断用剥离压缩用 stub7.2 一个调试技巧当你看到同一个 tool_call_id 在多个步骤中反复报告缺失结果时首先检查消息截断是否发生在 ReAct 循环的中间状态。这几乎总是分步写入 中间截断的组合问题。下期预告《异步压缩让用户感知不到上下文整理》同步压缩的用户体验问题异步后台摘要的架构设计快照 hash 保护机制敬请期待版权声明本文为 CSDN 博主「翁勇刚」的原创文章遵循 CC 4.0 BY-SA 版权协议转载请附上原文出处链接及本声明。