关键操作前,让人点个头:LangChain Human-in-the-Loop 实战 Agent 能自己调用工具固然强大但有些操作你绝不想让它自作主张删库、转账、对外发邮件、改生产配置……一旦做错代价是真金白银。Human-in-the-LoopHITL中间件就是给这类操作加一道人工闸门当模型打算执行敏感工具时暂停下来把这个动作交给人审批——批准、修改、拒绝或直接替工具作答。这篇文章基于 LangChain 官方 HITL 文档把它讲透。一、它解决什么问题怎么工作HITL 中间件会逐个检查模型发起的工具调用对照你配置的策略。若某个调用需要人工介入它就发起一个interrupt中断模型生成回复 → 中间件检查工具调用 → 命中策略 ├─ 是 → interrupt 暂停存档状态等人决策 └─ 否 → 直接执行关键点中断时整个图的状态会被 LangGraph 的持久化层保存下来所以可以安全地暂停、过几分钟甚至几天后再恢复。这也是为什么HITL 必须配 checkpointer thread_id。四种人工决策人收到中断后有四种回应方式决策含义典型场景✅approve原样批准执行邮件草稿照发✏️edit改参数后再执行发邮件前换个收件人❌reject拒绝执行并把理由加进对话拒绝删文件并说明原因respond跳过工具人的回复直接当作工具结果回答ask_user这类问用户工具⚠️ 区分reject和respond拒绝一个有副作用的动作用reject只有当人就是这个工具的实现如 ask_user 让用户澄清时才用respond——因为respond的消息会被当成成功的工具结果拿它去否决副作用工具会让模型误以为操作成功了。公共从 .env 初始化模型下文示例共用这段从.env读MODEL_NAME/OPENAI_API_BASE/OPENAI_API_KEYimportosfromdotenvimportload_dotenvfromlangchain.chat_modelsimportinit_chat_model load_dotenv()modelinit_chat_model(os.getenv(MODEL_NAME,glm-5.1),model_provideropenai,base_urlos.getenv(OPENAI_API_BASE),api_keyos.getenv(OPENAI_API_KEY),streamingTrue,)公共示例工具最小实现下文的write_file/execute_sql/read_data都是占位演示工具最小实现如下实际项目里换成你真正的实现即可。注意参数名要和后面when谓词读取的一致write_file用path、execute_sql用queryfromlangchain.toolsimporttooltooldefwrite_file(path:str,content:str)-str:把内容写入指定文件路径。# 实际项目with open(path, w) as f: f.write(content)returnf已写入{path}{len(content)}字符tooldefexecute_sql(query:str)-str:执行一条 SQL 语句。# 实际项目连接数据库执行returnf已执行 SQL{query}tooldefread_data(table:str)-str:读取指定表的数据只读、安全操作。returnf{table}的数据[模拟若干行]二、配置中断interrupt_on把HumanInTheLoopMiddleware加进middleware用interrupt_on配一张工具 → 是否/如何审批的映射表。值有三种True—— 中断允许全部四种决策False—— 自动放行安全操作InterruptOnConfigdict—— 精细控制比如只允许某几种决策importosfromdotenvimportload_dotenvfromlangchain.agentsimportcreate_agentfromlangchain.agents.middlewareimportHumanInTheLoopMiddlewarefromlangchain.chat_modelsimportinit_chat_modelfromlanggraph.checkpoint.memoryimportInMemorySaver load_dotenv()modelinit_chat_model(os.getenv(MODEL_NAME,glm-5.1),model_provideropenai,base_urlos.getenv(OPENAI_API_BASE),api_keyos.getenv(OPENAI_API_KEY),streamingTrue,)agentcreate_agent(modelmodel,tools[write_file,execute_sql,read_data],middleware[HumanInTheLoopMiddleware(interrupt_on{write_file:True,# 允许全部决策execute_sql:{allowed_decisions:[approve,reject]},# 不允许 editread_data:False,# 安全自动放行},description_prefix工具执行待审批,# 中断描述前缀),],# HITL 依赖检查点持久化状态生产用 PostgresSaver测试用 InMemorySavercheckpointerInMemorySaver(),)必须配checkpointer并在 invoke 时传带thread_id的config把这次执行关联到一个会话线程才能暂停后恢复。三、条件中断只拦该拦的when默认interrupt_on里列的工具每次调用都暂停。但很多时候你只想拦危险的那部分——比如只拦写库 SQL、放行只读 SELECT。给InterruptOnConfig加一个when谓词即可它收到ToolCallRequest、返回True才中断。importosfromdotenvimportload_dotenvfromlangchain.agentsimportcreate_agentfromlangchain.agents.middlewareimportHumanInTheLoopMiddleware,ToolCallRequestfromlangchain.chat_modelsimportinit_chat_modelfromlanggraph.checkpoint.memoryimportInMemorySaver load_dotenv()modelinit_chat_model(os.getenv(MODEL_NAME,glm-5.1),model_provideropenai,base_urlos.getenv(OPENAI_API_BASE),api_keyos.getenv(OPENAI_API_KEY),streamingTrue,)defwrites_outside_workspace(request:ToolCallRequest)-bool:只拦写到 /workspace/ 之外的路径。pathrequest.tool_call[args].get(path,)returnnotpath.startswith(/workspace/)defis_write_query(request:ToolCallRequest)-bool:只拦非只读非 SELECT的 SQL。queryrequest.tool_call[args].get(query,)returnnotquery.lstrip().upper().startswith(SELECT)agentcreate_agent(modelmodel,tools[write_file,execute_sql,read_data],middleware[HumanInTheLoopMiddleware(interrupt_on{write_file:{allowed_decisions:[approve,edit,reject],when:writes_outside_workspace},execute_sql:{allowed_decisions:[approve,reject],when:is_write_query},},),],checkpointerInMemorySaver(),)when返回False的调用根本不会进审批队列——审批者只看到真正需要决策的动作不被只读查询刷屏。条件中断需要langchain1.3.3。四、响应中断暂停 → 审批 → 恢复invoke 时加versionv2Agent 跑到中断点会返回一个带.interrupts的结果。你把待审批动作拿给人看拿到决策后再用Command(resume...)恢复。fromlanggraph.typesimportCommand config{configurable:{thread_id:some_id}}# HITL 必须有 thread_id# 1. 跑到中断点resultagent.invoke({messages:[{role:user,content:删除数据库里的旧记录}]},configconfig,versionv2,)# result 是 GraphOutput.interrupts 里是待审批的动作print(result.interrupts)# (Interrupt(value{# action_requests: [{name: execute_sql,# arguments: {query: DELETE FROM records WHERE ...},# description: 工具执行待审批\n\nTool: execute_sql\nArgs: {...}}],# review_configs: [{action_name: execute_sql,# allowed_decisions: [approve, reject]}]# }),)# 2. 人决策后用同一个 thread_id 恢复agent.invoke(Command(resume{decisions:[{type:approve}]}),# 或 rejectconfigconfig,versionv2,)四种决策怎么写✅ approve —— 原样执行agent.invoke(Command(resume{decisions:[{type:approve}]}),configconfig,versionv2)✏️ edit —— 改参数再执行给出新的工具名和参数agent.invoke(Command(resume{decisions:[{type:edit,edited_action:{name:execute_sql,# 通常和原来一样args:{query:DELETE FROM records WHERE created_at NOW() - INTERVAL 90 days},},}]}),configconfig,versionv2)改参数要保守。大改动可能让模型重新评估、甚至重复执行工具或做出意外动作。❌ reject —— 拒绝并反馈工具不执行message 进对话agent.invoke(Command(resume{decisions:[{type:reject,message:用户拒绝了该操作不要重试这个工具调用。,# 可选省略则用默认拒绝语}]}),configconfig,versionv2)message会作为反馈加进对话告诉模型为什么被拒、接下来该怎么做。对有副作用的工具最好写明确是放弃、还是换个更安全的方案。 respond —— 人替工具作答仅用于 ask_user 这类问用户工具agent.invoke(Command(resume{decisions:[{type:respond,message:蓝色。,# 人的回复直接作为工具结果返回}]}),configconfig,versionv2)message会被当成一条成功的 ToolMessage返回给模型。再强调一次别拿respond去否决副作用工具。多个动作决策按顺序一一对应如果同时有多个工具调用被暂停每个动作给一个决策顺序要和中断里动作出现的顺序一致{decisions:[{type:approve},{type:edit,edited_action:{name:tool_name,args:{param:new_value}}},{type:reject,message:这个操作不允许},]}五、边流式边审批想在 Agent 运行时实时看 token、并捕获中断用stream_events()versionv3fromlanggraph.typesimportCommand config{configurable:{thread_id:some_id}}# 流式跑到中断streamagent.stream_events({messages:[{role:user,content:删除数据库里的旧记录}]},configconfig,versionv3,)formessageinstream.messages:# 流式 LLM tokenfortokeninmessage.text:print(token,end,flushTrue)ifstream.interrupted:# 是否暂停等待人工print(f\n\n中断{stream.interrupts})# 人决策后继续流式恢复streamagent.stream_events(Command(resume{decisions:[{type:approve}]}),configconfig,versionv3,)formessageinstream.messages:fortokeninmessage.text:print(token,end,flushTrue)stream.messages流 tokenstream.interrupted/stream.interrupts检查是否需要人工。六、执行生命周期HITL 中间件本质是一个after_model钩子——在模型生成回复后、工具执行前介入Agent 调模型生成回复中间件检查回复里的工具调用若有调用需要人工构造HITLRequest含action_requests和review_configs并发起interruptAgent 暂停等人决策按决策处理执行 approve/edit 的调用、给 reject 的调用合成一条 ToolMessage 反馈、把 respond 的回复直接作为 ToolMessage 返回然后恢复执行。七、小结决策工具是否执行message 的作用approve✅ 原样执行—edit✅ 用新参数执行—reject❌ 不执行作为反馈进对话respond❌ 不执行直接当作工具结果落地三件套配interrupt_on哪些工具拦True/InterruptOnConfig、哪些放行False只想拦危险参数就加when。配checkpointerthread_id中断要靠持久化暂存状态缺一不可。用versionv2Command(resume...)跑到中断 → 取result.interrupts给人看 → 决策回传恢复。HITL 的价值在于把高风险动作从全自动改成人确认用一点点延迟换巨大的安全边界。删库、转账、对外通信这类操作值得这道闸门。