👁 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,避免并行调用中的时序问题。
🔗 相关阅读(你可能还想看)
📄 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/秒