Agent 在 HITL edit 决策后为何重发原始工具调用

👁 94 次阅读

Agent 在 HITL edit 决策后为何重发原始工具调用

问题来源:https://github.com/langchain-ai/langchain/issues/33787

现象描述

在 langchain 1.0.3 与 langchain_core 1.0.2 环境下,智能体同时发出 read_email_tool 和 send_email_tool 两个并行 tool call。read_email_tool 读取 email_001 被自动放行,send_email_tool 配置了 allowed_decisions=[approve, edit, reject],在 call ID call_vsQaCe4bDz0Z270STcbGB5BT 处被 HumanInTheLoopMiddleware 打断。 人工操作员通过 Command(resume={'decisions':[{'type':'edit', ...}]}) 提交编辑决策,将收件人从 alice@example.com 改为 alice@test.com、subject 改为 'this is a test'、body 改为 "don't reply"。被编辑后的工具调用执行成功,返回 'Email sent successfully to alice@test.com ...'。 问题出在 result['messages'] 的最后一条消息:它是一条新的 AiMessage,call ID 为 call_dUSgtnAQwmp9x3jBK1fKae32,参数完全回到原始值——to=alice@example.com、subject='Test'、body='Hello'。这条新调用会再次触发 HITL 打断,强迫用户 reject 一个自己以为已经被 edit 取代的动作。 用户视角的挫败感在于:edit 决策的语义应当是 replace——取消原调用、用新调用取代。但实际行为中,edit 只替换了工具参数,没有撤销原始 AiMessage 上的 tool call 意图,导致必须 edit 之后紧接着再 reject 同一个动作。

背景原因

根因在于消息历史的注入方式。HumanInTheLoopMiddleware 在 resume 时将 edited_action 的执行结果作为普通 ToolMessage 注入消息历史,但原始的 HumanMessage(用户最初请求)仍然保留在对话上下文中。 LLM 在下一轮推理时按「请求-结果」匹配判断任务是否完成。它看到原始 HumanMessage 要求 Send an email to alice@example.com with subject 'Test' and body 'Hello',而 ToolMessage 返回的是 Email sent successfully to alice@test.com——收件人、主题、正文均不匹配。LLM 据此判断原始请求仍未被满足,于是生成一条新的 AiMessage,尝试执行原始的、未被编辑的工具调用。 edit 决策只替换了工具参数并执行了被编辑后的调用,但没有替换或撤销那条 AiMessage 上的原始 tool call 意图。中间件缺少一个「意图清除」步骤:在 edited_action 执行完毕后,应当将导致首次工具调用的原始请求标记为 fulfilled 或 superseded,阻止 LLM 重新生成相同的 tool call。

排查与量化方法

打印 result['messages'] 尾部 3 条消息即可确认现象:
for msg in result['messages'][-3:]:
    print(f"type={msg.type}, content={msg.content}")
    if hasattr(msg, 'tool_calls'):
        for tc in msg.tool_calls:
            print(f"  call_id={tc['id']}, name={tc['name']}, args={tc['args']}")
如果尾部出现一条 AiMessage,其 tool_calls 中的 call ID 与 HITL 打断时的 call ID 不同,但 args 与原始请求一致,即可确认复现该 bug。

完整解决方案

当前版本(langchain 1.0.3 / langchain_core 1.0.2)无官方修复,以下为三种可行的人工绕过方案。

方案一:用 reject 替代 edit,在下一条消息重新下完整指令

# 将 send_email_tool 的 allowed_decisions 改为 [approve, reject]
# 人工操作员选择 reject 后,在下一条 HumanMessage 中重新给出完整指令
# 例如:"Send an email to alice@test.com with subject 'this is a test' and body 'don't reply'"
# 这样智能体会生成一条全新的 tool call,不存在原始意图残留问题

方案二:从入口消除 edit 语义歧义

将工具的 allowed_decisions 配置为仅包含 [approve, reject],从入口消除 edit 语义歧义。这与 issue #34974 中 execute_sql 的做法一致——不提供 edit 选项,避免中间件在意图清除上的缺失被暴露。

方案三:在 interrupt_on 中放行或自定义中间件 trim 消息

对不需要人工干预的工具,在 interrupt_on 中设为 False 完全放行。对确实需要编辑的工具,可编写自定义中间件,在 resume 后主动 trim 掉原始 tool call 对应的 AiMessage,再注入 edited_action 的结果。

最小复现脚本

from langchain.agents import create_agent
from langchain_core.messages import HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
from langchain.agents.middleware import HumanInTheLoopMiddleware
import json

checkpointer = MemorySaver()

def read_email_tool(email_id: str) -> str:
    return f"Email {email_id}: Subject: Hello, Body: Hi there"

def send_email_tool(to: str, subject: str, body: str) -> str:
    return f"Email sent successfully to {to}. Subject: {subject}. Body: {body}"

agent = create_agent(
    model="gpt-4o",
    tools=[read_email_tool, send_email_tool],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "send_email_tool": {
                    "allowed_decisions": ["approve", "edit", "reject"],
                }
            }
        )
    ],
    checkpointer=checkpointer,
)

config = {"configurable": {"thread_id": "test-001"}}

result = agent.invoke(
    [HumanMessage(content="Read email email_001 and send an email to alice@example.com with subject 'Test' and body 'Hello'")],
    config=config,
)

# 打印 result['messages'] 尾部 3 条确认现象
print("=== Messages tail ===")
for msg in result["messages"][-3:]:
    print(f"type={msg.type}, content={msg.content}")
    if hasattr(msg, "tool_calls") and msg.tool_calls:
        for tc in msg.tool_calls:
            print(f"  call_id={tc['id']}, name={tc['name']}, args={tc['args']}")

# 模拟人工 edit 决策
resume_cmd = Command(resume={
    "decisions": [
        {
            "type": "edit",
            "edited_action": {
                "name": "send_email_tool",
                "args": {
                    "to": "alice@test.com",
                    "subject": "this is a test",
                    "body": "don't reply",
                },
            },
        }
    ]
})

result2 = agent.invoke(resume_cmd, config=config)

# 打印 resume 后尾部 3 条,确认是否出现原始 tool call 重发
print("\n=== After resume, messages tail ===")
for msg in result2["messages"][-3:]:
    print(f"type={msg.type}, content={msg.content}")
    if hasattr(msg, "tool_calls") and msg.tool_calls:
        for tc in msg.tool_calls:
            print(f"  call_id={tc['id']}, name={tc['name']}, args={tc['args']}")
运行后,After resume 部分的输出中应出现一条新的 AiMessage,其 tool_calls 中的 args 回到 to=alice@example.com / subject='Test' / body='Hello',call ID 为 call_dUSgtnAQwmp9x3jBK1fKae32(具体 ID 因运行环境而异,但参数回退现象一致)。

参数对比表格

决策类型是否保留原始意图是否需要二次人工介入适用场景
edit保留(bug 状态)是——需紧接着 reject当前版本下不建议使用
approve不保留否人工确认参数正确后放行
reject不保留否——可在下一条消息重下指令需要修改参数时的推荐替代方案

常见错误排查

1. 把 edit 当成 cancel 使用 edit 的语义是「替换参数后执行」,不是「取消原调用」。当前版本中 edit 不会清除原始 tool call 意图,导致 LLM 重新发出原始调用。如果需要取消,应使用 reject。 2. checkpointer 缺失导致 interrupt 失效 HumanInTheLoopMiddleware 依赖 checkpointer 持久化中断状态。如果未传入 checkpointer(如 MemorySaver 或持久化后端),interrupt 不会生效,工具调用直接执行,edit 决策无意义。 3. resume 时 decisions 数组缺 edited_action 字段 edit 决策必须包含 edited_action 字段,内含 name 和 args。如果只传 {"type": "edit"} 而不提供 edited_action,中间件无法构造被编辑后的工具调用,行为未定义。 4. allowed_decisions 未包含 edit 但代码传了 edit 如果工具的 allowed_decisions 配置为 [approve, reject],而 resume 时传入 {"type": "edit"},中间件会拒绝该决策或行为异常。应确保 allowed_decisions 与 resume 决策类型一致。 5. 并行 tool call 中只有其中一个被打断导致另一半先执行 当智能体同时发出多个 tool call,HITL 只打断其中一个时,未被打断的调用会先执行完毕。这可能导致部分执行状态与人工决策不一致。在复现脚本中,read_email_tool 被自动放行,send_email_tool 被打断,两者执行时序不同。

实践建议

该 issue 当前处于 open 状态,官方未合入修复,不存在可用的 patch 版本号或 commit。建议读者:
  • 在 issue #33787 下跟进讨论,关注官方是否有后续修复计划
  • 在当前版本中,对需要人工编辑的工具采用 reject + 重下指令的绕行方案
  • 如果业务场景要求 edit 语义,可考虑编写自定义中间件,在 resume 后主动 trim 原始 tool call 对应的 AiMessage
  • 不要依赖 edit 决策来修改工具参数,当前版本下该行为不可靠

FAQ

Q: edit 决策执行后为什么智能体会重新发出原始 tool call? 中间件将 edited_action 结果作为 ToolMessage 注入,但原始 HumanMessage 仍在消息历史中。LLM 在下一轮推理时按请求-结果匹配判断,发现原始请求未被满足,于是重新生成原始 tool call。 Q: 当前版本能否通过配置修复这个问题? 不能。该问题涉及中间件的消息注入逻辑,无配置项可控制。只能通过 reject + 重下指令、移除 edit 选项、或自定义中间件 trim 消息来绕过。 Q: 如何确认我的环境是否复现了这个 bug? 运行最小复现脚本,打印 resume 后 result['messages'] 尾部 3 条消息。如果最后一条 AiMessage 的 tool_calls args 回到原始值而非编辑后的值,即确认复现。 Q: 这个 bug 有官方修复时间表吗? issue 当前为 open 状态,41 条评论,最后更新于 2026-08-22,官方未公布修复时间表或版本号。建议直接跟进 issue 讨论。 Q: 并行 tool call 场景下如何处理? 如果多个 tool call 中只有一个需要 HITL 打断,未被打断的调用会先执行。这可能导致部分执行状态不一致。建议对需要人工干预的工具单独设置 interrupt_on,避免并行调用中的时序问题。

订阅 AI 百事知

每周精选 AI 工具评测、行业资讯与使用教程,第一时间送达。

🔗 相关阅读(你可能还想看)

📄 Gemini Omni 1.2 Flash 发布:视频降价30%、原生15秒,单条成本只贵5分钱
谷歌 11 月 5 日开放 Gemini Omni 1.2 Flash:720p 视频每秒 0.07 美元,比上代降 3
📄 GPT-6 Cyber 发布:121 万美元,948 条通往 root 的路
OpenAI 发布 GPT-6 Cyber:在 Linux 内核上跑出 948 个本地提权利用,整轮 API 成本 12
📄 Anthropic冲刺IPO:170亿营收、8950亿算力承诺、首次在招股书披露递归自我改进风险
📄 Anthropic IPO招股书曝光:单季营收170亿美元背后的8954亿美元算力账单
📄 阿里千问接入 Apple Intelligence:中国版 iPhone 的 AI 时刻来了
📄 苹果AI中国落地靴子落地:千问接入Apple Intelligence,11月4日开测
📄 苹果官宣:Apple Intelligence 国行版落地,阿里千问深度接入 Siri 与系统写作工具
苹果官宣 Apple Intelligence 国行版 11 月 4 日开测、12 月随 iOS 27.3 正式上线。阿
📄 Anthropic 递交 S-1 启动 IPO:Claude 母公司估值剑指 2 万亿美元
📄 Cerebras 公开万亿参数模型部署方案:59 块晶圆跑 3T 参数,推理 2000 tokens/秒
🤖 想直接和 AI 对话?
免费体验 DeepSeek V4 等顶级模型,无需注册,随问随答
🎯 立即体验 AI 工具

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

🤖 嘟嘟客服
滚动至顶部
🤖 嘟嘟客服
🤖 嘟嘟客服
';i.value='';m.innerHTML+='
...
';fetch('/dify-chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:q,user:'w'+Date.now()})}).then(function(r){return r.json()}).then(function(d){var a=d.answer||'暂时无法回复';m.removeChild(m.lastChild);m.innerHTML+='
'+a+'
'}).catch(function(){m.removeChild(m.lastChild);m.innerHTML+='
网络异常
'})">发送