一次 agent.invoke 到底发生了什么?------拆解 LangGraph Agent 的完整调用链路
技术栈:LangChain / LangGraph · 中间件(Middleware)· Human-in-the-loop
关键词:
thread、SummarizationMiddleware、@wrap_model_call、interrupt、Command(resume)
很多人第一次用 LangGraph 写 Agent,代码跑通了,但脑子里是一团浆糊:中间件到底是按什么顺序执行的?@wrap_model_call 是在模型调用之后才套的一层,还是它本身就是那层包裹?interrupt 暂停之后,Command(resume=...) 又是喂给谁的?
这篇文章不讲怎么装环境、怎么配 API Key,只回答一个问题:从 agent.invoke() 被按下去的那一刻,到返回那个状态字典,中间到底走了哪些步骤,每一步在改什么。
一、先分清两层钩子:节点钩子 vs 模型钩子
这是整条链路里最容易搞混的地方。LangGraph 的中间件不是"平铺的一排",而是分层的 ,而分层的分界线就是"是否真的要去调模型了"。
lua
agent.invoke()
↓
【全部 before_model 钩子依次执行】 <-- 第一层:图/节点层面的前置钩子
↓
【wrap 洋葱包裹层开始】
wrap2 before
wrap3 before
wrap1 before
↓
调用 LLM 模型
↓
wrap1 after
wrap3 after
wrap2 after
【wrap 洋葱包裹层结束】
↓
【全部 after_model 钩子依次执行】 <-- 节点层面的后置钩子
↓
返回 AIMessage
读这张图要注意三个点:
第一,before_model 跑在图层面,不是每次模型调用前都跑。 像 SummarizationMiddleware 这种摘要中间件,它属于"图状态层"------它面对的是整个 thread 的消息列表,干的事情是压缩上下文。它不属于"模型调用"这件事。
第二,@wrap_model_call 只在真正要发 LLM 请求的那一刻才进入。 而且它是包裹钩子本身,不是"模型调用之后再额外套一层"。这一点后面单独展开。
第三,洋葱的进入顺序和退出顺序严格相反。 上图里是 wrap2 → wrap3 → wrap1 进去,wrap1 → wrap3 → wrap2 出来。谁在最外层取决于注册顺序,但规则是不变的:先进后出。这跟栈是一回事。
二、@wrap_model_call 的真相:它不是一个"事后钩子"
看到 wrap_model_call 这个名字,很容易理解成"包装了模型调用的钩子",然后顺手理解成"模型调用完之后再执行一下"。不对。
它的正确结构是这样的:
python
def wrap_model_call_middleware(request, handler):
# before:发给模型之前,修改请求消息
request.messages[-1].content += "before标记"
response = handler(request) # handler 才是真正调用 model_out
# after:拿到模型返回后,修改 AI 输出
response.result[0].content += "after标记"
return response
关键在于 handler 这个参数:真正发起 LLM 请求的代码不是你写的,是 handler。你的中间件只负责"把请求递给 handler 之前动一下"和"从 handler 拿回结果之后动一下"。
所以要建立的直觉是:
model_out是主模型,负责决策、生成文本、生成工具调用请求。wrap_model_call这一层不会新增任何模型调用 ,它只是对同一次 LLM 请求做前后加工。- 如果 Agent 进入循环、多次调用模型,那么每一轮 LLM 调用都会重新走一遍这个 wrap 钩子。
把它想成 HTTP 的拦截器或者 AOP 的切面就通了------拦的是"这一次调用",不是"整个流程"。
三、端到端流程:从 invoke 到 response
接下来是完整链路。先看流程图,后面逐步拆。
arduino
用户调用 agent.invoke(输入消息, config=config)
↓
【1】读取 config,取出 thread_id → 加载对应 thread 会话(历史消息、状态)
↓
【2】Graph 级别中间件(SummarizationMiddleware 摘要中间件)
├─ 读取当前 thread 里全部 messages
├─ 判断是否超长,触发摘要压缩,更新消息列表
↓
【3】准备调用大模型 → 进入 @wrap_model_call 模型包裹中间件
├─ before 阶段:修改模型请求 request(改消息)
├─ handler(request):真正发起 LLM 请求(model_out 主模型)
└─ after 阶段:拿到模型返回,修改 AIMessage 结果
↓
【4】解析模型输出:
├─ 情况A:纯文本回答,无工具调用 → 直接跳到【9】更新会话
└─ 情况B:模型输出 action_requests(想要调用工具)
↓
【5】触发 interrupt 人工中断(暂停 Agent 图)
├─ 提取所有工具请求 action_requests
├─ 遍历工具,生成审批 decisions(同意/拒绝)
↓
【6】外部调用 agent.invoke(Command(resume=decisions), config=config)
├─ Command:唤醒被暂停的图
├─ resume:把人工审批结果喂回中断节点
↓
【7】执行工具(如果有 LLMToolEmulator,用 model_in 模拟工具返回结果)
↓
【8】将工具返回包装成 ToolMessage,追加进 thread 的 messages 列表
└─ 回到【2】,再次循环:摘要中间件 → wrap 钩子 → 调用 model_out(Agent 循环)
↓
【9】全部任务完成,更新 thread 会话状态:追加新消息存入 thread_id 对应的会话
↓
【10】返回完整状态字典 response(包含所有 messages:Human / AI / ToolMessage)
1. agent.invoke(输入, config=config):拿到"钥匙"
python
config = {"configurable": {"thread_id": "1"}}
这里必须把两个概念分开:
config不是会话本身 ,它只是找到会话的钥匙 。它携带thread_id,告诉你"这次操作哪一条会话"。thread才是会话档案,里面保存着整套历史消息和 Agent 状态。
框架拿到 thread_id,去把这条会话的历史消息加载出来,作为后续所有步骤的上下文。
2. SummarizationMiddleware:在碰 LLM 之前先瘦身
摘要中间件跑在图状态层面,此时还完全没碰模型。它做三件事:
- 读取当前 thread 里的
messages列表; - 计算总 token,判断是否超过阈值;
- 超长就把旧消息压缩成摘要,替换消息列表,减小上下文长度。
要记住的是:它修改的是"会话的消息列表",这个修改后的列表会传给后面的模型。所以它虽然不直接参与模型调用,但实实在在地决定了模型看到什么。
3. @wrap_model_call:贴着 LLM 的那一层
到了这一步,才真的要发网络请求了。钩子逻辑就是第二节那段代码:before 改 request,handler(request) 真正调 model_out,after 改返回的 AIMessage。
这一层只包裹单次 LLM 调用,不新增调用。
4. 解析模型输出:两条分岔路
模型返回之后立刻分两条路:
- 分支一:普通文本回答,没有工具请求。 直接往下走,更新会话状态,返回结果。整个流程结束。
- 分支二:模型输出了工具调用请求
action_requests。 模型在说:"我要调用get_weather/send_email_tool。" 于是进入人工审批流程。
5. interrupt:Agent 主动踩刹车
这一步里,Agent 会主动暂停整个图 ,不再往下执行工具,而是把 action_requests(模型想调用的工具清单)暴露给外部代码。
外部代码要做的是:遍历所有工具请求,生成 decisions 审批列表(同意 / 拒绝)。此时 Agent 卡在断点上,等外部输入------这就是 human-in-the-loop 的落点。
6. Command(resume=decisions):把审批结果喂回去
python
agent.invoke(Command(resume=decisions), config=config)
Command:LangGraph 的图控制指令,作用是把被暂停的 Agent 唤醒。resume:传给断点的返回值,也就是人工审批结果decisions。- 必须带上相同的
config,这样框架才知道操作的是同一个thread_id会话,否则就跑到另一条会话上去了。
7. 执行工具:真实调用与模拟器
- 真实场景:调用真实 API,把数据拿回来。
LLMToolEmulator:工具模拟器中间件。它内部的model_in伪造工具返回结果 ,不需要对接真实接口,非常适合跑通链路或做测试。模拟结果一样会被包装成ToolMessage。
注意 model_in 和 model_out 的分工:model_out 是主大脑,负责决策;model_in 是模拟器模型,负责假装工具执行完并返回内容。
8. 回到第 2 步:Agent 循环成型
工具的返回结果以 ToolMessage 的形式被追加进 thread 的 messages 列表,然后重新走一轮完整的 Agent 循环:
lua
摘要中间件 → wrap 模型钩子 → 再次调用 model_out
模型会基于工具结果继续思考。这个循环会反复执行,直到模型输出纯文本、不再请求工具为止。
9. 落盘与返回
- 把本轮所有新消息(
AIMessage、ToolMessage)追加存入thread_id对应的会话,持久化记忆。 agent.invoke返回的是完整状态字典 ,不是单独一条AIMessage。response["messages"]里放着全部消息:HumanMessage、AIMessage、ToolMessage。
四、关键概念速查表
| 概念 | 作用 |
|---|---|
config |
携带 thread_id,用来选定操作哪一条会话 |
thread |
会话档案,存储聊天历史、Agent 状态 |
SummarizationMiddleware |
图层面中间件,模型调用前压缩长消息 |
@wrap_model_call |
模型调用钩子,包裹单次 LLM 请求,请求前后加工 |
model_out |
Agent 主模型,做决策、生成工具调用 |
model_in |
LLMToolEmulator 模拟器模型,伪造工具返回 |
interrupt |
暂停 Agent,等待人工审批 |
Command(resume) |
给断点传入审批结果,恢复 Agent 运行 |
action_requests |
模型想要调用的工具请求列表 |
五、几句必须记住的提醒
第一,循环的触发条件是"有工具调用"。 只要 Agent 触发了工具调用,就会进入循环、多次调用 model_out 。而每一次 model_out 调用都会触发 wrap_model_call 钩子,并且每一轮开始都会执行摘要中间件。
第二,没有工具调用就是单轮。 像"你好"这种简单对话,只走一轮,仅调用一次 model_out,不会进循环。
第三,把顺序刻进脑子里: before_model(图层面)→ wrap_model_call 洋葱(模型层,可多轮)→ after_model(图层面)。分界线永远是"是不是真的要调模型了"。
写在最后
这套链路最反直觉的地方,其实就两个:一是中间件是分层的 ,摘要这类图层面中间件和 wrap_model_call 这种模型层钩子压根不在一个层级上;二是**wrap_model_call 本身就是包裹层**,它不是模型调用之后补的一层。
把这两点想通,再看 interrupt + Command(resume) 的人工审批,就只是"在循环中间插了一个断点,等待外部把结果送回来"而已------整条链路自然就串起来了。
本文基于个人学习笔记整理,结合自己的理解写成,重点是建立直觉而非穷举 API。如有偏差,欢迎指正。