【LangChain中间件01】—— LangChain 中间件入门:六个钩子让 Agent 可插拔

LangChain 中间件入门:六个钩子让 Agent 可插拔

搞 LangChain Agent 的人多少都撞过这面墙:想给 Agent 加点日志、做权限校验、过滤敏感词、统计调用次数,结果要么往 create_agent 里塞一堆参数,要么直接改 Agent 源码,改完还不敢升版本。LangChain 的 Middleware(中间件)就是来解开这个死结的------不动 Agent 一行代码,在执行的各个环节插进你自己的逻辑。本文带你从零看懂中间件是什么、六个钩子各管什么,并用能直接跑通的例子把最常用的四个观察型钩子用起来。


一、先厘清:Middleware 到底在解决什么

先说结论:中间件是挂在 Agent 执行流程上的钩子(Hook),让你在特定时间点插入自定义逻辑,而不需要修改 Agent 本身的代码。

要理解它,先得看清 Agent 的执行循环长什么样。create_agent 本质上是一个基于 LangGraph 的 ReAct 循环,核心链路就五步:

复制代码
1. 用户输入
2. 模型思考
3. 可能调用工具
4. 模型再思考
5. 输出结果

这个循环能力很强,但它很"黑盒"------你很难在这五步之间塞逻辑。以前的做法是继承 Agent 类去改,或者干脆换用 LangGraph 显式画图。代价都很高。

中间件就是这层"可插拔胶水"。它像 Web 开发里的中间件一样,是个小插件,在请求(这里指 Agent 循环)的各个关口拦截、追加逻辑。官方文档把它分成三类:

类别 钩子 说明
Node-style before_agentbefore_modelafter_modelafter_agent 在固定执行点顺序运行,偏"观察/注入"
Wrap-style wrap_model_callwrap_tool_call 包裹真实调用,偏"接管/控制"
Convenience dynamic_prompt 动态生成 system prompt 的快捷钩子

🔴 重点:Node-style 是"看"和"加",Wrap-style 是"控"。 前者在模型/工具调用前后跑一段代码,后者直接把调用包起来,可以决定"要不要真的调、调几次、用什么调"。你先掌握前者的思维,进阶篇再进入后者。

让我把这些钩子挂到执行循环上看,就一目了然了:

复制代码
1. 用户输入
   ↓ [before_agent: 一次性。初始化、权限检查、输入预处理]
2. 模型思考
   ↓ [before_model: 每次循环。消息裁剪、内容过滤、上下文注入]
   ↓ [wrap_model_call: 每次循环。重试、降级、缓存、改写请求]
   ↓ [after_model: 每次循环。响应审核、内容追加、日志]
3. 工具执行
   ↓ [wrap_tool_call: 每次工具调用。工具重试、结果缓存、参数改写]
4. 回到模型思考(循环直到完成)
   ↓ [after_agent: 一次性。结果格式化、统计、清理]
5. 输出结果

二、六个钩子全景:一张表看懂

钩子 执行频率 执行位置 主要用途
before_agent 一次 Agent 开始前 初始化、权限检查、输入预处理
before_model 每次循环 模型调用前 消息预处理、动态上下文注入
wrap_model_call 每次循环 包裹模型调用 重试、降级、缓存、请求改写
after_model 每次循环 模型调用后 内容审核、响应过滤、日志
wrap_tool_call 每次工具调用 包裹工具执行 工具重试、结果缓存、参数改写
after_agent 一次 Agent 结束后 格式化输出、统计、清理资源

这里的关键记忆点是执行频率

  • Agent 级before_agent / after_agent):每个问题只跑一次,是整个"会话"的入口和出口。
  • 循环级before_model / after_model):每调一次模型就跑一次。一个需要工具的提问会调两次模型(先思考要不要调工具、再整合结果),所以这两个钩子也会各跑两次。
  • 调用级wrap_tool_call):每个工具真正执行一次就跑一次。

✅ 判断标准:"这个问题要不要算一次完整会话?" 决定用哪个层级。记录会话总次数用 Agent 级;记录每次模型调用、做上下文控制用循环级。


三、两种使用方式

3.1 装饰器(推荐,最直观)

装饰器方式最大优点是简单:函数签名统一是 (state, runtime),你要改消息就操作 state,要对外发事件就用 runtimeruntime相关内容参考:langchain-runtime

python 复制代码
from langchain.agents.middleware import before_model, after_model

@before_model
def log_before(state, runtime):
    """在每次模型调用前记录日志"""
    msg_count = len(state.get("messages", []))
    print(f"[before_model] 当前消息数: {msg_count}")
    return None

@after_model
def log_after(state, runtime):
    """在每次模型调用后记录日志"""
    last_msg = state["messages"][-1] if state.get("messages") else None
    if last_msg and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
        print(f"[after_model] 模型请求了工具调用")
    return None

3.2 类继承(复杂逻辑、需要状态时)

当你要在多个钩子之间共享状态、或逻辑很重时,继承 AgentMiddleware 更清晰:

python 复制代码
from langchain.agents.middleware import AgentMiddleware

class LoggingMiddleware(AgentMiddleware):
    """自定义日志中间件"""

    @property
    def name(self) -> str:
        return "logging"

    def before_agent(self, state, runtime):
        print("[Logging] Agent 开始执行")
        return None

    def before_model(self, state, runtime):
        msg_count = len(state.get("messages", []))
        print(f"[Logging] 准备调用模型,当前 {msg_count} 条消息")
        return None

    def after_model(self, state, runtime):
        print("[Logging] 模型调用完成")
        return None

    def after_agent(self, state, runtime):
        print("[Logging] Agent 执行结束")
        return None

💡 name 属性可自定义中间件名称(默认是类名),在多中间件日志里用来区分谁是谁。

两种方式都能通过 create_agent(middleware=[...]) 挂载。若可读性优先用装饰器;若逻辑复杂、钩子多、要跨钩子记状态,用类。


四、Agent 级钩子:@before_agent 与 @after_agent

这两个钩子各执行一次,是完整 Agent 会话的开头和结尾。适合做一次性的准备工作、收尾统计。

4.1 @before_agent:开始前的准备

能力:输入预处理、用户信息验证、资源初始化、权限检查。

场景一:输入预处理------自动修正用户输入
python 复制代码
from dotenv import load_dotenv
load_dotenv()

from langchain.agents import create_agent
from langchain.agents.middleware import before_agent
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langchain.tools import tool

@before_agent
def preprocess_input(state, runtime):
    """在 Agent 开始前处理用户输入"""
    messages = state.get("messages", [])
    if not messages:
        return None
    last_msg = messages[-1]
    content = str(last_msg.content) if hasattr(last_msg, 'content') else ""
    # 这里可以做清洗、归一化、补全上下文等
    return None

@tool
def search_course(keyword: str) -> str:
    """在菜鸟教程 RUNOOB 搜索课程"""
    courses = {
        "python": "Python3 基础教程(免费,30章)",
        "html": "HTML 基础教程(免费,25章)",
    }
    return courses.get(keyword.lower(), f"未找到 {keyword} 相关课程")

model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0)
agent = create_agent(
    model=model,
    tools=[search_course],
    middleware=[preprocess_input],
    system_prompt="你是菜鸟教程 RUNOOB 的课程顾问。",
)

result = agent.invoke({"messages": [HumanMessage(content="Python 课程")]})
print(f"回复: {result['messages'][-1].content}")
场景二:访问控制------权限检查

before_agent 最实用的场景------在 Agent 真正开始前就拦截,避免浪费模型调用。返回带 jump_to="end" 的字典可以提前终止整个 Agent。

python 复制代码
from langchain.agents.middleware import before_agent
from langchain.messages import HumanMessage

@before_agent
def access_control(state, runtime):
    """检查用户是否有权限使用 Agent"""
    context = runtime.context
    if context is None:
        return None
    user_role = context.get("user_role", "guest")

    if user_role == "guest":
        messages = state.get("messages", [])
        if messages:
            last_content = str(messages[-1].content)
            restricted_keywords = ["删除", "管理", "配置", "admin"]
            if any(kw in last_content for kw in restricted_keywords):
                return {
                    "jump_to": "end",
                    "messages": [HumanMessage(
                        content="您当前的权限不足,无法执行此操作。请登录后重试。"
                    )]
                }
    return None

4.2 @after_agent:完成后的处理

能力:格式化最终输出、记录统计信息、清理资源。

场景三:统计分析------记录对话数据

这个场景引入了 runtime.stream_writer,用来在流式输出里推送自定义事件。要接收它,需用 agent.stream(..., stream_mode=["updates", "custom"])

python 复制代码
from langchain.agents.middleware import after_agent

@after_agent
def conversation_stats(state, runtime):
    """统计对话信息并追加到结果中"""
    messages = state.get("messages", [])

    model_calls = 0
    tool_calls = 0
    total_chars = 0

    for msg in messages:
        if msg.type == "ai":
            model_calls += 1
        if hasattr(msg, 'tool_calls') and msg.tool_calls:
            tool_calls += len(msg.tool_calls)
        if hasattr(msg, 'content') and msg.content:
            total_chars += len(str(msg.content))

    runtime.stream_writer({
        "type": "stats",
        "model_calls": model_calls,
        "tool_calls": tool_calls,
        "total_messages": len(messages),
        "total_chars": total_chars,
    })
    return None

# 使用 stream_mode=["updates", "custom"] 接收自定义事件
for mode, chunk in agent.stream(
    {"messages": [HumanMessage(content="查一下 Python 课程")]},
    stream_mode=["updates", "custom"],
):
    if mode == "custom" and chunk.get("type") == "stats":
        print(f"统计信息: {chunk}")

运行结果:

复制代码
统计信息: {'type': 'stats', 'model_calls': 2, 'tool_calls': 1,
            'total_messages': 4, 'total_chars': 127}
场景四:格式化输出------统一回复风格

after_agent 里可以替换/追加最终消息,比如给每次回答统一加个页脚:

python 复制代码
from langchain.agents.middleware import after_agent
from langchain.messages import AIMessage

@after_agent
def format_output(state, runtime):
    """在结果中追加格式化的总结信息"""
    messages = state.get("messages", [])
    if not messages:
        return None

    last_ai = None
    for msg in reversed(messages):
        if msg.type == "ai" and msg.content:
            last_ai = msg
            break

    if last_ai:
        tool_msgs = [m for m in messages if m.type == "tool"]
        tool_count = len(tool_msgs)
        footer = (
            f"\n\n---\n"
            f"> 本次对话共进行 {len(messages)} 条消息,"
            f"调用了 {tool_count} 次工具。\n"
            f"> 由菜鸟教程 RUNOOB AI 助手提供支持。"
        )
        return {
            "messages": [AIMessage(content=last_ai.content + footer)]
        }
    return None

运行结果:

复制代码
菜鸟教程 RUNOOB 中有 Python3 基础教程,共30章,完全免费,非常适合 Python 初学者入门学习。

---
> 本次对话共进行 4 条消息,调用了 1 次工具。
> 由菜鸟教程 RUNOOB AI 助手提供支持。

五、模型级钩子:@before_model 与 @after_model

这两个是最常用的 钩子------它们在每次模型调用前后执行,天然适合做内容把关和上下文管理。注意它是循环级的,一个带工具的提问会触发两次。

5.1 @before_model:模型调用前拦截

能力:修改消息、注入上下文条件、或直接跳过模型调用。

场景一:消息预处理------限制对话长度

长对话会把上下文撑爆、白白烧 token。在 before_model 里裁剪历史消息是最常见的做法:

python 复制代码
from dotenv import load_dotenv
load_dotenv()

from langchain.agents import create_agent
from langchain.agents.middleware import before_model
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage

@before_model
def limit_context(state, runtime):
    """限制消息历史长度,防止上下文过长"""
    messages = state.get("messages", [])

    MAX_MESSAGES = 6
    if len(messages) > MAX_MESSAGES:
        trimmed = messages[-MAX_MESSAGES:]
        if trimmed and trimmed[0].type != "human":
            trimmed = trimmed[1:]
        return {"messages": trimmed}
    return None

model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0)
agent = create_agent(
    model=model,
    middleware=[limit_context],
    system_prompt="你是菜鸟教程 RUNOOB 的助手。",
)

result = agent.invoke({"messages": [
    HumanMessage(content="第一轮"),
    HumanMessage(content="第二轮"),
    HumanMessage(content="第三轮"),
    HumanMessage(content="第四轮"),
    HumanMessage(content="第五轮"),
    HumanMessage(content="第六轮"),
    HumanMessage(content="第七轮"),
]})
print(f"消息数: {len(result['messages'])}")
print(f"回复: {result['messages'][-1].content}")

运行结果:

复制代码
消息数: 8
回复: 你好!看起来你正在进行多轮对话测试。有什么可以帮你的吗?
场景二:内容过滤------屏蔽敏感词

配合 jump_to="end" 可以在模型介入前直接拦截,既安全又省钱:

python 复制代码
from langchain.agents.middleware import before_model

SENSITIVE_WORDS = ["密码", "银行卡号", "身份证号"]

@before_model
def content_filter(state, runtime):
    """检查用户消息是否包含敏感词,如果包含则拦截"""
    messages = state.get("messages", [])
    if not messages:
        return None

    last_msg = messages[-1]
    content = str(last_msg.content) if hasattr(last_msg, 'content') else ""

    for word in SENSITIVE_WORDS:
        if word in content:
            print(f"[拦截] 检测到敏感词: {word}")
            return {
                "jump_to": "end",
                "messages": [HumanMessage(
                    content=f"抱歉,为了安全,不能处理包含「{word}」的请求。"
                )]
            }
    return None

5.2 @after_model:模型调用后处理

能力:审核模型输出、提取关键信息、追加后续指令。

场景三:响应内容审核

after_model 可以替换模型输出------比如检测到回复涉及敏感话题时,用预设话术覆盖:

python 复制代码
from langchain.agents.middleware import after_model

FORBIDDEN_TOPICS = ["政治", "暴力", "色情"]

@after_model
def response_audit(state, runtime):
    """审核模型回复,如果涉及禁止话题则替换"""
    messages = state.get("messages", [])
    if not messages:
        return None

    last_msg = messages[-1]
    content = str(last_msg.content) if hasattr(last_msg, 'content') else ""

    for topic in FORBIDDEN_TOPICS:
        if topic in content:
            runtime.stream_writer({
                "type": "warning",
                "message": f"检测到回复包含「{topic}」相关内容,已被替换"
            })
            from langchain.messages import AIMessage
            return {
                "messages": [AIMessage(
                    content="抱歉,我无法回答这个问题。请询问编程学习相关的内容。"
                )]
            }
    return None
场景四:自动追加提示信息

这是个高频需求------每次模型回复后统一加个免责声明。注意判断条件:只在最终回复 (没有 tool_calls 时)追加,避免把页脚加到工具调用中间环节的消息上。

python 复制代码
from langchain.agents.middleware import after_model
from langchain.messages import AIMessage

@after_model
def append_disclaimer(state, runtime):
    """在每次模型回复后自动追加免责声明"""
    messages = state.get("messages", [])
    if not messages:
        return None

    last_msg = messages[-1]

    if (last_msg.type == "ai"
        and last_msg.content
        and not (hasattr(last_msg, 'tool_calls') and last_msg.tool_calls)):
        return {
            "messages": [AIMessage(
                content=(
                    last_msg.content
                    + "\n\n---\n*以上内容由菜鸟教程 RUNOOB AI 助手生成,仅供参考。*"
                )
            )]
        }
    return None

⚠️ 细节点:after_model 返回的 messages 走的是 add_messages reducer(追加而非覆盖)。所以上面用"替换为一条新的 AIMessage"等价于"把最后一条 AI 回复换成带页脚的新回复"。这个机制在下一节统一讲。


六、Middleware 的返回值机制

这一节是理解中间件"能不能改状态、能不能控流程"的核心。中间件的返回值决定了后续行为:

返回值 效果 示例
None 不修改任何状态,继续正常流程 纯日志记录
dict 更新 Agent 状态(合并到当前状态) return {"custom_field": "value"}
jump_to 的 dict 跳转到指定节点 return {"jump_to": "end"}

关键细节:

  • 返回的 dict 会通过 Agent 状态的 reducer 合并。
  • 对于 messages 字段,用的是 add_messages reducer ------所以返回的 messages 会追加而非覆盖。
  • jump_to 配合 can_jump_to 使用:在 before_agentbefore_modelafter_model 中,通过 can_jump_to 参数声明可以跳转的目标,这是一种安全机制,防止中间件意外跳到不合法节点。
python 复制代码
from langchain.agents.middleware import before_model

@before_model(can_jump_to=["end"])  # 声明可以跳转到的目标
def conditional_exit(state, runtime):
    """在特定条件下直接结束 Agent"""
    messages = state.get("messages", [])
    if not messages:
        return None

    last_content = str(messages[-1].content)
    if last_content.strip() in ["再见", "拜拜", "bye"]:
        return {
            "jump_to": "end",
            "messages": [{"role": "assistant", "content": "再见!期待下次为您服务。"}]
        }
    return None

can_jump_to 可选值一览:

can_jump_to 值 含义 适用场景
["end"] 可跳转到结束 条件退出、安全拦截
["model"] 可跳转回模型 需要让模型重新处理
["tools"] 可跳转到工具节点 跳过模型直接执行工具
["model", "end"] 可跳转到模型或结束 多种条件分支

⚠️ 避坑 :如果不在 can_jump_to 里声明目标,jump_to 会被忽略。这是官方的安全设计,别忘了声明。


七、完整协作示例

把这四个观察型钩子串起来,看它们在一个 Agent 里如何按顺序协作:

python 复制代码
from dotenv import load_dotenv
load_dotenv()

from langchain.agents.middleware import (
    before_agent, after_agent, before_model, after_model
)
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain.messages import HumanMessage
from langchain.tools import tool

@before_agent
def init_session(state, runtime):
    print(">>> [before_agent] 会话开始")
    return None

@before_model
def pre_model_check(state, runtime):
    msg_count = len(state.get("messages", []))
    print(f" -> [before_model] 消息数: {msg_count}")
    return None

@after_model
def post_model_check(state, runtime):
    last = state["messages"][-1] if state.get("messages") else None
    if last and hasattr(last, 'tool_calls') and last.tool_calls:
        tools = [tc['name'] for tc in last.tool_calls]
        print(f" <- [after_model] 请求工具: {tools}")
    return None

@after_agent
def finish_session(state, runtime):
    total = len(state.get("messages", []))
    print(f"<<< [after_agent] 会话结束,共 {total} 条消息")
    return None

@tool
def get_weather(city: str) -> str:
    """查询天气"""
    return f"{city}: 晴"

model = init_chat_model("deepseek:deepseek-v4-flash", temperature=0)
agent = create_agent(
    model=model,
    tools=[get_weather],
    middleware=[init_session, pre_model_check, post_model_check, finish_session],
    system_prompt="你是助手。",
)

result = agent.invoke({"messages": [HumanMessage(content="杭州天气?")]})
print(f"\n最终回复: {result['messages'][-1].content}")

运行结果:

复制代码
>>> [before_agent] 会话开始
  -> [before_model] 消息数: 2
  <- [after_model] 请求工具: ['get_weather']
  -> [before_model] 消息数: 3
<<< [after_agent] 会话结束,共 4 条消息

最终回复: 杭州今天晴天,适合出行。

从输出能看到关键规律:

  • before_agent / after_agent:整个会话只执行一次。
  • before_model / after_model:每次模型调用都执行。这个提问需要工具(先思考要不要调 → 再整合结果),所以各执行了两次。
  • 顺序正好印证了官方的执行模型:before 正序 → 模型调用 → after 逆序,Agent 级包在最外层。

八、总结:你真正需要记住的 6 件事

  1. 中间件 = 挂在 Agent 执行流程上的钩子,不侵入 Agent 代码,就能加日志、鉴权、过滤、统计等横切逻辑。
  2. 钩子分两类 :Node-style(before_agent/before_model/after_model/after_agent)偏观察,Wrap-style(wrap_model_call/wrap_tool_call)偏接管。
  3. 按频率记钩子 :Agent 级一次(before_agent/after_agent),循环级每次模型调用(before_model/after_model),调用级每次工具执行(wrap_tool_call)。
  4. 返回值三档None 不改状态,dict 合并状态,含 jump_to 的 dict 控制流程。
  5. jump_to 要配 can_jump_to 声明 ,否则会被忽略;messagesadd_messages reducer,返回即追加。
  6. 两种写法 :简单用装饰器(函数签名 (state, runtime)),复杂用 AgentMiddleware 类继承。

验证清单

  • middleware=[...] 传给 create_agent 时,确认 @before_agent 只被打了一次
  • 用带工具的提问测试,确认 before_model/after_model 各执行两次
  • before_agent 里写权限检查,确认返回 {"jump_to": "end"} 能提前结束 Agent
  • before_model 里做长度裁剪,确认长对话下消息数被限制
  • agent.stream(stream_mode=["updates", "custom"]) 验证 runtime.stream_writer 能推送自定义事件
  • 测试含 jump_to 的返回时,确认已声明对应的 can_jump_to

参考资源

相关推荐
Boop_wu4 小时前
[LangGraph] 案例 2 : 支持搜索的智能代理系统
服务器·windows·python·langchain
测试开发Kevin6 小时前
DeepEval + Eval‑Harness 完整讲解(结合 Playwright UI 自动化例子)
人工智能·ai·langchain
姚不倒6 小时前
HAProxy 系列(四):多中间件统一 VIP 入口 — 架构模式与最佳实践
运维·中间件·架构·haproxy
yoguo-2101 天前
TongWeb7控制台部署应用时,应用包大小限制
中间件·tongweb
码农小麦1 天前
LangChain 1.3.18 + DeepSeek-v4-flash 工具调用踩坑实录
网络·数据库·langchain
10年前端老司机1 天前
别卷CRUD了!前端用Next.js+LangChain.js,低成本冲进AI高薪赛道
前端·langchain·next.js
__zRainy__1 天前
Node系列 · Express:cors 中间件
中间件·express
StevenSurpass1 天前
智能工厂场景:FastPrintAgent 分布式打印中间件落地应用方案
分布式·mqtt·http·中间件·打印·fastreport
cui_ruicheng2 天前
LangChain 应用开发(十四):Agent 上下文与记忆机制
服务器·人工智能·python·langchain