大模型应用开发笔记03:LangChain 中间件实战

1. 引言

在使用 LangChain 构建智能体应用时,我们经常需要在智能体执行的不同阶段插入自定义逻辑------比如在消息发给大模型之前做预处理、在模型返回结果后做后处理、或者包装整个模型调用链路。这些需求都可以通过 LangChain 的中间件(Middleware)机制优雅地实现。

本文将从中间件的核心概念出发,结合实际场景------前端上传 PDF 文件并让大模型基于文件内容进行对话------手把手带你实现一个专业的 PDF 解析中间件。无论你是刚接触 LangChain 的开发者,还是正在为生产环境中的文件处理需求寻找方案,本文都能给你一份可复用的参考实现。

2. LangChain 中间件机制

2.1 什么是中间件

中间件是 LangChain 框架的核心扩展机制,允许开发者在智能体的关键执行节点插入自定义逻辑。你可以把它理解为一组钩子函数(Hooks),每个钩子在特定的生命周期节点被调用。

2.2 主要中间件类型

LangChain 提供了以下几类中间件钩子:

钩子 触发时机 典型用途
before_agent 智能体执行前 权限校验、参数预处理
before_model 消息发送给大模型前 上下文注入、消息改写
after_model 大模型返回结果后、给用户前 结果过滤、格式化
wrap_model_call 包裹整个模型调用过程 日志记录、缓存、重试

其中 wrap_model_call 是一种包装式(Wrap-style)中间件,它能完整包裹模型调用的前后逻辑,非常适合我们需要"在调用前修改消息内容"的场景。

2.3 执行流程

理解这个流程后,我们就能明确:PDF 解析这类"消息预处理"需求,应该放在 before_modelwrap_model_call 中实现

3. 前端数据格式与消息类型

3.1 消息类型体系

LangChain 的消息结构定义了四种核心消息类型,理解它们对中间件开发至关重要:

  • SystemMessage:系统指令,定义智能体的角色、身份和约束规则。
  • HumanMessage:用户发送给智能体的消息,可以携带文本和附件。
  • AIMessage:大模型返回的回复内容。
  • ToolMessage:工具执行后产生的消息,通常携带工具调用结果。

3.2 前端附件的数据格式

当前端上传文件时,文件数据会以以下两种格式之一嵌入 HumanMessage

  • image_url 格式:直接传递图片的 URL 地址。
  • Base64 编码格式 :将文件的二进制内容编码为 Base64 字符串,存于 additional_kwargs.attachments 字段中。

一个典型的前端消息样例如下:

python 复制代码
HumanMessage(
    content='解析一下',
    additional_kwargs={
        'attachments': [
            {
                'type': 'file',
                'mimeType': 'application/pdf',
                'data': 'JVBERi0xLjMNCiWhs8XXDQozIDAgb2Jq...',  # Base64 编码的 PDF 内容
                'metadata': {
                    'filename': '1- 副本.pdf'
                }
            }
        ]
    },
    response_metadata={},
    id='10901fb9-baa2-4a13-9f26-5035976709ee'
)

关键要点:我们不能直接把庞大的 Base64 数据放进发给大模型的 content 中 ,这会导致 token 超限和处理失败。正确做法是通过中间件解析文件、提取文本,再将文本注入到 SystemMessage 中作为对话上下文。

4. PDF 文件解析与处理流程

4.1 整体流程设计

当前端上传 PDF 文件时,完整的处理链路如下:
#mermaid-svg-RS1GwHksgbeGyhkQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RS1GwHksgbeGyhkQ .error-icon{fill:#552222;}#mermaid-svg-RS1GwHksgbeGyhkQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RS1GwHksgbeGyhkQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RS1GwHksgbeGyhkQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RS1GwHksgbeGyhkQ .marker.cross{stroke:#333333;}#mermaid-svg-RS1GwHksgbeGyhkQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RS1GwHksgbeGyhkQ p{margin:0;}#mermaid-svg-RS1GwHksgbeGyhkQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ .cluster-label text{fill:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ .cluster-label span{color:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ .cluster-label span p{background-color:transparent;}#mermaid-svg-RS1GwHksgbeGyhkQ .label text,#mermaid-svg-RS1GwHksgbeGyhkQ span{fill:#333;color:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ .node rect,#mermaid-svg-RS1GwHksgbeGyhkQ .node circle,#mermaid-svg-RS1GwHksgbeGyhkQ .node ellipse,#mermaid-svg-RS1GwHksgbeGyhkQ .node polygon,#mermaid-svg-RS1GwHksgbeGyhkQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RS1GwHksgbeGyhkQ .rough-node .label text,#mermaid-svg-RS1GwHksgbeGyhkQ .node .label text,#mermaid-svg-RS1GwHksgbeGyhkQ .image-shape .label,#mermaid-svg-RS1GwHksgbeGyhkQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-RS1GwHksgbeGyhkQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RS1GwHksgbeGyhkQ .rough-node .label,#mermaid-svg-RS1GwHksgbeGyhkQ .node .label,#mermaid-svg-RS1GwHksgbeGyhkQ .image-shape .label,#mermaid-svg-RS1GwHksgbeGyhkQ .icon-shape .label{text-align:center;}#mermaid-svg-RS1GwHksgbeGyhkQ .node.clickable{cursor:pointer;}#mermaid-svg-RS1GwHksgbeGyhkQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RS1GwHksgbeGyhkQ .arrowheadPath{fill:#333333;}#mermaid-svg-RS1GwHksgbeGyhkQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RS1GwHksgbeGyhkQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RS1GwHksgbeGyhkQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RS1GwHksgbeGyhkQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RS1GwHksgbeGyhkQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RS1GwHksgbeGyhkQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RS1GwHksgbeGyhkQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RS1GwHksgbeGyhkQ .cluster text{fill:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ .cluster span{color:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RS1GwHksgbeGyhkQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RS1GwHksgbeGyhkQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-RS1GwHksgbeGyhkQ .icon-shape,#mermaid-svg-RS1GwHksgbeGyhkQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RS1GwHksgbeGyhkQ .icon-shape p,#mermaid-svg-RS1GwHksgbeGyhkQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RS1GwHksgbeGyhkQ .icon-shape .label rect,#mermaid-svg-RS1GwHksgbeGyhkQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RS1GwHksgbeGyhkQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RS1GwHksgbeGyhkQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RS1GwHksgbeGyhkQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 前端上传 PDF 文件
数据以 Base64 编码存储在 HumanMessage.attachments 中
中间件拦截 before_model 钩子
提取 Base64 数据并解码为 PDF 字节流
使用 pymupdf4llm 提取 PDF 文本
将提取的文本注入 SystemMessage 作为上下文
调用大模型进行对话
返回结果给用户

4.2 核心设计原则

  • 在中间件中完成解析 :解析逻辑集中在一个继承自 AgentMiddleware 的类中,便于维护和复用。
  • 提取文本而非传递原始数据 :Base64 编码的 PDF 体积巨大,必须转为纯文本再放入 system_message
  • 异步兼容 :所有中间件方法应声明为 async,确保在异步环境中正常运行,避免"异步调用"错误。

5. 实战:PDF 解析中间件实现

5.1 环境准备

安装所需依赖:

bash 复制代码
pip install langchain langchain-openai pymupdf4llm

pymupdf4llm 是一个专门用于将 PDF 文档转换为 LLM 友好格式的文档加载器,它基于 PyMuPDF 开发,能高效提取 PDF 中的结构化文本。

5.2 中间件代码实现

参考 LangChain 官方文档中 Wrap-style Hooks 的实现方式,我们编写如下中间件:

python 复制代码
import base64
import tempfile
import os
from typing import Any, Awaitable, Callable, List

from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
import pymupdf4llm


class PDFParseMiddleware(AgentMiddleware):
    """PDF 文档解析中间件。
    
    功能:
    1. 从 HumanMessage 的 attachments 中提取 Base64 编码的 PDF 数据。
    2. 将 Base64 数据解码并写入临时 PDF 文件。
    3. 使用 pymupdf4llm 提取 PDF 中的文本内容。
    4. 将提取的文本注入到 SystemMessage 中,作为大模型对话的上下文。
    """

    def __init__(self):
        super().__init__()
        self.extracted_text_cache: str | None = None

    def _extract_pdf_text(self, base64_data: str) -> str:
        """从 Base64 编码数据中解析 PDF 并提取文本。

        Args:
            base64_data:PDF 文件的 Base64 编码字符串。

        Returns:
            提取出的 PDF 文本内容。
        """
        # 1. 将 Base64 字符串解码为二进制数据
        pdf_bytes = base64.b64decode(base64_data)

        # 2. 写入临时文件
        with tempfile.NamedTemporaryFile(
            suffix=".pdf", delete=False
        ) as tmp_file:
            tmp_file.write(pdf_bytes)
            tmp_path = tmp_file.name

        try:
            # 3. 使用 pymupdf4llm 提取文本
            extracted_text = pymupdf4llm.to_markdown(tmp_path)
            return extracted_text
        finally:
            # 4. 清理临时文件
            if os.path.exists(tmp_path):
                os.remove(tmp_path)

    async def awrap_model_call(
        self,
        request: Any,
        handler: Callable[[Any], Awaitable[Any]],
    ) -> Any:
        """包装模型调用,在调用前处理 PDF 附件并注入上下文。

        Args:
            request:原始请求对象,包含 messages 列表。
            handler:下一个处理器(通常是实际的模型调用)。

        Returns:
            模型调用的响应结果。
        """
        messages: List[BaseMessage] = request.get("messages", [])

        # 遍历消息,查找包含 PDF 附件的 HumanMessage
        for msg in messages:
            if not isinstance(msg, HumanMessage):
                continue

            attachments = (
                msg.additional_kwargs.get("attachments", [])
                if hasattr(msg, "additional_kwargs") and msg.additional_kwargs
                else []
            )

            for attachment in attachments:
                mime_type = attachment.get("mimeType", "")
                base64_data = attachment.get("data", "")

                # 仅处理 PDF 类型
                if mime_type == "application/pdf" and base64_data:
                    print(f"[PDFParseMiddleware] 检测到 PDF 附件,开始解析...")
                    extracted_text = self._extract_pdf_text(base64_data)
                    self.extracted_text_cache = extracted_text
                    print(
                        f"[PDFParseMiddleware] PDF 解析完成,"
                        f"提取文本长度:{len(extracted_text)} 字符"
                    )

                    # 将提取的文本注入到 SystemMessage 中
                    system_content = (
                        f"以下是用户上传的 PDF 文档的完整文本内容,"
                        f"请基于这些内容回答用户的问题:\n\n"
                        f"--- PDF 文档内容开始 ---\n"
                        f"{extracted_text}\n"
                        f"--- PDF 文档内容结束 ---\n\n"
                        f"请根据以上文档内容,用中文回答用户的问题。"
                        f"如果用户的提问与文档内容无关,"
                        f"请如实告知并引导用户提出与文档相关的问题。"
                    )

                    # 查找或创建 SystemMessage
                    system_msg_exists = False
                    for i, existing_msg in enumerate(messages):
                        if isinstance(existing_msg, SystemMessage):
                            # 在已有 system 提示词后追加 PDF 上下文
                            messages[i] = SystemMessage(
                                content=existing_msg.content + "\n\n" + system_content
                            )
                            system_msg_exists = True
                            break

                    if not system_msg_exists:
                        # 在消息列表开头插入 SystemMessage
                        messages.insert(0, SystemMessage(content=system_content))

                    # 清理 HumanMessage 中的原始 Base64 数据,
                    # 避免将大量二进制数据发送给模型
                    msg.additional_kwargs.pop("attachments", None)
                    break  # 本示例仅处理第一个 PDF 附件

        # 调用下一个处理器(模型调用)
        return await handler(request)

5.3 使用示例

python 复制代码
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent

# 初始化大模型(以豆包多模态模型为例)
model = ChatOpenAI(
    model="doubao-seed-2-0-lite-260215",
    base_url="https://ark.cn-beijing.volces.com/api/v3",
    api_key="your-api-key-here"  # 请替换为你的实际 API Key
)

# 创建中间件实例
pdf_middleware = PDFParseMiddleware()

# 构建智能体并挂载中间件
agent = create_react_agent(
    model=model,
    tools=[],  # 根据需要添加工具
    middleware=[pdf_middleware],
)

# 模拟前端发送的带 PDF 附件的消息
response = await agent.ainvoke({
    "messages": [
        HumanMessage(
            content="请总结这份文档的主要内容。",
            additional_kwargs={
                "attachments": [
                    {
                        "type": "file",
                        "mimeType": "application/pdf",
                        "data": "JVBERi0xLjMNCi...",  # Base64 编码的 PDF
                        "metadata": {
                            "filename": "1.pdf"
                        }
                    }
                ]
            }
        )
    ]
})

print(response)

5.4 关键实现细节

  • 异步兼容 :方法命名为 awrap_model_call 且声明为 async,与 LangChain 的异步调用链兼容。
  • 临时文件管理 :使用 tempfile.NamedTemporaryFile 创建临时文件,并在 try...finally 中确保清理,避免磁盘残留。
  • Base64 数据清理 :解析完成后从 additional_kwargs 中移除 attachments,防止将巨大的二进制数据发给模型。
  • SystemMessage 注入策略:优先在已有 SystemMessage 后追加 PDF 内容;若不存在则插入新的 SystemMessage 到消息列表开头。

6. 调试技巧

在开发中间件的过程中,以下几个技巧能帮你快速定位问题:

1. 观察前端数据格式

before_modelwrap_model_call 中添加 print 语句,打印消息的完整结构:

python 复制代码
print(f"消息类型:{type(msg).__name__}")
print(f"消息内容:{msg.content[:200]}...")  # 截断长内容
print(f"附加参数:{msg.additional_kwargs}")

2. 解决异步调用错误

如果遇到类似 object async_generator can't be used in 'await' expression 的错误,请确认:

  • 中间件方法使用了 async def 声明。
  • 调用方使用了 await
  • 没有在同步函数中调用异步方法。

3. 分步验证解析结果

在开发阶段,可以先独立测试 _extract_pdf_text 方法,确保 PDF 解析逻辑正确:

python 复制代码
# 独立测试
middleware = PDFParseMiddleware()
text = middleware._extract_pdf_text("JVBERi0xLjMNCi...")  # 用真实的 Base64 测试
print(text[:500])

7. 进阶:通用文件处理中间件

掌握了 PDF 解析后,我们可以进一步扩展,实现一个支持多种文件格式(PDF、图片 URL、Office 文档等)的通用文件处理中间件。核心思路是将"文件类型检测"和"对应的解析逻辑"模块化,形成一个可配置的解析器链。

python 复制代码
class UniversalFileMiddleware(AgentMiddleware):
    """通用文件处理中间件,支持 PDF、图片 URL 等多种文件格式。"""

    HANDLERS = {
        "application/pdf": "_handle_pdf",
        "image_url": "_handle_image_url",
        # 可按需扩展更多类型
    }

    async def awrap_model_call(self, request, handler):
        messages = request.get("messages", [])
        for msg in messages:
            if isinstance(msg, HumanMessage):
                await self._process_attachments(msg, messages)
        return await handler(request)

    async def _process_attachments(self, msg, messages):
        attachments = msg.additional_kwargs.get("attachments", []) if msg.additional_kwargs else []
        for att in attachments:
            handler_name = self.HANDLERS.get(att.get("mimeType", ""))
            if handler_name:
                handler_method = getattr(self, handler_name)
                extracted = await handler_method(att)
                self._inject_into_system(messages, extracted)

    async def _handle_pdf(self, attachment):
        return self._extract_pdf_text(attachment.get("data", ""))

    async def _handle_image_url(self, attachment):
        # 对于 URL 类型的图片,直接保留 URL 供多模态模型使用
        return f"[图片链接] {attachment.get('url', '')}"

    def _inject_into_system(self, messages, text):
        """将提取的文本注入 SystemMessage。"""
        # 实现逻辑与前面的 PDFParseMiddleware 类似
        pass

这样,无论前端上传的是 PDF 文件、图片 URL,还是其他格式,都能通过统一的中间件入口进行合理处理。

8. 总结

本文深入讲解了 LangChain 中间件机制的核心概念,并通过一个完整的 PDF 解析中间件实战案例,展示了如何在实际项目中应用这一机制。关键收获:

  1. 中间件是扩展点而非黑盒 :通过 before_modelafter_modelwrap_model_call 等钩子,我们可以精准地在智能体生命周期的每个阶段插入自定义逻辑。
  2. 文件处理要走"解析-注入"路径 :永远不要将 Base64 编码的原始文件数据直接发给大模型;应先提取文本,再作为上下文注入 SystemMessage
  3. 异步兼容是必须的 :在 LangChain 的异步执行环境中,中间件方法务必声明为 async
  4. 调试先看数据 :用 print 观察消息结构是排查问题的最快方式。
相关推荐
笑鸿的学习笔记2 小时前
操作系统笔记之CPU、寄存器、高速缓存、寻址总线的相对位置及寄存器,高速缓存区,内存,硬盘哪个快
笔记
Turbo正则2 小时前
机器学习入门笔记 | 基础算法及其应用场景
笔记·算法·机器学习
To_OC11 小时前
手搓 LangChain 工具调用:原来 Agent 的核心逻辑,就是个 while 循环
人工智能·langchain·llm
取地址符13 小时前
C++学习笔记(基于learn-cxx)(1)
c++·经验分享·笔记·学习
ysa05103013 小时前
【板子】ST表
c++·笔记·算法·板子
risc12345615 小时前
肌肉收缩会释放大量的肌细胞因子能抑制体内炎症因子
笔记
既然如此,那就开摆16 小时前
精读笔记06_CoCa
笔记
疯狂打码的少年17 小时前
【软件工程】软件开发模型:瀑布模型(特点与适用场景)
笔记·软件工程
三品吉他手会点灯17 小时前
嵌入式机器学习 - 学习笔记1.1.1 - 什么是机器学习?
c语言·人工智能·笔记·嵌入式硬件·学习·机器学习