DEBUG.WIKI
京东云;让 AI 进入真实业务;零售 · 物流 · 客服 · 硬件;查看方案 →
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
Evoxt;最高 6.0GHz CPU;高频云服务器 · 免费每周备份;查看配置 →

LangChain 裁剪历史后,Anthropic 报缺少 tool_result

Agent 第二轮裁剪历史后,工具请求和结果可能失去配对。核对 tool_call_id、完整工具组和实际模型输入,区分 token 计数错误与消息格式错误。

第一轮 Agent 调用了工具并返回回答,第二轮加载历史、裁剪后再调用模型,却收到:

tool_use ids were found without tool_result blocks immediately after

常见的断点是保留了 AIMessage.tool_calls,却把对应 ToolMessage 删掉;另一种情况是只留下工具结果,把请求它的 AI 消息裁掉。消息都能正常解析,也不代表它们组合成了合法的模型输入。

这里按 langchain-core 0.3.79 的消息和裁剪接口核对,关注 Anthropic 客户端工具调用。旧服务使用 ChatAnthropic 作为 token counter 时,还可能在裁剪过程中的候选消息序列上报错;它与最终请求里的工具配对丢失要分别判断,不把换计数器当成通用修复。

核对真正送给模型的那份历史

LangChain 用 AIMessage.tool_calls 中的 id 与 ToolMessage.tool_call_id 关联一次工具请求和结果。一次 AI 消息可以要求多个工具,每个调用都要有对应结果。

Anthropic 转换后的消息结构有所不同:工具调用在 assistant 消息的 tool_use 内容块中,结果放在紧接其后的 user 消息里,以 tool_use_id 配对。中间插入一条普通消息,或者漏掉其中一个结果,都可能使序列无效;包含结果的 user 消息也应先放结果块,再放其他文本。

记录过滤和裁剪前后的消息类型与 ID,就能定位在哪个阶段丢了配对。下面是不请求模型的简化消息示例:

from langchain_core.messages import AIMessage, HumanMessage, ToolMessage

history = [
    HumanMessage(content="查询示例城市天气"),
    AIMessage(content="", tool_calls=[{
        "name": "get_weather",
        "args": {"city": "example-city"},
        "id": "call-weather-1",
        "type": "tool_call",
    }]),
    ToolMessage(content="示例结果", tool_call_id="call-weather-1"),
    AIMessage(content="已取得天气结果"),
]

# 这种过滤会保留工具请求,却删除它的结果。
text_only = [
    message for message in history
    if isinstance(message, (HumanMessage, AIMessage))
]

example-city、调用 ID 和结果内容都只是示例。text_only 不能直接作为保留工具结构的合法历史送回模型。AI 消息正文为空不等于它无用,工具请求仍藏在 tool_calls 中。

裁剪时按整组保留,或明确丢弃工具结构

如果后续模型还需要依据工具结果作答,就保留请求及其全部结果,把它们作为同一个裁剪单元。并行调用三个工具时,保留一条 AI 消息和两个结果仍然是不完整的。不要只按最近 N 条消息切片,也不要假设 start_on="human" 就能保证所有中间工具组完整。

另一种策略是从模型输入中删除整组工具交互,保留应用需要的摘要或最终回答。它会丢失原始工具内容,应明确这是否符合下一轮任务需要。不要为了通过校验凭空制造一个“执行成功”的工具结果,也不要补跑有副作用的工具来填历史。

langchain-core 0.3.79 的 filter_messages(exclude_tool_calls=True) 会排除含工具调用的整条 AI 消息和工具消息。如果同一 AI 消息还含有解释文本,这段文本也会被移除。不能把这个参数描述成“只去掉工具字段,其他内容全部保留”;升级后也应重新核对具体版本行为。

若只裁剪模型输入,保留原始状态和工具执行记录,便于恢复与审计。决定永久删掉图状态里的消息时,要先核对 reducer 和保存方式,避免只改变了显示列表,却在下一次调用中又读回未裁剪的状态。

发送前做配对检查,别把 token 数当校验结果

下面的函数检查普通客户端工具历史中的请求和连续结果组。它接收完整的已结束工具历史,不处理流式半成品、特殊服务器工具或所有供应商格式,也不修改原消息:

from langchain_core.messages import AIMessage, ToolMessage

def check_tool_groups(messages):
    pending = set()
    seen = set()
    for index, message in enumerate(messages):
        if isinstance(message, ToolMessage):
            call_id = message.tool_call_id
            if call_id not in pending:
                raise ValueError(f"unexpected tool result at {index}")
            pending.remove(call_id)
            continue
        if pending:
            raise ValueError(f"missing tool results before {index}")
        if isinstance(message, AIMessage):
            for call in message.tool_calls:
                call_id = call.get("id")
                if not isinstance(call_id, str) or not call_id or call_id in seen:
                    raise ValueError(f"invalid or reused tool id at {index}")
                seen.add(call_id)
                pending.add(call_id)
    if pending:
        raise ValueError("history ends with unanswered tool calls")

这个检查还要求每次工具请求的 ID 不重复。它只能作为应用前置检查,不能代替供应商的完整协议校验;例如消息起始角色、内容块格式和工具定义仍需核对。正式日志也不必输出工具参数、结果或用户全文。

trim_messages 的 token counter 可能会看到用于寻找边界的中间候选序列。精确计数接口如果同时校验工具结构,错误可能发生在裁剪函数内部,此时先检查堆栈和计数器收到的序列。不要把中间序列简单倒序后当作最终历史,也不要给缺结果的请求配一个虚假工具来掩盖错误。

近似计数可以避免依赖远程计数请求,但仍需预算余量,尤其是图片、文件与较大的工具结果。它不会自动补齐工具配对,也不保证估算与最终请求完全一致。

验收时从经过工具调用的第一轮进入第二轮,检查实际模型输入中的每个调用都有紧随的结果,多个工具、混合文本及裁剪边界都符合规则。同时核对 token 预算和业务信息是否保留。请求不再报 400,只说明格式检查通过;如果工具结果被删得过多,回答仍可能失去依据。