Human-in-the-Loop 实战指南:在 LangChain 中为 Agent 装上"决策刹车"
随着 LLM Agent 越来越"敢动手",我们开始需要一种机制 —— 让 AI 在做关键动作前停下来,让人看一眼,再由人来决定放行、修改还是拒绝。
这就是 Human-in-the-Loop (HITL):AI 不再是"自动执行",而是"提建议 → 被审核 → 必要时修正"。本文带你从概念到 LangChain v1 的具体实现,把 HITL 的工作原理一次性打通。读完你会明白 HITL 中间件是什么、怎么配、怎么响应、卡在哪一步,以及怎么把它用在生产里。
代码基于 LangChain v1 + LangGraph,Mermaid 图可在线渲染(Mermaid 8+)。
全文约 8000 字,完整阅读 15 分钟;按目录跳读 5 分钟。
目录
- HITL 是什么?为什么需要?
- HITL 的运行架构
- 常见 HITL 模式
- HITL 设计的五条关键原则
- 常见追问:粒度 / 路由 / 成本
- 在 LangChain v1 中创建 HITL(动手)
- 响应中断:运行时机制详解
- Reject 与 Edit 的完整写法
- HITL 中间件的运行逻辑(5 阶段生命周期)
- 知识地图与一页纸速查
- 收束 + 进阶方向
Human-in-the-Loop(HITL)笔记
1. 核心定义(定义)
Human-in-the-Loop(HITL)中间件允许用户为代理工具调用添加人工监督。当模型提出可能需要审查的动作时——例如写入文件或执行 SQL,中间件可以暂停工作执行并等待人类决策。
三个关键词拆解:
- 中间件(middleware) —— 不是模型自身能力,而是独立的一层;模型本身不知道也不感知"被审查了"。
- 工具调用(tool call) —— 审查的最小单位是"这一次工具调用",而不是整个对话或任务。
- 暂停 + 等待 —— 必须能真正中断执行流,异步等人类回复后再继续,不能只是事后日志。
2. 工作流架构(还原原图)
参与方与流向:
| 节点 | 角色 |
|---|---|
| 模型(左) | 发起调用请求;"我想要调用……" |
| 中间件(中) | 截获调用、按规则路由、暂停执行 |
| 用户(下) | 决策人,断点介入 |
| 工具(右) | 实际执行者:写文件、执行 SQL、调用 API…… |
两条回环:
- 正常路径:模型请求 → 中间件放行 → 工具执行 → 结果回写模型 → 继续推理。
- 拒绝路径:模型请求 → 中间件拦截 → 回到模型并附带人类解释 → 模型重新规划。
3. 三种人类响应方式(原图核心)
① 批准(approve)
- 做了什么:按模型原始调用一字不改地执行。
- 何时用:对模型完全信任、动作可逆、风险低。
- 用户代价:≈ 0,一键放行。
- 风险:若模型错了,错也会被执行。
② 编辑(edit)
- 做了什么:在模型原始调用基础上人工改参数,再执行。
- 何时用:方向对、细节错(SQL 表名错、文件路径偏、参数超范围)。
- 用户代价:低,但需要在 diff 视图里改字段。
- 价值:纠错不纠向,实战中最高频的选项,也是 HITL 真正"用得起来"的关键。
③ 拒绝(reject)
- 做了什么:不执行,并把"为什么不执行 + 期望的下一步"以对话形式回退给模型。
- 何时用:方向就错了,或动作不可逆(删库、付款、对外发邮件、批量修改生产配置)。
- 用户代价:中等——需要写一句解释。
- 价值:本质是in-context RLHF,这次拒绝会进上下文,让模型下一步动作更准。
4. 三种响应的对比
| 维度 | approve | edit | reject |
|---|---|---|---|
| 模型被改变的程度 | 无 | 仅参数 | 路径与下一步动作 |
| 用户付出的认知成本 | 极低 | 中 | 中–高(要写解释) |
| 适用频率(经验值) | 低 | 最高 | 中 |
| 典型触发 | 已有审批策略 / 低风险 | 90% 的真实场景 | 方向错 / 高风险 |
| 数据回流的信号 | 隐式(放行=认可) | 强(改了什么=正确做法) | 最强(否决理由) |
5. 这张图揭示的 HITL 关键设计点
-
中间件必须是真"截断点"
不能是事后日志/审计;必须是执行流上的同步等待。 -
审查粒度 = 一次工具调用,不是"整段对话"
HITL 普遍做的是每次 tool call 都要过一次门,而不是整轮对话一锤定音。 -
三种响应覆盖完整动作空间
approve / edit / reject 几乎穷尽了人类想给出的反馈,设计成三选一即可。 -
拒绝路径自带"解释反哺"
不只是 block,而是把自然语言反馈塞回模型上下文,这等效于一次 in-context RLHF。 -
执行结果必须回写模型
模型需要看到工具返回值,否则推理链断裂——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 里配置的策略,逐个对照每个工具调用,触发条件后中断执行,等用户裁决。
三步流程:
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中配置的策略,不进行配置的工具不会触发执行。
这句话有两层含义,容易踩坑:
interrupt_on没列出的工具 = 没有策略,不仅不会中断,甚至不会注册触发 HITL 的资格——你需要保证每个你想让中间件"看得见"的工具全部列出来(哪怕写False)。- 表面上看是"没配置 = 不中断",实际更严格——有的实现里,没列出的工具直接拒绝运行或静默放行,取决于框架版本。务必查文档。
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__ 字段,显示需要复审的工具调用。人类用户根据决策响应中断,框架继续执行。
两种调用走向:
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__里的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) 响应中间件中断。
三种响应在数据层互不冲突,但语义强度递增:
10.2 三种决策的完整结构对比
| 字段 | approve | edit | reject |
|---|---|---|---|
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 的隐藏能力
例子:模型想删 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 五阶段生命周期
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": [...]}→ 列入待审批,记录允许的决策集合
- 解析模型回复里的 tool_calls(
- 输出:
- 完全放行 → 直接进入阶段 3 但不中断,跳到阶段 5 的执行分支。
- 有任意一条需要审批 → 收集起来,进入真正的中断路径。
阶段 3:触发中断
- 触发者:上一阶段发现至少 1 条需审批调用。
- 做了什么:
- 构建 HITL 请求对象(包含操作请求 + 审核配置),对应
__interrupt__字段。 - 调用 LangGraph 的 interrupt 机制——进程被冻结,状态序列化进 checkpointer。
- 这是同步的等待:程序卡在这里,不会推进到下一节点。
- 构建 HITL 请求对象(包含操作请求 + 审核配置),对应
- 输出: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_model | prompt 重写、上下文压缩、guardrails | 这时模型还没"想",没法判断要批什么 |
After_model ★ | 内容审查、工具审批 | 正好——模型刚产出想法,立刻拦截 |
Before_tool | 参数改写、动态重定向 | 单工具粒度可以,但没法一次批多工具(HITL 想做的是"一次决策多个调用") |
After_tool | 结果脱敏、报错注入 | 太晚——动作已经发生,HITL 失去"拦截"意义 |
After_model 给 HITL 提供了三个关键能力:
- 看到模型全部意图——可能一次多个 tool call,可统一调度。
- 在动作发生前拦截——这是"审核"的本质。
- 决策粒度灵活——可以一个 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 全生命周期时序图
11.7 这套生命周期设计揭示的产品哲学
| 设计点 | 体现的产品哲学 |
|---|---|
钩子是 After_model 而非 Before | HITL 是"看完想法再决定",不是"不许模型想" |
| 中断 = 进程级冻结 | 等待期间不消耗资源,只是被序列化 |
| 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 大块:
| # | 原图主题 | 对应本笔记章节 | 一句话定位 |
|---|---|---|---|
| 1 | HITL 中间件的概念 | 第 1 节(以及第 3、5、6 节) | 是什么、为什么、有什么设计原则 |
| 2 | HITL 中间件的创建与配置 | 第 8 节 | 怎么搭:create_agent + middleware + interrupt_on + checkpointer |
| 3 | HITL 中间件中断进程的响应 | 第 9 节 | 中断后端:__interrupt__ → Command resume |
| 4 | 中断的多种响应方式 | 第 4 节、第 10 节 | approve / edit / reject,以及 edit 的隐藏能力 |
| 5 | HITL 中间件运行的逻辑过程 | 第 11 节 | After_model 钩子 + 5 阶段生命周期 |
12.2 整本笔记的章节地图
12.3 一页纸 cheat sheet
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": "..."} |
| 应该换工具来做 | edit | edited_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 call | HITL 是工具级,要参数级得自定义 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 节(总览)。再深入可以走这四条线:
12.8 一句话收束(对应第 7 节 + 11 节合并)
HITL = 在
After_model钩子上,用interrupt_on策略把tool_calls拦下来,把决策权临时交回给人类;人类用Command(resume=...)给 approve / edit / reject,执行结果(无论成功还是合成的拒绝 ToolMessage)回写模型继续推理——闭环反馈让人教模型越用越准。