Back to Blog
Best Practices2026-05-286 min read

Tool Pairing Integrity: The Most Insidious Bug in ReAct Agents

Uncovering the most hidden bug in ReAct loops — tool_call and tool_result pairing failures — and how to fix it.

Tool 配对完整性:ReAct Agent 最隐蔽的 Bug


专栏信息

《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏

本文是模块八第 7 篇,深入剖析 tool_call/tool_result 配对问题的诊断与修复。


作者与项目

作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


摘要

本文结构概览: 本文从一个"同一个 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 分钟

关键词ReActTool 配对孤儿消息原子写入消息截断


一、诡异现象:同一个工具被"遗忘"了 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)
def validate_and_fix(messages):
    for msg in messages:
        if msg["role"] == "assistant":
            for tc in msg.get("tool_calls", []):
                tc_id = tc["id"]
                if not has_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_result
placeholder = {
    "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_call
def strip_orphan_tool_calls(messages):
    """剥离孤儿 tool_call:从 assistant 消息中移除无结果的调用"""
    result = list(messages)  # 浅拷贝

    # 收集所有有结果的 tool_call_id
    result_ids = {
        m["tool_call_id"] for m in result
        if m.get("role") == "tool"
    }

    # 遍历 assistant 消息,移除孤儿 tool_call
    for msg in result:
        if msg.get("role") == "assistant" and "tool_calls" in msg:
            msg["tool_calls"] = [
                tc for tc in msg["tool_calls"]
                if tc["id"] in result_ids
            ]
            # 如果所有 tool_calls 都被移除了,转为纯文本消息
            if not msg["tool_calls"]:
                del msg["tool_calls"]

    return result

优势

  • 直接修改源数据中的 assistant 消息
  • 不添加额外消息 → 列表不膨胀
  • 一次剥离,永久生效

3.3 选择剥离策略的理由

维度占位消息剥离孤儿
消息膨胀累积增长稳定
修复持久性仅当次有效永久修复
API 兼容性好(保留 tool_call 结构)好(移除无效调用)
信息保留中(占位文本无信息量)低(完全移除)
实现复杂度

四、原子批量写入

4.1 问题:分步写入的中间状态

旧方案中,ReAct 循环的消息是分步写入的:

# 旧方案:分步写入
async def handle_tool_calls(self, tool_calls):
    # 先写 assistant 消息
    await self.add_assistant_message(tool_calls=tool_calls)

    # 然后逐个执行工具并写入结果
    for tc in tool_calls:
        result = await execute_tool(tc)
        await self.add_tool_message(tc["id"], result)
        # ⚠️ 此时可能触发截断检查
        # assistant(tool_calls) 已写入,但后续 tool_result 尚未写入

4.2 原子批量写入

# 新方案:原子批量写入
async def handle_tool_calls(self, tool_calls):
    # 收集所有消息
    batch = []
    batch.append({"role": "assistant", "tool_calls": tool_calls})

    for tc in tool_calls:
        result = await execute_tool(tc)
        batch.append({
            "role": "tool",
            "tool_call_id": tc["id"],
            "content": result
        })

    # 一次性写入所有消息(原子操作)
    await self.add_message_batch(batch)

4.3 add_message_batch 实现

async def add_message_batch(self, messages):
    """原子性批量添加消息

    确保 assistant(tool_calls) + tool(results) 作为一个整体写入,
    不会在中间状态被截断函数打断
    """
    # 先全部添加到内存列表
    self._messages.extend(messages)

    # 然后批量写入数据库
    await self._db.batch_insert(self.session_id, messages)

    # 最后检查是否需要截断(此时所有消息都已完整)
    if self._needs_truncation():
        self._enforce_limit()

五、统一 Stub 策略

5.1 两条路径的一致性问题

上下文管理有两条可能产生"孤儿"的路径:

  1. 截断路径_enforce_limit() 截断消息时可能切断配对
  2. 压缩路径ContextEngine.compress() 压缩旧消息时可能丢失 tool_result

两条路径需要使用一致的孤儿处理策略

# 统一 stub 策略
ORPHAN_STUB_CONTENT = "[Result unavailable due to context management]"

def ensure_tool_pair_integrity(messages):
    """确保所有 tool_call 都有对应的 tool_result

    对于缺失结果的 tool_call,添加 stub result(而非剥离)
    注意:这与 validate_and_fix 的剥离策略互补——
    剥离用于截断路径,stub 用于压缩路径
    """
    result_ids = {
        m["tool_call_id"] for m in messages
        if m.get("role") == "tool"
    }

    stubs = []
    for msg in messages:
        if msg.get("role") == "assistant":
            for tc in msg.get("tool_calls", []):
                if tc["id"] not in result_ids:
                    stubs.append({
                        "role": "tool",
                        "tool_call_id": tc["id"],
                        "content": ORPHAN_STUB_CONTENT
                    })
                    result_ids.add(tc["id"])  # 避免重复添加

    return messages + stubs

5.2 为什么压缩路径用 stub 而非剥离

  • 截断路径:孤儿是"临时状态",剥离更安全(不增加消息数)
  • 压缩路径:摘要是"永久替换",stub 更安全(保留 tool_call 结构,模型知道之前调用了什么工具)

六、Pre-scan 与 Consecutive Loop 的协调陷阱

6.1 验证流程的两个阶段

def validate_message_structure(self, messages):
    """验证消息结构完整性"""

    # Phase 1: Pre-scan(预扫描)
    # 快速检测是否有孤儿 tool_call
    consumed_ids = set()
    for msg in messages:
        if msg.get("role") == "tool":
            consumed_ids.add(msg["tool_call_id"])

    # Phase 2: Consecutive Loop(连续遍历)
    # 逐对检查 assistant → tool 配对
    i = 0
    while i < len(messages):
        msg = messages[i]
        if msg.get("role") == "assistant" and "tool_calls" in msg:
            assistant_pos = i  # 记录 assistant 的真实位置
            for tc in msg["tool_calls"]:
                # 向后查找对应的 tool_result
                found = False
                for j in range(i + 1, min(i + 10, len(messages))):
                    if messages[j].get("tool_call_id") == tc["id"]:
                        found = True
                        break
                if not found and tc["id"] not in consumed_ids:
                    # 孤儿!需要处理
                    self._handle_orphan(messages, assistant_pos, tc)
        i += 1

6.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 核心要点回顾

  1. 孤儿消息的根因是"中间状态截断":ReAct 循环的分步写入导致配对断裂
  2. 占位消息会累积:副本操作不修复源头,每个步骤都添加新占位
  3. 原子批量写入治本:assistant + tool_results 作为整体写入
  4. 两条路径需要一致策略:截断用剥离,压缩用 stub

7.2 一个调试技巧

当你看到同一个 tool_call_id 在多个步骤中反复报告"缺失结果"时,首先检查消息截断是否发生在 ReAct 循环的中间状态。 这几乎总是"分步写入 + 中间截断"的组合问题。


下期预告:《异步压缩:让用户感知不到上下文整理》

  • 同步压缩的用户体验问题
  • 异步后台摘要的架构设计
  • 快照 hash 保护机制

敬请期待!


版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。