☰
Spring AI进阶实战:结构化输出、多模态、RAG与Function Calling
2026/10/22 12:08:29 网站建设 项目流程

Spring AI学习(二),把前面埋的坑填上

上一篇我们聊了Spring AI的基本概念和最简单的对话调用,算是把门打开了。这篇继续往里走,聊点真正用到生产环境时绕不开的东西:结构化输出、多模态、向量库、还有Function Calling。这些都是Spring AI里比较有区分度的功能,搞懂了它们,你手里的Spring AI才算是一套趁手的工具,而不是一个只能聊天的Demo。

先说清楚,这篇内容建立在你已经跑通Spring AI基础调用的前提上。如果还没有跑通过,建议先回去把环境和最简单的对话请求弄明白,再来读这篇,不然有些代码会让你觉得“怎么多了这么多配置”。

1. 结构化输出:让AI返回能直接用的数据

1.1 为什么需要结构化输出

直接让大模型聊天,返回的是自然语言文本。但真实项目里,你往往需要的是JSON、一个List、一个枚举,甚至是一个Java对象。举个最常见的场景:从用户评价里抽取情感倾向和关键观点,返回给前端渲染成标签列表。如果让模型瞎说,前端拿到的就是一段带着语气词的话,还得自己写正则去拆,拆完还容易错。

有人可能会说,我在Prompt里写清楚“请返回JSON”不就行了?我试过,有两个坑:一是模型偶尔会在JSON外面包一层解释性的文字,比如“好的,以下是您需要的JSON:”,这层包装解析的时候直接让人头大;二是字段名你不一定控制得住,你说返回“sentiment”,它给你来个“情绪”,字段不统一,程序就得跟着乱。

Spring AI的结构化输出就是解决这个问题的。它用了一套机制,把大模型的输出格式约束住,让你能把回复直接绑定到Java类型上,省去手工解析的麻烦。

1.2 BeanOutputConverter:最常用的方案

Spring AI里最常用的是BeanOutputConverter<T>。它的工作方式是这样的:你告诉它你想要的Java类型,它会自动生成一个Prompt片段,附加在你的业务Prompt后面,要求模型严格按照指定的JSON Schema来返回;模型返回之后,它再用Jackson把字符串反序列化成你的Java类型。

代码如下:

public record SentimentResult(String sentiment, int confidence, List<String> keywords) { } @RestController public class SentimentController { private final ChatClient chatClient; private final BeanOutputConverter<SentimentResult> converter = new BeanOutputConverter<>(SentimentResult.class); public SentimentController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping("/sentiment") public SentimentResult analyze(@RequestBody String content) { String prompt = """ 分析以下用户评论的情感倾向。 评论内容:%s 请严格按照要求的JSON格式返回。 """.formatted(content); // 关键:把converter.getFormat()拼进Prompt String fullPrompt = prompt + converter.getFormat(); String response = chatClient.prompt() .user(fullPrompt) .call() .content(); return converter.convert(response); } }

这里有个非常关键的细节:converter.getFormat()生成的这段Prompt一定不能漏。你要是忘了拼进去,模型就不知道你要JSON,自然就返回普通文本,然后converter.convert()直接抛异常。

另一个细节是关于Java类型的定义。我强烈建议用Java 16之后的record,因为Spring AI对record的反射处理很友好,代码也更简洁,不用写一堆getter和setter。字段命名也尽量用英文,避免出现“情感”这样的中文属性名,否则序列化时容易出怪问题。

1.3 复杂类型与嵌套结构

单层的record好解决,嵌套的也没问题。比如你要返回一个文章摘要列表,每篇摘要又有标题、正文、标签数组,那就定义嵌套record:

public record ArticleSummary(String title, String summary, List<String> tags) { } public record ArticleDigestResponse(List<ArticleSummary> articles) { }

这种嵌套结构,Spring AI会自动生成对应的JSON Schema,模型会照着这个Schema生成数据。实测下来,只要字段说明写得稍微清楚一点,模型基本不会格式出错。

有一点要提醒:嵌套层级别太深。我之前试着定义过四层嵌套的对象,模型偶尔会出现字段缺失的情况。后来我把结构拍扁了一些,问题就好了很多。模型毕竟不是数据库,嵌套越浅,它越不容易犯错。

1.4 枚举和服务化封装

有个细节容易踩坑:如果你在record里用了Java枚举,比如SentimentType { POSITIVE, NEGATIVE, NEUTRAL },Spring AI在生成JSON Schema时会把枚举值列出来,这有助于模型选择正确的值。但如果枚举中文名和英文名混用,就可能出现模型返回了不在枚举范围内的值,反序列化报错。建议枚举值统一用大写英文,并在枚举字段上加上@JsonPropertyDescription说明每个取值对应的含义。

如果结构化输出要被多个业务方法共用,我建议封装成一个小的工具类或者配置Bean。比如定义一个ChatClient的配置类,统一配置模型、温度参数、超时时间,然后把BeanOutputConverter的创建也放进去,避免每个接口里都重复new一个转换器。

2. 多模态支持:让模型“看到”图片

2.1 多模态的基本概念

Spring AI 从 1.0 开始对多模态的支持越来越完整。所谓多模态,就是输入不再局限于文本,还可以是图片、音频、视频。以图片输入为例,你可以把一张图片的URL或者Base64编码传给模型,让模型描述图片内容、识别图片里的文字、分析图片里的情绪等等。

这个能力在真实项目里应用很广。比如电商平台要审核用户上传的商品图,自动识别图片里是否包含违禁品;内容平台要识别用户头像是否包含广告信息;还有像我之前做过的一个小项目,输入一张发票照片,自动提取发票号、金额、开票日期。

2.2 通过消息类型传递图片

Spring AI里多模态的实现方式,是通过构造不同类型的消息内容。最基础的方式是使用UserMessage的构建器,把文本和图片一并塞进去:

String imageUrl = "https://example.com/sample.jpg"; UserMessage message = UserMessage.builder() .text("请描述这张图片中的主要内容,并提取图中出现的所有文字。") .media(new Media(MimeType.TEXT_PLAIN, imageUrl)) .build(); String response = chatClient.prompt() .user(message) .call() .content();

注意看,Media对象接收两个参数:MIME类型和数据的值。MIME类型常见的有MediaType.IMAGE_PNG、MediaType.IMAGE_JPEG,表示这是一张图片。我一开始没注意这个参数,随便填了个TEXT_PLAIN,结果模型直接忽略图片,只回了文本——这个细节还是排查了半天才发现的。

2.3 Base64与本地图片的处理

URL方式适合图片已经在公网可访问的场景。如果你的图片在本地文件系统,那就得转成Base64再传给模型。过程也不复杂:

byte[] imageBytes = Files.readAllBytes(Path.of("/path/to/local/image.jpg")); String base64Image = Base64.getEncoder().encodeToString(imageBytes); UserMessage message = UserMessage.builder() .text("请识别这张图片中的文字内容。") .media(new Media(MediaType.IMAGE_JPEG, "data:image/jpeg;base64," + base64Image)) .build();

用Base64传图的时候有几个要注意的点。一是体积,一张几MB的图片转成Base64后体积会再膨胀约三分之一,加上请求体里还有其他内容,很容易触碰模型服务端的单次请求体大小限制。建议上传前先做压缩,把长边压到2000像素以内,质量压缩到0.8左右,基本不影响识别精度。二是超时时间要适当调大,图片识别的响应时间会比纯文本慢不少,特别是大图。我一般会把超时设成30秒以上,避免因为响应稍慢就被客户端取消。

2.4 多模态的模型选择

不是所有模型都支持图片输入。目前市面上的主流多模态模型里,OpenAI系、Google的Gemini、Anthropic的Claude都支持图片。国内的一些模型像通义千问VL、智谱GLM-4V也支持。选型时要特别注意你当前配置的模型是否有多模态能力,如果你用的是一个只支持文本的模型,传图片进去多数情况下会报错或者忽略图片。

有一个比较实用的技巧:你可以故意把一张不存在的图片URL传给模型,看它是直接报错还是假装看到了。假装看到了就是模型没有多模态能力,只是为了迎合你的指令在瞎编。这个测试方法比较糙,但确实能快速筛出一批假多模态模型。

3. 向量数据库与RAG:给模型装上外部知识库

3.1 RAG的基本思路和Spring AI的抽象

RAG(Retrieval-Augmented Generation,检索增强生成)是现在落地AI应用最主流的技术路线之一。它解决的核心问题是:大模型的训练数据是有截止日期的,你问它你公司内部某个项目的细节,它完全不知道;但如果把公司文档切分、向量化、存进向量数据库,每次提问时先检索相关片段,再把检索结果和问题一起发给模型,模型就能“现学现卖”。

Spring AI对RAG做了比较完整的抽象,核心概念包括:VectorStore(向量存储)、Document(文档对象)、EmbeddingModel(向量化模型)。你做配置的时候,主要是选一个向量数据库实现和一个向量化模型。

3.2 搭建一个Spring AI的小型RAG流程

完整的RAG流程是这样的:文档加载、文档切分、向量化并写入向量库;用户提问时,问题向量化,在向量库里做相似度检索,取回TopK个相关片段,组装成Prompt发给大模型。

Spring AI里,你可以直接用现成的工具类来组装这个流程。以内存向量存储为例:

@Configuration public class RagConfig { @Bean VectorStore vectorStore(EmbeddingModel embeddingModel) { return new SimpleVectorStore(embeddingModel); } @Bean DocumentRetriever documentRetriever(VectorStore vectorStore) { return new VectorStoreDocumentRetriever.Builder() .vectorStore(vectorStore) .similarityThreshold(0.5) .topK(4) .build(); } }

文档接入的部分,Spring AI有TextLoader之类的工具可以从各种格式的文件里提取文本。比如加载一个本地Markdown文档:

Document document = new Document("src/main/resources/docs/product-guide.md", Map.of("source", "product-guide"));

实际项目里,文档往往不只一个。我会维护一个简单的文档扫描器,把某个目录下的所有Markdown和TXT文件都加载进来,逐条写入向量库。写入前用DocumentSplitter把长文档切分成块,每个块大概300到500字。这个切分长度很关键:太短了语义不完整,太长了这个检索的精度会下降,因为一个块里可能混了好几个主题。

3.3 向量的选择与检索效果

向量化模型负责把文本变成一串浮点数,这个质量直接决定了检索效果。Spring AI支持多种Embedding模型,国内常用的有通义千问的text-embedding-v2,也有开源的bge系列。选型建议先测一下效果,不要盲目追求大模型——几百MB的Embedding模型可能比几GB的模型更适合你的垂直领域。

相似度阈值这里我也提一句:如果设置太高,比如0.8,很多相关文档会被过滤掉;设置太低,比如0.3,又会有大量不相关内容混进来,干扰模型回答。0.4到0.6是一个比较合理的起手区间,具体要看你用的Embedding模型对文本的区分度。

3.4 RAG的进阶优化技巧

基础RAG跑通之后,可以做一些优化。一是文档切分的策略,不要固定按字数切,可以按标题、段落这些语义边界切,切出来的块更有结构性。二是检索时给文档块加权,比如标题和摘要这种关键位置的块,权重可以更高。三是引入重排模型,第一步先用向量检索召回20个候选块,再用重排模型精排选出5个最相关的,这个方案在文档数量比较大的时候效果好很多。

我自己的经验是:RAG效果不好,最常见的问题不是模型不好,而是检索没做好。常常是用户的提问方式和文档里的表述方式不一致,导致向量检索匹配不上。解决思路是拆解用户问题,把问句转成关键词组合去检索,效果会有明显改善。

4. Function Calling:让AI能“执行操作”

4.1 Function Calling的作用

如果AI只能回答“理论上应该怎么做”,那就只是个高级搜索。真正让AI从“说”变成“做”的,是Function Calling。它允许你给模型注册一些Java方法,模型在回答过程中如果发现需要调用外部能力,会先返回一个结构化的函数调用请求,你的程序执行完函数后,再把结果回传给模型,模型基于结果生成最终回答。

举个最直观的例子:用户问“北京现在天气怎么样?”你完全没有必要让模型内置天气知识,因为即使它知道,也只是训练时的旧数据。你给模型注册一个查询天气的函数,模型收到这个问题后,会调用这个函数拿到实时数据,再基于数据进行回答。

4.2 在Spring AI中定义和使用函数

在Spring AI中使用函数调用的方式比较简洁。你需要定义一个Spring管理的Bean,同时给它一个名称和描述。模型看到这个描述之后,就知道遇到什么样的问题时该调用哪个函数。

@Service public class WeatherService { @Description("查询指定城市的当前天气情况,输入城市名称,返回温度、天气现象和风力") @JsonSchema(name = "query_weather", description = "根据城市名查询实时天气") public WeatherInfo getWeather(String city) { // 这里调用实际的天气服务API return weatherApiClient.fetch(city); } }

然后把服务注入到ChatClient的配置里:

@Bean ChatClient chatClient(ChatClient.Builder builder, WeatherService weatherService) { return builder .defaultFunctions("query_weather") .build(); }

这里的@Description不是可选的装饰,而是必须有的核心配置。模型没有你的代码,它只知道这个函数的描述,描述写得越清楚,模型越知道该在什么时机调用。如果描述太模糊,模型可能宁可自己瞎编天气也不会去调用函数。

4.3 参数类型的注意事项

Function Calling的参数类型也有讲究。模型内部是通过JSON Schema来描述你的参数结构的,如果你把参数定义成一个复杂的Java对象,模型需要先从用户的问题里推断出各个字段的值,再拼出JSON参数。这个过程如果字段很多,模型就容易漏字段。我的经验是,函数参数尽量用简单类型和少量字段,避免超过四五个必填参数。如果参数确实很多,考虑拆成几个函数,每个函数负责一小块。

比如查询天气这个函数,参数就是城市名一个字段,模型非常容易理解和填充。我之前试过一个函数带了五个参数:城市、日期、天气类型、预警类型、返回格式,模型经常漏掉某个参数,或者把参数值猜错。后来拆分成按城市查实时天气、按城市查未来七天、按城市查预警三个函数,准确率一下就上来了。

4.4 多函数协作与上下文维护

真实场景里,一次对话往往需要多次调用函数。用户可能先说“帮我查下北京的天气”,然后再问“那上海呢?”这时候你需要在会话里把历史消息传下去,模型才能理解“那”指的是天气查询。Spring AI里使用ChatMemory来维护会话历史,你可以在每次请求时把历史消息和当前消息一起组装到Prompt里。

多函数协作的另一个问题是相互依赖。比如用户说“帮我订一张明天从北京到上海的高铁票,然后查一下上海明天的天气”,这需要先后调用查票、订票、查天气三个函数。目前主流模型在处理这种连续调用上的能力在逐渐增强,但你的函数设计还是要尽量解耦,不要让某个函数必须在另一个函数的结果基础上才能工作太多层,否则很容易因为中间任何一次函数调用的输出格式不对而断掉。

5. 生产落地时的几个硬经验

5.1 模型参数的调优思路

Spring AI里可调的模型参数不少,最常用的是温度、最大Token数、超时时间。温度参数控制输出的随机性,取值0到2,低温度适合任务型场景(信息抽取、分类、结构化输出),高温度适合创意型场景(写文章、头脑风暴)。

我常用的配置是:结构化输出任务温度设为0,这样能最大程度减少格式错误;客服对话场景温度设为0.3到0.5,既有一定灵活性又不会太飘;创意文案场景才会用到0.8以上。注意温度并不是越高越好,我之前试过把温度拉到1.5去生成产品卖点文案,结果生成的内容天马行空,但不适合直接用。

Token上限这个参数也要盯紧。有些模型按输入加输出总长度计费,输出Token上限设太大,不仅费钱,而且响应时间拉长。我一般先看一下业务场景需要的输出长度,比如结构化抽取任务通常几百Token就够了,没必要设置成4096。

5.2 日志与可观测性的设计

AI应用和传统接口最大的不同是它足够黑盒,你出了问题很难复现。建议从一开始就把完整的请求和响应日志打好:记录Prompt全文、模型返回内容、耗时、Token消耗、是否触发函数调用。最好在开发环境把日志级别设为DEBUG,能看到Spring AI内部对整个请求链路做的各种封装处理。生产环境至少保留INFO级别的请求快照,方便出问题时定位。

有一个比较隐蔽的问题是模型输出的格式与你预期不符时,框架的解析失败会抛出异常,但这时候你往往找不到真正的Prompt是什么。我在项目里专门加了一个拦截器,把每次请求的Prompt以及最终模型返回的原始文本都记录到独立的日志文件里,排查问题效率高得多。

5.3 重试与降级

大模型接口相对不稳定,偶尔会遇到超时、限流、网络抖动。Spring AI框架本身在部分模型实现中内置了简单的重试机制,但实际生产建议自己做一层封装。我常用的做法是:自定义一个重试注解,对聊天调用方法做最多三次重试,每次间隔递增。比如第一次失败等2秒再试,第二次失败等4秒,第三次再失败就走降级逻辑,返回一个预设的兜底文本或者走其他备用模型。

需要注意的是,重试不能盲目做。模型的一次调用可能已经成功执行了函数,只是响应在你这边因为网络原因丢了,这时候重试会导致函数被重复执行。对于不是幂等的函数调用,例如扣款、发消息之类的操作,必须在Prompt里约定好执行的时机,或者在业务层面做好幂等控制。

6. 一些踩坑记录与个人建议

这段时间用Spring AI,踩了不少坑,有几个印象特别深的写在这里给各位参考。

第一个坑是版本匹配问题。Spring AI的版本迭代很快,不同版本之间API变动不小,尤其是ChatClient和ChatModel的命名和构建方式。我在某个版本上写好的代码,升级一个小版本后,编译直接报错。强烈建议固定在某个具体版本上,不要随便跟最新的SNAPSHOT版本。升级前先看一眼官方的迁移说明,不要蒙头升。

第二个坑是模型厂商的接入差异。虽然Spring AI统一了接口,但不同厂商在参数细节上多少有一些区别。比如有些厂商的模型不支持某些参数,传了会报错。我的建议是配置项尽量精简,只配置确实需要的参数,不要把所有参数都堆上去。

第三个坑是JVM内存和响应时间。大模型的响应是流式的,如果你用同步等待,在高并发下线程会被长时间占用,线程池很容易被打满。生产环境建议配合WebFlux等响应式方式使用,或者至少把同步调用的线程池设置得宽松一些。

第四个坑是成本。Token用起来比想象中快很多,尤其是RAG场景,每个请求都可能要携带几万Token的上下文,账单数字蹭蹭往上涨。建议给每个请求加上Token统计,定期看哪些接口消耗最大,优化Prompt长度和文档检索策略。

Spring AI这个框架还在快速演进,但已经具备了不少可以落地到生产的能力。这篇文章讲的结构化输出、多模态、RAG、Function Calling,基本覆盖了日常开发里最常用的几个能力面。如果大家在实际落地中遇到什么有意思的问题,欢迎在评论区聊聊,互相踩一下坑也是好事。后面有时间,我打算专门写一篇关于流式输出和WebSocket推送整合的文章,那个场景在实际项目里也比较常用,到时候再和大家分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询