【LangChain 1.x】12、HITL人机协同|敏感操作审批、四种决策与条件拦截

摘要 :有些操作不该由 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 文件。

完整流程共有三步:

  1. agent.invoke(..., version="v2"):遇到敏感工具中断,返回 GraphOutput
  2. result.interrupts:拿到待审批操作 action_requests + review_configs
  3. agent.invoke(Command(resume={"decisions": [...]})):传入决策、恢复执行。

version="v2" 与 GraphOutput

HITL 必须使用 version="v2":它让 invoke 返回 GraphOutput,把「正常输出」(.value)和「中断信号」(.interrupts)分离。不传这个参数就拿不到 .interrupts,增加处理难度。

HITL 生命周期

整个 HITL 流程,分为五个阶段:

  1. 模型调用:Agent 调用模型生成响应(其中可能包含工具调用信息)。
  2. 中间件拦截after_model 钩子在工具执行前检查,发现需要审批的工具时会构建 HITLRequest
  3. 发出中断 :调用 interrupt(),Agent 中断挂起,状态存入 checkpointer。
  4. 等待决策:外部系统(Web 界面 / 命令行)拿到 Agent 中断信息,进入人工决策。
  5. 恢复执行 :收到 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 后工具不执行 ,反馈作为 ToolMessagestatus=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 这类「占位工具」:工具本身不做任何事,它的「返回值」就是人工的回复。人工回复作为成功的 ToolMessagestatus=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__"]中,结构和 invokeresult.interrupts 一致。
  • 两层循环:外层是对话轮次,内层处理同一轮的「中断 → 恢复 → 可能再中断」。中断后使用 Command(resume=...) 作为下一次 stream 的输入。

五、总结

本篇,把 Agent 的人机协同梳理了一遍:

  • HITL = HumanInTheLoopMiddleware + checkpointer:中间件负责拦截、checkpointer 负责持久化中断点状态,缺一不可。
  • 四种决策:approve(原样执行)/ edit(改参数)/ reject(拒绝 + 反馈)/ respond(人当工具回复),加批量响应(decisions 按顺序对应)。
  • 条件拦截when 谓词(配置式,简单条件)+ 自定义 HITL(@after_model + interrupt(),代码式,复杂逻辑)。
  • 流式 + HITLstream_mode=["updates","messages"] 同时拿 token 流和中断信号,两层循环处理中断 / 恢复。
  • version="v2" + GraphOutput.interrupts 是 HITL 的基础(拿中断信号的唯一方式)。

一句话概括:高风险工具挂 HumanInTheLoopMiddleware,中断等人决策、Command(resume) 恢复;version="v2" 拿中断、四种决策控行为、when / 自定义做条件、流式监听中断。

下一篇,将介绍安全护栏(Guardrails):在输入 / 输出层加防护,拦截敏感信息、不当内容。

相关推荐
青山是哪个青山2 小时前
LangChain 1.x 学习笔记(一):为什么需要 LangChain?一文彻底理解 LangChain 生态
笔记·学习·langchain
草莓熊Lotso3 小时前
【LangChain】核心组件详解:文档加载器(Document Loaders)
开发语言·c++·python·langchain·软件工程
早点睡啊Y5 小时前
精读 LangChain 官方文档(三):
服务器·数据库·langchain
chaors18 小时前
DeepResearchSystem 0x01:Agent 基础
langchain·aigc·ai编程
一只小bit19 小时前
LangGraph 子图使用和房源搜索Agent综合案例实现
机器学习·langchain·llm·langgraph
bloglin999991 天前
langchain 和 langgraph 和 react
javascript·react.js·langchain
吃饱了得干活1 天前
LangChain Agent 高级玩法:命名、结构化输出与流式模式
langchain·llm·agent
phltxy1 天前
LangGraph智能租房助手实践
大数据·人工智能·python·深度学习·语言模型·langchain
Esaka_Forever1 天前
LangChain RunnableSequence 获取中间输出的几种方案(替代SequentialChain获取中间结果)
langchain