LangChain源码学习路线:第 15 篇 Middleware / Guardrails 与 Agent 执行控制源码解剖
核心问题: LangChain Agent 如何在不改写主循环的前提下,把动态模型选择、工具调用拦截、重试和安全策略插入执行链?
源码主线:
create_agent(..., middleware=[...])→Agent graph→middleware hooks→model/tool request→response / retry / block前置文章: 第 5 篇
create_agent / ReAct、第 14 篇Memory / Store依赖基线:
langgraph==1.2.7、langchain==1.3.11、langchain-core==1.4.8阅读边界: 本文覆盖 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 如何在不改写主循环的前提下,把动态模型选择、工具调用拦截、重试和安全策略插入执行链?
学习目标
完成本篇后,读者必须能够:
- 画出Middleware / Guardrails的构建期对象关系。
- 解释一次运行时调用如何进入主链。
- 说明关键分支、异常和停止条件。
- 区分公共 API、扩展接口和内部实现。
- 根据旅行规划助手场景做工程选型。
能力边界
| 能力 | 本篇是否覆盖 | 说明 |
|---|---|---|
| 构建期对象组装 | 是 | 解释公开参数如何变成运行时对象 |
| 运行时主链 | 是 | 解释 invoke/stream/tool/task 等主路径 |
| 分支与异常 | 是 | 解释失败、降级、重试、终止边界 |
| 扩展协作 | 是 | 解释与相邻框架组件如何组合 |
| 底层供应商实现 | 否 | 不展开模型、数据库或平台内部实现 |
2. 核心概念与最小心智模型
一句话定义
Middleware 是 LangChain Agent 执行链上的可组合拦截层,负责在模型、工具和状态流转前后注入控制逻辑,不负责替代 Agent 主循环。
最小心智模型
create_agent / ReAct 主循环 ↓Middleware / Guardrails ↓Agentic RAG / Retrieval Runtime ↓工程化 Agent 能力核心术语
| 术语 | 源码对象 | 语义 | 不要误解为 |
|---|---|---|---|
| Middleware | AgentMiddleware | 包裹 Agent 执行步骤的 hook 机制 | 业务节点本身 |
| Guardrail | policy hook | 执行前后安全策略 | 模型系统提示词 |
| Dynamic model | model selection hook | 按请求选择模型 | 随机切换模型 |
| Tool retry | tool 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, AgentMiddlewarefrom 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_agent 的 model/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 | 理解稳定扩展契约 |
| 2 | libs/core/langchain_core/tools/base.py | BaseTool | 理解工具调用协议 |
| 3 | libs/core/langchain_core/runnables/config.py | RunnableConfig | 理解 metadata/callbacks 传播 |
| 4 | libs/langgraph/langgraph/graph/state.py | StateGraph | 理解 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 / Runnable | model 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.tool 和 request.state 时发生。
读源码时要观察 policy 是否只读。guardrail 配置应尽量是不可变依赖;每次请求的可变信息应该来自 request.state 或 runtime.context,避免跨请求污染。
构建期产物
| 产物 | 保存的信息 | 运行时用途 |
|---|---|---|
| Agent graph | model 节点、tools 节点、循环边 | 执行 ReAct 主链 |
| hook stack | middleware 顺序和阶段 | 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.model、request.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 对齐。
对象形态改变发生在 ToolCallRequest:AIMessage.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 写回 state9. 关键分支、异常与边界
本章回答:guardrail 什么时候应该放行、改写、拒绝、重试或降级?这些分支分别改变什么对象?
分支矩阵
| 分支类型 | 触发条件 | 介入点 | 返回对象 |
|---|---|---|---|
| 请求拒绝 | 用户请求违反策略 | before_model | {"messages": [AIMessage]} |
| 请求改写 | 需要脱敏、补系统约束或换模型 | wrap_model_call | 改写后的 ModelRequest / AIMessage |
| 输出拦截 | 模型输出不合规或 tool_call 越权 | after_model | 安全 state update |
| 工具拒绝 | 工具名或参数不允许 | wrap_tool_call | ToolMessage |
| 异常降级 | 限流、超时、临时失败 | wrapper / handler | fallback 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 prompt | middleware + tool 权限 + 输出校验 | 在请求和工具边界强制执行 |
| retry 可以修复所有失败 | 幂等工具和错误分类 | 只处理可恢复失败 |
| fallback 可以绕开失败 | 等价安全标准的备用路径 | 降级但不降低安全要求 |
10. 扩展机制与框架协作
本章回答:Middleware / Guardrails 如何与工具、Memory、LangSmith 和主 Agent graph 协作,同时避免业务代码依赖内部 runner 细节?
扩展点总览
| 扩展点 | 扩展方式 | 执行时机 | 可修改内容 | 约束 |
|---|---|---|---|---|
before_model | state hook | 模型调用前 | 拒绝、补充 state update | 返回对象必须符合 state schema |
wrap_model_call | request wrapper | 模型调用周围 | 模型、messages、异常处理 | 必须调用或明确不调用 handler |
after_model | response hook | 模型返回后 | 响应校验、tool call 拦截 | 不应破坏消息协议 |
wrap_tool_call | tool 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. 参考资料与下一篇衔接
官方概念文档
-
LangChain Middleware
https://docs.langchain.com/oss/python/langchain/middleware -
LangChain Agents
https://docs.langchain.com/oss/python/langchain/agents -
LangChain Tools
https://docs.langchain.com/oss/python/langchain/tools
官方 API Reference
官方源码
-
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 -
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 -
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 源码解剖需要继续回答:
这项能力在旅行规划助手中应该放在哪一层?它和前一篇能力如何协作?哪些边界需要通过源码证据确认?