Spring AI 大特性,你知道几个?
前面几篇聊了 Spring AI 的搭建、特色功能和一些偏聊天场景的案例。今天换个口味,聊两个我最近在生产环境里折腾出来的真实案例——多模态数据处理和批量流水线。
说实在的,现在的AI教程十个有九个都在讲“怎么写一个聊天机器人”,但企业里真正的需求往往是:上千份合同批量提取关键字段、会议录音自动生成纪要、PDF里的表格和图片一起理解……这些东西才是Java工程师真正能发光发热的地方。
一、多模态数据处理:从“只认文字”到“能看懂PDF里的表格和图片”
先说第一个案例,也是让我最头疼的一个。
真实的痛点:PDF里的信息藏在表格里
去年年底接了个需求,客户是做供应链金融的,每天收到几百份供应商传来的采购合同PDF。这些PDF格式五花八门,有的里面只有文字,有的是扫描件,有的还嵌了表格和产品图片。他们的需求很简单但也很棘手:自动识别合同里的关键字段(合同编号、签订日期、金额、产品清单),然后存到数据库里。
以前怎么做?人工录入。效率低不说,还容易出错。
Spring AI 1.0 之后的多模态支持让我看到了希望。Spring AI 通过统一的 AudioTranscriptionModel 和 SpeechModel 接口支持音频转录和语音合成,覆盖了 OpenAI Whisper、Azure OpenAI 等主流提供商。更关键的是,很多模型提供商(比如 OpenAI 的 GPT-4V、Azure OpenAI、Google Gemini)都开始支持多模态输入,也就是能看懂图片、PDF里的内容。
架构思路:ETL + 多模态识别
我设计的方案分三步:
- 提取层:用 Spring AI 的 ETL 框架读取 PDF,Spring AI 提供了多种
DocumentReader实现,包括支持 PDF 的PagePdfDocumentReader、支持多格式的TikaDocumentReader等。但普通文本提取器只能拿到文字,表格和图片信息会丢失。所以这里需要特殊处理——针对表格部分,用模型的多模态能力直接“看图说话”。 - 转换层:用
DocumentTransformer处理分块和内容增强,Spring AI 内置了TokenTextSplitter进行智能分块,以及KeywordMetadataEnricher和SummaryMetadataEnricher这种利用大模型生成关键词和摘要的增强器。 - 加载层:通过
DocumentWriter写入向量数据库或业务数据库,VectorStore是官方提供的向量数据库写入器实现。
代码实现:读取 PDF + 多模态识别
以下是核心的代码片段:
第一步:读取 PDF 文件
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
@Service
public class ContractReaderService {
/**
* 使用 TikaDocumentReader 读取 PDF(适合纯文本 PDF)
* Tika 可以读取 PDF、DOCX、PPTX、HTML 等 50+ 种格式
*/
public List<Document> readWithTika(MultipartFile file) {
Resource resource = new InputStreamResource(file.getInputStream());
TikaDocumentReader reader = new TikaDocumentReader(resource);
return reader.read();
}
/**
* 按页读取 PDF(适合需要保留页面结构的场景)
* 每个页面作为一个独立的 Document 对象,保留页码元数据
*/
public List<Document> readPageByPage(MultipartFile file) {
Resource resource = new InputStreamResource(file.getInputStream());
PagePdfDocumentReader reader = new PagePdfDocumentReader(resource);
List<Document> documents = reader.read();
// 每个 document 的 metadata 中包含 pageNumber 信息
return documents;
}
}
第二步:把 PDF 页面转换成图片,交给多模态模型识别
这是关键步骤。对于含有复杂表格和图片的 PDF,光靠文本提取不够,需要用多模态模型直接“看”页面内容。
@Service
public class MultimodalExtractorService {
private final ChatClient chatClient;
private final PdfToImageConverter pdfConverter; // 自研组件,基于 PDFBox
public MultimodalExtractorService(ChatClient.Builder builder) {
this.chatClient = builder.build();
this.pdfConverter = new PdfToImageConverter();
}
/**
* 从 PDF 页面中提取合同关键字段
* 直接交给多模态模型(如 GPT-4V、Qwen-VL)去“看”页面截图
*/
public ContractInfo extractFromPage(MultipartFile file, int pageNum) {
// 1. 将 PDF 页面转为图片
byte[] pageImage = pdfConverter.convertPageToImage(file, pageNum);
// 2. 通过 ChatClient 调用多模态模型
// 注意:需要配置支持多模态的模型(如 OpenAI 的 gpt-4o、Azure OpenAI 等)
String result = chatClient.prompt()
.user(userSpec -> userSpec
.text("请从这份合同的第" + pageNum + "页中提取以下字段:合同编号、签订日期、甲方公司名称、合同总金额。以 JSON 格式返回。")
.media(MimeTypeUtils.IMAGE_PNG, pageImage) // 多模态输入!
)
.call()
.content();
// 3. 将 JSON 结果映射成 Java 对象
return JsonMapper.fromJson(result, ContractInfo.class);
}
}
看到没?关键就这一行 .media()——直接把图片丢给模型去“看”。Spring AI 的 ChatClient 对多模态输入的支持就是这样简洁,你不需要自己去封装 HTTP 请求、处理 base64 编码什么的。
踩过的坑
这个案例我踩了不少坑,说两个最要命的:
坑一:PDF 转图片的质量直接影响识别率。 如果页面分辨率太低,模型连字都看不清。我最后用的是 PDFBox + ImageIO 的组合,输出 PNG 格式,分辨率设到 150 DPI 以上,识别率才稳定在 90% 以上。
坑二:表格识别别用文本提取器。 我一开始用 PagePdfDocumentReader 提取纯文本,结果多列表格的行列关系完全乱掉了。后来改用多模态方式——直接把页面截图喂给模型,让模型自己去理解表格结构,准确率从 60% 飙升到 95%。
坑三:大文档的分页处理需要控制并发。 一份合同几十页,每页都调用一次模型 API,串行处理太慢了。后面会讲到用虚拟线程做并发批量调用,这个场景就很适合。
二、ETL Pipeline:构建企业级数据处理流水线
说完多模态,接着聊 Spring AI 的 ETL 框架。这是 Spring AI 1.0 最重磅的功能之一,专门为 RAG 场景的数据准备阶段设计的。
ETL 是什么?
ETL 是 Extract(提取)、Transform(转换)、Load(加载)的缩写。在 Spring AI 里,它做的事情就是:从各种来源读取原始文档(PDF、Word、Excel、网页、JSON 等),把文档切分成适合向量化的小块,最后存到向量数据库里。
Spring AI 的 ETL 框架围绕三个核心接口构建:
| 接口 | 职责 | 内置实现举例 |
|---|---|---|
DocumentReader |
提取原始文档 | TikaDocumentReader, PagePdfDocumentReader, JsonReader, MarkdownDocumentReader |
DocumentTransformer |
转换/增强文档 | TokenTextSplitter, KeywordMetadataEnricher, SummaryMetadataEnricher |
DocumentWriter |
写入目标存储 | VectorStore, FileDocumentWriter |
这三个接口的巧妙之处在于它们的函数式设计——DocumentReader 是 Supplier<List<Document>>,DocumentTransformer 是 Function<List<Document>, List<Document>>,DocumentWriter 是 Consumer<List<Document>>,可以像流水线一样串联起来。
实战:构建一个文档处理流水线
假设你有一个文件夹,里面有 PDF、Word、Markdown 三种格式的文档,你想把它们全部读出来、切分成合适大小的小块、提取关键词和摘要、最后存到向量数据库里。
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.transformer.metadata.KeywordMetadataEnricher;
import org.springframework.ai.transformer.metadata.SummaryMetadataEnricher;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.FileSystemResource;
@Service
public class DocumentETLPipeline {
private final VectorStore vectorStore;
private final ChatModel chatModel; // 用于生成关键词和摘要
public DocumentETLPipeline(VectorStore vectorStore, ChatModel chatModel) {
this.vectorStore = vectorStore;
this.chatModel = chatModel;
}
/**
* 执行完整的 ETL 流水线
* 函数式风格:writer.accept(transformer.apply(reader.read()))
*/
public void processFile(String filePath) {
// 1. Extract: 用 TikaDocumentReader 读取文件(支持 PDF/Word/Markdown 等 50+ 格式)
DocumentReader reader = new TikaDocumentReader(new FileSystemResource(filePath));
List<Document> rawDocuments = reader.read();
// 2. Transform: 切分成合适大小的小块(TokenTextSplitter 是 workhorse)
DocumentTransformer splitter = new TokenTextSplitter(800, 200); // chunkSize=800, overlap=200
List<Document> chunkedDocuments = splitter.apply(rawDocuments);
// 3. Transform: 用 AI 提取关键词(加到 metadata 里)
KeywordMetadataEnricher keywordEnricher = new KeywordMetadataEnricher(chatModel, 5);
List<Document> enrichedWithKeywords = keywordEnricher.apply(chunkedDocuments);
// 4. Transform: 用 AI 生成摘要(加到 metadata 里)
SummaryMetadataEnricher summaryEnricher = new SummaryMetadataEnricher(chatModel);
List<Document> finalDocuments = summaryEnricher.apply(enrichedWithKeywords);
// 5. Load: 写入向量数据库
vectorStore.write(finalDocuments);
log.info("处理完成,共生成 {} 个文档块", finalDocuments.size());
}
}
关于 TokenTextSplitter 的参数选择,多说两句: chunkSize 是每个块的最大 token 数,overlap 是块与块之间重叠的 token 数。overlap 的作用是保留块边界的上下文,避免重要信息被“切”在边缘。经过多次测试,800 token 的块大小搭配 200 token 的重叠长度,在检索准确性和 token 消耗之间取得了比较好的平衡。
更优雅的流水线写法
上面的代码是分步写的,Spring AI 的函数式设计允许你直接把流水线串联成一条链:
// 一行代码完成整个 ETL 流程!
vectorStore.write(
new SummaryMetadataEnricher(chatModel)
.apply(
new KeywordMetadataEnricher(chatModel, 5)
.apply(
new TokenTextSplitter(800, 200)
.apply(
new TikaDocumentReader(resource).read()
)
)
)
);
虽然可读性差点,但那种“数据像流水一样流过一个个处理器”的感觉真的很爽。
三、批量处理 + 虚拟线程:让 AI 应用从“能跑”到“跑得快”
聊完 ETL 框架,再说一个我最近特别喜欢的特性——结合 Java 虚拟线程(Virtual Threads)做批量 AI 调用。
痛点:串行调用大模型太慢了
做过 AI 应用的都知道,调用一次大模型 API 的延迟通常在 1 到 3 秒之间。如果你需要处理 1000 条数据,串行调用就是 1000 到 3000 秒——差不多一个小时到一个半小时。这在生产环境是不可接受的。
虚拟线程(Java 21+)完美解决了这个问题。 虚拟线程是轻量级线程,专为 IO 密集型任务设计。调用大模型 API 是典型的 IO 密集型任务(大部分时间在等待网络响应),虚拟线程可以在等待时让出 CPU,实现极高的并发度。
实战:批量总结 1000 篇文档
下面这个例子展示了如何用虚拟线程并发调用大模型,批量生成文档摘要:
@Service
public class BulkSummarizationService {
private final ChatClient chatClient;
private static final int BATCH_SIZE = 100; // 每批 100 个请求
public void summarizeDocuments(List<Document> documents) {
// 分批次处理,避免一次性请求太多触发 API 限流
for (int i = 0; i < documents.size(); i += BATCH_SIZE) {
List<Document> batch = documents.subList(i, Math.min(i + BATCH_SIZE, documents.size()));
processBatch(batch);
}
}
private void processBatch(List<Document> batch) {
// 创建虚拟线程执行器(Java 21+ 特性)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 为每个文档创建一个并发任务
List<CompletableFuture<DocumentSummary>> futures = batch.stream()
.map(doc -> CompletableFuture.supplyAsync(() -> {
try {
// 调用大模型生成摘要
String summary = chatClient.prompt()
.user("请为以下文档生成 200 字以内的摘要:\n" + doc.getContent())
.call()
.content();
return new DocumentSummary(doc.getId(), summary);
} catch (Exception e) {
log.error("处理文档 {} 失败", doc.getId(), e);
return null; // 单条失败不影响整批
}
}, executor))
.toList();
// 等待本批次所有任务完成
List<DocumentSummary> results = futures.stream()
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.toList();
// 批量保存结果到数据库
summaryRepository.saveAll(results);
log.info("批次处理完成,成功 {} / {}", results.size(), batch.size());
}
}
}
这段代码的核心思路:
- 先把数据分批(每批 100 个),避免 API 限流或内存溢出
- 每批内部用虚拟线程并发调用大模型 API
- 用
CompletableFuture.join()等待整批完成 - 单条失败不影响整批,通过
filter(Objects::nonNull)过滤掉失败的
我实测过:处理 1000 篇文档,串行大约需要 50 分钟(平均每篇 3 秒)。用虚拟线程并发(每批 100 个并发),处理时间直接压缩到 3 分钟以内。虚拟线程的轻量级特性决定了你开几百个并发也不会像传统线程池那样 OOM,这是 Java 21 带给 AI 应用最大的性能红利。
四、再聊两个用过的坑
写完案例,顺手补充两个在这两个场景里踩过的坑。
坑一:TikaDocumentReader 的内存问题。 Tika 虽然格式支持全面,但它会把整个文件加载到内存里。处理超大 PDF(几百 MB)时很容易 OOM。我的解决方案是:超过 50MB 的文件改用 PagePdfDocumentReader 按页处理,配合虚拟线程做流式处理,每读一页就处理一页,内存占用大大降低。
坑二:虚拟线程的并发数不是越高越好。 虽然虚拟线程本身很轻量,但目标 API 是有速率限制的。我一开始设了每批 500 个并发,结果直接把 OpenAI 的 API 限流给触发了,一堆 429 错误。后来根据 API 的 RPM(每分钟请求数)限制来调整并发数,经验值是 RPM ÷ 60 × 虚拟线程处理单条的平均耗时。比如 RPM=3000,单条约 3 秒,那并发控制在 150 左右比较安全。
坑三:ETL 流程中的元数据丢失。 TokenTextSplitter 切分文档后,元数据会复制到每个切分出来的小块上,这是好事。但如果你用了多个 DocumentTransformer,中间的某个步骤可能会意外修改或丢失元数据。我的建议是在流水线的最后一步做一次元数据完整性检查,确保每个块都保留了关键的元数据字段(比如原始文件名、页号、文档类型)。
五、写在最后
聊了三个实战案例——多模态 PDF 信息提取、ETL 数据流水线、批量并发调用——回头看,它们背后其实是一个共同的思路:Spring AI 不只是帮你调模型,更是帮你把 AI 能力“工程化”地嵌入到现有系统中。
多模态 PDF 识别解决了“AI 如何看懂复杂格式文档”的问题。ETL 框架解决了“如何标准化地处理海量异构数据”的问题。虚拟线程批量调用解决了“如何让 AI 应用从单条调用走向大规模生产”的问题。
我一直在强调一件事:Java 开发者做大模型应用,优势不在模型训练(那是 Python 的地盘),而在于工程化。Spring AI 给的,恰恰就是工程化的武器——统一抽象、函数式流水线、与 Spring 生态无缝集成、对 Java 21 新特性的拥抱。
下一篇文章可能聊聊 MCP(模型上下文协议) 和 Session API,这些是 Spring AI 最近更新里比较新也比较值得关注的方向。如果你在生产环境用 Spring AI 做了什么有意思的事,也欢迎来分享。
更多推荐




所有评论(0)