LangChain Human-in-the-Loop
代码仓库ChainReaction
模型能调工具之后,第一个让人睡不踏实的问题就是它什么时候会自己动手。
查天气、算数、读文档,模型自己跑没问题。发邮件、删数据库记录、调支付接口,这类动作发出去就收不回来。Human-in-the-Loop 中间件处理的就是这一层:模型提议一个工具调用,真正执行之前先停下来,等人点个头。
这篇讲四件事。中间件怎么配,interrupt 怎么把执行挂起、状态落在哪、又怎么恢复,审批粒度能细到什么程度,以及和前端对接时待审批的卡片需要哪些字段。代码都在 DeepSeek 上真跑过,输出是终端里直接抄下来的,脚本在仓库的 HumanInTheLoop/ 目录下。
闸门装在哪一步
create_agent 跑起来是个循环:模型产出 AIMessage,里面可能带 tool_calls,工具节点执行,结果作为 ToolMessage 回到模型,再产出下一轮。HumanInTheLoopMiddleware 挂的是 after_model 钩子,位置在”模型已经想好要调什么”和”工具真的被执行”之间。
钩子里做的事情很直接。把这条 AIMessage 里的 tool_calls 逐个拿去过 interrupt_on 这张表,表里没有的工具直接放行,命中的攒成一个 HITLRequest,然后调 interrupt()。执行停在这里。
有人会问,在 system prompt 里写一句”执行敏感操作前先问我”不行吗。行,但不稳。模型可能忘,也可能理解成”在回复里提一句就算问过”。中间件是代码,命中就是命中,没得商量。
官方文档把这段生命周期拆成五步:模型生成回复,中间件检查回复里的 tool_calls,命中策略就构造 HITLRequest 并调 interrupt,然后等决策;拿到 HITLResponse 之后,批准和编辑过的调用照常执行,被拒绝的合成一条 ToolMessage,人代答的内容也直接当成 ToolMessage 塞回去;之后继续跑。这套顺序值得记住,出问题时排查基本就沿着它走。
flowchart TD
A[模型产出 AIMessage] --> B{有 tool_calls 吗}
B -- 否 --> Z[直接返回]
B -- 是 --> C[after_model 钩子]
C --> D{命中 interrupt_on 吗}
D -- 否 --> E[工具节点直接执行]
D -- 是 --> F{when 返回 True 吗}
F -- 否 --> E
F -- 是 --> G[攒成 HITLRequest]
G --> H[interrupt 挂起并存 checkpoint]
H --> I[人做决策]
I --> J[Command resume 恢复]
J --> K{决策类型}
K -- approve --> E
K -- edit --> L[替换参数后执行]
K -- reject --> M[合成 error ToolMessage]
K -- respond --> N[合成 success ToolMessage]
E --> O[结果回到模型]
L --> O
M --> O
N --> O
一次中断和一次批准
先看能跑的最小版本。两个工具,发邮件要审批,读邮件放行。
1 | import os |
真实输出,第一段是中断载荷:
1 | result type: GraphOutput |
第二段是 approve 之后跑完的结果:
1 | [TOOL EXECUTED] send_email -> alice@example.com / Weekly sync moved |
两个细节值得留意。
第一,version="v2" 让 invoke 返回 GraphOutput,中断载荷挂在 .interrupts 上,最终状态在 .value 里。不传 version 也能跑,返回的是普通 dict,中断载荷在 result["__interrupt__"] 里。两种格式都对,v2 读起来顺一点。
第二,[TOOL EXECUTED] 这行出现在 approve 之后。第一次 invoke 时它没有打印,这就是闸门生效的证据。
interrupt_on 怎么配
interrupt_on 是一个字典,key 是工具名,value 有三种写法。
| value | 含义 |
|---|---|
False |
这个工具不审批,直接执行。不写在字典里也是一样的效果 |
True |
允许全部决策:approve、edit、reject、respond |
{"allowed_decisions": [...]} |
只允许列出的决策,还能带 description、when、args_schema |
允许的决策一共四种,来自官方文档。
| 决策 | 行为 | 典型场景 |
|---|---|---|
approve |
按模型给的参数原样执行 | 邮件草稿没问题,直接发 |
edit |
改掉参数再执行 | 收件人写错了,改一个再发 |
reject |
不执行,把拒绝理由作为反馈交回模型 | 拒绝删除文件,并说明原因 |
respond |
不执行,把人的回复直接当成工具结果 | 给 ask_user 这类占位工具做人肉回答 |
reject 和 respond 的区别容易搞混。reject 合成的 ToolMessage 状态是 error,语义是”这个动作被否了”;respond 合成的是 success,语义是”工具成功返回了人给的答案”。拿 respond 去否一个写操作,模型会以为写成功了。文档里专门提醒过这一点。
InterruptOnConfig 里还能配几个东西:
description:字符串或者callable(tool_call, state, runtime),决定中断载荷里description字段长什么样。做前端卡片就靠它。description_prefix:构造在中间件上,默认"Tool execution requires approval",只在工具没有自定义description时生效。when:callable(ToolCallRequest) -> bool,返回 True 才中断。需要langchain>=1.3.3。args_schema:可选的 JSON Schema,会出现在review_configs里,给前端判断哪些字段可以编辑。中间件目前不会自动从工具签名生成它,要自己填。
配置写成这样:
1 | HumanInTheLoopMiddleware( |
有个坑要提前说。如果某个工具的配置字典里 allowed_decisions 是空的或者拼错了 key,中间件在构造时直接抛 ValueError,不会静默跳过。这个设计是对的,否则你以为装了闸门,实际上门一直开着。
interrupt 挂起和恢复的完整时序
interrupt() 内部是抛一个特殊异常。运行时接住它,把当前图状态写进 checkpointer,然后把控制权交还给调用方。恢复时 Command(resume=...) 的值会成为 interrupt() 的返回值。
sequenceDiagram
participant U as 用户
participant M as 模型
participant H as HITL 中间件
participant CP as Checkpointer
participant T as 工具
U->>M: 提出需求
M->>H: AIMessage 带 tool_calls
H->>H: 逐个比对 interrupt_on
H->>CP: 写入当前图状态
H-->>U: interrupt 载荷 action_requests + review_configs
Note over H: 图挂起,无限期等待
U->>H: Command(resume=decisions)
H->>CP: 按 thread_id 读回状态
H->>H: 节点从头重跑,interrupt 返回决策
H->>T: approve 或 edit 的执行
H->>M: reject 或 respond 合成 ToolMessage
M->>U: 最终回复
这里有个必须记住的语义:恢复时节点是从头重跑的,不是从断点那一行继续。文档写得很清楚,interrupt 之前已经执行过的代码会再跑一遍。中间件这个场景里重跑的是 after_model 钩子,它重新比对一遍 tool_calls,这次 interrupt() 直接拿到决策返回,然后继续往下处理。代价是钩子里的副作用会重复,所以别在钩子之前做不幂等的事。
stateDiagram-v2
[*] --> 待审批
待审批 --> 已批准: approve
待审批 --> 已修改: edit
待审批 --> 已拒绝: reject
待审批 --> 人代答: respond
已批准 --> 执行工具
已修改 --> 执行工具
已拒绝 --> 合成错误结果
人代答 --> 合成成功结果
执行工具 --> 结果回到模型
合成错误结果 --> 结果回到模型
合成成功结果 --> 结果回到模型
结果回到模型 --> [*]
checkpointer 是硬前提
文档里给 HumanInTheLoopMiddleware 的警告只有一句:需要 checkpointer 来跨中断保存状态。这句话的分量比它看起来重,缺了它整个机制根本转不起来。
没有 checkpointer 会怎样,我实际试了。中断本身还是会触发,载荷也在,但恢复直接炸:
1 | step1 interrupted keys: ['__interrupt__'] |
原因不复杂。中断意味着进程要停在一个”待续”的位置上,消息历史、还没执行的 tool call、任务级的 resume 列表,这些都得有个地方存。checkpointer 就是那个地方,config 里的 thread_id 是取回这份状态的指针。没有它,第二次 invoke 只能从头开始,而从头开始就意味着模型重新想一遍,之前那个待审批的调用消失得无影无踪。
InMemorySaver 存在进程内存里,适合本地调试。进程一退,状态就没了,线上别用。生产上按文档换成 AsyncPostgresSaver 或者 MongoDBSaver,这样服务重启之后未审批的单子还在。
恢复执行的写法
四种决策的载荷长这样:
1 | from langgraph.types import Command |
edit 的 edited_action 里要写完整的 name 和 args,不是只写改动的那一项。name 通常和原来一样,也允许换成别的工具。
我跑了一次 edit,把收件人从 wrong@example.com 改成 right@example.com,工具确实用新参数执行了。有意思的是回到模型的那条 ToolMessage:
1 | --- tool --- |
这段提示是中间件自动加的,可以通过 edit_notice=None 关掉。它的作用是防止模型看到参数被改之后,又自作主张把原始调用重发一遍。
reject 那条路我也跑了。拒绝理由会拼进 ToolMessage,状态标成 error:
1 | --- tool (status=error) --- |
模型没有硬来,转成向用户追问。这就是把 message 写具体的好处。不写 message 的话,中间件会用默认文案告诉模型”工具没执行,除非用户明确要求否则不要重试”。
不用中间件,自己写闸门
中间件是现成品。审批逻辑特别绕的时候,比如要根据外部工单系统的状态决定放不放行,可以直接拿 interrupt 原语自己写。核心只有两行:
1 | from langgraph.types import interrupt |
节点跑到 interrupt 就停住,恢复时 Command(resume=...) 的值从 interrupt 的返回值进来。自己写要多留神三件事。别把 interrupt 包在裸的 try/except 里,它靠抛异常暂停,异常被捕获就静默失效,图看起来”跑过去了”,实际上闸门没了。同一个节点里有多个 interrupt 时,resume 值是按下标严格匹配的,不能有条件跳过某一次调用,循环里调也要保证每次执行的顺序完全一致。传给 interrupt 的载荷必须能 JSON 序列化,函数、类实例都不行,生产环境的 checkpointer 会直接序列化失败。这三条出自 LangGraph 的 interrupts 文档,中间件内部遵守的也是同一套规则。
审批粒度
粒度可以拧三档。
按工具是最粗的一档,interrupt_on 的 key 决定哪个工具进闸门。读类工具写 False,写类工具给完整决策集,破坏性工具只留 approve 和 reject,不允许改参数。
按条件细一档,用 when 谓词。它收到一个 ToolCallRequest,里面有 tool_call["args"],可以拿参数做判断:
1 | from langchain.agents.middleware import HumanInTheLoopMiddleware, ToolCallRequest |
发内部同事直接放行,发外部地址才拦。实测效果:
1 | [TOOL EXECUTED] send_email -> bob@internal.com / Hi |
内部那封一次没停,外部那封先挂起再执行。注意文档里的一句话:when 返回 False 的调用根本不会进中断批次,审批人看到的只有真正需要他决定的动作。
按参数是第三档,也就是 edit。when 决定要不要拦,edit 决定拦下来之后改哪个字段。
流式场景下的中断
界面要一边出字一边等审批,用 stream_events。它把 token 和中断状态分开展示,适合聊天窗口:
1 | config = {"configurable": {"thread_id": "some_id"}} |
stream.interrupted 是判断要不要弹审批窗的信号,stream.interrupts 就是那张卡片的数据。注意 version="v3" 和前面 invoke 用的 "v2" 不是一回事,前者是事件流接口,后者是单次调用的返回格式,别混着传。
待审批卡片要渲染哪些字段
中断载荷就是前端卡片的数据源,结构是 HITLRequest:
| 字段 | 位置 | 用途 |
|---|---|---|
action_requests[].name |
中断载荷 | 要调哪个工具,卡片标题 |
action_requests[].args |
中断载荷 | 完整参数,卡片正文 |
action_requests[].description |
中断载荷 | 可读描述,卡片摘要 |
review_configs[].action_name |
中断载荷 | 和 action 对应 |
review_configs[].allowed_decisions |
中断载荷 | 这张卡片上该显示哪几个按钮 |
review_configs[].args_schema |
中断载荷 | 可编辑字段的 JSON Schema,只有配置里给了才有 |
description 默认是 description_prefix 加上工具名和参数字典,读起来像调试信息。要给用户看,就传一个 callable 自己拼:
1 | def card(tool_call, state, runtime): |
一次让模型同时发邮件和删记录,会攒成一个中断、两个动作:
1 | { |
前端回传的形状是对称的,一个 decisions 数组,长度和顺序都必须和 action_requests 对齐:
1 | Command(resume={"decisions": [ |
这里有个容易踩的地方:action_requests 里没有 tool call 的 id,只有 name、args、description。文档的表述是按顺序匹配,中间件源码里也是按下标去对。所以前端列表的排序必须稳定,用户提交时按同一个下标顺序拼 decisions。如果卡片列表支持拖拽或者重排,务必把原始下标一起带上,别用 UI 上的位置。
几个真实的坑
下面这些都在本机踩过,报错是从终端抄的。
中断能触发,恢复却抛 RuntimeError: Cannot use Command(resume=...) without checkpointer,多半是 agent 创建时没挂 checkpointer。这个前面演示过了。
decisions 的数量和挂起的 tool call 对不上,少给或多给都会炸:
1 | ValueError : Number of human decisions (2) does not match number of hanging tool calls (1). |
决策类型不在配置允许的范围内,同样是 ValueError。配置里只开了 approve 和 reject,却回传 edit:
1 | ValueError : Unexpected human decision: {'type': 'edit', ...}. Decision type 'edit' is not allowed for tool 'send_email'. Expected one of ['approve', 'reject'] based on the tool's configuration. |
恢复之后工具被重复执行,来源有两个。一是节点从头重跑,interrupt 之前的副作用会再发生一次,文档要求这些操作幂等,能挪到 interrupt 后面就挪过去。二是模型拿到 edit 后的结果,可能觉得不对劲,把原始调用重新发起;中间件用 edit_notice 那段提示压这个行为,reject 的默认文案也明确写了不要重试。
thread_id 换了,状态就丢了。恢复必须用中断时那个,用一个新的,checkpointer 会当成全新会话,空状态起跑。这个错误的表现是模型一脸茫然地重新问一遍需求,而不是报错,比较难查。
一次中断只处理当前这批 tool_calls。模型拿到结果之后如果又发起新的敏感调用,会再触发一次中断,界面上就是第二次审批。前端得有个循环,每次 invoke 完都检查 .interrupts 是否非空,非空就渲染卡片、等决策、再 resume。
剩下几条属于设计取舍,没有标准答案:
respond合成的是success状态的工具结果。用它来否一个写操作,模型会认为写成功了,拒绝就该用reject。InMemorySaver重启即失忆,未审批的单子全丢。这个错误在开发环境永远复现不了,上线前记得换成持久化的 saver。- 审批人关掉页面之后,图会无限期挂起,没有超时机制,这是文档写明的行为。挂起期间那个
thread_id一直是”待续”状态,没人处理这条会话就卡死。要不要加超时、超时后自动 reject 还是转人工,属于业务决定,中间件不替你选;落地时通常在后端存一份待审批队列,配个定时任务清理。 args是模型生成的原始参数,键名是工具签名里的英文,值可能是嵌套结构。直接json.dumps出来给用户看,信息是够的,但没人愿意读。至少给每个工具配一个descriptioncallable 把参数翻译成人话,字段多的时候还可以用args_schema驱动一个表单。
小结
- 闸门装在
after_model,模型提议工具调用之后、工具执行之前。配置入口是HumanInTheLoopMiddleware(interrupt_on={...})。 - 决策有四种:
approve、edit、reject、respond。拒绝用reject,respond只留给需要人肉回答的占位工具。 interrupt()靠抛异常暂停,状态写进 checkpointer,thread_id是取回状态的指针。没有 checkpointer,Command(resume=...)直接抛RuntimeError。- 恢复时节点从头重跑,不是断点续传。
interrupt之前的副作用必须幂等。 - 审批粒度三档:按工具(
interrupt_on的 key)、按条件(when谓词读tool_call["args"])、按参数(edit改edited_action.args)。前端卡片按action_requests和review_configs渲染,回传的decisions顺序必须和action_requests一一对应。

