把 Agent 从"能跑"变成"可控":LangChain v1 中间件实战
很多 Agent 项目在演示阶段表现不错:模型能够理解问题、选择工具并返回答案。但一旦进入真实业务环境,问题很快就会暴露出来:对话越聊越长、工具偶尔报错、不同用户需要不同权限、模型输出必须经过审核,而且每一次调用都需要记录和监控。
如果把这些逻辑全部塞进 Agent 主流程,代码会迅速变得难以维护。LangChain v1 的中间件机制提供了更清晰的解决方案:在不改动核心推理循环的情况下,拦截 Agent 生命周期中的关键节点,对上下文、模型调用、工具执行和最终结果进行统一处理。
本文将系统介绍 LangChain Agent 中间件的工作方式,并结合对话摘要、内容审核、自定义钩子、状态更新和流程跳转等场景,说明如何让 Agent 具备真正面向生产环境的可控性。
一、中间件解决了什么问题
一个典型的 Agent 执行过程大致如下:
text
用户输入
↓
调用模型进行推理
↓
模型判断是否调用工具
├─ 否 → 生成最终回答
└─ 是 → 执行工具 → 将结果交给模型继续推理
中间件可以介入这条链路的多个位置:
text
用户输入
↓
[模型调用前]
↓
模型推理
↓
[模型调用后]
↓
[工具执行前] → 工具执行 → [工具执行后]
↓
[Agent 结束前]
↓
最终回答
这些拦截点可以处理大量与业务流程无关、但对系统稳定性至关重要的横切需求。
| 介入位置 | 常见用途 |
|---|---|
| Agent 启动前 | 初始化状态、身份校验、记录请求 |
| 模型调用前 | 动态提示词、模型切换、工具过滤、上下文注入 |
| 模型调用后 | 输出校验、调用计数、日志记录、提前结束 |
| 工具执行前 | 权限检查、参数校验、审计记录 |
| 工具执行后 | 异常转换、结果审核、状态写入 |
| Agent 结束后 | 指标统计、资源清理、最终结果处理 |
中间件的价值可以概括为四点:
- 非侵入式控制:无需修改 Agent 的核心循环,就能干预模型和工具调用。
- 可观测性:通过钩子收集运行日志、调用次数、Token 用量和耗时数据。
- 可靠性增强:集中实现重试、降级、限流、异常处理和内容安全策略。
- 能力复用:把通用逻辑封装成独立组件,在多个 Agent 中组合使用。
对于复杂应用来说,中间件并不是锦上添花的功能,而是将原型系统推进到生产环境的重要基础设施。
二、动态控制模型与工具
Agent 的失败不一定来自模型能力不足。很多时候,真正的问题是模型没有获得正确的上下文,或者看到了不应该使用的工具。
根据对话复杂度切换模型
短对话可以使用成本较低、响应更快的模型;当消息数量增加、任务变复杂时,再切换到能力更强的模型。
python
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
@wrap_model_call
def dynamic_model_selection(
request: ModelRequest,
handler,
) -> ModelResponse:
message_count = len(request.state["messages"])
model = advanced_model if message_count > 10 else basic_model
return handler(request.override(model=model))
request.override() 会生成一份修改后的请求。Agent 主流程不需要知道模型何时发生了切换,只负责继续执行。
根据状态过滤工具
如果某些工具只能由已认证用户调用,可以在模型看到工具列表之前完成过滤。
python
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
@wrap_model_call
def filter_tools_by_auth(
request: ModelRequest,
handler,
) -> ModelResponse:
authenticated = request.state.get("authenticated", False)
if not authenticated:
public_tools = [
tool for tool in request.tools
if tool.name.startswith("public_")
]
request = request.override(tools=public_tools)
return handler(request)
这种方式有两个好处:一是减少无关工具对模型决策的干扰,二是避免仅靠提示词声明权限。真正的权限控制应该落实在程序逻辑中,而不是寄希望于模型"自觉遵守"。
三、用对话摘要控制上下文长度
长对话会持续占用上下文窗口。直接删除早期消息虽然节省 Token,却可能让 Agent 忘记用户偏好、关键决策和未完成任务。
SummarizationMiddleware 的思路是:当对话达到指定阈值时,调用一个摘要模型压缩较早的消息,再把"历史摘要 + 最近消息"交给主模型。
text
完整历史消息
↓
检查 Token 或消息数量
↓ 达到阈值
摘要早期消息
↓
历史摘要 + 最近若干条完整消息
↓
交给主模型继续推理
两个核心参数
摘要行为主要由 trigger 和 keep 控制。
trigger 决定何时触发摘要:
| 配置方式 | 含义 | 示例 |
|---|---|---|
("tokens", n) |
Token 数超过阈值时触发 | ("tokens", 4000) |
("messages", n) |
消息数量超过阈值时触发 | ("messages", 20) |
("fraction", x) |
上下文使用率超过指定比例时触发 | ("fraction", 0.8) |
keep 决定摘要后保留多少近期内容:
| 配置方式 | 含义 | 示例 |
|---|---|---|
("messages", n) |
保留最近 n 条完整消息 | ("messages", 20) |
("tokens", n) |
将保留内容控制在指定 Token 范围 | ("tokens", 2000) |
("fraction", x) |
将上下文压缩到窗口的指定比例 | ("fraction", 0.3) |
基础配置
python
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
model="gpt-5-mini",
tools=[weather_tool, calculator_tool],
checkpointer=InMemorySaver(),
middleware=[
SummarizationMiddleware(
model="gpt-4o-mini",
trigger=("tokens", 4000),
keep=("messages", 20),
)
],
)
这里使用较轻量的模型负责摘要,可以降低额外成本。检查点则负责保存同一会话的状态,调用时需要传入稳定的 thread_id:
python
config = {"configurable": {"thread_id": "user-session-001"}}
result = agent.invoke(
{"messages": [{"role": "user", "content": "继续刚才的话题"}]},
config=config,
)
多条件触发
trigger 可以接收多个条件,只要其中一个满足就会执行摘要。
python
SummarizationMiddleware(
model="gpt-4o-mini",
trigger=[
("tokens", 3000),
("messages", 20),
],
keep=("messages", 10),
)
如果应用会在不同上下文窗口的模型之间切换,可以使用比例配置:
python
SummarizationMiddleware(
model="gpt-4o-mini",
trigger=("fraction", 0.8),
keep=("fraction", 0.3),
)
生产环境中还应关注以下问题:
- 摘要模型不必与主模型相同,通常可以选择更快、更便宜的模型。
- 摘要提示词应明确要求保留用户偏好、关键事实、决策和待办事项。
- 阈值过低会频繁触发摘要,增加延迟和成本;阈值过高则可能逼近上下文上限。
- 摘要是一种有损压缩,关键业务数据不应只存在于自然语言历史中,还应写入结构化状态或数据库。
四、用内容审核建立安全边界
面向用户开放的 Agent 不仅要检查输入,还要考虑模型输出和工具返回结果。工具可能读取外部网页、用户文档或第三方接口,这些内容同样可能包含不安全信息。
OpenAIModerationMiddleware 可以在多个位置执行内容审核:
python
from langchain_openai.middleware import OpenAIModerationMiddleware
moderation = OpenAIModerationMiddleware(
model="omni-moderation-latest",
check_input=True,
check_output=True,
check_tool_results=True,
exit_behavior="end",
violation_message="请求或响应触发了内容安全策略。",
)
主要配置项如下:
| 参数 | 作用 |
|---|---|
check_input |
在调用主模型前审核用户输入 |
check_output |
在返回结果前审核模型输出 |
check_tool_results |
审核工具执行结果 |
exit_behavior |
定义检测到违规内容后的处理方式 |
violation_message |
自定义拦截或替换消息 |
三种违规处理策略
end:立即结束
检测到违规后停止后续执行,直接返回提示消息。它适合大多数对外服务,行为明确,安全边界也最容易理解。
python
OpenAIModerationMiddleware(
check_input=True,
check_output=True,
exit_behavior="end",
)
error:抛出异常
中间件抛出 OpenAIModerationError,由上层应用决定如何记录日志、触发告警或转换为统一的 API 响应。
python
from langchain_openai.middleware import (
OpenAIModerationError,
OpenAIModerationMiddleware,
)
agent = create_agent(
model="gpt-5-mini",
tools=tools,
middleware=[
OpenAIModerationMiddleware(
check_input=True,
check_output=True,
check_tool_results=True,
exit_behavior="error",
violation_message="内容违反安全策略:{categories}",
)
],
)
try:
result = agent.invoke({"messages": messages})
except OpenAIModerationError as exc:
logger.warning("Moderation blocked request: %s", exc)
这种方式更适合已经建立统一异常处理和监控体系的后端服务。
replace:替换后继续
违规内容会被替换为指定文本,然后 Agent 继续运行。
python
OpenAIModerationMiddleware(
check_input=True,
exit_behavior="replace",
violation_message="[部分内容已根据安全策略移除]",
)
替换模式能保持对话连续,但需要谨慎使用。原始内容被替换后,语义可能不完整,模型也可能无法准确理解用户意图。高风险场景通常更适合直接结束或抛出异常。
五、自定义中间件的两种钩子风格
LangChain 提供节点风格和包装风格两类钩子。二者的差异不只是写法不同,更重要的是控制能力不同。
节点风格:在固定时机执行逻辑
节点风格钩子按生命周期顺序执行,适合日志、计数、校验和简单状态更新。
| 钩子 | 触发时机 |
|---|---|
before_agent |
Agent 启动前,只执行一次 |
before_model |
每次调用模型前 |
after_model |
每次模型返回后 |
after_agent |
Agent 结束后,只执行一次 |
python
from typing import Any
from langchain.agents import AgentState
from langchain.agents.middleware import before_agent, after_model
from langgraph.runtime import Runtime
@before_agent
def log_request(
state: AgentState,
runtime: Runtime,
) -> dict[str, Any] | None:
print("Agent started")
return None
@after_model
def count_model_calls(
state: AgentState,
runtime: Runtime,
) -> dict[str, Any]:
return {
"model_call_count": state.get("model_call_count", 0) + 1
}
节点钩子返回字典时,LangChain 会将其合并到 Agent 状态中。
包装风格:完全控制一次调用
包装风格钩子会包裹模型或工具调用,可以决定是否调用、调用几次、使用哪些参数,以及如何处理返回值。
| 钩子 | 包裹对象 | 典型用途 |
|---|---|---|
wrap_model_call |
模型调用 | 重试、缓存、模型切换、请求改写 |
wrap_tool_call |
工具调用 | 异常转换、权限控制、审计、结果处理 |
下面的中间件为模型调用增加最多三次尝试:
python
from collections.abc import Callable
from langchain.agents.middleware import (
ModelRequest,
ModelResponse,
wrap_model_call,
)
@wrap_model_call
def retry_model(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
for attempt in range(3):
try:
return handler(request)
except Exception:
if attempt == 2:
raise
raise RuntimeError("unreachable")
工具异常也可以被转换为模型能够理解的 ToolMessage,避免单次工具故障让整个 Agent 崩溃:
python
from collections.abc import Callable
from langchain.agents.middleware import wrap_tool_call
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
@wrap_tool_call
def handle_tool_error(
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
try:
return handler(request)
except Exception as exc:
return ToolMessage(
content=f"工具执行失败,请检查参数后重试:{exc}",
tool_call_id=request.tool_call["id"],
)
需要注意的是,不应把内部堆栈、数据库信息或密钥直接暴露给模型和最终用户。生产系统通常要先记录完整异常,再返回经过脱敏的简短说明。
六、装饰器与类式中间件如何选择
当一段逻辑只有一个明确职责时,装饰器写法最直接。例如单独记录日志、统计模型调用次数或包装工具异常。
如果一个中间件需要同时实现多个钩子,类式写法会更容易组织:
python
from collections.abc import Callable
from typing import Any
from langchain.agents import AgentState
from langchain.agents.middleware import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
from langgraph.runtime import Runtime
class ObservabilityMiddleware(AgentMiddleware):
def before_agent(
self,
state: AgentState,
runtime: Runtime,
) -> dict[str, Any] | None:
print("Agent started")
return None
def after_agent(
self,
state: AgentState,
runtime: Runtime,
) -> dict[str, Any] | None:
print("Agent finished")
return None
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
print("Calling model")
response = handler(request)
print("Model completed")
return response
注册时传入实例即可:
python
agent = create_agent(
model="gpt-5-mini",
tools=tools,
middleware=[ObservabilityMiddleware()],
)
简单来说:单一钩子优先使用装饰器;需要共享配置、内部成员或多个钩子协作时,使用类式中间件。
七、多个中间件的执行顺序
中间件顺序会直接影响系统行为。假设注册顺序如下:
python
middleware=[middleware_a, middleware_b]
节点风格的前置钩子按注册顺序执行,后置钩子按相反顺序返回:
text
A.before → B.before → 模型调用 → B.after → A.after
包装风格类似嵌套函数,前面的中间件位于外层:
text
A 进入
B 进入
执行模型或工具
B 返回
A 返回
这意味着列表顺序不是无关紧要的配置细节。例如,安全审核、认证和审计通常应尽早介入;缓存放在审核之前还是之后,也会影响未经审核的内容是否可能进入缓存。
一个实用原则是:先明确每个中间件保护或改变的对象,再决定它位于调用链的内层还是外层。
八、扩展状态并记录运行数据
AgentState 是默认状态结构。自定义中间件可以扩展专属字段,让多个钩子共享数据。
python
from typing import NotRequired
from langchain.agents import AgentState
class TrackingState(AgentState):
model_call_count: NotRequired[int]
last_model_call_tokens: NotRequired[int]
节点风格钩子适合返回简单的状态更新:
python
from typing import Any
from langchain.agents.middleware import after_model
from langgraph.runtime import Runtime
@after_model(state_schema=TrackingState)
def add_model_call_count(
state: TrackingState,
runtime: Runtime,
) -> dict[str, Any]:
return {
"model_call_count": state.get("model_call_count", 0) + 1
}
包装风格钩子需要同时返回模型响应和状态更新时,可以使用 ExtendedModelResponse 与 Command:
python
from collections.abc import Callable
from langchain.agents.middleware import (
ExtendedModelResponse,
ModelRequest,
ModelResponse,
wrap_model_call,
)
from langgraph.types import Command
@wrap_model_call(state_schema=TrackingState)
def track_token_usage(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ExtendedModelResponse:
response = handler(request)
tokens = response.result[-1].response_metadata[
"token_usage"
]["completion_tokens"]
return ExtendedModelResponse(
model_response=response,
command=Command(
update={"last_model_call_tokens": tokens}
),
)
对于带 reducer 的字段,例如消息列表,多次更新会按照 reducer 规则合并;普通自定义字段发生冲突时,则需要关注中间件嵌套顺序带来的覆盖结果。
九、在满足条件时提前结束或跳转
节点风格钩子不仅能更新状态,还可以通过 jump_to 改变执行方向。常见目标包括:
"end":结束 Agent,并继续触发结束钩子。"tools":跳转到工具节点。"model":重新进入模型节点。
钩子必须通过 hook_config 显式声明允许跳转到哪些目标,否则跳转请求会被忽略。
python
from typing import Any
from langchain.agents import AgentState
from langchain.agents.middleware import after_model, hook_config
from langchain_core.messages import AIMessage
from langgraph.runtime import Runtime
@after_model
@hook_config(can_jump_to=["end"])
def stop_when_policy_blocks(
state: AgentState,
runtime: Runtime,
) -> dict[str, Any] | None:
last_message = state["messages"][-1]
if "POLICY_BLOCKED" in last_message.content:
return {
"messages": [AIMessage("当前请求无法继续处理。")],
"jump_to": "end",
}
return None
流程跳转只适用于节点风格钩子。包装风格钩子的控制方式是决定是否调用 handler,不能使用 jump_to。
十、面向生产环境的设计原则
中间件能力很强,但过度堆叠也会让调用链难以理解。实践中可以遵循以下原则。
保持单一职责
日志、审核、重试、缓存和权限控制尽量拆成不同中间件。每个组件只解决一类问题,更容易测试和复用。
优先优雅降级
监控或日志中间件自身发生异常时,通常不应让业务 Agent 一起崩溃。对于非关键功能,可以捕获异常并降级;对于认证和安全审核等关键功能,则应采用失败即关闭的策略。
根据控制需求选择钩子
- 日志、计数、状态标记适合节点风格。
- 重试、缓存、动态模型和动态工具适合包装风格。
- 需要同时处理多个阶段并共享配置时,适合类式中间件。
单独测试后再组合
先验证每个中间件的输入、输出、异常和状态更新,再测试多个中间件组合后的顺序。尤其要覆盖模型失败、工具超时、审核拦截和摘要触发等分支。
关注可观测性成本
记录所有原始提示词和模型输出可能带来隐私风险,也会增加日志存储成本。日志中间件应具备脱敏、采样和分级能力。
优先使用成熟实现
对于摘要、内容审核等通用能力,优先使用框架提供的预构建中间件。只有当默认行为不能满足业务要求时,再实现自定义版本。
结语
Agent 的核心循环解决的是"如何推理和调用工具",中间件解决的则是"如何让这套能力长期、稳定、安全地运行"。
通过中间件,我们可以动态选择模型和工具、压缩长对话、审核输入输出、统一处理异常、记录运行指标、扩展状态,甚至改变执行方向。更重要的是,这些能力能够与业务逻辑保持解耦,以独立组件的形式组合和复用。
当 Agent 从实验代码走向真实系统时,真正决定质量的往往不是又增加了多少工具,而是是否建立了清晰的控制边界。中间件正是这条边界的主要承载方式。