第 5 期:人工确认——interrupt 停下来等人
第 3 期加分练习留了一个问题:加一个 cancel_order(order_id) 工具,模型愿意调,
但这个工具该不该不经确认就执行?答案是不该。上册练习 9 遇到过同一个问题:
同一条“改文件前必须先读“的规矩,DeepSeek 守了,本机小模型没守——软约束的强度
取决于你恰好用了哪个模型。练习 9 加了一张三档规则表,ask 那一档在终端里
直接印一行问题,等你敲 y/n,模型自己说了不算。
LangGraph 里有专门支持这个流程的机制,叫 interrupt()。这一期把练习 9
“问人的函数“搬进图里。
敲进去
第 5 期的代码在 code/ep05/。state.py、get_order、get_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_order,interrupt() 之前的代码原样又跑了一次,只是这次 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 会一直存着这个冻结点,等多久都行,超时提醒要在应用层
自己加一个定时检查。
加分练习
- 把
decision != "approve"换成更严格的判断:既不是"approve"也不是"reject"的输入,应该报错还是当拒绝处理?想清楚再改。 - 中断没处理完就发新消息会报错,试着在
main.py里加一道检查:graph.get_state(config).next不为空的时候,提示用户“先 –resume, 别的以后再说“,别让这个坑真的发生。 - 给
get_order也套一层interrupt,问一句“帮我查 KL-778“,感受一下 “查询也要等人确认“是不是过度设计——想想为什么这一期只给cancel_order加了这一层,get_order、get_policy没加。 - 查官方文档里
HumanInTheLoopMiddleware的用法,用create_agent重写这一期, 跟手写版比一比哪些代码是它替你做了的。