7425 字
37 分钟
LangChain 源码深潜:Middleware、Guardrails 与 Agent 执行控制机制解剖

LangChain源码学习路线:第 15 篇 Middleware / Guardrails 与 Agent 执行控制源码解剖#

核心问题: LangChain Agent 如何在不改写主循环的前提下,把动态模型选择、工具调用拦截、重试和安全策略插入执行链?

源码主线: create_agent(..., middleware=[...])Agent graphmiddleware hooksmodel/tool requestresponse / retry / block

前置文章: 第 5 篇 create_agent / ReAct、第 14 篇 Memory / Store

依赖基线: langgraph==1.2.7langchain==1.3.11langchain-core==1.4.8

源码基线: https://github.com/langchain-ai/langchain/tree/langchain==1.3.11、https://github.com/langchain-ai/langchain/tree/langchain-core==1.4.8、https://github.com/langchain-ai/langgraph/tree/1.2.7

阅读边界: 本文覆盖 Agent Middleware 的构建、运行、hook 边界和 guardrails 工程化;不展开具体内容安全模型、企业合规策略库和第三方网关实现。


0. 本篇在源码学习主线中的位置#

前面的文章已经解释了底层 Runnable、Tool、StateGraph、Checkpoint 和 Streaming。本篇把这些能力放到一个更接近生产 Agent 的问题里:Middleware / Guardrails。

create_agent / ReAct 主循环
Middleware / Guardrails
Agentic RAG / Retrieval Runtime

本篇只解决:

  • 解释 middleware 如何注册到 Agent graph。
  • 解释模型前后、工具前后和错误恢复 hooks 的职责。
  • 说明 guardrails 应该拦截请求、工具还是最终输出。

本篇不展开:

  • 不设计完整安全审计平台。
  • 不讨论所有模型供应商的安全接口差异。

1. 本篇问题、学习目标与能力边界#

核心问题#

LangChain Agent 如何在不改写主循环的前提下,把动态模型选择、工具调用拦截、重试和安全策略插入执行链?

学习目标#

完成本篇后,读者必须能够:

  1. 画出Middleware / Guardrails的构建期对象关系。
  2. 解释一次运行时调用如何进入主链。
  3. 说明关键分支、异常和停止条件。
  4. 区分公共 API、扩展接口和内部实现。
  5. 根据旅行规划助手场景做工程选型。

能力边界#

能力本篇是否覆盖说明
构建期对象组装解释公开参数如何变成运行时对象
运行时主链解释 invoke/stream/tool/task 等主路径
分支与异常解释失败、降级、重试、终止边界
扩展协作解释与相邻框架组件如何组合
底层供应商实现不展开模型、数据库或平台内部实现

2. 核心概念与最小心智模型#

一句话定义#

Middleware 是 LangChain Agent 执行链上的可组合拦截层,负责在模型、工具和状态流转前后注入控制逻辑,不负责替代 Agent 主循环。

最小心智模型#

create_agent / ReAct 主循环
Middleware / Guardrails
Agentic RAG / Retrieval Runtime
工程化 Agent 能力

核心术语#

术语源码对象语义不要误解为
MiddlewareAgentMiddleware包裹 Agent 执行步骤的 hook 机制业务节点本身
Guardrailpolicy hook执行前后安全策略模型系统提示词
Dynamic modelmodel selection hook按请求选择模型随机切换模型
Tool retrytool error handler工具失败恢复吞掉所有异常

与相邻抽象的边界#

对象负责什么不负责什么与本篇对象的关系
Runnable统一 invoke / stream / batch 协议不决定业务策略提供可组合执行底座
StateGraph编排状态、节点和边不实现所有外部系统承载复杂 Agent 工作流
RunnableConfig传递 config、metadata、callbacks不保存业务状态让观测和配置沿调用链传播

3. 完整执行链路#

本篇的完整链路要追踪的是:create_agent(..., middleware=[...]) 如何把 middleware 注册到 Agent graph;运行时如何把 state/messages 组装成 model request;before/after/wrap hook 如何拦截、改写、拒绝或放行;异常如何转成重试、降级或显式失败。

从一次高风险旅行工具请求观察对象变化#

from langchain.agents import create_agent, AgentMiddleware
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
class TravelGuardrail(AgentMiddleware):
def before_model(self, state, runtime):
last_user = state["messages"][-1]
if asks_for_unsafe_booking(last_user.content):
return {"messages": [AIMessage(content="我不能直接替你完成高风险预订。")]}
return None
def wrap_model_call(self, request, handler):
if request.state.get("trip_complexity") == "high":
request.model = strong_model
response = handler(request)
return validate_model_response(response)
def wrap_tool_call(self, request, handler):
if request.tool.name == "book_hotel" and not request.state.get("user_confirmed"):
return ToolMessage(
content="需要用户确认后才能调用 book_hotel。",
tool_call_id=request.tool_call["id"],
)
return handler(request)
agent = create_agent(
model=default_model,
tools=[search_flights, book_hotel],
middleware=[TravelGuardrail()],
)
result = agent.invoke({
"messages": [HumanMessage(content="帮我直接订今晚最便宜的酒店")]
})

输入对象先是 create_agentmodel/tools/middleware,构建后变成一个可执行 Agent graph。运行时输入是 {"messages": [...]},它会进入 agent state,而不是直接传给底层模型。

输出对象可能有三种形态:before_model 可以直接返回 state update 以短路模型调用;wrap_model_call 返回模型响应或改写后的响应;wrap_tool_call 返回 ToolMessage 或真实工具结果。它们都不是普通字符串,而是 Agent graph 能继续合并和路由的协议对象。

对象形态改变的关键点在 request 包装层:state["messages"] 被组装成 ModelRequest,模型提出的 AIMessage.tool_calls 被组装成 ToolCallRequest,middleware 再决定放行、改写、拒绝或降级。读源码时要观察 hook 的返回值是否会短路后续 handler。

middleware 注册到运行时 hook 的链路#

create_agent(model, tools, middleware=[TravelGuardrail()])
归一化 middleware 实例列表
检查每个实例实现了哪些 hook:before_model / after_model / wrap_model_call / wrap_tool_call
构造 model 节点、tools 节点和 hook wrapper stack
编译为 Agent graph
agent.invoke(input)
before_model(state, runtime)
wrap_model_call(ModelRequest, handler)
AIMessage 可能包含 tool_calls
wrap_tool_call(ToolCallRequest, handler)
after_model(state, runtime) 或下一轮循环

构建期输入是 middleware 对象列表,输出是带 hook stack 的 Agent graph。注册不是把 policy 文本塞进 prompt,而是把可调用 hook 挂到模型节点和工具节点周围。

运行时输入是 state 和 request 对象。before_model/after_model 更靠近 state 视角,适合改写消息、短路回答或记录策略;wrap_model_call/wrap_tool_call 更靠近请求视角,适合动态换模型、拦截工具、捕获异常和降级。

读源码时要观察 hook 顺序。多个 middleware 同时存在时,谁先看到 request、谁最后处理 response,会直接影响 guardrail 是否可靠。

请求拦截、改写与拒绝的对象链#

def run_model_with_middleware(state, runtime, middleware_stack):
update = run_before_model_hooks(middleware_stack, state, runtime)
if update is not None:
return update
request = ModelRequest(
model=runtime.model,
messages=state["messages"],
state=state,
runtime=runtime,
)
response = call_wrapped_model(request, middleware_stack, base_model_handler)
checked_update = run_after_model_hooks(middleware_stack, response, state, runtime)
return checked_update or {"messages": [response]}

输入对象是 Agent state、runtime 和 middleware stack。输出对象始终是 graph 能处理的 state update,通常是 {"messages": [AIMessage]},也可能是拒绝时的安全 AIMessage

对象形态改变发生在 ModelRequest(...):state 中的 messages 被包装成可被 middleware 读取和修改的请求对象。guardrail 不必改写主循环,只需要在 request/response 边界上返回符合协议的对象。

读源码时要观察 None 和 state update 的区别。hook 返回 None 通常表示不介入;返回 update 则可能短路或改写主链。把两者混淆会导致 guardrail 看似运行,实际没有生效。

正常结束条件#

一次 Middleware / Guardrails 调用正常结束,至少要满足三件事:所有命中的 hook 返回了符合协议的对象;被拒绝或改写的请求没有继续执行高风险 handler;异常、降级和策略命中都能在 state、metadata 或 trace 中留下可复盘的原因。


4. 源码地图、关键文件与阅读顺序#

核心目录#

langgraph/
├── graph/
├── pregel/
├── types.py
└── func/
langchain_core/
├── runnables/
├── tools/
├── retrievers.py
└── messages/

关键文件#

优先级文件核心对象阅读目的
1官方 middleware 文档AgentMiddleware理解稳定扩展契约
2libs/core/langchain_core/tools/base.pyBaseTool理解工具调用协议
3libs/core/langchain_core/runnables/config.pyRunnableConfig理解 metadata/callbacks 传播
4libs/langgraph/langgraph/graph/state.pyStateGraph理解 Agent graph 底层拓扑

推荐阅读顺序#

1. 先读官方概念文档,确认公共契约。
2. 再读公开 API 或装饰器入口。
3. 顺着构建期对象进入 runtime。
4. 追踪一次 invoke / stream / tool call / task call。
5. 单独检查异常、重试、恢复和扩展点。
6. 最后回到旅行规划助手做工程判断。

不建议的阅读顺序#

不建议直接从最底层 private helper 开始读。源码学习的第一目标是建立调用链,而不是收集函数名。先找公开入口,再沿参数和返回值追下去,才不会把内部实现误当成稳定 API。


5. 对象模型、继承关系与协议边界#

核心对象关系#

Public API / Decorator / Tool Protocol
Runtime wrapper
State / Config / Context
Storage / Model / Tool / Graph Runtime

对象职责#

对象生命周期输入输出核心职责
公开入口构建期或调用期用户参数runtime object提供稳定 API
runtime context单次调用config/state下游上下文传递配置、状态和观测信息
协议对象单步执行上游对象下游可消费结果维持模块边界
扩展点构建期注册、运行时触发request/response修改或观察后的结果插入业务控制逻辑

协议边界#

公共 API
负责:给业务代码稳定入口。
不负责:暴露所有内部调度细节。
运行时协议
负责:让状态、配置、工具、模型和持久化协作。
不负责:替业务判断所有策略。
扩展接口
负责:允许业务插入可维护的定制逻辑。
不负责:保证错误扩展仍然安全。

稳定接口与内部实现#

类型对象文章中的使用原则
公共 API官方文档列出的函数、类和装饰器可以用于工程示例
扩展接口middleware、store、retriever、task、Command 等协议说明契约和约束
内部实现private helper、runner、loop 细节只用于解释,不建议业务依赖

6. 源码阅读策略与证据标准#

本篇阅读策略#

先找公开入口
确认输入输出类型
沿调用方追到核心实现
记录状态与对象变化
检查分支、异常和结束条件
回到设计目的

证据等级#

标记含义写作要求
源码事实当前正式版源码可以证明附源码链接
官方契约官方文档或 API Reference 明确说明附官方链接
简化伪代码压缩真实控制流标注不是源码逐字复制
作者推断根据调用链得出的理解明确使用“从调用关系可以推断”
工程建议面向项目实践的建议说明适用条件

本篇证据清单#

结论证据类型文件或文档定位
公共入口存在稳定契约官方契约官方 docs / reference第 14 章链接
核心协议对象存在源码定义源码事实官方源码仓库第 4 章关键文件
运行时分支需要按协议处理作者推断调用链和源码结构第 9 章
工程选型依赖场景边界工程建议旅行规划助手场景第 11 章

7. 构建期源码解剖#

本章回答:middleware=[...] 如何从用户传入的对象列表,变成模型节点、工具节点和错误处理路径周围的 hook stack?

构建期职责#

输入归一化动作构建结果
model统一为可调用 ChatModel / Runnablemodel handler
tools归一化为工具协议tool handler
middleware识别 hook 方法和顺序hook stack
guardrail 配置构造成 middleware 实例运行时 policy 依赖

middleware 列表归一化#

def create_travel_agent():
middleware = [
TravelGuardrail(policy=travel_policy),
DynamicModelMiddleware(default=cheap_model, fallback=strong_model),
ToolErrorMiddleware(max_retries=2),
]
return create_agent(
model=cheap_model,
tools=[search_flights, book_hotel],
middleware=middleware,
)
def normalize_middleware(middleware):
normalized = []
for item in middleware or []:
if isinstance(item, type):
item = item()
if not isinstance(item, AgentMiddleware):
raise TypeError("middleware must be AgentMiddleware")
normalized.append(item)
return normalized

输入对象是用户传入的 middleware 列表,元素可能是实例,也可能是可实例化的类。输出对象是有序的 list[AgentMiddleware]

对象形态改变发生在 item = item() 和类型校验:普通业务配置被固定为有 hook 方法的 middleware 实例。顺序必须保留,因为后续 wrapper stack 会按顺序影响 request 和 response。

读源码时要观察框架是否复制、实例化或复用 middleware。带内部状态的 guardrail 如果被多个 agent 共享,可能引入并发和串线风险。

hook 能力登记#

def collect_agent_hooks(middleware_stack):
hooks = {
"before_model": [],
"after_model": [],
"wrap_model_call": [],
"wrap_tool_call": [],
}
for middleware in middleware_stack:
for hook_name in hooks:
hook = getattr(middleware, hook_name, None)
if hook is not None and is_overridden(middleware, hook_name):
hooks[hook_name].append(hook)
return hooks

输入对象是归一化后的 middleware 实例列表。输出对象是按阶段分组的 hook 表,而不是立即执行的结果。

对象形态改变发生在 hooks[hook_name].append(hook):一个 middleware 对象被拆成多个阶段函数,分别挂到 model 前、model 后、model wrapper 或 tool wrapper。这样 guardrail 可以只实现自己关心的介入点。

读源码时要观察 hook 是“存在方法就登记”,还是“只有覆写默认方法才登记”。如果默认空实现被误登记,可能造成额外开销或改变 wrapper 顺序。

guardrail policy 配置#

class TravelPolicyGuardrail(AgentMiddleware):
def __init__(self, blocked_tools: set[str], requires_confirmation: set[str]):
self.blocked_tools = blocked_tools
self.requires_confirmation = requires_confirmation
def wrap_tool_call(self, request, handler):
tool_name = request.tool.name
if tool_name in self.blocked_tools:
return make_denied_tool_message(request, reason="tool_blocked")
if tool_name in self.requires_confirmation and not request.state.get("user_confirmed"):
return make_denied_tool_message(request, reason="confirmation_required")
return handler(request)

输入对象是业务 policy,例如禁用工具列表、需确认工具列表。输出对象是一个带闭包状态的 AgentMiddleware 实例。

对象形态改变发生在 __init__:外部配置被封装成运行时 hook 能读取的字段。真正的拦截不在构建期发生,而是在 wrap_tool_call 收到具体 request.toolrequest.state 时发生。

读源码时要观察 policy 是否只读。guardrail 配置应尽量是不可变依赖;每次请求的可变信息应该来自 request.stateruntime.context,避免跨请求污染。

构建期产物#

产物保存的信息运行时用途
Agent graphmodel 节点、tools 节点、循环边执行 ReAct 主链
hook stackmiddleware 顺序和阶段before/after/wrap 介入
model handler默认模型调用方式wrap_model_call 包裹
tool handler工具执行方式wrap_tool_call 包裹

8. 运行时主链源码解剖#

本章回答:一次 Agent 调用中,middleware 如何在模型前后、工具前后和异常路径上介入?

运行时总链路#

agent.invoke({"messages": [...]})
恢复 / 初始化 agent state
before_model(state, runtime)
构造 ModelRequest(messages, model, state, runtime)
wrap_model_call(request, handler)
ChatModel 返回 AIMessage
after_model(response/state, runtime)
如果 AIMessage.tool_calls 非空,构造 ToolCallRequest
wrap_tool_call(request, handler)
ToolMessage 写回 state,进入下一轮或结束

before_model hook#

def run_before_model_hooks(hooks, state, runtime):
for hook in hooks["before_model"]:
update = hook(state, runtime)
if update is not None:
validate_state_update(update)
return update
return None

输入对象是当前 Agent state 和 runtime。输出对象有两种:None 表示放行,state update 表示 hook 已经处理本轮,可能短路后续模型调用。

对象形态改变只在返回 update 时发生:guardrail 可以把高风险用户请求变成一条安全 AIMessage,交给 graph 合并进 messages。如果返回 None,state 不应被隐式修改。

读源码时要观察 before hook 的短路语义。请求拦截型 guardrail 适合放在这里,因为它能在模型消耗 token 和产生 tool call 之前拒绝。

wrap_model_call 与 after_model hook#

def call_wrapped_model(request, hooks, base_handler):
handler = base_handler
for wrap in reversed(hooks["wrap_model_call"]):
next_handler = handler
handler = lambda req, wrap=wrap, next_handler=next_handler: wrap(req, next_handler)
response = handler(request)
for hook in hooks["after_model"]:
update = hook(response, request.state, request.runtime)
if update is not None:
validate_state_update(update)
return update
return {"messages": [response]}

输入对象是 ModelRequest,其中包含模型、messages、state 和 runtime。输出对象是 state update。

对象形态改变发生在两处:wrap_model_call 可以改写 request.modelrequest.messages 或捕获 handler 异常;after_model 可以把 AIMessage 改写成安全回答、删除不允许的 tool call,或追加审计消息。

读源码时要观察 wrapper 的嵌套顺序。外层 middleware 能先看 request、后看 response;内层 middleware 更靠近真实模型调用。动态模型选择通常放在 wrapper,输出安全检查通常放在 after hook。

工具调用拦截#

def run_tool_with_guardrails(tool_call, state, runtime, hooks):
request = ToolCallRequest(
tool=runtime.tools[tool_call["name"]],
tool_call=tool_call,
state=state,
runtime=runtime,
)
def base_handler(req):
result = req.tool.invoke(req.tool_call["args"])
return ToolMessage(content=str(result), tool_call_id=req.tool_call["id"])
handler = base_handler
for wrap in reversed(hooks["wrap_tool_call"]):
next_handler = handler
handler = lambda req, wrap=wrap, next_handler=next_handler: wrap(req, next_handler)
return handler(request)

输入对象是模型产生的 tool_call、当前 state 和 runtime。输出对象必须是 ToolMessage 或等价的工具结果消息,这样后续模型轮次才能通过 tool_call_id 对齐。

对象形态改变发生在 ToolCallRequestAIMessage.tool_calls 中的结构化请求被绑定到真实 BaseTool 和当前 state。guardrail 可以在真实工具执行前拒绝、改写参数或要求人工确认。

读源码时要重点看 tool_call_id 是否保留。拒绝工具调用时也应该返回带同一 id 的 ToolMessage,否则后续模型上下文会失去“哪个工具请求得到了哪个结果”的对应关系。

异常与降级路径#

def guarded_model_call(request, handler):
try:
return handler(request)
except RateLimitError:
request.model = request.runtime.fallback_model
return handler(request)
except PolicyViolation as error:
return AIMessage(content=f"请求被策略拒绝:{error.reason}")
except Exception:
request.runtime.callbacks.on_error(...)
raise

输入对象是模型请求和下游 handler。输出对象可能是 fallback 模型的 AIMessage,也可能是安全拒绝消息;不可恢复异常则继续抛出。

对象形态改变发生在异常分支:限流错误被改写成 fallback request,策略错误被改写成可合并的 AIMessage,未知异常保持异常对象向上抛出。不是所有异常都应该吞掉。

读源码时要观察降级是否降低安全标准。fallback 可以换模型或减少功能,但不能绕过原本的 guardrail policy。

运行时主链总结#

State
before_model 可短路
ModelRequest 可被 wrap_model_call 改写
AIMessage 可被 after_model 校验
ToolCallRequest 可被 wrap_tool_call 拦截
ToolMessage / AIMessage 写回 state

9. 关键分支、异常与边界#

本章回答:guardrail 什么时候应该放行、改写、拒绝、重试或降级?这些分支分别改变什么对象?

分支矩阵#

分支类型触发条件介入点返回对象
请求拒绝用户请求违反策略before_model{"messages": [AIMessage]}
请求改写需要脱敏、补系统约束或换模型wrap_model_call改写后的 ModelRequest / AIMessage
输出拦截模型输出不合规或 tool_call 越权after_model安全 state update
工具拒绝工具名或参数不允许wrap_tool_callToolMessage
异常降级限流、超时、临时失败wrapper / handlerfallback response 或 raise

阻断分支#

def deny_unsafe_booking_before_model(state, runtime):
user_text = state["messages"][-1].content
if asks_to_book_without_confirmation(user_text):
return {
"messages": [AIMessage(content="我可以帮你比较选项,但需要你确认后才能预订。")],
"policy_decisions": [{"policy": "booking_confirmation", "action": "blocked"}],
}
return None

输入对象是 state 和 runtime。输出对象是 state update;一旦返回 update,模型节点不应继续执行。

对象形态改变发生在拒绝命中时:用户请求没有被送入模型,而是被转成安全 AIMessage 和可审计的 policy decision。这样既节省调用,也避免模型生成越权 tool call。

读源码时要观察短路返回是否仍会经过 reducer 和 checkpoint。安全拒绝也应该成为对话历史的一部分,否则用户下一轮追问时上下文会断裂。

改写分支#

def redact_and_route_model(request, handler):
request.messages = redact_sensitive_blocks(request.messages)
if requires_stronger_reasoning(request.state):
request.model = request.runtime.models["strong"]
response = handler(request)
response.content = remove_internal_policy_text(response.content)
return response

输入对象是 ModelRequest 和 handler。输出对象是 AIMessage

对象形态改变发生在 request 和 response 两侧:模型调用前,messages 被脱敏或模型被替换;模型调用后,响应被清理。主循环不需要知道这些细节,只消费最终 AIMessage

读源码时要观察改写是否保留必要元数据。比如换模型应写入 metadata 或 trace,脱敏不能破坏 ToolMessage.tool_call_id,否则后续工具对齐会失败。

工具拒绝与工具异常分支#

def guard_tool_call(request, handler):
tool_name = request.tool.name
args = request.tool_call["args"]
if tool_name == "book_hotel" and not request.state.get("user_confirmed"):
return ToolMessage(
content="已拦截:book_hotel 需要用户确认。",
tool_call_id=request.tool_call["id"],
)
try:
return handler(request)
except TimeoutError:
return ToolMessage(
content="工具超时,请稍后重试或改用搜索结果。",
tool_call_id=request.tool_call["id"],
)

输入对象是 ToolCallRequest 和 handler。输出对象是 ToolMessage

对象形态改变发生在拒绝和异常分支:真实工具没有执行,但 Agent 仍获得一条与原 tool call 对齐的观察消息。这样下一轮模型能理解“工具被拦截或失败”,而不是卡在未完成的 tool call。

读源码时要观察哪些异常可以转成 ToolMessage,哪些必须抛出。权限拒绝、超时、可解释的业务失败可以返回消息;序列化错误、状态损坏和未知异常通常应该进入 trace 并抛出。

降级分支#

def fallback_model_on_transient_error(request, handler):
try:
return handler(request)
except RateLimitError:
if request.runtime.fallback_model is None:
raise
downgraded = request.copy(update={"model": request.runtime.fallback_model})
response = handler(downgraded)
response.response_metadata["fallback_reason"] = "rate_limit"
return response

输入对象是模型请求和 handler。输出对象仍是 AIMessage,但 metadata 标记了降级原因。

对象形态改变发生在 downgraded:原请求被复制并替换模型,而不是在未知状态下继续修改同一个对象。这样更容易在 trace 中比较主路径和降级路径。

读源码时要观察 fallback 是否重新经过输出校验。降级后的模型能力可能不同,但 guardrail 标准不能降低。

Retry、Fallback 与恢复边界#

机制适用条件不适用条件幂等要求
Retry临时网络、限流、工具超时已执行外部副作用工具必须可安全重放
Fallback主模型不可用或成本预算不足安全策略要求更高能力fallback 后仍跑校验
Repair输出格式轻微错误工具已经执行且参数错误只修复消息,不重复动作
Human review预订、支付、删除等高风险动作低风险信息检索保留 request、args、policy reason

停止条件与保护上限#

正常结束:hook 放行,模型或工具返回协议对象,state update 被合并。
安全拒绝:before/after/tool guardrail 返回安全消息,并阻止高风险 handler 继续执行。
可恢复降级:限流、超时或临时失败进入 retry/fallback,并记录原因。
不可恢复失败:hook 返回非法对象、state 损坏、未知异常继续向上抛出并进入 trace。

能力边界#

容易误判的能力实际提供者本篇对象的真实职责
middleware 自动让 Agent 安全明确 policy、测试和审计提供 hook 介入点
guardrail 可以只写 system promptmiddleware + tool 权限 + 输出校验在请求和工具边界强制执行
retry 可以修复所有失败幂等工具和错误分类只处理可恢复失败
fallback 可以绕开失败等价安全标准的备用路径降级但不降低安全要求

10. 扩展机制与框架协作#

本章回答:Middleware / Guardrails 如何与工具、Memory、LangSmith 和主 Agent graph 协作,同时避免业务代码依赖内部 runner 细节?

扩展点总览#

扩展点扩展方式执行时机可修改内容约束
before_modelstate hook模型调用前拒绝、补充 state update返回对象必须符合 state schema
wrap_model_callrequest wrapper模型调用周围模型、messages、异常处理必须调用或明确不调用 handler
after_modelresponse hook模型返回后响应校验、tool call 拦截不应破坏消息协议
wrap_tool_calltool request wrapper工具执行周围工具参数、拒绝、重试必须保留 tool_call_id

自定义 AgentMiddleware#

class BudgetMiddleware(AgentMiddleware):
def __init__(self, max_tool_calls: int):
self.max_tool_calls = max_tool_calls
def before_model(self, state, runtime):
used = state.get("tool_call_count", 0)
if used >= self.max_tool_calls:
return {"messages": [AIMessage(content="本轮工具调用次数已达上限。")]}
return None
def wrap_tool_call(self, request, handler):
result = handler(request)
request.state["tool_call_count"] = request.state.get("tool_call_count", 0) + 1
return result

输入对象是构建期的 max_tool_calls 和运行时的 state/request。输出对象要么是 None,要么是 state update 或 ToolMessage

对象形态改变发生在 hook 边界:构建期参数被保存为 middleware 字段,运行时 state 被读取以判断是否短路,工具执行结果仍按工具协议返回。middleware 不需要接触内部 graph runner。

读源码时要观察 hook 是否直接修改可变 state。更稳妥的方式是返回 state update;如果框架传入的是可变 request/state,也要确认这种修改是否被官方契约允许。

与 LangSmith 协作#

def trace_policy_decision(runtime, request, decision):
runtime.config["metadata"] = {
**runtime.config.get("metadata", {}),
"policy_name": decision.policy,
"policy_action": decision.action,
"tool_name": getattr(request.tool, "name", None),
}
def guarded_tool_call(request, handler):
decision = evaluate_tool_policy(request)
trace_policy_decision(request.runtime, request, decision)
if decision.action == "deny":
return ToolMessage(content=decision.reason, tool_call_id=request.tool_call["id"])
return handler(request)

输入对象是 ToolCallRequest 和 policy decision。输出对象是 ToolMessage 或真实工具结果,同时 metadata 获得可观测字段。

对象形态改变发生在 metadata 写入:策略命中不只是自然语言解释,还变成可查询、可聚合的 trace 信息。这样后续可以统计误杀率、降级率和高风险工具命中率。

读源码时要观察 metadata 是否会进入模型上下文。观测数据应该服务调试和审计,不应该被无意注入 prompt,尤其不要记录完整敏感参数。

与 Memory 协作#

class UserPolicyMiddleware(AgentMiddleware):
def wrap_tool_call(self, request, handler):
user_id = request.runtime.config["configurable"]["user_id"]
namespace = ("travel_assistant", user_id, "tool_policy")
policy_items = request.runtime.store.search(namespace, query=request.tool.name, limit=3)
if denies_tool(policy_items, request.tool.name, request.tool_call["args"]):
return ToolMessage(
content="根据你的账户策略,当前工具调用被拦截。",
tool_call_id=request.tool_call["id"],
)
return handler(request)

输入对象是 ToolCallRequest、runtime store 和 config。输出对象是 ToolMessage 或 handler 的真实结果。

对象形态改变发生在 policy_items -> denies_tool(...):长期记忆或账户策略被读取为 store item,再转成一次工具调用的权限判断。权限判断不应只写进 prompt,因为 prompt 不能强制阻止工具执行。

读源码时要观察 store 读取失败的降级策略。安全相关 policy 读取失败时通常应 fail closed;偏好类 memory 读取失败则可以 fail open 并继续回答。

选择扩展还是重写流程#

条件选择 middleware选择重写 graph
拦截模型前输入或模型后输出
拦截单次工具调用
增加人工审批节点和恢复点
改变 ReAct 主循环拓扑
只增加审计 metadata

11. 工程决策与适用场景#

适用场景#

场景是否推荐原因
旅行规划助手生产化需要状态、工具、检索、观测和恢复闭环
一次性脚本问答直接调用模型更简单
高风险工具调用可以加入 guardrail、interrupt、trace
大规模知识问答可以把 retrieval、rerank、grounding 拆清楚
简单静态 FAQ复杂运行时收益不高

工程决策表#

决策点推荐选择前提风险
是否抽象成独立模块有稳定职责时抽象边界清楚过早拆分增加复杂度
是否持久化需要恢复或复盘时持久化有 thread_id / user_id隐私和清理成本
是否加入 guardrail涉及外部工具或敏感数据时加入有明确策略误杀正常请求
是否流式观测用户等待时间长时加入前端能消费事件泄露内部状态

性能、可靠性与安全边界#

性能:额外抽象会增加序列化、检索、trace 和存储成本。
可靠性:可恢复机制要求状态 schema、幂等工具和错误分类清楚。
安全:metadata、state、tool args、retrieved docs 都可能包含敏感信息。
可观测性:至少记录 thread_id、node/tool、策略命中、错误和耗时。

12. 常见误区与源码纠正#

误区:把公共 API 当成全部源码机制#

错误原因:

公开 API 很短,看起来像全部逻辑都在这一层。

源码事实:

公开 API 通常只负责归一化和装配;真正的运行语义在 runtime、协议对象和扩展点之间流转。

工程影响:

只读公开 API 会误判能力边界,导致扩展时依赖错误位置。

误区:把所有中间结果都写进模型上下文#

错误原因:

Agent 运行时的 state、memory、documents、trace 看起来都像“上下文”。

源码事实:

state、store、Document、metadata、trace、prompt messages 是不同协议;只有经过明确注入的内容才进入模型上下文。

工程影响:

如果不区分这些协议,轻则上下文膨胀,重则泄露敏感数据或污染长期记忆。

误区:异常都应该在框架层吞掉#

错误原因:

Agent 产品希望“永远给用户一个答案”。

源码事实:

可恢复异常可以转成 retry、fallback、interrupt;不可恢复异常必须上抛并进入 trace。

工程影响:

吞掉异常会让错误状态继续进入后续节点,调试成本比显式失败高得多。


13. 最终心智模型与掌握检查#

构建期心智模型#

Public Config
Normalize
Build Runtime Object
Attach Protocol / Extension / Storage

运行时心智模型#

Input
Runtime Entry
Context / State / Config
Core Execute
State / Event / Output

分支与异常心智模型#

正常路径 → 返回协议对象并更新必要状态
分支路径 → 路由、降级、拒答、人工确认或恢复
可恢复异常 → retry / fallback / repair / resume
不可恢复异常 → trace + raise
保护上限 → 停止循环、预算或高风险动作

一句话总结#

Middleware / Guardrails通过构建期协议装配形成可运行对象,运行时沿公开入口进入核心执行链,使用分支机制处理边界,并通过扩展点与 LangChain / LangGraph 的状态、工具、模型、存储和观测能力协作。

掌握检查#

  • 能说清公开入口与真实执行入口的区别。
  • 能画出构建期对象关系。
  • 能画出运行时对象流转。
  • 能解释至少一个核心函数的伪代码。
  • 能指出同步、异步、流式或批量路径的边界。
  • 能说明异常在哪里抛出、在哪里处理。
  • 能说明正常结束和保护性终止的区别。
  • 能区分公共 API、扩展接口和内部实现。
  • 能根据旅行规划助手场景判断是否应该使用该抽象。

14. 参考资料与下一篇衔接#

官方概念文档#

  1. LangChain Middleware
    https://docs.langchain.com/oss/python/langchain/middleware

  2. LangChain Agents
    https://docs.langchain.com/oss/python/langchain/agents

  3. LangChain Tools
    https://docs.langchain.com/oss/python/langchain/tools

官方 API Reference#

  1. AgentMiddleware
    https://reference.langchain.com/python/langchain/agents/

  2. BaseTool
    https://reference.langchain.com/python/langchain-core/tools/

官方源码#

  1. libs/core/langchain_core/tools/base.py
    https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/tools/base.py

  2. libs/core/langchain_core/runnables/config.py
    https://github.com/langchain-ai/langchain/blob/langchain-core==1.4.8/libs/core/langchain_core/runnables/config.py

  3. libs/langgraph/langgraph/graph/state.py
    https://github.com/langchain-ai/langgraph/blob/1.2.7/libs/langgraph/langgraph/graph/state.py

下一篇衔接#

下一篇进入:

第 16 篇:Agentic RAG 与 Retrieval Runtime 源码解剖

需要继续回答:

这项能力在旅行规划助手中应该放在哪一层?
它和前一篇能力如何协作?
哪些边界需要通过源码证据确认?
LangChain 源码深潜:Middleware、Guardrails 与 Agent 执行控制机制解剖
https://jupiter-ws.cn/posts/agent-frameworks/15_langchain_agent_middleware_guardrails_source_deep_dive/
作者
Jupiter
发布于
2026-03-19
许可协议
CC BY-NC-SA 4.0