AI Agent 开发(11):人机协同 HITL

QuibblerAgentQuibblerAgent 2026-10-25 约 27 分钟 1 次阅读

第 10 篇结尾留了一句话:下一篇讲人机协同,让 Agent 在关键动作前停下来等你点头。这篇兑现承诺。create_agent 参数表里那两个"停下来"的入口——interrupt_before 参数与 HumanInTheLoopMiddleware——只是表层封装,底下是同一套机制:LangGraph 的动态中断。本篇把这套机制拆到最小零件:图在哪停、停下来状态存哪、人的决定怎么传回去。

Agent 接管的动作越真实,"会不会闯祸"就越不是概率问题而是设计问题——工具列表里只要有写操作,模型的每次决策都带着真实代价,而人工审批是给不可逆动作上的最后一道保险。全文按"介入场景 → 中断原理 → 最小示例 → 恢复值形态 → 断点选型 → 工具审批实战 → 总结"推进,代码基于 langgraph 1.2.x(2026-10 基线),环境沿用第 2 篇的 uv 项目。

1、为什么需要人工介入

第 1 篇给 Agent 下过定义:自主使用工具完成目标。自主性是收益,也是风险源。读错一个接口,重跑一次就完;给全公司客户群发一封错邮件、删掉一张生产表、付一笔不该付的款——这些动作不可撤销,错误没有重试机会。模型的可靠性可以逼近但永远到不了 100%,不可逆动作的可靠性却必须接近 100%,中间的缺口只能由人来补。

工程上的解法不是把自主性整个收回,而是分级放行。三类典型场景值得停下来:

  • 高风险动作审批:发邮件、删数据、付款、发部署,执行前过一道人工确认
  • 内容审核:AI 起草、人工把关——公告、客户回复、代码变更,发布前改两笔
  • 方向纠偏:长任务中途汇报下一步计划,人工点头或转向后再继续

人工介入不是对 Agent 的不信任,而是对不可逆动作的工程保险——闸门只装在最贵的那一步。

这套模式叫 HITL(Human-in-the-Loop,人机协同),要回答三个工程问题:图在哪停、状态存哪、人的决定怎么传回去。LangGraph 的答案依次是 interrupt()、checkpointer、Command(resume=...),下一节串起来看。

2、interrupt 机制原理

interrupt() 是节点内可调用的中断原语,与恢复用的 Command 同在 langgraph.types 模块。它在节点函数体内任意位置调用,参数是一份 JSON 可序列化的"申请单"——想让审批人看到什么,就放什么。完整的暂停-恢复时序如下:

调用方                        LangGraph 运行时                  checkpointer
  │                                │                                │
  │ invoke(input, thread_id)       │                                │
  │───────────────────────────────►│                                │
  │                                │ 执行 plan:产出待审动作           │
  │                                │ 执行 approve:调用 interrupt()   │
  │                                │──暂停 + 写入断点状态────────────►│
  │◄───提前返回,携带 __interrupt__──│                                │
  │                                │      ……等待人工,时长不限……      │
  │ (人工查看申请单,做出决定)       │                                │
  │ invoke(Command(resume=值))     │                                │
  │───────────────────────────────►│──按 thread_id 读断点状态────────►│
  │                                │ approve 节点从头重跑             │
  │                                │ interrupt() 返回人工的值         │
  │◄────────────最终 state─────────│                                │

时序图里有五个关键点。其一,节点执行到 interrupt() 时向运行时抛出内部信号,图在这一步停住。其二,当前状态连同断点位置写入 checkpointer,按 thread_id 归档——所以 HITL 有两个硬前提:编译时挂 checkpointer、调用时带 thread_id,缺一个中断时直接报错。其三,invoke 提前返回,结果里多一个 __interrupt__ 键,装着申请单。其四,等待期不占任何计算资源,隔几分钟还是隔一天由审批人定;跨进程、跨机器恢复则要求持久化后端,InMemorySaver 只活在当前进程,第 12 篇展开。其五,恢复时用同一个 thread_id 再调一次 invoke,输入换成 Command(resume=值),interrupt() 那一行返回人工给的值,节点继续往下执行。

第 5 点藏着最容易踩的坑:恢复不是从 interrupt() 那一行续跑,而是把整个节点从头重跑。机制细节见 Interrupts 官方文档与 interrupt API 参考。

注意:节点恢复时从头执行,interrupt() 之前的代码会跑第二遍——副作用要么做成幂等,要么挪到 interrupt() 之后;也别用裸 try/except 包住 interrupt(),它靠特殊异常实现暂停,会被吞掉。

3、最小审批示例

用一张两节点的图把机制跑通:plan 产出高危动作,approve 停下来等人。先定义 State 与两个节点:

from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command

class State(TypedDict):
    action: str    # 待审批的高危动作
    result: str    # 执行结果

def plan(state: State) -> dict:
    # 真实场景里这一步由模型节点产出,示例直接写死
    return {"action": "DELETE FROM orders WHERE id=1001"}

def approve(state: State) -> dict:
    decision = interrupt({            # 申请单:暂停并递给前端
        "action": state["action"],
        "risk": "高危:删除订单,不可撤销",
    })
    if decision:                      # resume 值流回这里
        return {"result": f"已执行:{state['action']}"}
    return {"result": "已取消:人工驳回"}

解读:plan 在完整 Agent 里就是模型节点的产出位,示例写死一条删除语句,把注意力留给审批。approve 里的 interrupt() 携带申请单(动作 + 风险说明)暂停,返回值就是人工的决定——True 执行、False 驳回,语义完全由你自己定义。接着组装成图,第一次调用:

from langgraph.checkpoint.memory import InMemorySaver

builder = StateGraph(State)
builder.add_node("plan", plan)
builder.add_node("approve", approve)
builder.add_edge(START, "plan")
builder.add_edge("plan", "approve")
builder.add_edge("approve", END)
graph = builder.compile(checkpointer=InMemorySaver())  # HITL 的前提

config = {"configurable": {"thread_id": "t-001"}}
r1 = graph.invoke({}, config)              # 第一次调用:断在 approve
print(r1["__interrupt__"][0].value)        # 打印申请单

输出如下:

{'action': 'DELETE FROM orders WHERE id=1001', 'risk': '高危:删除订单,不可撤销'}

解读:invoke 既没报错也没跑完——它带着 __interrupt__ 正常返回,图停在 approve 中途,状态已存进 checkpointer。r1["__interrupt__"] 是列表(并行分支可能同时断多处),每项的 .value 是申请单,.id 是这条中断的编号。接着恢复,批准与驳回各走一遍:

r2 = graph.invoke(Command(resume=True), config)     # 批准
print(r2["result"])

config2 = {"configurable": {"thread_id": "t-002"}}  # 另开线程走驳回
graph.invoke({}, config2)                           # 再次断在 approve
r3 = graph.invoke(Command(resume=False), config2)   # 驳回
print(r3["result"])

输出如下:

已执行:DELETE FROM orders WHERE id=1001
已取消:人工驳回

解读:恢复就是再调一次 invoke,输入从普通消息换成 Command(resume=True)——True 流回 approve 里 interrupt() 的返回值,节点重跑后走到 END。驳回那条线另开了 thread_id:t-001 已经跑到终态,没有挂起的中断可恢复;真实系统里一张审批单对应一个线程。两次调用、一次暂停,审批闭环完整落地——Web 服务里这两次 invoke 往往隔着几小时、来自两台机器,靠 thread_id 加持久化 checkpointer 串起来。

4、恢复值的两种形态

第 3 节的 resume 传的是布尔,够用但浪费了通道——resume 值可以是任意 JSON,审批接口的契约由你设计。第一种形态是简单决策:布尔或 "approve"/"reject" 字符串,节点据此分支,适合"放不放行"的二元问题。第二种形态是结构化改写:人工的决定、修改后的内容一起传回去,审批从"过或不过"升级为"改完再过"。内容审核场景最直观——Agent 起草邮件,人工改写定稿:

class DraftState(TypedDict):
    draft: str    # Agent 起草的邮件
    final: str    # 定稿

def review(state: DraftState) -> dict:
    # 草稿全文作为申请单,人工的返回值直接定稿
    decision = interrupt({"draft": state["draft"]})
    if decision["approved"]:
        return {"final": decision["text"]}   # 人工改写后的版本
    return {"final": "已取消发送"}

graph.invoke(Command(resume={                # 审批卡片的提交结果
    "approved": True,
    "text": "尊敬的客户:您的订单已于今日发出,预计 3 个工作日内送达。",
}), config)

解读:图结构换成 draft 节点接 review 节点即可,与第 3 节同构。申请单带草稿全文,人工在卡片上改两笔,resume 回一份 dict——review 拿到的是人工版本,直接定稿。改写的粒度自由发挥:改参数、换收件人、重写正文,都是往这份 dict 里加字段。langgraph 1.2.12 起 interrupt() 还接受 response_schema 参数(Pydantic 模型),人工提交的值先过校验再返回——格式错抛 ValidationError,原线程重答即可,审批接口从此有了类型契约。

这两种形态会贯穿后面所有模式:第 6 节官方中间件的四种决策,本质就是把结构化 resume 标准化了。

5、节点级断点对比

第 10 篇 create_agent 签名里的 interrupt_before 是另一族中断:编译期声明的节点级断点,还有个孪生兄弟 interrupt_after。把第 3 节的图改一下——approve 换成不含 interrupt() 的 execute 节点(builder2 同理组装成 plan → execute),compile 时点名断点:

def execute(state: State) -> dict:      # 不含 interrupt() 的普通节点
    return {"result": f"已执行:{state['action']}"}

graph2 = builder2.compile(
    checkpointer=InMemorySaver(),
    interrupt_before=["execute"],       # 进入 execute 前断住
)

config = {"configurable": {"thread_id": "t-101"}}
r1 = graph2.invoke({}, config)
print(graph2.get_state(config).next)    # 断点位置
r2 = graph2.invoke(None, config)        # 恢复:不带人工载荷
print(r2["result"])

解读:进入 execute 前图暂停,get_state(config).next 打印 ('execute',)——断点停在哪个节点一目了然;恢复传 None,图继续执行到 END。它与 interrupt() 的差异一张表说清:

维度 interrupt() interrupt_before / after
声明位置 节点函数体内 compile 参数,也可 invoke 时传
申请单 任意 JSON payload 无,前端不知道为何而停
人工输入 resume 值流回节点 无通道,恢复只传 None
节点执行 恢复时从头重跑 断点前或后,不重复执行
官方定位 HITL 主路径 调试、单步、检查状态

官方文档的定位很直白:节点级断点是调试工具,人机协同场景一律推荐 interrupt()——差别就在"人的决定能不能传进图"。

补一句公道话:interrupt_before 并非无用。审批已由外部工单系统完成、图只负责执行的场景,或者想在暂停时用 get_state 检查甚至改一把状态再放行,节点级断点反而顺手。选型口诀:要传人工决定用 interrupt(),只要停一下用 interrupt_before。

6、工具审批实战

6.1、工具节点内嵌审批

真实 Agent 的危险动作几乎都发生在工具执行时——ReAct 循环里模型决定调用,工具节点负责执行,闸门就装在执行前。把第 9 篇手写的 call_tools 升级(AgentState 与 tools_by_name 沿用原定义,compile 记得挂 checkpointer):

from langchain_core.messages import ToolMessage

DANGEROUS = {"send_email", "delete_rows"}      # 危险工具名单

def call_tools(state: AgentState) -> dict:
    results = []
    for tc in state["messages"][-1].tool_calls:
        if tc["name"] in DANGEROUS:
            decision = interrupt({             # 申请单:工具名 + 参数
                "tool": tc["name"],
                "args": tc["args"],
            })
            if not decision["approved"]:       # 驳回:理由回喂模型
                results.append(ToolMessage(
                    content=decision.get("message", "人工驳回本次调用"),
                    tool_call_id=tc["id"]))
                continue
            tc = {**tc, "args": decision["args"]}   # 批准且可能改过参数
        output = tools_by_name[tc["name"]].invoke(tc["args"])
        results.append(ToolMessage(content=str(output),
                                   tool_call_id=tc["id"]))
    return {"messages": results}

解读:名单外的工具原路直通;命中的先把工具名与参数打包成申请单 interrupt 出去。批准后可能拿到人工改过的参数——decision["args"] 覆盖原值再执行,第 4 节的改写形态落了地;驳回则把理由包成 ToolMessage 回喂,模型读到"人工驳回"通常会调整方案或放弃,循环不崩——正是第 9 篇"错误回传、模型自愈"思路的审批版。

两个工程提醒。其一,同一节点内多次 interrupt() 按出现顺序与恢复值配对:本例遍历的是 checkpoint 里固定的 tool_calls,首跑与重跑序列一致,配对才成立——"哪些调用会中断"绝不能依赖易变的外部条件,否则重跑时序列错位、决定张冠李戴。其二,并行分支同时中断时,resume 可以传 {中断 id: 决定} 的字典一次作答,中断对象的 .id 就是为这个场景准备的。

6.2、批量审批数据结构

上面是手搓版。官方 HumanInTheLoopMiddleware(第 10 篇提过)把这套模式标准化,create_agent 挂上即用:

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware

agent = create_agent(
    model,                                   # 沿用 init_chat_model 实例
    tools=[send_email, execute_sql, read_data],
    checkpointer=InMemorySaver(),            # 中断状态必须持久化
    middleware=[HumanInTheLoopMiddleware(
        interrupt_on={                       # 审批策略表
            "send_email": True,              # 四种决策全放开
            "execute_sql": {"allowed_decisions": ["approve", "reject"]},
            "read_data": False,              # 只读工具直接放行
        },
    )],
)

解读:interrupt_on 是策略表——True 放开全部决策,dict 限定可选决策,False 放行;langchain 1.3.3 起还支持 when 条件函数,比如 SQL 以 SELECT 开头就不打断。默认的批量模式把一轮所有待审调用攒成一次中断——payload 里的 action_requests 列表逐项带工具名、参数、描述,review_configs 告诉前端每个动作允许哪些决策。审批 UI 的数据结构照着这两份清单设计即可:一张卡一个动作,按 allowed_decisions 渲染按钮。人工作答时按顺序回一张决定列表:

agent.invoke(Command(resume={          # 与 action_requests 顺序对应
    "decisions": [
        {"type": "approve"},
        {"type": "edit", "edited_action": {
            "name": "send_email",
            "args": {"to": "ops@example.com"}}},
        {"type": "reject", "message": "先归档再删除,不要重试"},
    ],
}), config=config, version="v2")

四种决策的语义是固定的:

决策 行为 适用
approve 原样执行 认可动作与参数
edit 按改写后的工具与参数执行 人工修正 Agent 产出
reject 不执行,message 回喂模型 否决并给理由
respond 不执行,message 直接代答 人工替工具给结果

version="v2" 是 create_agent 的新版调用形态,挂起的申请单直接在结果的 .interrupts 属性里取;嫌批量粒度粗可设 interrupt_mode="per_call"(langchain 1.4.6 起),一个调用一次中断、按中断 id 作答。前后端打通的完整链路:后端轮询挂起线程——get_state(config).next 非空即有断点,snapshot.tasks 里的 interrupts 逐个取申请单渲染成卡片;人工提交后 Command(resume=...) 一次送回。细节见 HITL 中间件文档与 HumanInTheLoopMiddleware API 参考。

7、总结

本篇把 HITL 三件套配齐:interrupt() 在节点内任何位置挂起图,申请单随 __interrupt__ 交给前端;checkpointer 按 thread_id 存下断点状态,等待期时长不限;Command(resume=...) 把人工的决定送回 interrupt() 的返回值,节点从头重跑继续执行。恢复值从布尔到结构化 dict 都是自由设计空间,1.2.12 起还能用 response_schema 上类型契约;节点级断点留给调试,人工决定要进图就用 interrupt();工具审批的手搓版与中间件标准版,你手里都有了。

留一个练习,回到第 9 篇的手写 ReAct:

  • 加一件 send_email 工具,并维护 DANGEROUS 危险名单
  • 按 6.1 节升级工具节点:危险工具 interrupt 出申请单,驳回理由回喂模型
  • 验收:提问"给 alice@example.com 发一封会议通知",断点处看到申请单;批准、驳回各跑一次,驳回后模型应改为文字回复或放弃
  • 进阶:给决定定义 Pydantic 模型,用 response_schema 上校验

对于准备上生产的系统,建议从 6.2 节的中间件起步,interrupt_on 策略表足以覆盖多数审批需求,手搓 interrupt() 留给非标准拓扑。下一篇深入 checkpointer——本篇只用了它的内存版,SQLite 与 Postgres 后端、时间旅行、按线程回放,是持久化的完整版图。

相关推荐

Hermes Agent 详解:自进化学习型 AI 代理
Agent

Hermes Agent 详解:自进化学习型 AI 代理

Hermes Agent 详解:自进化学习型 AI 代理Hermes Agent 是由 Nous Research 于 2026 年 2 月开源的自主 AI 代理框架,其核心定位是"与你共同成长的智能体"。与传统的 IDE 编程助手或单一 API 聊天机器人不同,Hermes 运行在你的服务器上,能够记住所学内容,运行时间越长能力越强。它采用 MIT 开源许可,支持 Linux、macOS 和 W

393
Hermes Agent:与你一起成长的开源 AI 代理
Agent

Hermes Agent:与你一起成长的开源 AI 代理

Hermes Agent:与你一起成长的开源 AI 代理大多数 AI 助手是"健忘的租客"——换个窗口就失忆、换个平台就重学、干过的活不留下任何沉淀。Nous Research 出品的 Hermes Agent 反其道而行,口号是"The Agent That Grows With You"(与你一起成长的代理):它是一个开源(MIT)、自托管的 AI 代理,具备持久记忆(学习你的项目、自动生成技

322
speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线
AI

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线语音 Agent 的"全开源"路线一直有个尴尬:框架开源了,模型却离不开云端 API。huggingface/speech-to-speech 把这条路走通了:VAD → STT → LLM → TTS 四段低延迟流水线,每个组件都可换成开源模型本地跑——Apple Silicon 一台 Mac 就能撑起

264