在上一篇将状态持久化到文件的探讨中,我们通过引入 SqliteSaver 成功实现了跨进程续跑。虽然底层的暂停与恢复逻辑已经完全跑通,但每次构建新的 Agent 应用时都需要开发者手动定义 StateGraph 并逐个 add_node,这种纯手工编排的模式在日常开发中略显繁琐。
正如我们在架构概述中曾预告过的那样,LangChain 提供的 create_agent 这个高级 API 正是为解决这一痛点而生。它能够将模型、工具列表和系统提示词无缝封装起来,直接交出一条编排完整的标准图。与此同时,需要人工确认的业务逻辑也不必再硬编码到具体的工具函数(比如 book_hotel)内部了,而是可以通过挂载统一的 HumanInTheLoopMiddleware 在框架层实现拦截。
本文将演示如何把同一条“西安订房链路”平滑迁移到这个高级入口上。请注意,虽然上层抽象变了,但其底层依然是基于 interrupt() 机制实现的,并且我们将继续沿用 SqliteSaver,从而实现“第一次运行拦截退出,第二次新进程确认续跑”的完整生产体验。
Table of contents
Open Table of contents
一、换个高级入口,图的拓扑依然健在
create_agent 方法位于 langchain.agents 模块中。在调用时,模型、工具集合和 system_prompt 是必传参数;而 middleware 和 checkpointer 则可以按需灵活配置。尽管调用方式变了,但其返回值类型依然是 CompiledStateGraph,这意味着它与我们此前通过 builder.compile(...) 费劲拿到的对象在本质上是同一种图结构。
graph = create_agent(
model=chat_model(),
tools=TOOLS,
system_prompt=SYSTEM,
middleware=[
HumanInTheLoopMiddleware(
interrupt_on={"book_hotel": {"allowed_decisions": ["approve", "reject"]}},
description_prefix="订房待确认",
)
],
state_schema=State,
checkpointer=TextSqliteSaver(conn),
)
回顾我们自己手绘图拓扑时,负责询问大模型的节点通常会被命名为 agent。而在 create_agent 自动生成的标准图中,这个节点被称为 model,而负责执行工具的节点则依然保留了 tools 的命名。此外,当我们挂载了 Middleware 之后,它的拦截钩子会在图的流转中额外占据一个处理位置。这也意味着,当我们在终端尝试读回已停滞节点的状态时,会发现执行流的 next 指针指向了 HumanInTheLoopMiddleware.after_model,说明系统此时停留在中间件里,尚未进入订房函数的内部。
这里传入的 system_prompt 直接对标了手搓系列里我们在主循环外部定义的那份系统设定。框架会在向模型发起请求时自动将其作为 SystemMessage 拼接进去,我们再也无需手动向 messages 列表中去追加这条设定。而在第一次调用 invoke 时,只需简单传入用户的自然语言目标以及用于初始化的空字典(如白名单和订单)即可。
至于 Checkpointer,它的挂载方式与上一篇一脉相承,仅仅是从 compile(...) 转移到了 create_agent(...) 的参数列表中。如果忘记传入这个参数,底层触发 interrupt() 时就会因为无处记录状态而直接报错,进而导致整个钩子机制彻底失效。另外,thread_id 依然对标手搓时代的 run_id,这个核心追踪概念并未发生任何改变。
二、安全拦截被前置到了执行之前
HumanInTheLoopMiddleware 的钩子挂载在 after_model 阶段:此时大模型刚刚生成了带有 tool_calls 意图的 AIMessage,但工具节点本身尚未开始真正执行。中间件会根据当前准备调用的工具名称去逐一匹配 interrupt_on 配置表。由于我们在表里注册了 book_hotel,所以一旦命中,系统就会立刻将其拦下;而像天气查询和地点搜索这类纯读操作并不在配置表中,便会被直接放行。
需要强调的是,虽然在代码表象上这是一次中间件级别的拦截,但其底层的核心原语调用的依然是 interrupt()。不过,这次带出循环的负载发生了一些变化。它不再是我们上一篇在工具函数里手写的 pending 字典(包含查询出的酒店名和总价),而是当前 tool_call 最原始的名称与推断参数:hotel_id、room_type、check_in 以及 nights。因为真实的业务函数还没开始运行,最终的总价自然也就无从算起。当终端打印出「—— 停住 ——」的提示时,你所看到的正是这些原始参数,外加由 description_prefix 拼接而成的前缀说明。
这里有一个关键的并发逻辑值得注意:如果模型在同一条 AIMessage 的推断中同时计划调用天气和订房工具,这一整批调用此时都处于排队未执行状态。一旦钩子捕捉并拦下了 book_hotel,整个 invoke 的执行过程就会被立刻中断并返回。这也意味着,原本无需确认的天气的回灌结果暂时也不会出现。直到用户最终敲定批准后,被放行的 book_hotel 才会和本就免检的天气工具一起进入 tools 节点执行,两者的回灌消息随后也会一并被写入 messages 列表中。对于只读查询而言,因为节点重跑多执行一次影响不大,但这种设计的核心价值在于:它确保了涉及真实写操作的订房行为在获批之前绝对不会落单被误执行。
对比我们在上一篇的实现,当时的 interrupt() 是硬编码在 book_hotel 函数体内的。那个时候,诸如白名单在内的基础校验都已经走完,执行流正好停在修改库存的前一刻。这就导致在后续续跑时,整个 tools 节点不得不经历一次“从头再来”。而在本篇的新架构中,具体的业务函数体内不再掺杂任何 interrupt() 的阻塞逻辑。用户点头批准后,book_hotel 才正式登场,从第一行代码按部就班地跑到最后落写订单,整体清清爽爽地只执行一遍。当然,白名单、房型和日期等业务校验依然坚守在函数内部,一旦在此环节校验不通过,工具便会如实回灌 error 信息,不会因为用户已经确认过就绕过底层的业务规则。
关于中间件中 interrupt_on 的配置,如果直接将其写成 True,则意味着对该工具放开所有四种高级决策选项(approve 批准、edit 修改参数、reject 拒绝 和 respond 直接代答)。但在本文的纯人工确认场景中,我们通过细颗粒度的配置,仅仅保留了最基础的“批准”和“拒绝”功能。至于在终端直接修改 LLM 给出的参数,或者将人类的回复直接伪装成工具的执行结果(respond),暂时还用不上。
三、终端输入「确认」,背后传递的是 approve 决策
在上一篇的演示中,我们将随意约定的字符串 "确认" 塞进 Command(resume=...),原样返回给等待中的 interrupt()。但是,框架级别的 Middleware 拥有一套严谨的标准数据规范,它并不能直接识别这种随意的自定义词汇。相反,它要求接收一份结构化的“决策列表”,并且这份列表内的决策必须与停住时抛出的 action_requests 顺序严格对齐:
if decision == "确认":
resume = {"decisions": [{"type": "approve"}]}
else:
resume = {"decisions": [{"type": "reject", "message": f"未确认,不下单:{decision}"}]}
graph.invoke(Command(resume=resume), config)
在这套规范语义下,approve 代表用户明确同意按模型之前推断出的参数,直接放行执行 book_hotel;而 reject 则代表拒绝执行该工具,同时它会将附带的 message 字段作为执行反馈反向回灌给大模型。这样一来,大模型就能根据这条明确的拒信重新调整策略或组织语言作答,从而在机制上彻底避免了意外下单。为了在操作层面依然保持与手搓时代以及上一篇完全相同的极简交互体验,我们的脚本在终端界面仍然只会提示用户“请输入确认”,而将其转换为这套复杂结构的真正逻辑,则隐藏在了 input 终端读取之后的代码里。
这里必须再次强调一个铁律:在启动第二次调用尝试续跑时,绝对不要再画蛇添足地传入一份新的 messages 结构。只要你维持 thread_id 保持不变,底层的 get_state 机制就会自动去 SQLite 文件里翻找并挂载最新那一笔上下文快照。要知道,上一次 invoke 产生的返回值早已随着前一个进程的终结而消亡,当前续跑链路赖以生存的唯一依据,就是这份持久化落地的数据存储。
至于两次独立进程的启动方式,则与上一篇的设计完全一致:
python agent.py
python agent.py --resume
第一次运行会在控制台打印出中间件吐出的 action_requests 后退出进程。第二次运行则会启动一个全新的进程,并挂载上同一份 checkpoints.sqlite 文件。这两次运行在表现上最大的不同就在于:图发生停滞的节点位置变了(从业务函数内退到了中间件上),并且通过 resume 载荷传递的内容从一句单薄的字符串,变成了结构清晰的决策列表。为了保证这份演示链路的纯粹性,避免历史数据的干扰,脚本在第一次不带参数启动时仍会强制清空旧的数据库文件;一旦程序进入停滞状态,再想施加任何干预,就只能加上 --resume 标志来接续执行了。
四、白名单依然需要托管给 State
create_agent 默认内置的 AgentState 仅仅包含 messages 这一个基础字段。如果不把白名单和订单强行加进去,它们就不会被 Checkpointer 捕捉并存入 SQLite 文件中。到了新进程启动时,由于原本保存在内存中的模块级变量已被尽数清空,book_hotel 刚一进去查验白名单,就会立刻抛出“不在本次搜索结果里”的业务拦截。为了彻底解决跨进程带来的上下文丢失问题,我们在本篇中同样需要对 State 进行自定义扩展:
class State(AgentState):
seen_hotel_ids: list[str]
orders: dict[str, dict]
通过配置 state_schema=State,即可让 create_agent 使用这份扩展结构来管理状态。在底层工具的实现侧,search_hotels 和 book_hotel 依然通过 Command(update=...) 来回写状态,而读取时同样依赖 InjectedState 注解进行注入。至于天气相关的两个查询工具,既然它们只负责提供回灌信息而完全不涉及任何业务状态的修改,继续保持返回普通的 dict 即可。
正因为现在真正的工具函数是在拦截批准之后才开始执行的,这便彻底从架构上杜绝了上一篇中“节点中途重跑可能导致订单被重复写入”的隐患。当然,如果在执行中遇到了一笔此前已经预订成功过的重放请求,工具函数依然会基于 State 中的 orders 记录直接返回已有的订单结果,这完美契合了我们在手搓阶段对幂等键的严苛诉求。
五、对照手搓看差异
| 核心机制 | 手搓时代 | 上一篇手工画图 | 本篇 create_agent |
|---|---|---|---|
| 主循环写在哪 | 外部控制的 while 循环 | 纯手工逐个 add_node | 调用 create_agent 直接交出现成的图 |
| 订房动作前如何拦截 | 在执行核心 dispatch 前判断拦截 | 深入 book_hotel 内部硬编码 interrupt() | 中间件钩子在工具正式执行前统一拦截 |
| 确认放行后的行为 | 强行追加 user 消息,逼迫模型重做推断 | 节点从断点所在的工具调用重新跑完 | 工具函数这才刚刚获得执行权开始运行 |
| 续跑时传递什么内容 | 普通字符串「确认」 | Command(resume="确认") | 结构化决策列表(包含 approve / reject 等) |
| 进程重启与恢复机制 | 审计日志文件 + --confirm 命令 | SQLite 文件 + --resume 命令 | 机制高度一致,仅状态载荷的格式有所不同 |
相较于手搓时代需要在外部“拦截未确认操作”,本篇借助中间件将安全防线前置到了工具执行之前。对比上一篇在业务层里的断点,现在的中断逻辑已经从工具函数体中被抽离并转移到了框架统一的钩子上。book_hotel 终于卸下了自我中断的包袱,可以专注于其核心的业务逻辑;而大模型也照旧只需决断“下一步调用什么工具”即可。
至于像文本压缩、长周期计划规划、甚至 API 调用次数上限等其他高级特性中间件,本文为了聚焦主线暂未进行挂载。如果在未来的业务演进中,默认生成的这张 Agent 拓扑图实在无法满足某些极其特殊的流转需求(比如必须将某一中间层业务单拉成一个显式的物理节点),那时再退回到手动编排 StateGraph 的模式也完全不迟。
六、跑起来看轨迹
完整的代码示例存放在 agent-in-action/part2-agent-frameworks/langgraph/04-create-agent/。你可以通过与上一层的代码进行 Compare Files 比对:最核心的变化就是将繁琐的 add_node 换成了 create_agent 一步到位,移除了 book_hotel 内部的 interrupt() 调用,并多挂载了一个 HumanInTheLoopMiddleware 中间件。
cd part2-agent-frameworks/langgraph/04-create-agent
python agent.py
我们的终极目标依然是这句指令:「西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。」
—— 停住前 ——
HumanMessage 西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。
AIMessage 要调 ['search_location', 'search_hotels']
ToolMessage {"name": "西安", "country": "中国", "latitude": 34.25833, "longitude": 108.92861}
ToolMessage {"check_in": "2026-10-01", "hotels": [{"id": "HT-001", "name": "西安钟楼饭店", "city": "西安", "room_types": ["standard", "deluxe"], "price_per_night": {"standard": 180, "deluxe": 260}}, {"id": "HT-002", "name": "西安香格里拉", "city": "西安", "room_types": ["standard", "deluxe", "suite"], "price_per_night": {"standard": 150, "deluxe": 220, "suite": 400}}, {"id": "HT-003", "name": "回民街文化酒店", "city": "西安", "room_types": ["standard"], "price_per_night": {"standard": 110}}]}
AIMessage 要调 ['get_current_weather', 'book_hotel']
State.seen_hotel_ids ['HT-001', 'HT-002', 'HT-003']
—— 停住 ——
{'name': 'book_hotel', 'args': {'hotel_id': 'HT-002', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2}, 'description': "订房待确认\n\nTool: book_hotel\nArgs: {'hotel_id': 'HT-002', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2}"}
已写入 checkpoints.sqlite(thread_id=xian-suite-2026-10-01)。进程退出。
续跑:python agent.py --resume
从停滞前打印的信息可以清晰地看到:地点坐标和候选酒店列表都已经成功完成回灌,同时用于防重复下单的白名单也已存入 State。根据模型的规划,下一轮本该一并调用天气和订房工具,但整个链路被中间件拦截了。此时终端既没有打印出实时的温度数据,也没有弹出“预订成功”的提示,这充分证明这一批规划好的工具尚未得到执行。而停住时打印出的那几行字典信息,仅仅是从原始 tool_call 请求中提取出的参数快照,这也就解释了为什么此时根本看不到具体的酒店名和计算后的总价。
python agent.py --resume
—— 新进程读回 ——
HumanMessage 西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。
AIMessage 要调 ['search_location', 'search_hotels']
ToolMessage {"name": "西安", "country": "中国", "latitude": 34.25833, "longitude": 108.92861}
ToolMessage {"check_in": "2026-10-01", "hotels": [{"id": "HT-001", "name": "西安钟楼饭店", "city": "西安", "room_types": ["standard", "deluxe"], "price_per_night": {"standard": 180, "deluxe": 260}}, {"id": "HT-002", "name": "西安香格里拉", "city": "西安", "room_types": ["standard", "deluxe", "suite"], "price_per_night": {"standard": 150, "deluxe": 220, "suite": 400}}, {"id": "HT-003", "name": "回民街文化酒店", "city": "西安", "room_types": ["standard"], "price_per_night": {"standard": 110}}]}
AIMessage 要调 ['get_current_weather', 'book_hotel']
State.seen_hotel_ids ['HT-001', 'HT-002', 'HT-003']
next=('HumanInTheLoopMiddleware.after_model',)
—— 停住(从文件读回)——
{'name': 'book_hotel', 'args': {'hotel_id': 'HT-002', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2}, 'description': "订房待确认\n\nTool: book_hotel\nArgs: {'hotel_id': 'HT-002', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2}"}
请输入「确认」下单,其他内容拒绝:确认
确认:确认
—— 续跑后 ——
HumanMessage 西安现在天气怎么样?再帮我订一间豪华套房,2026 年 10 月 1 日住两晚。
AIMessage 要调 ['search_location', 'search_hotels']
ToolMessage {"name": "西安", "country": "中国", "latitude": 34.25833, "longitude": 108.92861}
ToolMessage {"check_in": "2026-10-01", "hotels": [{"id": "HT-001", "name": "西安钟楼饭店", "city": "西安", "room_types": ["standard", "deluxe"], "price_per_night": {"standard": 180, "deluxe": 260}}, {"id": "HT-002", "name": "西安香格里拉", "city": "西安", "room_types": ["standard", "deluxe", "suite"], "price_per_night": {"standard": 150, "deluxe": 220, "suite": 400}}, {"id": "HT-003", "name": "回民街文化酒店", "city": "西安", "room_types": ["standard"], "price_per_night": {"standard": 110}}]}
AIMessage 要调 ['get_current_weather', 'book_hotel']
ToolMessage {"temperature": 25.8, "apparent_temperature": 28.6, "humidity": 60, "wind_speed": 6.2}
ToolMessage {"status": "ok", "hotel_id": "HT-002", "hotel_name": "西安香格里拉", "room_type": "suite", "check_in": "2026-10-01", "nights": 2, "total": 800, "currency": "RMB"}
AIMessage 西安现在的天气是25.8摄氏度,湿度60%,风速6.2米每秒。我帮您预订了西安香格里拉饭店的一间豪华套房,2026年10月1日入住,住两晚,总价为800元人民币。
State.seen_hotel_ids ['HT-001', 'HT-002', 'HT-003']
State.orders {'HT-002:2026-10-01': {'status': 'ok', 'hotel_id': 'HT-002', 'hotel_name': '西安香格里拉', 'room_type': 'suite', 'check_in': '2026-10-01', 'nights': 2, 'total': 800, 'currency': 'RMB'}}
当从文件中读回历史快照时,可以清晰地看到:next 指针依然停留在中间件的钩子上,尚未进入 book_hotel 函数的内部。在你手动输入「确认」指令之后,原本被暂扣的天气数据、预订成功的落地提示以及大模型的最终总结回答,才相继涌现。值得注意的是,终答里播报的实时温度与刚刚放行的回灌天气数据完全一致,hotel_id 依然来自最初的搜索结果,而那 800 元的总价,也是在工具函数内部真正获得执行权后才实时计算得出的。
| 故障现象 | 核心排查方向 |
|---|---|
遇到 interrupt() 报错,提示缺失 Checkpointer | 核对 create_agent 的参数列表中是否遗漏了必要的 Checkpointer 配置实例 |
| 确认后程序无法顺利接续,像是新开了一轮对话 | 仔细检查 thread_id 标识是否保持了全局一致;核查 resume 结构里是否正确传递了包含 type 字段的决定列表;最后确认是否误传了全新的 messages 参数 |
| 节点确实停住了,但天气信息已经回灌、酒店也已经订成 | 去检查 interrupt_on 的监控配置列表中,是否不慎遗漏了核心的 book_hotel |
| 用户确认之后,反而回灌报错「不在本次搜索结果里」 | 返回去确认白名单字典 seen_hotel_ids 是否已经成功提升并存入了 State 结构中,还是仍旧被孤零零地留在了模块级变量里 |
| 用户根本还未确认,系统就已经提前下单订成 | 回顾并检查 book_hotel 函数体内是否还残留着旧版的 interrupt() 阻塞逻辑未被清理干净;同时确认中间件实例是否已经成功挂载到了 create_agent 上 |