Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 5 期:人工确认——interrupt 停下来等人

第 3 期加分练习留了一个问题:加一个 cancel_order(order_id) 工具,模型愿意调, 但这个工具该不该不经确认就执行?答案是不该。上册练习 9 遇到过同一个问题: 同一条“改文件前必须先读“的规矩,DeepSeek 守了,本机小模型没守——软约束的强度 取决于你恰好用了哪个模型。练习 9 加了一张三档规则表,ask 那一档在终端里 直接印一行问题,等你敲 y/n,模型自己说了不算。

LangGraph 里有专门支持这个流程的机制,叫 interrupt()。这一期把练习 9 “问人的函数“搬进图里。

敲进去

第 5 期的代码在 code/ep05/state.pyget_orderget_policy 跟第 3-4 期 原样,改动集中在 tools.py 新增的 cancel_order

from langgraph.types import interrupt

@tool
def cancel_order(order_id: str) -> str:
    """取消订单。传订单号。这个操作会先停下来等人工审批,人工同意才真正取消,
    拒绝的话订单不受影响。"""
    order = ORDERS.get(order_id)
    if order is None:
        return f"没有找到订单 {order_id}"

    # 这一行写在 interrupt 之前,纯粹是为了这一期让你亲眼看见"恢复时会重跑"——
    # 真实业务不会把这种记账代码放在 interrupt 前面。
    with CANCEL_LOG.open("a") as f:
        f.write(f"{order_id}\n")

    decision = interrupt(
        {
            "action": "cancel_order",
            "order_id": order_id,
            "customer": order["customer"],
            "product": POLICIES[order["product_id"]]["name"],
        }
    )
    if decision != "approve":
        return f"人工拒绝,订单 {order_id} 未取消"

    # 真正的取消动作(调订单服务、扣库存之类)必须放在这一行之后:
    # interrupt 之前的代码会在恢复时重跑,这一行不会。
    return f"订单 {order_id} 已取消"

interrupt(payload) 调用时,图在这里冻结,payload 会被外层拿到;状态照常靠 checkpointer 落盘。外层拿着 payload 去问人,问完用 Command(resume=decision) 把答案送回来,图从冻结的地方接着跑,interrupt() 这一行返回的就是 decision。 节点和边一行没改,graph.py 跟第 4 期完全一样,只是 import 换成了 ep05.tools

main.py 多了两处。识别中断:

def print_stream(stream) -> None:
    for update in stream:
        if "__interrupt__" in update:
            (info,) = update["__interrupt__"]
            print(f"[需要人工确认] {info.value}")
            print("  用 --resume <thread_id> approve 或 reject 继续")
            continue
        ...

恢复:

if args[0] == "--resume":
    thread_id, decision = args[1], args[2]
    config = {"configurable": {"thread_id": thread_id}}
    print_stream(graph.stream(Command(resume=decision), config=config, stream_mode="updates"))
    return

普通提问传的是一份新状态({"messages": [...]}),恢复传的是 Command(resume=...), 两者都交给同一个 graph.stream

跑起来

cd code
uv run python -m ep05.main t1 "帮我取消订单 KL-315"
uv run python -m ep05.main --history t1
uv run python -m ep05.main --resume t1 approve
uv run python -m ep05.main --history t1

uv run python -m ep05.main t2 "帮我取消订单 KL-901"
uv run python -m ep05.main --resume t2 reject
uv run python -m ep05.main t2 "KL-901 现在是什么状态"

你应该看到什么

实验一:请求取消,图停在 tools 节点

[agent] 要调 cancel_order({'order_id': 'KL-315'})
[需要人工确认] {'action': 'cancel_order', 'order_id': 'KL-315', 'customer': '李先生', 'product': '首尔往返机场大巴票'}
  用 --resume <thread_id> approve 或 reject 继续

系统提示词里写了“cancel_order 会自己处理审批,你不用替它多问一句“,模型也确实 没有反问就直接调了工具——审批全在工具内部处理,模型只管调不调。

这时候 --history t1 看到的是:

thread t1:2 条消息,下一步待执行节点:('tools',)
  Human  帮我取消订单 KL-315
  AI     tool_calls=['cancel_order']

next 字段就是图当前冻结在哪个节点,get_state 不用等 resume 就能看见。

实验二:批准,同时验证“恢复会重跑“

[tools] cancel_order 返回:订单 KL-315 已取消
[agent] 回答:您的订单 KL-315 已取消成功。如后续有其他需求,欢迎随时咨询!

再看 cancel_log.txt

KL-315
KL-315

一次取消,写了两行。第一次是模型第一次调用 cancel_order、跑到 interrupt() 冻结之前写的;第二次是 --resume 之后,tools 节点从头重新跑了一遍 cancel_orderinterrupt() 之前的代码原样又跑了一次,只是这次 interrupt() 不再冻结,直接把 "approve" 还给了 decision。这就是研究材料里那句 “恢复时整个节点从头重跑,interrupt 之前的副作用要幂等“的实锤:真正的取消动作 写在 interrupt() 之后,只跑了一次;日志写在之前,跑了两次。

实验三:拒绝

[agent] 要调 cancel_order({'order_id': 'KL-901'})
[需要人工确认] {'action': 'cancel_order', 'order_id': 'KL-901', 'customer': '陈先生', 'product': '东京迪士尼一日票'}
  用 --resume <thread_id> approve 或 reject 继续
[tools] cancel_order 返回:人工拒绝,订单 KL-901 未取消
[agent] 回答:您的订单 KL-901 取消申请被人工拒绝了,订单目前未取消、不受影响。请问还有什么可以帮您?

再问一句“KL-901 现在是什么状态“,模型查了一次订单,回答“并未取消,目前仍是 正常有效状态“——拒绝这件事是真落地了,模型没有再偷偷调用取消。

意外收获:中断没处理完就发新消息,会报错

我手滑试了一次:图停在 interrupt 之后,没有 --resume,直接又问了一句别的 问题。结果模型端直接报了错:

openai.BadRequestError: Error code: 400 - {'error': {'message': "An assistant
message with 'tool_calls' must be followed by tool messages responding to
each 'tool_call_id'. (insufficient tool messages following tool_calls
message)", ...}}

普通输入跟 Command(resume=...) 是两条不同的路:图会把新消息追加进状态, 照常往下走,但对话历史里那条 AI 消息的 tool_calls 还没配对 ToolMessage, 模型的 API 直接拒收这份历史。更麻烦的是,事后再补一次 --resume, 同一个错误还会再报一遍——这个 thread 到这一步已经没法用正常操作救回来了, 见下面常见问题。

发生了什么

interrupt() 冻结的只是执行。 图停在这里之后,checkpointer 里存的 还是最新一步的状态,get_state(config).next 告诉你冻结在哪个节点, Command(resume=...) 告诉图从这里继续、interrupt() 那一行该返回什么。 状态存在哪、停在哪、接着传什么,三样都能单独查、单独传。

恢复的机制是这个节点从头重跑一遍。 cancel_log.txt 两行日志证明了这一点: interrupt() 那一行不会重新冻结,但它之前的代码会原样再跑一次。写代码时只有 一条硬规矩:interrupt() 之前的代码必须幂等,或者干脆不要有副作用;真正改变 外部状态的调用放在 interrupt() 之后,因为那一段只会在拿到确认之后跑一次。

中断状态是一件待办,得尽快用 resume 处理掉。 普通输入会把新消息硬塞进 一份还没配对好的历史,交给模型 API 直接拒绝。这跟第 4 期“同一个 thread 两个 请求同时进来会怎样,这一期没有处理“是同一类问题:框架给你冻结和恢复的原语, 谁来保证“没恢复之前不接受别的输入“,是应用层的事,第 12 期包 FastAPI 服务时 要专门处理。

常见问题

decision 为什么只认 "approve",别的字符串一律当拒绝? 简单、可预测。 真实系统里“审批“往往还要带理由、带审批人身份,那些字段放进 interrupt() 的 payload 或者 Command(resume=...) 传的值里就行,interrupt 本身不关心 你传的是字符串还是字典。

真实产品怎么防止“中断没处理完就发新消息“这个坑? 两层,都在应用层和 UI 层, 不在图里。应用层:新消息进来之前先查一下 graph.get_state(config).next, 非空就说明有一个中断在等着,直接挡掉,不当聊天消息处理。UI 层更彻底: 需要审批的操作走专门的确认按钮,点按钮调 resume 接口,压根不给用户在这个当口 打字的机会——从产品设计上就不让这个坑有机会发生,比事后修历史可靠得多。

能不能多个人同时处理同一个中断? 没测。resume 靠 thread_id 找到冻结的 执行点,两个人同时对同一个 thread 调 --resume 会发生什么、后到的那次是报错 还是空跑,我没跑过,加分练习里可以自己试。

create_agent 那套 HumanInTheLoopMiddleware 是不是也能做到同样的事? 能,官方文档里它包在中间件层,interrupt_on={"tool": True} 之类的配置, 恢复用 Command(resume={"decisions": [...]})。这一期手写是为了看见 interrupt() 本身怎么工作;deepagents 的 interrupt_on 也是包在这个机制外面的一层,第 1 期提过。

中断能不能设置超时,比如一小时没人处理就自动拒绝? 这一期没做。 checkpointer 会一直存着这个冻结点,等多久都行,超时提醒要在应用层 自己加一个定时检查。

加分练习

  1. decision != "approve" 换成更严格的判断:既不是 "approve" 也不是 "reject" 的输入,应该报错还是当拒绝处理?想清楚再改。
  2. 中断没处理完就发新消息会报错,试着在 main.py 里加一道检查: graph.get_state(config).next 不为空的时候,提示用户“先 –resume, 别的以后再说“,别让这个坑真的发生。
  3. get_order 也套一层 interrupt,问一句“帮我查 KL-778“,感受一下 “查询也要等人确认“是不是过度设计——想想为什么这一期只给 cancel_order 加了这一层,get_orderget_policy 没加。
  4. 查官方文档里 HumanInTheLoopMiddleware 的用法,用 create_agent 重写这一期, 跟手写版比一比哪些代码是它替你做了的。