avatar

命令行小屋

A text-focused Halo theme

  • Ai
  • Linux
  • 游戏
  • 数据库
  • Apache Hadoop
  • Windows
  • 手机
主页 Human-in-the-Loop 实战指南:在 LangChain 中为 Agent 装上"决策刹车"
文章

Human-in-the-Loop 实战指南:在 LangChain 中为 Agent 装上"决策刹车"

发表于 最近 更新于 最近
作者 KennethCheng
742~954 分钟 阅读

随着 LLM Agent 越来越"敢动手",我们开始需要一种机制 —— 让 AI 在做关键动作前停下来,让人看一眼,再由人来决定放行、修改还是拒绝。
这就是 Human-in-the-Loop (HITL):AI 不再是"自动执行",而是"提建议 → 被审核 → 必要时修正"。

本文带你从概念到 LangChain v1 的具体实现,把 HITL 的工作原理一次性打通。读完你会明白 HITL 中间件是什么、怎么配、怎么响应、卡在哪一步,以及怎么把它用在生产里。

代码基于 LangChain v1 + LangGraph,Mermaid 图可在线渲染(Mermaid 8+)。

全文约 8000 字,完整阅读 15 分钟;按目录跳读 5 分钟。

目录

  1. HITL 是什么?为什么需要?
  2. HITL 的运行架构
  3. 常见 HITL 模式
  4. HITL 设计的五条关键原则
  5. 常见追问:粒度 / 路由 / 成本
  6. 在 LangChain v1 中创建 HITL(动手)
  7. 响应中断:运行时机制详解
  8. Reject 与 Edit 的完整写法
  9. HITL 中间件的运行逻辑(5 阶段生命周期)
  10. 知识地图与一页纸速查
  11. 收束 + 进阶方向

Human-in-the-Loop(HITL)笔记


1. 核心定义(定义)

Human-in-the-Loop(HITL)中间件允许用户为代理工具调用添加人工监督。当模型提出可能需要审查的动作时——例如写入文件或执行 SQL,中间件可以暂停工作执行并等待人类决策。

三个关键词拆解:

  • 中间件(middleware) —— 不是模型自身能力,而是独立的一层;模型本身不知道也不感知"被审查了"。
  • 工具调用(tool call) —— 审查的最小单位是"这一次工具调用",而不是整个对话或任务。
  • 暂停 + 等待 —— 必须能真正中断执行流,异步等人类回复后再继续,不能只是事后日志。

2. 工作流架构(还原原图)

? 我想要调用...
允许 / 调整参数
执行结果
拒绝调用
解释引导下一步
模型
中间件 / 决策门
工具
用户反馈

参与方与流向:

节点角色
模型(左)发起调用请求;"我想要调用……"
中间件(中)截获调用、按规则路由、暂停执行
用户(下)决策人,断点介入
工具(右)实际执行者:写文件、执行 SQL、调用 API……

两条回环:

  1. 正常路径:模型请求 → 中间件放行 → 工具执行 → 结果回写模型 → 继续推理。
  2. 拒绝路径:模型请求 → 中间件拦截 → 回到模型并附带人类解释 → 模型重新规划。

3. 三种人类响应方式(原图核心)

① 批准(approve)

  • 做了什么:按模型原始调用一字不改地执行。
  • 何时用:对模型完全信任、动作可逆、风险低。
  • 用户代价:≈ 0,一键放行。
  • 风险:若模型错了,错也会被执行。

② 编辑(edit)

  • 做了什么:在模型原始调用基础上人工改参数,再执行。
  • 何时用:方向对、细节错(SQL 表名错、文件路径偏、参数超范围)。
  • 用户代价:低,但需要在 diff 视图里改字段。
  • 价值:纠错不纠向,实战中最高频的选项,也是 HITL 真正"用得起来"的关键。

③ 拒绝(reject)

  • 做了什么:不执行,并把"为什么不执行 + 期望的下一步"以对话形式回退给模型。
  • 何时用:方向就错了,或动作不可逆(删库、付款、对外发邮件、批量修改生产配置)。
  • 用户代价:中等——需要写一句解释。
  • 价值:本质是in-context RLHF,这次拒绝会进上下文,让模型下一步动作更准。

4. 三种响应的对比

维度approveeditreject
模型被改变的程度无仅参数路径与下一步动作
用户付出的认知成本极低中中–高(要写解释)
适用频率(经验值)低最高中
典型触发已有审批策略 / 低风险90% 的真实场景方向错 / 高风险
数据回流的信号隐式(放行=认可)强(改了什么=正确做法)最强(否决理由)

5. 这张图揭示的 HITL 关键设计点

  1. 中间件必须是真"截断点"
    不能是事后日志/审计;必须是执行流上的同步等待。

  2. 审查粒度 = 一次工具调用,不是"整段对话"
    HITL 普遍做的是每次 tool call 都要过一次门,而不是整轮对话一锤定音。

  3. 三种响应覆盖完整动作空间
    approve / edit / reject 几乎穷尽了人类想给出的反馈,设计成三选一即可。

  4. 拒绝路径自带"解释反哺"
    不只是 block,而是把自然语言反馈塞回模型上下文,这等效于一次 in-context RLHF。

  5. 执行结果必须回写模型
    模型需要看到工具返回值,否则推理链断裂——HITL 不是把人塞进去当裁判,是要让模型继续干活。

6. 常见追问

  • 每次都要人审吗?
    不是。生产里通常按工具/动作风险分级:只让真正需要审查的进入 HITL,其余放行。e.g. SELECT 自动跑,DELETE/UPDATE 必审。

  • 谁决定哪些调用要审?
    策略引擎 + 工具元数据。常见维度:工具类型(写盘/网络/支付?)、目标范围(白名单/黑名单)、参数特征(金额、关键词)、用户/角色、累计次数。

  • 能不能多人协作?
    能。HITL 可路由到 SME、合规、安全、风控,不必让本人生审。系统把人当作外部决策资源来调度。

  • 怎么避免"信任税"? (每次都要点,烦到不想用)

    • 统计 approve/edit/reject 比例;
    • 高频 approve 的工具/参数组合毕业(自动放行);
    • 提供"会话级信任":本次会话剩余调用默认 approve;
    • 用 edit 的 diff 训练规则,把常见改法沉淀成自动修正。
  • HITL 和 RLHF 啥关系?
    HITL 是在线、产品级的人介入;RLHF 是离线、训练级的偏好学习。本图的 reject 路径天然就是 in-context RLHF 的数据来源,可以回收做训练。

7. 一句话总结

HITL = 中间件在模型与工具之间拦截每一次"动作请求",把决策权临时交回给人类;人类用 approve / edit / reject 三种方式裁决;执行结果回写模型继续推理,并把人的反馈沉淀为下一次更好的输出。


8. HITL 的创建(LangChain v1 实现)

8.1 概述

思路一句话:在创建 Agent 时,把 HumanInTheLoopMiddleware 加进 middleware 列表,中间件就会按照 interrupt_on 里配置的策略,逐个对照每个工具调用,触发条件后中断执行,等用户裁决。

三步流程:

1. 用 @tool 定义工具
2. create_agent 时传入 middleware
3. 用 interrupt_on 配置每个工具的策略
4. 传入 checkpointer 持久化

8.2 Step 1 —— 创建工具(用 @tool 装饰器)

from langchain_core.tools import tool
import os

@tool
def write_txt_tool(path: str, content: str) -> str:
    """将文本内容写入给定路径。"""
    with open(path, "w", encoding="utf-8") as file:
        file.write(content)
    return f"写入完成: {path}"

@tool
def read_txt_tool(path: str) -> str:
    """读取给定路径的文本内容。"""
    with open(path, "r", encoding="utf-8") as file:
        return file.read()

@tool
def delete_txt_tool(path: str) -> str:
    """删除给定路径的文本文件。"""
    if not os.path.exists(path):
        return f"文件不存在: {path}"
    os.remove(path)
    return f"删除完成: {path}"

要点:

  • 必须写 docstring,模型靠它判断什么时候用哪个工具。
  • 返回 str,失败也要返回字符串(便于模型把错误传回上下文)。

8.3 Step 2 —— 创建 Agent 并把 HITL 中间件接进去

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(
    model=model,
    tools=[write_txt_tool, read_txt_tool, delete_txt_tool],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "write_txt_tool": True,
                "delete_txt_tool": {"allowed_decisions": ["approve", "reject"]},
                "read_txt_tool": False,
            },
            description_prefix="工具执行待批准",
        ),
    ],
    checkpointer=InMemorySaver(),
)

四件事一件不能少(图上编号 1→4):

#做了什么代码位置必须?
1传入工具列表tools=[...]✅
2传入 HITL 中间件middleware=[HumanInTheLoopMiddleware(...)]✅ 想用 HITL 就必须
3定义中断决策interrupt_on={...}✅ 想用 HITL 就必须
4配置检查点checkpointer=InMemorySaver()✅ 用了 HITL 就必须(后面解释)

8.4 Step 3 —— interrupt_on 策略详解

interrupt_on 是一个字典,key 是工具名,value 是中断策略。三种写法:

写法含义适用场景
True该工具每次调用都中断,允许 approve / edit / reject 三种决策写文件、改配置——需要细看
False该工具完全不限只读类,如 read_txt_tool
{"allowed_decisions": ["approve", "reject"]}该工具每次调用都中断,但只允许列出来的决策delete_txt_tool 不允许"改后执行",只能放行或否决

图里的 True ≡ (approve, edit, reject) 是这个意思:True 是三选一全开的快捷写法。

对照例子里的策略设计意图:

"write_txt_tool": True,                              # 写:三选一(可以改后写)
"delete_txt_tool": {"allowed_decisions": ["approve", "reject"]},  # 删:只能批准/否决,不许"改着删"
"read_txt_tool": False,                              # 读:不中断

设计哲学:"读"放行,"写"允许修正,"删"只许放行或否决——这是符合直觉的风险分级。

8.5 Step 4 —— 为什么必须配 checkpointer?

LangGraph 是有状态图,Agent 在中断瞬间,需要把
"走到哪个节点、消息历史、模型状态、待审批的工具调用"
全部持久化下来,等用户回复后再从这里恢复。

不配 checkpointer 会怎样:

  • 中断后没法 resume(重启就没了)
  • 用户的 approve / edit / reject 无挂载点回传
  • 整个 interrupt → human → resume 链条断裂

内存版 (InMemorySaver) 适合单进程 demo / notebook;生产请换:

from langgraph.checkpoint.postgres import PostgresSaver  # 或
from langgraph.checkpoint.redis  import RedisSaver        # 等

8.6 ⚠️ 注意(原图重点提示)

工具决策取决于用户在 interrupt_on 中配置的策略,不进行配置的工具不会触发执行。

这句话有两层含义,容易踩坑:

  1. interrupt_on 没列出的工具 = 没有策略,不仅不会中断,甚至不会注册触发 HITL 的资格——你需要保证每个你想让中间件"看得见"的工具全部列出来(哪怕写 False)。
  2. 表面上看是"没配置 = 不中断",实际更严格——有的实现里,没列出的工具直接拒绝运行或静默放行,取决于框架版本。务必查文档。

8.7 运行时:用户怎么审批?

调用 Agent 时传入 config,框架中断后产出待审消息;客户端 UI 收集决策后,用同一个 thread_id 再次 invoke,框架自动从 checkpoint 恢复。

config = {"configurable": {"thread_id": "user-42"}}

# 第一次:模型生成 → 中断 → 返回
result = agent.invoke(
    {"messages": [{"role": "user", "content": "把 hello 写入 /tmp/a.txt"}]},
    config=config,
)
# 此时 result 里包含 __interrupt__,告诉 UI "需要人工裁决"

# 用户在 UI 点 approve 后,用同 thread_id 再 invoke 一次
resumed = agent.invoke(
    new Command(resume={"decisions": [{"type": "approve"}]}),  # 或 edit / reject
    config=config,
)

决策结构是 Command(resume={"decisions": [{"type": "approve|edit|reject", ...}]}),每个工具调用一项。

8.8 这套设计体现的 HITL 原则

回到第 5 节的五条关键设计点,在 LangChain v1 的实现里一一对应:

设计原则LangChain v1 实现
中间件是真截断点HumanInTheLoopMiddleware 在 tool_node 前挂载
粒度 = 一次工具调用interrupt_on 以工具名为 key,逐个工具独立决策
三种响应覆盖动作空间allowed_decisions 列表 = approve / edit / reject
拒绝路径带解释反哺reject 时可在对话中加说明,作为下一次模型输入
执行结果回写checkpointer + LangGraph state 持久化消息历史

8.9 常见调试问题

  • Q: 中断后 invoke 不返回?
    A: 检查 config 里 thread_id 是否和第一次一致;不配 checkpointer 会导致状态丢失。
  • Q: 写了 interrupt_on 但工具还是直接跑了?
    A: 检查 key 名是否和工具函数名一致(下划线一致);别忘了每个想管的工具都要列。
  • Q: 想批量 approve 怎么办?
    A: 在 resume 里给对应工具的决策项填 {"type": "approve"},多项就能一次性批准。
  • Q: 想做"按参数判定是否要审"?
    A: v1 的 interrupt_on 只支持工具级粒度;参数级需要自定义 middleware,在 before_tool_call 钩子里动态判断。

9. 响应中断(Responding to Interrupts) —— 运行时详解

9.1 总览(定义)

当工具调用与 interrupt_on 配置的策略匹配时,会触发中断。这种情况下,调用结果里会出现一个 __interrupt__ 字段,显示需要复审的工具调用。人类用户根据决策响应中断,框架继续执行。

两种调用走向:

否:工具在 interrupt_on 里是 False 或不在
是:True 或 allowed_decisions
approve
edit
reject
agent.invoke
策略匹配?
直接执行 → result 无 __interrupt__
触发中断 → result 含 __interrupt__
End:对话结束
UI 读 __interrupt__,展示给用户
用户决策
Command resume=approve → 继续执行
Command resume=edit → 用修改后参数继续
Command resume=reject + 解释 → 回到模型重新规划

9.2 调用代理:不触发中断(Read 示例)

当 read_txt_tool 在 interrupt_on 里配的是 False,模型调用它会被直接执行,返回值正常进 result,没有 __interrupt__ 字段。

config = {"configurable": {"thread_id": "some_id"}}

result = agent.invoke(
    {"messages": [{"role": "user",
                    "content": "帮我查看data.txt文件中有什么内容?"}]},
    config=config,
)
# result 里没有 __interrupt__,直接就是模型回复
print(result["messages"][-1].content)

要点:

  • config 必须每次 invoke 都传,否则 checkpoint 找不到对应 thread。
  • thread_id 是会话/任务维度的键 —— 同一会话复用同一 id,不同会话必须不同,否则状态会串。

9.3 调用代理:触发中断(Write 示例)

当模型要调用 write_txt_tool(配的是 True 或含 allowed_decisions),中间件截住这一次调用,把状态持久化进 checkpointer,返回值里塞入 __interrupt__,然后停下。

result_1 = agent.invoke(
    {"messages": [{"role": "user",
                    "content": "为我在data.txt中添加小虎的成绩为90。"}]},
    config=config,
)
print(result_1["__interrupt__"])

9.3.1 __interrupt__ 字段解读

打印出来的内容形如:

[{
    "name": "write_txt_tool",                                            # 工具名
    "args": {"content": "6,小虎,90\n", "path": "data.txt"},              # 模型想用什么参数
    "description": "工具执行待批准",                                       # 中间件自己加的提示前缀
    "review_configs": [                                                  # 这个工具允许的决策
        {
            "action_name": "write_txt_tool",
            "allowed_decisions": ["approve", "edit", "reject"]
        }
    ]
}]

四个字段逐项含义:

字段含义UI 怎么用
name工具名直接展示成"工具:"后面的标签
args模型请求的参数关键展示对象,让用户看清楚要做什么
description中间件添加的提示语(来自 description_prefix 参数)放在卡片顶部,提醒"这是待审批"
review_configs这个工具这个调用允许哪些决策用来决定渲染哪几个按钮

9.3.2 review_configs vs interrupt_on 策略

interrupt_on 是静态配置,review_configs 是运行时字段——它告诉前端"这个工具、这一次调用,允许 approve/edit/reject 的哪些"。

例子对照:

interrupt_on 配置运行时 review_configs.allowed_decisions
True["approve", "edit", "reject"] —— 全开
False不会产生 __interrupt__
{"allowed_decisions": ["approve", "reject"]}["approve", "reject"] —— UI 上不应渲染 edit 按钮

9.4 用 Command 响应中断

用户做完决策,客户端用 from langgraph.types import Command 把决策作为下一次 invoke 的入参传回去,框架会从 checkpoint 恢复执行。

from langgraph.types import Command

agent.invoke(
    Command(
        resume={"decisions": [{"type": "approve"}]}
    ),
    config=config,
)

9.4.1 三种决策的具体写法

# ① approve:批准原始调用,一字不改执行
Command(resume={"decisions": [{"type": "approve"}]})

# ② edit:批准,但用修改后的参数执行
Command(resume={"decisions": [{
    "type": "edit",
    "args": {"content": "6,小虎,99\n", "path": "data.txt"}   # 改了的参数
}]})

# ③ reject:否决,可附自然语言解释返回给模型
Command(resume={"decisions": [{
    "type": "reject",
    "message": "成绩格式应该是名字+分数,不能写成 '6,小虎' 这种省略形式。请改回 '小虎,90'。"
}]})

9.4.2 decisions 是列表——为什么?

原图重点提示:当多个工具调用同时暂停时,每个工具都需要独立决策。请按请求中出现的动作顺序提供。

# 模型若一次想调 3 个工具(都在 interrupt_on 里),__interrupt__ 会有 3 项
# 用户的回应要按 __interrupt__ 出现的顺序,逐项给一个决策:
Command(resume={
    "decisions": [
        {"type": "approve"},                                # 第 1 个工具
        {"type": "edit", "args": {...}},                    # 第 2 个工具
        {"type": "reject", "message": "..."},               # 第 3 个工具
    ]
})

如果 decisions 数量对不上 __interrupt__ 项数,框架通常会报错或全部拒绝执行——必须 1:1 对齐。

9.5 完整端到端示例

把第 8 节、第 9 节拼起来,一个可以跑通的最小例子:

# ---------- Step 1: 准备(同 8.x) ----------
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

@tool
def write_txt_tool(path: str, content: str) -> str:
    """将文本内容写入给定路径。"""
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return f"写入完成: {path}"

@tool
def read_txt_tool(path: str) -> str:
    """读取给定路径的文本内容。"""
    with open(path, "r", encoding="utf-8") as f:
        return f.read()

agent = create_agent(
    model=model,
    tools=[write_txt_tool, read_txt_tool],
    middleware=[
        HumanInTheLoopMiddleware(
            interrupt_on={
                "write_txt_tool": True,
                "read_txt_tool": False,
            },
            description_prefix="工具执行待批准",
        ),
    ],
    checkpointer=InMemorySaver(),
)

# ---------- Step 2: 第一次 invoke → 触发中断 ----------
config = {"configurable": {"thread_id": "session-001"}}
result = agent.invoke(
    {"messages": [{"role": "user",
                    "content": "把 hello world 写入 /tmp/note.txt"}]},
    config=config,
)

# 检查 __interrupt__
if "__interrupt__" in result:
    interrupt = result["__interrupt__"]                    # list,每项一个待审批工具
    print("待审批:", interrupt)
    # >>> [{'name': 'write_txt_tool', 'args': {...}, 'review_configs': [...]}]

# ---------- Step 3: 用 UI 收集决策 → 第二次 invoke ----------
# (这里假读:用户点 approve)
final = agent.invoke(
    Command(resume={"decisions": [{"type": "approve"}]}),
    config=config,
)
print(final["messages"][-1].content)   # 模型基于工具执行结果的回复

9.6 UI 层怎么对接

__interrupt__ 是一个纯数据结构,前端拿到后一般这样映射:

返回 __interrupt__
approve/edit/reject 全在
只有 approve/reject
没有 __interrupt__
后端 agent.invoke
前端:toast/对话框
读 review_configs.allowed_decisions
渲染 3 按钮:通过/编辑/拒绝
渲染 2 按钮:通过/拒绝
直接展示对话回复
用户点选/编辑
组装 Command 再次 invoke

实用要点:

  • __interrupt__ 里的 args 展示成 diff 视图最友好(让用户一眼看到模型想改什么)。
  • "edit" 按钮按下后,给一份参数表单(而不是让用户敲 JSON),按字段渲染。
  • "reject" 最好提供一个预设解释模板(例如"语义不对 / 风险过高 / 操作错误 / 其他"),加快输入。
  • 超时处理:如果用户长时间不决策,thread 状态会僵在 checkpoint 里。前端可加个"5 分钟后自动 reject 并说明"的兜底。

9.7 调试清单

  • Q: result 里没有 __interrupt__,但配置明明写了 True?
    A: 检查工具名是否一致(下划线、字母全等);检查是不是多 Agent 子图传递过程中包了一层,导致中间件没生效。
  • Q: 用 Command(resume=...) invoke 后,模型答错了,跟我的决策无关?
    A: 如果是 approve,运行的就是模型原请求;如果是 reject 但 message 没填,模型可能"猜"不到你想让它做什么。
  • Q: 一次 decisions 不够 / 多了一条?
    A: 框架要求 len(decisions) == len(__interrupt__),且顺序一致。要么补,要么截断并 reject 多出来的。
  • Q: 想"整个会话所有工具一次性批"?
    A: 可以:在 decisions 里给每个中断项都填 {"type": "approve"},框架会按列表顺序放行。
  • Q: 重启服务后还能 resume 吗?
    A: 取决于 checkpointer:InMemorySaver 重启即丢失;生产请换 Postgres / Redis。

10. 中断的多种响应回复方式(Reject 与 Edit 详解)

10.1 总览(定义)

除了允许(approve)操作外,用户还可以通过 Command 命令,以编辑(edit) 或 拒绝(reject) 响应中间件中断。

三种响应在数据层互不冲突,但语义强度递增:

approve
edit
reject
__interrupt__
用户决策
保留模型原请求,继续执行
替换为 edited_action,可以改 args 或改 name
不执行,message 进上下文引导重规划

10.2 三种决策的完整结构对比

字段approveeditreject
type"approve""edit""reject"
message——可选,自然语言解释
args———
edited_action.name—可改为另一个工具名—
edited_action.args—必须给出完整 args—
模型后续推理用原工具结果继续用你编辑后的调用继续基于你的 message 重新规划

注意一个隐藏但重要的点:edit 不仅能改参数,还能换工具。但 name + args 都得给,且 args 必须填全。

10.3 Reject 详解

10.3.1 完整结构

agent.invoke(
    Command(
        resume={
            "decisions": [
                {
                    "type": "reject",
                    "message": "No, this is wrong because ..., instead do this ...",
                }
            ]
        }
    ),
    config=config,
)

message 字段是可选的(原图红字提示)。但强烈建议总填,因为:

10.3.2 不写 message 的代价

  • reject 后调用被取消,这次动作不会发生。
  • 但模型下次还能看到这一次空消息的 reject,它会"猜"你想干什么——大概率猜错。
  • 等于浪费一轮,且容易让模型陷入循环(反复发起同一个调用)。

10.3.3 message 怎么写最有效

四要素模板:

[否定] + [原因] + [期望的下一步] + [可选的边界约束]

举例(对应 9.3 节那个写文件的例子):

"message": (
    "不要直接覆盖整个文件。\n"
    "原因:data.txt 里还有其他同学的成绩,全部重写会丢失。\n"
    "期望:请改用 append 模式(read 先读 → 追加新行 → 再 write 回原路径)。\n"
    "边界:小虎的成绩应该是 '小虎,90' 这种 名字,分数 格式,不要写成 '6,小虎'。"
)

要点:

  • 不要只说 "no"——模型不知道为什么不行。
  • 最好给出替代方案——这本质就是在做 in-context RLHF。
  • 带上约束格式——下次同样的歧义再出现,模型能直接复用。

10.3.4 message 在上下文里怎么呈现

不同框架实现略有差异,但通常会被序列化成类似:

[Tool Call Was Rejected]
Tool: write_txt_tool
Original Args: {"content": "6,小虎,90\n", "path": "data.txt"}
User Rejection Reason:
"不要直接覆盖整个文件。原因:……"

然后作为一条消息塞给模型,模型在下一个 reasoning 轮看到并据此调整。

10.4 Edit 详解(重点)

10.4.1 完整结构

agent.invoke(
    Command(
        resume={
            "decisions": [
                {
                    "type": "edit",
                    "edited_action": {
                        "name": "new_tool_name",                              # 改工具名(可选)
                        "args": {"key1": "new_value", "key2": "original_value"}  # 改/补参数
                    },
                }
            ]
        }
    ),
    config=config,
)

edited_action 由两块组成:

子字段含义是否必填说明
name要调用的工具名必填不填会报错;可填原名(只改参数)或别的工具名(换工具)
args传给工具的参数必填即使只是改一个 key,也要把全部参数重写(不是 patch)

这跟普通的 dict.update() 不同。edit 不是"增量修改",是"完全替换"。

10.4.2 场景 A —— 只改参数(name 不变)

模型本来想:

write_txt_tool(path="data.txt", content="6,小虎,90\n")

用户改成(只动 content,path 不变):

"edited_action": {
    "name": "write_txt_tool",          # 跟原 name 保持一致
    "args": {
        "path": "data.txt",             # 原样写回
        "content": "小虎,90\n"          # 改的部分
    }
}

前端实现技巧:UI 上先把原 args 渲染成可编辑表单,用户改完点"提交"——后端拿到的是完整的新 args,而不是 diff。

10.4.3 场景 B —— 换工具(name 也改)—— 这是 edit 的隐藏能力

模型想 delete_txt_tool
用户认为不能直接删
改成 archive_txt_tool / write_txt_tool(覆盖)
执行新工具,继续对话

例子:模型想删 draft.txt,用户改成"先备份再标记"——把"删除"动作重定向成一个"归档"工具:

"edited_action": {
    "name": "archive_txt_tool",         # 换成另一个工具
    "args": {
        "src": "draft.txt",
        "dst": "archive/draft.bak.txt"
    }
}

关键能力:edit 让你纠正工具选择,而不只是改参数。

10.4.4 edit 中 name 改了之后的兼容性检查

实际中要小心:

检查项怎么验
新 name 是 Agent 已注册的工具?看 tools=[...] 列表,没注册过直接报错
新 args 的字段名匹配新工具的签名?框架会用新工具的 schema 校验,不匹配 reject
新工具本身在 interrupt_on 里配置成 False?通常不影响执行(因为你已经审批过了),但有的实现里可能仍触发中断

10.4.5 edit 不支持的"半改"模式

❌ 不能只写 {"content": "小虎,90\n"} 然后保留 path 不变——框架要求完整 args。
❌ 不能留一个 placeholder 或省略字段——校验失败。
✅ 正确做法:要么 UI 表单一开始就渲染所有字段让用户编辑,要么后端从 __interrupt__ 里把原 args deep copy 一遍再 patch。

10.5 三种决策的"语义强度"对比

强 ←─────────────────────────────────────→ 弱
edit(reject 同时支持换工具) > reject(message) > approve
  • approve:模型错了怎么办?——你担责
  • edit:你细化/纠错/换工具——你和模型合作
  • reject:你否决整个意图 + 引导方向——你来定方向,模型执行

这跟第 4 节那个"模式"是同一语义在不同层的体现:

  • approve ≈ "Review & Approve"
  • edit ≈ "Edit & Refine" + "Route & Triage"(换工具版)
  • reject ≈ "Plan-then-Execute" 的反向:你重写计划

10.6 实战工作流示例

场景:客服 Agent 写入工单时把客户 id 写反了

# 模型打算:
write_txt_tool(
    path="tickets/2025-001.txt",
    content="客户ID: 12345,内容: 退款申请"
)
# __interrupt__ 让你看到 args={'content': '客户ID: 12345, 内容: 退款申请', 'path': 'tickets/2025-001.txt'}
# 你发现 ID 应该是 54321

三个选项:

# (1) approve:模型没错就不动
"decisions": [{"type": "approve"}]

# (2) edit:改 ID(参数级纠错)
"decisions": [{
    "type": "edit",
    "edited_action": {
        "name": "write_txt_tool",
        "args": {"path": "tickets/2025-001.txt", "content": "客户ID: 54321,内容: 退款申请"}
    }
}]

# (3) reject:不要写文件,改用查 ticket 系统接口
"decisions": [{
    "type": "reject",
    "message": "不要把工单写本地文件。请改调用 create_ticket_api(customer_id='54321', content='退款申请'),由工单系统统一管理。"
}]

10.7 调试清单(本节专项)

  • Q: edit 改了 args 后还是按原 args 执行?
    A: 检查 args 是不是完整字典(包含所有字段),不是 patch。
  • Q: edit 想换工具,但新工具没注册?
    A: 框架会在 retry 时报错,UI 上需要预校验——前端实现可以拉 tools 列表动态渲染可选工具。
  • Q: reject 没填 message,模型下一次又调了同样的工具?
    A: 几乎肯定会。message 不是可选,而是"建议必填"——除非你想让它走完一轮再说。
  • Q: 能不能 edit + reject 混用(同一个 invoke 里)?
    A: 不可以。每条 decision 必须是单独一种 type;要么 edit 要么 reject,不能"又改又否"。
  • Q: 一次 decisions 里前面是 approve,后面是 reject 能不能?
    A: 可以。每个工具独立选一种,顺序对应 __interrupt__ 列表的顺序。

10.8 进阶技巧:用 edit 做"安全网"

一个高级用法——把"高风险工具"配 True,靠 edit 当安全网:

  • 模型一开始可能给出 chmod 777,你 edit 成 chmod 755
  • 模型选错的 SQL 表名,你 edit 改成 schema 里存在的那张
  • 模型删 ~/.bashrc,你 edit 成 cp ~/.bashrc ~/.bashrc.bak && write 备份文件

这等于"让模型自由发挥,但你永远能最后把关"——比 strict 的 prompt 限制灵活得多。


11. HITL 中间件的运行逻辑(5 阶段生命周期)

11.1 总览(定义)

Human in the loop (HITL)中间件,属于 After_model 类型钩子 (Hook)。该钩子在模型生成响应之后、任何工具调用执行之前触发判定。

这一句话定位了 HITL 在 LangGraph 执行图里的精确时序位置:

[用户消息]
   ↓
[Before_model 钩子]    ← 系统提示、上下文注入、guardrails
   ↓
[模型生成响应]              ← LLM 推理
   ↓
[After_model 钩子]      ← ★ HITL 中间件在这里
   ↓
[Tool 节点 / 工具执行]   ← 模型请求的工具被实际调用
   ↓
[After_tool 钩子]      ← 工具结果处理
   ↓
[回到模型 / 结束]

HITL 不是在模型前,也不是在工具后,而是刚好夹在"模型想了"和"工具做"之间。

11.2 五阶段生命周期

否,全部通过
是
1. 调用模型
Agent 依据用户消息
调用模型生成回复
2. 验证条件
检查响应中的工具调用
是否符合 interrupt_on 策略
需要人工干预?
直接进入工具执行
3. 触发中断
构建 HITL 请求
触发进程中断
4. 等待决策
Agent 返回 __interrupt__ 给用户
5. 接受决策
执行 approve/edit/把 reject 合成 ToolMessage
继续后续流程
结果回模型 / 结束

11.3 各阶段详解

阶段 1:调用模型

  • 触发者:Agent 收到用户消息后进入图节点。
  • 做了什么:LangGraph 调 LLM,把当前消息历史、工具列表、系统提示组装好送进去。
  • HITL 中间件的状态:未激活,等模型返回。

阶段 2:验证条件

  • 触发者:After_model 钩子被调起,HITL 中间件收到模型回复。
  • 做了什么:
    • 解析模型回复里的 tool_calls(response.choices[0].message.tool_calls)。
    • 对每一条 tool_call,对照 interrupt_on 配置:
      • False 或未列入 → 放行
      • True → 列入待审批
      • {"allowed_decisions": [...]} → 列入待审批,记录允许的决策集合
  • 输出:
    • 完全放行 → 直接进入阶段 3 但不中断,跳到阶段 5 的执行分支。
    • 有任意一条需要审批 → 收集起来,进入真正的中断路径。

阶段 3:触发中断

  • 触发者:上一阶段发现至少 1 条需审批调用。
  • 做了什么:
    • 构建 HITL 请求对象(包含操作请求 + 审核配置),对应 __interrupt__ 字段。
    • 调用 LangGraph 的 interrupt 机制——进程被冻结,状态序列化进 checkpointer。
    • 这是同步的等待:程序卡在这里,不会推进到下一节点。
  • 输出:LangGraph 状态机收到 interrupt,触发"图挂起"。

阶段 4:等待决策

  • 触发者:框架从中断状态返回给客户端。
  • 做了什么:
    • agent.invoke(...) 调用正常返回(不是抛错),但返回值里有 __interrupt__ 字段。
    • 客户端/UI 读到 __interrupt__,渲染给用户。
    • 用户花时间思考、点击、编辑参数。
  • 状态:Agent 进程线程可被复用(用 InMemorySaver 时),或可被回收(用持久 checkpointer 时)。

阶段 5:接受决策

  • 触发者:用户用 Command(resume=...) 再次 invoke,框架从 checkpoint 恢复执行。
  • 做了什么:
    • 反序列化用户决策列表(顺序对应 __interrupt__ 项)。
    • 对每个 tool_call:
      • approve → 把模型原始 tool_call 送进 tool_node 执行,ToolMessage 带上执行结果回模型。
      • edit → 把 edited_action(可能换了 name,改了 args)送进 tool_node 执行。
      • reject → 不执行;合成一条 ToolMessage 直接返回给模型,内容是"用户拒绝了 + reject.message"。
  • 输出:消息流继续(可能还有下一轮模型决策),直至任务结束。

11.4 为什么是 After_model 而不是别的钩子?

钩子位置适用场景为什么不放 HITL 在这里
Before_modelprompt 重写、上下文压缩、guardrails这时模型还没"想",没法判断要批什么
After_model ★内容审查、工具审批正好——模型刚产出想法,立刻拦截
Before_tool参数改写、动态重定向单工具粒度可以,但没法一次批多工具(HITL 想做的是"一次决策多个调用")
After_tool结果脱敏、报错注入太晚——动作已经发生,HITL 失去"拦截"意义

After_model 给 HITL 提供了三个关键能力:

  1. 看到模型全部意图——可能一次多个 tool call,可统一调度。
  2. 在动作发生前拦截——这是"审核"的本质。
  3. 决策粒度灵活——可以一个 approve 一个 reject 一个 edit。

11.5 ToolMessage 在第 5 阶段的关键角色

很多人不理解"reject 时为什么合成 ToolMessage 给模型"——因为模型需要看到反馈才能调整。

# 一个合成的 ToolMessage 长这样:
ToolMessage(
    content="[Tool Call Rejected by User]\nReason: 不要直接覆盖整个文件……",
    tool_call_id="call_abc123"
)

它模仿了"工具跑了,但失败"的形态。模型看到这条消息后:

  • 知道自己的请求被拒;
  • 知道为什么;
  • 能在下一轮 reasoning 里用这些信息重新规划。

如果 replace 成 AIMessage("用户拒绝了你的请求"),模型也能看到,但消息类型混乱(工具调用对应 ToolMessage 是规范);框架对 ToolMessage 还会触发特定的工具失败处理逻辑,比如自动把这一项从 history 里"已结算"标记。

11.6 全生命周期时序图

HITL 中间件工具节点模型 LLM框架(Agent 图)用户HITL 中间件工具节点模型 LLM框架(Agent 图)用户alt[决策 = approve/edit][决策 = reject]alt[全部放行][需要审批]发送消息invoke(用户消息)响应(含 tool_calls)After_model 钩子验 interrupt_on 策略放行执行工具ToolMessage(结果)喂回结果继续最终回复返回(无 __interrupt__)触发 interrupt返回(含 __interrupt__)用户做决策agent.invoke(Command(resume=...))resume 调度调工具(原始或 edit 后)ToolMessage(结果)合成 ToolMessage(拒绝原因)接回消息流喂回 ToolMessage(s) 继续下一轮推理 / 结束返回最终结果

11.7 这套生命周期设计揭示的产品哲学

设计点体现的产品哲学
钩子是 After_model 而非 BeforeHITL 是"看完想法再决定",不是"不许模型想"
中断 = 进程级冻结等待期间不消耗资源,只是被序列化
Checkpointer 是强制的中断状态要能 resume,远超过单会话时长都可以
决策用 Command 而不是改 system prompt设计者不想让 prompt 反复被改——决策是"用户事实",不是"模型配置"
Reject 走 ToolMessage模型视角统一:无论是"工具失败"还是"被拒",都是 ToolMessage,行为一致

11.8 调试清单

  • Q: 中间件根本没触发,但配置了 True?
    A: 大概率是钩子位置不对——确认你的 HumanInTheLoopMiddleware 在 middleware=[...] 里,且框架版本支持(老版本不识别)。
  • Q: 多 tool call 只触发了第一个,后面的没拦住?
    A: 检查实现是"短路"还是"全收集"。LangChain v1 是全收集——一次返回所有待审。如果只拦截了一个,考虑更新框架版本。
  • Q: 用户 reject 后,模型立刻又发了同样的调用?
    A: 看 reject.message 写得清不清楚,或者模型是不是单步 hardcoded模式(用 plan-then-execute 缓解)。
  • Q: 决策后 ToolMessage 在消息历史里重复出现?
    A: 检查 thread_id 是否复用错(同一个 thread 串话)。每个 thread 应该有自己独立 history。
  • Q: 想让中间件在工具执行前(而不是模型响应后)也拦截一次?
    A: 那是 Before_tool 钩子,与 HITL 是两套独立机制。HITL 不适合做参数级微拦截——参数级要写自定义 middleware(参考 8.9 的 Q&A)。

11.9 进阶:如果想看钩子时序长什么样

可以在 LangGraph 里打印每次钩子回调来观察:

from langchain.agents.middleware import AgentMiddleware

class DebugLogMiddleware(AgentMiddleware):
    def after_model(self, state, runtime):  # 注意:和 HITL 同一个钩子位
        print("== after_model 钩子触发 ==")
        last = state["messages"][-1]
        print(f"模型输出类型: {type(last).__name__}")
        if hasattr(last, "tool_calls"):
            print(f"  工具调用数: {len(last.tool_calls)}")
        return state

agent = create_agent(
    model=model,
    tools=tools,
    middleware=[DebugLogMiddleware(), HumanInTheLoopMiddleware(...)],
    ...
)

把这块和 HITL 中间件并排放,就能看到 HITL 是什么时候被触发、有多少 tool_calls 被拦截的——debug 利器。


12. 知识地图 · 一页纸 cheat sheet(总览)

来源:你发的最后一张图(5 主题总结)

12.1 这张图是"笔记索引"

它把整份笔记压成 5 个主题,正好对应本笔记的 5 大块:

#原图主题对应本笔记章节一句话定位
1HITL 中间件的概念第 1 节(以及第 3、5、6 节)是什么、为什么、有什么设计原则
2HITL 中间件的创建与配置第 8 节怎么搭:create_agent + middleware + interrupt_on + checkpointer
3HITL 中间件中断进程的响应第 9 节中断后端:__interrupt__ → Command resume
4中断的多种响应方式第 4 节、第 10 节approve / edit / reject,以及 edit 的隐藏能力
5HITL 中间件运行的逻辑过程第 11 节After_model 钩子 + 5 阶段生命周期

12.2 整本笔记的章节地图

HITL 笔记概念篇实现篇 LangChain v18.1 概述8.2 创建工具8.3 创建 Agent8.4 interrupt_on 策略8.5 checkpointer8.6 配置注意8.7 运行时审批8.8 与设计原则对应8.9 调试清单运行时篇9.1 总览9.2 不触发9.3 触发与 interrupt9.4 Command 响应9.5 端到端示例9.6 UI 对接9.7 调试清单10.2 结构对比10.3 reject 详解10.4 edit 详解10.5 语义强度10.6 实战工作流10.8 安全网技巧生命周期篇11.1 After_model 钩子位置11.2 5 阶段生命周期11.4 为什么是 After_model11.5 ToolMessage 合成11.6 时序图总览篇

12.3 一页纸 cheat sheet

工具全部放行
有需要审批
approve
edit
reject
任务结束
还要继续
Agent.invoke
After_model
HITL 中间件
工具直接执行
触发 interrupt
__interrupt__ → 用户决策
用户选什么?
原 tool_call 进 ToolNode
edited_action 进 ToolNode
可改 name + args
合成 ToolMessage
不执行,塞 reject.message
ToolMessage 成功 → 回到模型
ToolMessage 反馈 → 模型重规划
下一轮推理
对话结束

12.4 速查决策表

12.4.1 配置阶段:interrupt_on 该怎么写?

你想让工具...配 interrupt_on
总是要人审(可以改参数)True
完全不审False(或不列)
只许批准/否决,不许改后执行{"allowed_decisions": ["approve", "reject"]}
只许读一次(教学场景)True 但是用 thread_id 区分

12.4.2 决策阶段:用户该选什么?

模型想做的推荐决策用什么字段
完美,直接执行approve{"type": "approve"}
方向对,参数错edit{"type": "edit", "edited_action": {...}}
工具有原则问题(删库、付款)reject{"type": "reject", "message": "..."}
应该换工具来做editedited_action.name = "new_tool"
整体方向错reject + 替代方案message 中给出新方向

12.4.3 调试阶段:在哪个章节查?

问题看哪一节
为什么没触发中断?8.6、11.8
配置 True 但工具直接跑了8.6、11.8
中断后 invoke 不返回8.5、9.7
edit 改了 args 还是按原 args 执行10.7
reject 没效果,模型还重复调用10.3.2、10.7
UI 怎么对接 __interrupt__9.6
怎么统一查看钩子时序11.9
多工具同时暂停怎么处理9.4.2

12.5 常见错误汇总(全笔记一次性回顾)

错误解决
配了 True 但工具直接跑了检查工具名是否完全一致 + 检查框架版本是否支持
中断后不能 resume加 checkpointer;InMemorySaver 重启即丢
编辑 args 仍按原 args 执行edit 要完整 args,不是 patch
reject.message 不写,模型陷入循环写四要素:否定 + 原因 + 期望下一步 + 约束
一次 decisions 对不上 __interrupt__ 长度严格 1:1,顺序一致
__interrupt__ 字段没读到看是不是用了不同 thread_id
重启服务就丢状态换 Postgres / Redis checkpointer
HITL 配置后所有工具都拦截可能 interrupt_on 用了 True 而不是 False
想拦截"参数级别" 的 tool callHITL 是工具级,要参数级得自定义 middleware
edit 想换没注册的工具框架直接报错,UI 需预校验

12.6 第一次学 HITL 的阅读路径

如果你是第一次学 HITL,按这个顺序读效果最好:

第 1 节 (是什么)
    ↓
第 2 节 (为什么)
    ↓
第 3 节 (架构,看图)
    ↓
第 4 节 (模式)
    ↓
第 8 节 (动手,在 LangChain v1 跑通)
    ↓
第 9 节 (运行时,读 __interrupt__、发 Command)
    ↓
第 10 节 (决策细节,reject/edit 写法)
    ↓
第 11 节 (生命周期,理解 5 阶段)
    ↓
第 5、6 节 (回看设计原则与常见追问)
    ↓
第 7 节 (一句话收敛)

第 8 节和第 9 节建议边读边跑代码,其它节都可以快速扫。

12.7 后续进阶方向

笔记目前覆盖到 11 节(主线) + 12 节(总览)。再深入可以走这四条线:

HITL 进阶多 Agent 协同子图 HITL 传递主+审核谁审?工程化Checkpointer 选型thread_id 与多用户隔离可观测性 + 决策时延Override Rate 监控合规安全审计日志决策可追溯敏感工具隔离沙箱学习闭环reject 数据集收集DPO / RLAIF 训练Prompt 迭代

12.8 一句话收束(对应第 7 节 + 11 节合并)

HITL = 在 After_model 钩子上,用 interrupt_on 策略把 tool_calls 拦下来,把决策权临时交回给人类;人类用 Command(resume=...) 给 approve / edit / reject,执行结果(无论成功还是合成的拒绝 ToolMessage)回写模型继续推理——闭环反馈让人教模型越用越准。

Ai, LangChain
LangChain Ai Python
许可协议:  CC BY 4.0
分享

相关文章

7月 31, 2026

Human-in-the-Loop 实战指南:在 LangChain 中为 Agent 装上"决策刹车"

随着 LLM Agent 越来越"敢动手",我们开始需要一种机制 —— 让 AI 在做关键动作前停下来,让人看一眼,再由人来决定放行、修改还是拒绝。 这就是 Human-in-the-Loop (HITL):AI 不再是"自动执行",而是"提建议 → 被审核 → 必要时修正"。 本文带你从概念到 L

7月 30, 2026

LangChain Agent 中间件完全指南:从拦截器设计到自定义钩子实战

读懂中间件机制,让你的 Agent 从"能跑"走向"可控、可观测、可生产"。 写在前面 如果你已经用 LangChain 搭过 Agent,大概率会遇到这些痛点: 想给 Agent 加上日志,却不知道在哪一层埋点 想做调用次数限流,防止 token 成本失控 想在关键工具调用前让人工把关,但不想重写

7月 30, 2026

一文搞懂 LangChain Message:从 4 种消息类型到 Agent 状态流转

如果你正在学习 LangChain 或基于 LangChain 搭建 Agent,那么「Message(消息)」是绕不开的第一道坎。 它既是模型的输入输出,也是对话的上下文单元,更是 Agent 状态机的"燃料"。 本文将带你系统掌握 Message 的全部要点:4 种消息类型 → 工具调用流程 →

下一篇

上一篇

LangChain Agent 中间件完全指南:从拦截器设计到自定义钩子实战

最近更新

  • Human-in-the-Loop 实战指南:在 LangChain 中为 Agent 装上"决策刹车"
  • LangChain Agent 中间件完全指南:从拦截器设计到自定义钩子实战
  • 一文搞懂 LangChain Message:从 4 种消息类型到 Agent 状态流转
  • 一文读懂 LangChain Tools:从 @tool 装饰器到 StructuredTool,让大模型真正'动手'
  • LangChain Agent 完全指南:从 Tools 到 ReAct 循环的工程实践

热门标签

samsung WireGuard Chevereto docker 破解 llama LangChain Ai Python Gemma

目录

©2026 命令行小屋. 保留部分权利。

使用 Halo 主题 Chirpy