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_model 或 wrap_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_model 或 wrap_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 解析中间件实战案例,展示了如何在实际项目中应用这一机制。关键收获:
- 中间件是扩展点而非黑盒 :通过
before_model、after_model和wrap_model_call等钩子,我们可以精准地在智能体生命周期的每个阶段插入自定义逻辑。 - 文件处理要走"解析-注入"路径 :永远不要将 Base64 编码的原始文件数据直接发给大模型;应先提取文本,再作为上下文注入
SystemMessage。 - 异步兼容是必须的 :在 LangChain 的异步执行环境中,中间件方法务必声明为
async。 - 调试先看数据 :用
print观察消息结构是排查问题的最快方式。