Tool 配对完整性:ReAct Agent 最隐蔽的 Bug
Tool 配对完整性:ReAct Agent 最隐蔽的 Bug
专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏
本文是模块八第 7 篇,深入剖析 tool_call/tool_result 配对问题的诊断与修复。
作者与项目
作者简介:翁勇刚 WENG YONGGANG
新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者
理念:“再复杂的技术,也能用代码讲清楚”
- 项目地址:https://github.com/wyg5208/weclaw.git
- 官网地址:https://weclaw.link
- 作者 CSDN:https://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_N+1] tool: call_abc → "搜索结果..." [msg_N+2] tool: call_def → "文件内容..." Step 2 开始时: [msg_N] assistant: tool_calls: [call_abc, call_def] [msg_N+1] tool: call_abc → "搜索结果..." [msg_N+2] tool: call_def → "文件内容..." [msg_N+3] assistant: "分析结果如下..." ← Step 1 的回复2.2 截断发生的时机
当消息数超过限制时,截断函数_enforce_limit()被调用。在 ReAct 循环中,这个函数可能在工具结果尚未全部写入时被调用:
中间状态(Step 1 执行中): [msg_N] assistant: tool_calls: [call_abc, call_def] ← 已写入 [msg_N+1] tool: call_abc → "搜索结果..." ← 已写入 [msg_N+2] ← 还没写入! 此时触发截断 → msg_N+1 被保留,但 msg_N+2 不存在 → call_def 的 tool_result "丢失" → 验证器报告"缺失结果"2.3 占位消息为什么越积越多
# 旧方案:添加占位消息(有 Bug)defvalidate_and_fix(messages):formsginmessages:ifmsg["role"]=="assistant":fortcinmsg.get("tool_calls",[]):tc_id=tc["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 消息中移除无结果的调用"""result=list(messages)# 浅拷贝# 收集所有有结果的 tool_call_idresult_ids={m["tool_call_id"]forminresultifm.get("role")=="tool"}# 遍历 assistant 消息,移除孤儿 tool_callformsginresult:ifmsg.get("role")=="assistant"and"tool_calls"inmsg: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_calls=tool_calls)# 然后逐个执行工具并写入结果fortcintool_calls:result=awaitexecute_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:result=awaitexecute_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"])# 避免重复添加returnmessages+stubs5.2 为什么压缩路径用 stub 而非剥离
- 截断路径:孤儿是"临时状态",剥离更安全(不增加消息数)
- 压缩路径:摘要是"永久替换",stub 更安全(保留 tool_call 结构,模型知道之前调用了什么工具)
六、Pre-scan 与 Consecutive Loop 的协调陷阱
6.1 验证流程的两个阶段
defvalidate_message_structure(self,messages):"""验证消息结构完整性"""# Phase 1: Pre-scan(预扫描)# 快速检测是否有孤儿 tool_callconsumed_ids=set()formsginmessages:ifmsg.get("role")=="tool":consumed_ids.add(msg["tool_call_id"])# Phase 2: Consecutive Loop(连续遍历)# 逐对检查 assistant → tool 配对i=0whilei<len(messages):msg=messages[i]ifmsg.get("role")=="assistant"and"tool_calls"inmsg:assistant_pos=i# 记录 assistant 的真实位置fortcinmsg["tool_calls"]:# 向后查找对应的 tool_resultfound=Falseforjinrange(i+1,min(i+10,len(messages))):ifmessages[j].get("tool_call_id")==tc["id"]:found=Truebreakifnotfoundandtc["id"]notinconsumed_ids:# 孤儿!需要处理self._handle_orphan(messages,assistant_pos,tc)i+=16.2 陷阱:_assistant_pos记录真实位置
旧代码使用i - 1来定位 assistant 消息:
# 旧代码(有 Bug)assistant_msg=messages[i-1]# 假设 tool 消息前面一定是 assistant# 问题:如果中间有 extras(额外插入的消息),i-1 可能不是 assistant新代码显式记录 assistant 的位置:
# 新代码(修复)assistant_pos=i# 在处理 assistant 消息时记录位置# ...# 后续使用 assistant_pos 定位 assistant 消息assistant_msg=messages[assistant_pos]七、总结与展望
7.1 核心要点回顾
- 孤儿消息的根因是"中间状态截断":ReAct 循环的分步写入导致配对断裂
- 占位消息会累积:副本操作不修复源头,每个步骤都添加新占位
- 原子批量写入治本:assistant + tool_results 作为整体写入
- 两条路径需要一致策略:截断用剥离,压缩用 stub
7.2 一个调试技巧
当你看到同一个 tool_call_id 在多个步骤中反复报告"缺失结果"时,首先检查消息截断是否发生在 ReAct 循环的中间状态。这几乎总是"分步写入 + 中间截断"的组合问题。
下期预告:《异步压缩:让用户感知不到上下文整理》
- 同步压缩的用户体验问题
- 异步后台摘要的架构设计
- 快照 hash 保护机制
敬请期待!
版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
