摘要 :有些操作不该由 AI 独立完成(删库、发邮件、转账),需要人工把关。本篇介绍人机协同(HITL):使用
HumanInTheLoopMiddleware在工具执行前拦截、等人决策,配合checkpointer持久化中断点状态、Command(resume)恢复执行。介绍四种决策(approve / edit / reject / respond)、批量响应、条件拦截(when 谓词 + 自定义 HITL)、流式 + HITL,配合 DeepSeek 实测。
前言
上一篇,我们介绍了长期记忆:Store 让 Agent 跨会话记住用户偏好。
传送门:【LangChain 1.x】11、长期记忆|Store 跨会话持久化与向量搜索
但记忆再好,有些操作也不该由 AI 独立完成,比如像:删库、发邮件、转账,这些高风险动作一旦执行可能会造成业务损失,需要人工介入把关后才能放行。这就是人机协同(HITL,Human-in-the-loop)。
本篇,主要介绍 Agent 的人机协同机制,主要包含一些内容:
- 使用LangChain1.x内置中间件 HumanInTheLoopMiddleware :在工具执行前进行拦截,等待人工决策介入
- 四种决策:approve / edit / reject / respond
- 批量响应:多个工具同时中断时的逐一决策
- 条件拦截:when 谓词(配置式)+ 自定义 HITL(代码式)
- 流式 + HITL:边流式输出、边监听中断
一、为什么需要 HITL + 基础接入
有些操作不该 AI 独立完成
Agent 能够通过调用工具执行任务动作。但有些动作风险高、不可逆,比如:删库操作、发送重要邮件、执行资金交易等。这些动作不应该由 AI 独立决定,需要人工把关后才能放行。
人机协同(HITL)就是这个把关机制:在模型调用敏感工具时,由中间件暂停 Agent,待人工决策后再继续执行。
两层机制,缺一不可
HITL 依靠两层机制配合来实现:
- Middleware :在工具执行前进行拦截并发出中断信号(
interrupt)。 - checkpointer:持久化中断点的完整状态,让 Agent 能在中断后(甚至数小时后)恢复。
所以,没有 Middleware 就无法主动中断;没有 checkpointer 就无法安全恢复。
完整流程:HITL接入+使用
python
from langchain.agents.middleware import HumanInTheLoopMiddleware
agent = create_agent(
model=deepseek_llm,
tools=[get_weather, read_file, delete_file],
middleware=[
HumanInTheLoopMiddleware(interrupt_on={
"read_file": False, # 读文件:无需审批
"delete_file": True, # 删文件:必须审批
# get_weather 没列:默认放行
}),
],
checkpointer=InMemorySaver(), # HITL 必须:持久化中断点状态
)
config = {"configurable": {"thread_id": "session-01"}}
# 第一步:invoke,遇到 delete_file 中断(version="v2" 返回 GraphOutput)
result = agent.invoke(
{"messages": [{"role": "user", "content": "请删除 a.txt 文件"}]},
config=config, version="v2",
)
if result.interrupts:
req = result.interrupts[0].value["action_requests"][0]
print(f"待审批:{req['name']}({req['args']})")
# 第二步:人工 approve,恢复执行
final = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config=config, version="v2",
)
css
待审批:delete_file({'file_path': 'a.txt'})
允许的决策类型:['approve', 'edit', 'reject', 'respond']
最终回复: 已成功删除 a.txt 文件。
完整流程共有三步:
agent.invoke(..., version="v2"):遇到敏感工具中断,返回GraphOutput。result.interrupts:拿到待审批操作action_requests+review_configs。agent.invoke(Command(resume={"decisions": [...]})):传入决策、恢复执行。
version="v2" 与 GraphOutput
HITL 必须使用 version="v2":它让 invoke 返回 GraphOutput,把「正常输出」(.value)和「中断信号」(.interrupts)分离。不传这个参数就拿不到 .interrupts,增加处理难度。
HITL 生命周期
整个 HITL 流程,分为五个阶段:
- 模型调用:Agent 调用模型生成响应(其中可能包含工具调用信息)。
- 中间件拦截 :
after_model钩子在工具执行前检查,发现需要审批的工具时会构建HITLRequest。 - 发出中断 :调用
interrupt(),Agent 中断挂起,状态存入 checkpointer。 - 等待决策:外部系统(Web 界面 / 命令行)拿到 Agent 中断信息,进入人工决策。
- 恢复执行 :收到
Command(resume=...),按决策类型执行(approve 原样、edit 改参、reject 拒绝、respond 回复)。
二、四种决策 + 批量响应
HITL 定义四种人工决策,每种对应不同场景:
| 决策 | 含义 | 使用场景 |
|---|---|---|
| approve | 原样执行工具调用 | 确认无误的邮件发送 |
| edit | 修改工具参数后执行 | 修改删除条件后,再执行 SQL |
| reject | 拒绝执行 + 反馈原因 | 拒绝不合理的退款请求 |
| respond | 跳过工具、直接返回人工回复 | 回答「询问用户」类工具 |
reject:拒绝 + 反馈
python
final = agent.invoke(
Command(resume={"decisions": [
{
"type": "reject",
"message": "report.txt 是月度报告,尚未归档,禁止删除"
}
]}),
config=config, version="v2",
)
ini
reject 后回复: report.txt 是月度报告,尚未归档,禁止删除。文件无法删除...
→ delete_file 未执行;反馈作为 ToolMessage(status=error)返回给模型
reject 后工具不执行 ,反馈作为 ToolMessage(status=error)返回,模型会换种方式回应。
edit:修改参数后执行
python
final = agent.invoke(
Command(resume={"decisions": [{
"type": "edit",
"edited_action": {
"name": "batch_update_discount",
"args": {
"product_ids": ["P001", "P002"],
"discount_rate": 0.8 # 将 0.5 改为 0.8
},
},
}]}),
config=config, version="v2",
)
less
edit 后回复: 已对 ['P001', 'P002'] 打 0.8 折
→ 工具使用修改后的参数(0.8 折)执行
注意 :edit 需要在 system_prompt 中提示「参数可能被人工修改,若结果与预期不一致请直接接受、不要重新调用」,否则模型可能重复调用工具试图「纠正」。
respond:人当工具回复
python
final = agent.invoke(
Command(resume={"decisions": [
{
"type": "respond",
"message": "客户新地址:北京市海淀区中关村大街 1 号"
}
]}),
config=config, version="v2",
)
ini
respond 后回复: 好的,客户提供的新收货地址是:北京市海淀区中关村大街 1 号...
→ ask_customer 未执行;人工回复作为成功的 ToolMessage(status=success)返回
respond 用于 ask_customer 这类「占位工具」:工具本身不做任何事,它的「返回值」就是人工的回复。人工回复作为成功的 ToolMessage(status=success)返回。
reject vs respond 的关键区别:
- reject:告诉模型「操作被拒绝」(
status=error),模型需要另寻他法。 - respond:将人工回复作为「工具成功的结果」(
status=success),用于 ask_user 类工具。
为什么不直接让 Agent 反问用户?
- Agent 反问是对话层面的(输出文本 → 等新消息 → 重新推理);
- respond 是把人工输入注入到工具执行链中,作为
ToolMessage返回、Agent 在同一条推理链上继续。适合结构化工作流里固定的「需外部输入」节点。
批量响应:多个工具同时中断
当模型一次性调用多个敏感工具时,就会同时产生多个中断。 在响应时, decisions 列表按顺序与 action_requests 一一对应:
python
agent = create_agent(
model=deepseek_llm, tools=[restart_service, send_notification],
middleware=[HumanInTheLoopMiddleware(interrupt_on={
"restart_service": {"allowed_decisions": ["approve", "reject"]},
"send_notification": {"allowed_decisions": ["approve", "reject"]},
})],
checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "batch-1"}}
# 一次调用两个敏感工具 → 同时中断
result = agent.invoke(
{"messages": [{"role": "user", "content": "立即重启订单服务,同时给运维群发通知:订单服务重启中"}]},
config=config, version="v2",
)
# decisions 列表按顺序对应 action_requests:第一个 approve、第二个 reject
final = agent.invoke(
Command(resume={"decisions": [
{"type": "approve"}, # restart_service:批准
{"type": "reject", "message": "通知内容需要再确认"}, # send_notification:拒绝
]}),
config=config, version="v2",
)
css
本次中断包含 2 个操作:
[0] restart_service({'service_name': '订单服务'})
[1] send_notification({'channel': '运维群', 'content': '订单服务重启中'})
批量决策: [{'type': 'approve'}, {'type': 'reject', 'message': '通知内容需要再确认'}]
批量决策后回复: 订单服务已成功重启。关于通知,请您再次确认内容...
这里批准了重启、拒绝了通知。每个操作都要一个决策,顺序不能乱。
三、条件拦截与自定义 HITL
interrupt_on 是「静态」的:某个工具要么一定中断、要么不会中断。 但实际业务中,可能需要「动态」审批,比如:delete_file 只在删除 .log 文件时触发中断、退款时只有金额 > 500 会触发中断。
有以下两种实现方式:
when 谓词(配置式)
InterruptOnConfig.when 接收一个谓词函数,返回 True 中断 / False 放行。适合简单的、基于工具参数的判断:
python
from langchain.agents.middleware import HumanInTheLoopMiddleware, InterruptOnConfig
agent = create_agent(
model=deepseek_llm, tools=[delete_file],
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={
"delete_file": InterruptOnConfig(
allowed_decisions=["approve", "reject"],
# when 返回 True 才中断:只有删 .log 日志文件才中断
when=lambda req: req["tool_call"]["args"].get("file_path", "").endswith(".log"),
),
}
)
],
checkpointer=InMemorySaver(),
)
删除普通文件(notes.txt)时,when 返回 False、直接执行; 删除 .log 文件(app.log)时,when 返回 True、中断审批;
when 谓词接收 ToolCallRequest,用 req["tool_call"]["args"] 拿工具参数判断。
自定义 HITL(代码式)
当审批逻辑更复杂(查数据库、多条件组合),when 谓词不够用,可以完全手写:使用 @after_model 中间件 + interrupt() 原语。
python
from langchain.agents.middleware import after_model, AgentState
from langgraph.types import interrupt
from langchain_core.messages import ToolMessage
@after_model
def refund_hitl(state: AgentState, runtime: Runtime):
"""退款金额 > 500 才中断,否则放行"""
last_msg = state["messages"][-1]
for tc in last_msg.tool_calls:
if tc["name"] != "process_refund":
continue
order = ORDERS.get(tc["args"].get("order_id", ""))
if not order:
return None
if order["amount"] <= 500:
return None # 放行,工具正常执行
# 金额 > 500,中断等审批
review = interrupt({
"action_requests": [{"name": tc["name"], "args": tc["args"], "description": "..."}],
"review_configs": [{"action_name": tc["name"], "allowed_decisions": ["approve", "reject"]}],
})
decision = review["decisions"][0]
if decision["type"] == "approve":
return None # 放行
else:
return {"messages": [ToolMessage(content=decision.get("message", ""), tool_call_id=tc["id"])]}
return None
css
--- 退 ORD001(¥200,预期自动放行)---
[自定义HITL] 订单 ORD001 金额 ¥200 ≤ 500,自动放行
--- 退 ORD002(¥3000,预期中断)---
[自定义HITL] 订单 ORD002 金额 ¥3000 > 500,触发中断
中断了:process_refund({'order_id': 'ORD002'})
自定义 HITL 的关键:
@after_model在模型生成工具调用后、工具执行前运行,这正是 HITL 拦截的位置。interrupt()是 LangGraph 原语,调用后整个 Graph 图暂停,外部通过Command(resume=...)恢复执行。interrupt()的参数格式需要与HumanInTheLoopMiddleware一致(action_requests+review_configs),这样Command(resume)可以无缝兼容。- 返回
None放行工具;返回{"messages": [ToolMessage]}拒绝(tool_call_id必须与模型输出的一致)。
when 谓词 vs 自定义 HITL:when 是配置式(简单条件、几行搞定),自定义是代码式(复杂逻辑、完全自由)。
四、流式 + HITL
HITL 可以和流式输出结合:一边实时打印模型的 token,一边监听中断信号。
python
next_input = {"messages": [{"role": "user", "content": "发邮件给 boss,内容:项目已上线"}]}
while True: # 内层循环:处理中断 / 恢复
print("[Agent 流式]: ", end="", flush=True)
interrupted = False
for chunk in agent.stream(
next_input, config=config,
stream_mode=["updates", "messages"], version="v2",
):
if chunk["type"] == "messages":
# token 流:chunk["data"] 是 (token_chunk, metadata) 元组
token_chunk = chunk["data"][0]
if token_chunk.content:
print(token_chunk.content, end="", flush=True)
elif chunk["type"] == "updates":
# 状态更新:检查是否触发中断
if "__interrupt__" in chunk["data"]:
interrupted = True
req = chunk["data"]["__interrupt__"][0].value["action_requests"][0]
print(f"\n [中断] {req['name']}({req['args']})待审批")
next_input = Command(resume={"decisions": [{"type": "approve"}]})
break
if not interrupted:
break
css
[Agent 流式]:
[中断] send_email({'to': 'boss', 'content': '项目已上线'})待审批
[恢复执行,继续流式输出]
[Agent 流式]: 邮件已发送给 boss:项目已上线邮件已成功发送...
三个要点:
stream_mode=["updates", "messages"]+version="v2":同时拿 token 流(messages)和中断信号(updates)。- 中断信号在
chunk["data"]["__interrupt__"]中,结构和invoke的result.interrupts一致。 - 两层循环:外层是对话轮次,内层处理同一轮的「中断 → 恢复 → 可能再中断」。中断后使用
Command(resume=...)作为下一次stream的输入。
五、总结
本篇,把 Agent 的人机协同梳理了一遍:
- HITL = HumanInTheLoopMiddleware + checkpointer:中间件负责拦截、checkpointer 负责持久化中断点状态,缺一不可。
- 四种决策:approve(原样执行)/ edit(改参数)/ reject(拒绝 + 反馈)/ respond(人当工具回复),加批量响应(decisions 按顺序对应)。
- 条件拦截 :
when谓词(配置式,简单条件)+ 自定义 HITL(@after_model+interrupt(),代码式,复杂逻辑)。 - 流式 + HITL :
stream_mode=["updates","messages"]同时拿 token 流和中断信号,两层循环处理中断 / 恢复。 version="v2"+GraphOutput.interrupts是 HITL 的基础(拿中断信号的唯一方式)。
一句话概括:高风险工具挂 HumanInTheLoopMiddleware,中断等人决策、Command(resume) 恢复;version="v2" 拿中断、四种决策控行为、when / 自定义做条件、流式监听中断。
下一篇,将介绍安全护栏(Guardrails):在输入 / 输出层加防护,拦截敏感信息、不当内容。