使用场景:通过 Python create_agent() 创建 Agent,绑定工具,并用 SQLite checkpointer 保存会话状态。希望理解中间件执行顺序、哪些修改会持久化,以及在模型或工具运行中用户点击停止后如何继续对话。
本次查询 PyPI 时,Python langchain 最新稳定版为 1.4.4。实际落地示例的主要依赖已经固定为:
txt langchain==1.4.4 langchain-openai==1.7.0 langgraph==1.2.14 langgraph-checkpoint-sqlite==3.1.1
这些是本次确认和测试的版本,不是对未来最新版本的承诺。Python 和 JavaScript/TypeScript 的 API 不应混用。
Agent 的常见循环是:
调用模型 → 模型提出工具调用 → 执行工具 → 将结果交给模型 → …… → 最终回答
中间件是在这个过程中插入可组合的控制逻辑,例如修改上下文、动态提示词、摘要、重试、调用次数限制、人工审批和提前结束。
通过 create_agent(middleware=[...]) 注册。中间件运行在 Agent 编译出的 LangGraph 中,本身不是一套独立运行时。
本次提到的常见预置中间件:
| 需求 | 中间件 |
|---|---|
| 总结长对话 | SummarizationMiddleware |
| 工具执行前人工审批 | HumanInTheLoopMiddleware |
| 限制调用次数 | ModelCallLimitMiddleware、ToolCallLimitMiddleware |
| 模型失败后切换备用模型 | ModelFallbackMiddleware |
| 工具失败自动重试 | ToolRetryMiddleware |
| 敏感信息处理 | PIIMiddleware |
| 从很多工具中选择相关工具 | LLMToolSelectorMiddleware |
| 任务清单 | TodoListMiddleware |
run_limit 用于单次 Agent 调用的限制;跨多次调用累计的 thread_limit 需要 checkpointer 保存线程状态。官方预置中间件页面还包含 Deep Agents 的能力,具体导入包应分别确认。
| 钩子 | 执行时机 | 主要作用 |
|---|---|---|
before_agent | 一次 Agent 调用开始时 | 初始化、输入检查、提交状态更新 |
before_model | 每轮模型调用前 | 检查和更新消息、执行摘要等 |
after_model | 每轮模型节点返回后 | 检查模型响应、更新状态、决定后续路线 |
after_agent | Agent 正常走到结束路线时 | 收尾、提交状态更新 |
wrap_model_call | 包住模型调用 | 修改请求、切换模型、缓存、重试、处理响应 |
wrap_tool_call | 包住每个工具调用 | 参数检查、调用控制、异常处理、重试 |
dynamic_prompt | 每次模型调用时生成系统提示词 | 动态系统提示词的便捷写法 |
一个 AgentMiddleware 类可以实现多个钩子。示例中的 StopMiddleware 是一个中间件、三个钩子,不是三个中间件。
下面省略异常和提前跳转:
mermaid flowchart TD A[before_agent] --> B[before_model] B --> C[wrap_model_call 调用前] C --> D[模型调用] D --> E[wrap_model_call 调用后] E --> F[模型响应合并到 state] F --> G[after_model] G --> H{需要调用工具?} H -- 是 --> I[wrap_tool_call 执行前] I --> J[工具执行] J --> K[wrap_tool_call 执行后] K --> L[工具结果更新状态] L --> B H -- 否 --> M[after_agent] M --> N[END]
要点:
before_model 先于整个 wrap_model_call 执行。handler(request) 返回后,包装函数还可以继续处理响应;要等整个包装层返回后,模型节点才完成。after_model 在模型响应进入状态后执行。after_model 先于这一轮的工具执行。它看到的是模型提出的工具调用,不是尚未运行的工具结果。after_model,但不会进入 wrap_tool_call。对于 middleware=[A, B, C]:
txt before_*:A → B → C after_*: C → B → A wrap_*: A(B(C(实际调用)))
例如计时包装放在重试包装外面,记录包含重试的总时间;放在里面,记录每一次尝试的时间。
包装层内部多次调用 handler(),并不等于重新执行外面的 before_model 节点。
这是本次讨论最核心的区分:
| 对象 | 含义 |
|---|---|
state | Agent 的共享状态,包括 messages 和已声明的自定义字段 |
request | 本次模型调用的请求,包含消息、系统提示词、模型、工具等 |
response | 模型调用包装层返回的响应,框架将其中的模型消息并入状态 |
request.override(...) 修改请求,不自动修改历史python@wrap_model_call
def customize_input(request, handler):
return handler(request.override(messages=request.messages[-10:]))
这里改变的是这次模型看到的消息。它不是“临时修改 state”,也不是自动替换历史。
示意:
txt 原 state.messages: [A, B, C] 本次 override 输入:[B, C] 模型返回: D 新 state.messages: [A, B, C, D]
最终发送给供应商的请求还可能包括系统消息、工具定义等,因此“checkpoint 中的历史”不等于“完整模型请求”。
wrap_model_call 不只是修改输入它可以决定:
handler():例如缓存命中后直接返回响应。所以,“只有响应会进入状态”只是在没有额外状态更新、正常返回普通响应时,对本次讨论场景的简化描述,不能理解为包装钩子绝对不能更新状态。
before_agent、before_model、after_model、after_agent 都可以提交状态更新:
pythonreturn {"some_field": new_value}
准确过程是:
txt 钩子正常 return 有效更新 ↓ 框架按字段的 reducer / 合并规则更新 state ↓ 后续节点读取更新后的 state
因此,本次最后确认的结论是:
在“正常返回值包含有效状态字段更新”的前提下,这四个钩子每次返回后,框架都会应用这些更新。
需要同时保留这些边界:
| 返回值 | 含义 |
|---|---|
None | 没有状态更新,不改变默认执行路线 |
{} | 没有更新内容 |
{"some_field": value} | 按该字段规则提交更新 |
{"jump_to": "end"} | 控制路由;不等于修改、持久化对话历史 |
state["messages"];使用规定的返回更新方式。after_model 负责写入。messages 不是普通覆盖字段默认消息 reducer 按 ID 合并:
RemoveMessage:执行删除操作。所以:
pythonreturn {"messages": [some_message]}
不等于将完整历史替换为只有一条消息。
需要整体替换或调整顺序时,可使用:
pythonfrom langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*repaired_messages,
]
}
这是替换当前状态的消息列表,不是物理删除数据库中所有旧 checkpoint。
ExtendedModelResponse 和 Command(update=...)ExtendedModelResponse。它允许包装钩子同时返回模型响应和额外状态更新:
pythonreturn ExtendedModelResponse(
model_response=response,
command=Command(update={
"last_call_completed": True,
"model_call_count": request.state.get("model_call_count", 0) + 1,
}),
)
含义:
model_response:本次模型响应。command.update:请求框架额外合并的状态字段更新。这不是“强制覆盖整个 state”。仍然需要:
只调用 request.override(...) 不会提交这些状态更新;需要明确返回 Command(update=...)。
SummarizationMiddleware:在 before_model 中检查阈值,必要时调用摘要模型,返回消息状态更新。摘要前后:
txt 摘要前 state.messages: [旧消息1, 旧消息2, ……, 近期消息1, 本次用户问题] 摘要后 state.messages: [摘要消息, 近期消息1, 本次用户问题]
摘要中间件是真的更新 state,不是仅临时缩短请求。 因此下一次同一线程加载的是摘要后的当前状态;更早的完整消息仍可能存在于历史 checkpoint。
| 目标 | 方式 |
|---|---|
| 控制摘要模型如何总结 | SummarizationMiddleware(summary_prompt=...) |
| 固定的主模型系统提示词 | create_agent(system_prompt=...) |
| 动态主模型系统提示词 | dynamic_prompt 或 wrap_model_call |
| 只改变当前模型请求的消息 | request.override(messages=...) |
| 修改后续轮次保存的对话状态 | before_model 返回消息更新,或其他显式状态更新方式 |
摘要提示词示例:
pythonSummarizationMiddleware(
model=summary_model,
trigger=("tokens", 4000),
keep=("messages", 10),
summary_prompt=(
"总结以下对话,保留用户目标、约束、已完成操作和待办事项。"
"不要编造信息。\n\n{messages}"
),
)
{messages} 是待总结历史的占位符。摘要模型的内部调用不经过给 Agent 主模型配置的 wrap_model_call 包装链。
如果要先修复不完整历史、再摘要,顺序应是:
python middleware=[repair_history, SummarizationMiddleware(...)]
如果只希望“先摘要,再临时修改发给主模型的提示词”,可在 wrap_model_call 中处理;它位于摘要的 before_model 阶段之后。
| 内容 | 默认是否随 Agent 状态保存 |
|---|---|
state.messages 的用户消息、AI 消息、工具消息、摘要 | 是 |
| 已注册的自定义状态字段 | 是,遵循相应状态配置 |
静态 system_prompt 配置 | 不会自动成为消息状态 |
request.override(...) 产生的临时输入变化 | 不会自动保存 |
| 单纯局部变量变化 | 不会自动保存 |
SQLite checkpointer 将 checkpoint 保存到本地数据库。InMemorySaver 只保存在内存,进程退出后丢失。
txt wrap_model_call 调用前逻辑 ↓ handler(request) ↓ wrap_model_call 调用后逻辑 ↓ 整个包装层返回 ↓ 模型节点提交 AIMessage,形成状态更新和 checkpoint ↓ after_model
严格来说,checkpoint 在 super-step 边界形成;顺序图中可以理解为各节点对应步骤之间的保存点。图还会保存任务级 pending writes,用于失败恢复;它不等同于一个新的完整状态快照。
模型响应只是其中一个保存点。输入、摘要节点、工具节点、后置钩子等对应步骤也可能产生 checkpoint。
durability | 行为 |
|---|---|
sync | 等待 checkpoint 保存完成,再执行下一步 |
async | 保存和后续步骤并行,突然退出可能丢失最近更新 |
exit | 退出执行时持久化,不适合依赖中间状态恢复进程崩溃 |
因此,“AI 响应在 after_model 前已经完成 SQLite 写入”需要结合 sync 来表述,不能对所有持久化模式一概而论。
txt Human:旧问题 AI:tool_calls=[call_1] ← 进程退出,缺少工具结果 Human:新问题
如果把这个历史传给模型,就可能报错:带有工具调用的 AI 消息后,必须有匹配的工具结果。
正常关系是:一条 AI 消息中的每个工具调用 ID,都要有对应 ToolMessage.tool_call_id。多个调用需要分别配对,不能只补一条笼统错误。
继续旧任务:
thread_id,读取 get_state/aget_state。next、tasks、错误和 interrupts,并核实工具是否允许重试。invoke(None, config) / ainvoke(None, config)。interrupt() 的人工审批需要 Command(resume=...),不是一概传 None。放弃旧任务:
update_state 仍遵循消息合并规则,不能用普通列表返回假装完成重排。会话早期给出的“检查最后两条消息并补错”的代码只覆盖特定尾部结构。它不是通用生产恢复器;生产逻辑还必须处理部分工具已完成、并行工具、外部副作用和旧执行仍存活等情况。
同一 thread_id 的所有执行与状态修改应经过同一套串行机制。不同线程可以并行。
txt thread-A:问题1 → 问题2 → 问题3 → worker-A thread-B:问题1 → 问题2 → worker-B
排队中的问题尚未进入 state.messages。上一个问题完整结束包括全部模型—工具循环,而不只是第一次模型响应。
异常、人工审批暂停不算正常完成,不能立刻启动下一条。
txt 问题 → 队列 → worker → 当前运行 Task ↑ 停止 → 独立取消入口 ──────────┘
即使没有并发问题,也需要这套取消机制。取消请求应指定 run_id,避免迟到的停止信号误取消下一轮。
生产级应用应持久化 run_id、thread_id、状态和取消标记。否则重启恢复可能继续执行用户已经停止的任务。
txt running → cancelling → reconciling → cancelled / ready ↘ needs_recovery
python@before_model(can_jump_to=["end"])
def check_stop(state, runtime):
if runtime.conShell.stop.is_set():
return {"jump_to": "end"}
return None
类方法可用 @hook_config(can_jump_to=["end"]) 声明跳转能力。
jump_to="end" 是正常路由到结束流程,有 after_agent 时仍会经过它。after_model 决定结束时,如果刚生成了工具调用,需要补齐明确不再执行的工具结果,否则会留下不完整历史。python run_task = asyncio.create_task(execute_agent()) run_task.cancel()
Task.cancel() 是协作式取消,在可响应取消的位置抛出 asyncio.CancelledError。BaseException,不会被普通 except Exception 捕获。finally 释放资源,捕获取消后通常应再次 raise。通过 Agent Server 运行时,可用:
pythonawait client.runs.cancel(
thread_id=thread_id,
run_id=run_id,
wait=True,
action="interrupt",
)
另有 rollback 方式,但回滚图状态不会撤销外部系统已完成的操作。这个服务端 API 不能直接取消本地 Python agent.ainvoke() 的 Task。
目录:F:\360MoveData\Users\AO237\Desktop\nest-server\langchain-cancel-demo
文件:
| 文件 | 用途 |
|---|---|
demo.py | Agent、中间件、只读工具、队列、取消和 checkpoint 收尾 |
requirements.txt | 固定已验证的主要依赖版本 |
README.txt | 安装、运行方式和实现边界 |
test_demo.py | 无需 API Key 的集成测试 |
.gitignore | 排除虚拟环境、缓存、数据库和环境文件 |
原 test-demo 是 NestJS 项目,本次没有修改;Python 示例与其并列。示例没有前端,也没有 HTTP API 服务。
pythonrun = manager.submit(thread_id, question)
status = manager.cancel(run.id)
result = await manager.wait(run.id, timeout=30)
RunControl 中的 Event 是运行时控制信息,不作为 checkpoint 状态保存。RunManager 的运行状态包括 queued、preparing、running、cancelling、reconciling、cancelled、completed、needs_recovery。wait 超时只影响等待方,不主动取消运行或收尾。astream(..., stream_mode="messages", durability="sync") 消费流,退出时关闭流。wait_for_stage 等待目标阶段或提前结束,避免模型报错后还无意义等待完整超时时间。slow_lookup 的取消工具开始前及等待过程中检查本轮停止 Event;异步等待也可以收到 CancelledError。
它是模拟的只读查询,不会创建订单或修改外部数据。取消结果的文本是“结果未交付”,而不是断言外部操作从未执行。
after_agent 只返回 None正常的 after_model 返回 None 表示放行;示例里的 after_agent 则是刻意保留的空钩子:
pythondef after_agent(self, state, runtime):
return None
目的:让编译图包含一个明确、无副作用、直接连接 END 的收尾节点 StopMiddleware.after_agent。
取消后:
pythonawait agent.aupdate_state(
config,
update,
as_node="StopMiddleware.after_agent",
)
含义是“把此次更新视为这个节点的输出”,让后续调度走到 END。它不会执行 after_agent() 函数。
初始化时验证节点存在且直连 END,降低依赖版本变化造成错误调度的风险。这里不能随意改成 model 或 tools,也不能把有关键业务副作用的收尾逻辑放入空节点后假定 as_node 会执行它。
工具执行时,这一轮的 after_model 已经执行过:
txt 模型 → after_model → 工具 → CancelledError → Agent 执行退出
after_model 不会因取消再执行一次。after_agent 不会作为异常收尾自动执行,它不是 Python 的 finally。finally 仍负责释放资源。_finalize_cancel()。两种退出路线不同:
txt jump_to=end:正常路由 → after_agent → END CancelledError:异常退出 → 外层 worker 整理状态与调度
config 是定位会话的配置,不是被更新的对象;aupdate_state 更新的是它所指向的状态。
在示例的 sync 模式中:
txt 模型产生 AIMessage(tool_calls) ↓ 状态保存完成 ↓ 工具开始但尚未完成 ↓ 取消并等待执行 Task 退出 ↓ aget_state 读取已保存的 AIMessage ↓ 为缺少的 ToolMessage 补写取消结果
缺少工具结果并不是读取时“忽略了 tool 消息”,而是工具尚未正常提交结果。收尾流程就是为此补写状态。
如果工具结果已经存在,则保留,不重复补齐。生产中的外部副作用工具仍需单独核实,不能仅凭 Task 已退出判断远端已停止。
在 Python 3.11 环境下运行了真实 LangChain 图、真实工具节点和文件型 SQLite checkpoint,模型使用本地可控模拟实现,四项测试通过:
真实供应商调用没有使用用户的 API Key 验证。真实运行需配置 OPENAI_API_KEY、AGENT_MODEL。
DEMO_MODE=tool 测试工具阶段取消;DEMO_MODE=stream 测试首个文本片段后取消。
该示例不是完整生产基础设施:
update_state,都应经过同一套线程互斥机制。cancelling / close() 持续等待;需要生产级超时监控、隔离和核实。wrap_tool_call 适合捕获工具异常,决定重试、返回错误结果或继续抛出。
示意:
python@wrap_tool_call
async def retry_tool(request, handler):
for attempt in range(3):
try:
return await handler(request)
except asyncio.CancelledError:
raise
except (TimeoutError, ConnectionError):
if attempt < 2:
await asyncio.sleep(0.5 * (2 ** attempt))
continue
return ToolMessage(
content="工具暂时不可用,多次尝试仍未成功。",
tool_call_id=request.tool_call["id"],
status="error",
)
注意:
ToolMessage 通常会让 Agent 继续调用模型,并不自动结束整轮。| 层级 | 职责 |
|---|---|
| 应用请求层 | 用户授权检查、run_id、排队、取消入口、持久化任务状态 |
| 每线程执行管理 | 串行执行、执行者归属、等待旧执行退出、控制何时启动新问题 |
before_model | 停止标记与历史合法性检查,在摘要前阻止不完整历史继续 |
after_model | 在工具派发前决定停止,补齐明确不再执行的调用 |
wrap_model_call | 当前请求定制、模型调用控制、重试和响应处理 |
wrap_tool_call | 工具启动前检查、幂等接入、异常和重试策略 |
| 工具服务 | 业务幂等、稳定 operation_id、结果查询、必要的业务撤销 |
| 取消收尾 / 恢复服务 | 核实结果、修复历史、处理 checkpoint 调度、保存最终状态 |
| Checkpointer | 保存 Agent 图状态与执行进度,不替代业务操作日志和任务队列 |
外部工具的幂等键不能只依赖模型新生成的 tool_call_id:同一业务操作重试时,模型可能生成新 ID。应有稳定业务操作标识。
对订单、付款、发邮件等,图 checkpoint 保存与外部业务执行通常不是一个事务。即使使用 durability="sync",也不能保证外部操作 exactly-once。
| 容易产生的理解 | 最终确认 |
|---|---|
| wrap 只是在模型调用前运行 | wrap 同时包住调用前和调用后,并控制 handler |
| wrap_tool_call 与 after_model 都只是模型后置处理 | 前者包住工具执行,后者检查模型已产生的响应 |
| request.override 临时修改了 state | 它修改本次请求,不自动修改 state |
| 包装钩子不能修改状态 | 可以通过 ExtendedModelResponse + Command 显式提交 |
| Command 强制覆盖任意 state | 更新已注册字段,遵循 reducer |
| 只要钩子执行就会修改 state | 需要正常返回有效更新;None、空字典不更新 |
| 所有非 None 返回都改变历史 | 路由控制不等于历史修改;同值更新也可能无可见变化 |
| prompt 总是在 after_model 前落盘 | 保存的是状态;物理写入先后还取决于 durability |
| 缺少 ToolMessage 说明工具没执行 | 可能已成功、但结果未保存,需要核实 |
| 取消只需在 before_model jump 到 end | 只在钩子边界生效,不能立即打断进行中的调用 |
| 工具取消异常会自动执行 after_agent | 不会;需要外层 finally / worker 收尾 |
| as_node 会执行该节点函数 | 不会,它指定状态更新归属并影响后续调度 |
| 补齐工具消息等于取消旧任务 | 还需要处理待执行调度及旧执行者 |
| 队列意味着用户不能随时停止 | 停止走独立控制通道,普通问题继续排队 |
| Task.cancel 能停止所有工具 | 对线程和远程外部效果没有通用强制停止或回滚保障 |
这些链接用于定位本次讨论所依据的官方资料;在线文档与主分支源码会继续更新,实际项目应以锁定版本为准。