本章内容
- 将音频转写为文本
- 从文本合成语音
- 将图像作为提示上下文
- 生成图像
自古以来,人类发展出多种彼此沟通的方式。或许最古老的形式就是“以声传意”,人们通过说和听来交流。文字交流也演化出多种形态:从早期象形文字、腓尼基人创造的字母,到书信、电子邮件、短信等。有时“一图胜千言”,艺术作品和照片所带来的表达力量,是文字与语音难以企及的。
到目前为止,我们的项目一直围绕与 Board Game Buddy 应用的文本交互展开:提问以文本提交,回答也以文本返回。既然最终与应用互动的是人类,那么提供更贴近人类的交流方式就很有意义。
本章我们将使用 Spring AI 跳出纯文本交互,给应用同时加入语音与图像的输入/输出能力。先从如何为应用“加上声音”开始。
8.1 使用语音
计算机的语音交互长期活在科幻作品里——从《星际迷航》中企业号的计算机,到钢铁侠的 JARVIS。近年来,随着 Siri、Alexa 等助手的普及,语音交互逐渐走向主流,带来免手操作的语音驱动体验。语音是一种自然、丰富且常常高效的交互方式,很多时候比敲字、点按或点击更清晰直观。毕竟,语音作为沟通手段的历史,比计算机要早上好几个千年。
借助生成式 AI,你的 Spring 应用可以具备“聆听”的能力:对音频文件进行“所闻即所写”的转写,帮助应用更自然地与用户互动。反过来,将文本合成为语音,可以让应用像用户与它说话那样“开口”回应。
接下来我们为 Board Game Buddy 添加语音能力,先实现“语音提问 -> 文本转写 -> 回答”的流程。
8.1.1 语音转写(Transcribing speech)
“转写”是把口语音频转换为文本的过程。Spring AI 通过 TranscriptionModel 接口提供转写支持,目前有两个实现:OpenAiAudioTranscriptionModel 与 AzureOpenAiAudioTranscriptionModel。虽然这意味着你只能用 OpenAI 或 Azure OpenAI,但这对我们的项目没影响——我们一直在使用 OpenAI,而且无需在构建中额外添加依赖。
如果你选用的并非 OpenAI 相关 API,也并非完全无解。你需要:
- 按第 1 章所述,把 OpenAI Starter 依赖加入构建。
- 在应用配置里设置 OpenAI API Key(同样见第 1 章)。
- 禁用 OpenAI 的聊天与向量嵌入自动配置,以避免与你选用的 API 发生冲突。
要禁用 OpenAI 的聊天与嵌入自动配置,在 application.properties 中加入:
spring.ai.openai.chat.enabled=false
spring.ai.openai.embedding.enabled=false
上述两项会关闭 OpenAI 的聊天与嵌入自动配置,但保留与语音相关的自动配置启用。
现在为 Board Game Buddy 新增语音能力。先定义一个自定义接口 VoiceService 并提供实现。创建位于 com.example.boardgamebuddy 包下的接口:
package com.example.boardgamebuddy;
import org.springframework.core.io.Resource;
public interface VoiceService {
String transcribe(Resource audioFileResource);
Resource textToSpeech(String text);
}
VoiceService 定义了应用的完整语音体验:既包括语音转文本,也包括文本合成语音。transcribe() 接收音频文件(Resource),返回转写文本(String);textToSpeech() 则反向工作,接收文本,返回包含合成语音的 Resource。
接下来实现 VoiceService。先专注于 transcribe(),textToSpeech() 先留空到下一节再补。如下是初版实现 OpenAiVoiceService:
package com.example.boardgamebuddy;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.audio.speech.SpeechModel;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@Service
public class OpenAiVoiceService implements VoiceService {
private final OpenAiAudioTranscriptionModel transcriptionModel;
public OpenAiVoiceService(
OpenAiAudioTranscriptionModel transcriptionModel) {
this.transcriptionModel = transcriptionModel; // #1
}
@Override
public String transcribe(Resource audioFileResource) {
return transcriptionModel.call(audioFileResource); // #2
}
@Override
public Resource textToSpeech(String text) {
throw new UnsupportedOperationException("Not implemented yet");
}
}
如上,OpenAiVoiceService 用 @Service 标注,会被 Spring 容器自动发现并创建。创建时,它会通过构造器注入 OpenAiAudioTranscriptionModel。这得益于 Spring AI 对 OpenAI 的自动配置会创建该模型实例。若你使用 Azure OpenAI,则应注入 AzureOpenAiAudioTranscriptionModel。
说明:如果 mng.bz/xZ07 的变更被采纳,上述两句将不再需要。
转写逻辑位于 transcribe():把 Resource 交给 transcriptionModel.call(),返回其结果即可。框架会把音频文件发送到 OpenAI(或 Azure OpenAI)的转写 API 完成“重活”。
实现好 AudioService(此处为 VoiceService)后,需要把它注入到 AskController。
清单 8.2 将 AudioService 注入 AskController
package com.example.boardgamebuddy;
import org.springframework.core.io.Resource;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
public class AskController {
private final BoardGameService boardGameService;
private final VoiceService voiceService;
public AskController(BoardGameService boardGameService,
VoiceService voiceService) { // #1
this.boardGameService = boardGameService;
this.voiceService = voiceService;
}
// ...
}
然后新增一个用于转写并提问的 audioAsk() 方法。
8.3 将转写后的问题转交给 askQuestion()
@PostMapping(path="/audioAsk", produces = "application/json") // #1
public Answer audioAsk(
@RequestHeader(name="X_AI_CONVERSATION_ID",
defaultValue = "default") String conversationId,
@RequestParam("audio") MultipartFile audioBlob, // #2
@RequestParam("gameTitle") String gameTitle) { // #3
var transcription = voiceService.transcribe(audioBlob.getResource());
var transcribedQuestion = new Question(gameTitle, transcription);
return boardGameService.askQuestion(transcribedQuestion, conversationId);
}
可以看到,这个 audioAsk() 与前几章的 ask() 很像,但有几点重要差异:
- 处理的是
/audioAsk的POST请求,而非/ask; - 不再从请求体接收
Question,而是通过MultipartFile接收音频(假定其中包含口述问题); - 因为没有
Question对象,所以通过额外的gameTitle字符串参数明确是针对哪款游戏发问。
在方法内部,从 audioBlob 获取 Resource,交给 voiceService.transcribe() 做转写。得到文本后,与 gameTitle 一起构造 Question,再交给 boardGameService.askQuestion() 获取答案并返回。
按理,一个前端应用会负责录音并把音频提交给 API。为避免偏离本书重点,你需要用任一音频工具自行录制音频文件,比如 Audacity、Windows 录音机或 QuickTime。无论使用何种工具,请确保能保存为以下格式之一:MP3、MP4、MPEG、MPGA、M4A、WAV、WEBM——这是 OpenAI 支持的音频格式列表。
现成测试音频
为方便测试,我在本章的 Board Game Buddy 项目中放了几个 MP3 文件(目录 test-audio),覆盖了与 Burger Battle 相关的一些问题,例如:
- 每位玩家起手发几张牌?
- 什么是 Graveyard?
- 什么是 Pickle Plague?
Burger Force Field是否能防御Burgerpocalypse?
你可以直接使用这些文件,或自行录音测试转写功能。
准备好音频后,启动应用,向 /audioAsk 发送请求。假设音频内容是“What is the Graveyard? ”(什么是 Graveyard?),用 HTTPie 可这样提交:
$ http -f POST :8080/audioAsk \
audio@'test-audio/what_is_graveyard.mp3;type=audio/mp3' \
gameTitle="Burger Battle"
如果一切顺利,你会收到类似这样的响应:
{
"answer": "The Graveyard is where destroyed Battle Cards and ingredients
are tossed during the game.",
"game": "Burger Battle"
}
至此,Board Game Buddy 已具备“倾听”用户的能力。而“说话”是语音交互的另一面:让应用把文本“说出来”。下一节我们将用 Spring AI 为 API 增加“文本转语音”。
8.1.2 从文本生成语音
与 OpenAiAudioTranscriptionModel 负责“语音转文本”相对应,SpeechModel 则负责“文本转语音”。你将用 SpeechModel 来实现上一节在 VoiceService 中尚未完成的 textToSpeech() 方法。首先,通过构造器把 SpeechModel 注入到 OpenAiVoiceService 中:
package com.example.boardgamebuddy;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.audio.speech.SpeechModel;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@Service
public class OpenAiVoiceService implements VoiceService {
private final OpenAiAudioTranscriptionModel transcriptionModel;
private final SpeechModel speechModel;
public OpenAiVoiceService(
OpenAiAudioTranscriptionModel transcriptionModel,
SpeechModel speechModel) {
this.transcriptionModel = transcriptionModel;
this.speechModel = speechModel;
}
...
}
现在用它来替换原先抛异常的 textToSpeech() 实现,使其真正产出音频:
@Override
public Resource textToSpeech(String text) {
var speechBytes = speechModel.call(text);
return new ByteArrayResource(speechBytes);
}
信不信由你,这就够了!传入的 String 会直接喂给 SpeechModel.call(),返回的是音频文件的字节数组(默认 MP3)。为了满足返回类型为 Resource,我们用 ByteArrayResource 包一层返回即可。
接下来在 AskController 里新增一个处理方法,实际调用 textToSpeech()。下面的 audioAskAudioResponse() 借鉴了之前的 audioAsk(),但这次返回音频:
@PostMapping(path="/audioAsk", produces = "audio/mpeg")
public Resource audioAskAudioResponse(
@RequestHeader(name="X_AI_CONVERSATION_ID",
defaultValue = "default") String conversationId,
@RequestParam("audio") MultipartFile blob,
@RequestParam("gameTitle") String game) {
var transcription = voiceService.transcribe(blob.getResource());
var transcribedQuestion = new Question(game, transcription);
var answer = boardGameService.askQuestion(
transcribedQuestion, conversationId);
return voiceService.textToSpeech(answer.answer());
}
与 audioAsk() 的关键区别在于:从 BoardGameService 收到 Answer 后,将其中的文本交给 textToSpeech(),并把得到的 Resource(音频字节)直接返回给客户端。
注意 @PostMapping 的路径仍是 /audioAsk,但 produces 指定为 audio/mpeg,这样当请求的 Accept 头为 audio/mpeg 时,会路由到这个处理方法。
和转写一样,编写一个真正播放音频给用户的客户端超出本书范围。但你可以用 HTTPie 试一下:加上 Accept 头并把响应重定向到 MP3 文件:
$ http -f POST :8080/audioAsk \
audio@'test-audio/what_is_graveyard.mp3;type=audio/mp3' \
gameTitle="Burger Battle" accept:audio/mpeg > answer.mp3
提交后,终端不会显示响应正文,但当前目录会出现 answer.mp3。用任意播放器打开,即可听到“What is the Graveyard? ”这个问题的语音回答。
设置文本转语音的可选项
虽然直接调用 speechModel.call(text) 很简洁,你也可以换一种写法:
public Resource textToSpeech(String text) {
SpeechPrompt speechPrompt = new SpeechPrompt(text);
SpeechResponse response = speechModel.call(speechPrompt);
byte[] speechBytes = response.getResult().getOutput();
return new ByteArrayResource(speechBytes);
}
这段代码与前者效果相同,但它允许你配置音频生成的若干选项,例如:
- 使用的 发音人/音色(voice)
- 生成音频所用的 模型(model)
- 输出音频格式(response format)
做法是:创建 SpeechPrompt 时,附带一个选项对象(OpenAiAudioSpeechOptions),该对象通过 builder 设置所需选项。
示例 1:切换发音人(voice)
默认使用 “Alloy”。在 Spring AI 1.0.3 中,可选音色包括:
Alloy、Ash、Ballad、Coral、Echo、Fable、Nova、Onyx、Sage、Shimmer、Verse
若要改为 Nova:
public Resource textToSpeech(String text) {
OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.NOVA)
.build();
byte[] speechBytes = speechModel.call(text);
return new ByteArrayResource(speechBytes);
}
示例 2:切换模型(model)
OpenAI 支持 tts-1 与 tts-1-hd 两个模型(默认 tts-1;tts-1-hd 音色变化更丰富):
public Resource textToSpeech(String text) {
OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.NOVA)
.model("tts-1-hd")
.build();
byte[] speechBytes = speechModel.call(text);
return new ByteArrayResource(speechBytes);
}
示例 3:切换音频格式(responseFormat)
默认 MP3,可改为任意 OpenAI 支持的格式(如 AAC、FLAC、MP3、Opus、PCM、WAV):
public Resource textToSpeech(String text) {
OpenAiAudioSpeechOptions options = OpenAiAudioSpeechOptions.builder()
.voice(OpenAiAudioApi.SpeechRequest.Voice.NOVA)
.model("tts-1-hd")
.responseFormat(
OpenAiAudioApi.SpeechRequest.AudioResponseFormat.AAC)
.build();
byte[] speechBytes = speechModel.call(text);
return new ByteArrayResource(speechBytes);
}
到这里,你已经看到了如何用 Spring AI 的转写模型把语音转成文本,再用语音模型把文本转成语音。如果你使用的是 OpenAI 的 LLM,还有一种更直接的方式:在一次聊天请求中直接发送音频并让响应返回音频。下面我们来看看它如何工作。
8.1.3 直接使用音频作为输入与输出
在写作本章时,OpenAI 提供了两种特殊的“音频增强”模型:
- GPT-4o Audio
- GPT-4o mini Audio
顾名思义,这两种模型分别基于 GPT-4o 与 GPT-4o mini,具备与对应模型相当的补全能力。但它们还能在提示中接收音频并在响应中生成音频。
因此,你可以直接传入音频而不必先转写成文本;也可以直接得到音频而不必把文本再交给语音模型合成。
要在 Board Game Buddy 中使用这些模型,首先需要选择具体的音频模型。虽然 GPT-4o Audio 能力更强,但其每百万 token 的价格显著高于 GPT-4o mini Audio。你可任选其一;出于成本考虑,这里选择 GPT-4o mini Audio,在 application.properties 中设置:
spring.ai.openai.chat.options.model=gpt-4o-mini-audio-preview
你会注意到该配置值与实际模型名略有不同:首先全小写并用连字符分隔;此外在写作时这些模型仍处于 preview,因此后缀带有 -preview。
若你更想使用能力更强的音频模型,可设置为:
spring.ai.openai.chat.options.model=gpt-4o-audio-preview
接下来,需要修改 SpringAiBoardGameService 中的 askQuestion()。下面的代码展示了与音频模型配合的新版本。
清单 8.4 直接发送与接收音频的 askQuestion()
@Override
public AudioAnswer askQuestion(AudioQuestion question, String conversationId) {
var gameNameMatch = String.format(
"gameTitle == '%s'",
normalizeGameTitle(question.gameTitle()));
Media questionAudio = Media.builder()
.data(question.questionAudio())
.mimeType(MimeTypeUtils.parseMimeType("audio/mp3"))
.build(); // #1
var chatResponse = chatClient.prompt()
.user(userSpec -> userSpec
.text("Answer the question from the given audio file.")
.media(questionAudio)) // #2
.system(systemSpec -> systemSpec
.text(promptTemplate)
.param("gameTitle", question.gameTitle()))
.advisors(advisorSpec -> advisorSpec
.param(FILTER_EXPRESSION, gameNameMatch)
.param(CONVERSATION_ID, conversationId))
.options(OpenAiChatOptions.builder()
.outputModalities(List.of("text", "audio")) // #3
.outputAudio(
new AudioParameters(
Voice.ALLOY, AudioResponseFormat.MP3))
.build())
.call()
.chatResponse();
var answerAudio = chatResponse.getResult()
.getOutput()
.getMedia()
.getFirst()
.getDataAsByteArray(); // #4
return new AudioAnswer(question.gameTitle(), answerAudio);
}
新的 askQuestion() 接收的是 AudioQuestion 而非 Question。AudioQuestion 记录类型如下:
package com.example.boardgamebuddy;
import org.springframework.core.io.Resource;
public record AudioQuestion(String gameTitle, Resource questionAudio) {
}
AudioQuestion 与以往一样包含游戏标题,但问题内容不再是 String,而是指向问题音频的 Resource。利用该 Resource 构造一个 Media 对象;在构建「用户消息」时,通过 media() 把该 Media 附上。由于用户消息必须包含文本,这里用 text() 补充一句 “Answer the question from the given audio file.”(请基于给定音频回答问题)。
这就是把音频作为提示一部分传给 LLM 的全部工作。接下来几行与前几章类似。在真正发送前,通过 options() 要求模型同时返回文本与音频,并指定音频使用 ALLOY 音色、MP3 格式。
发送后,调用 chatResponse()(而非 entity() 或 content())。chatResponse() 返回 ChatResponse,其中包含大量响应细节,包括音频结果。随后将该音频提取为 byte[],再构造 AudioAnswer 返回。
与 AudioQuestion 类似,AudioAnswer 是带二进制音频的 Answer 变体:
package com.example.boardgamebuddy;
public record AudioAnswer(String gameTitle, byte[] answerAudio) {
}
最后,需要调整 AskController 中的 audioAskAudioResponse() 以调用新的 askQuestion():
@PostMapping(path="/audioAsk", produces = "audio/mpeg")
public byte[] audioAskAudioResponse(
@RequestHeader(name="X_AI_CONVERSATION_ID",
defaultValue = "default") String conversationId,
@RequestParam("audio") MultipartFile blob,
@RequestParam("gameTitle") String game) {
var audioResource = blob.getResource();
var questionWithAudio = new AudioQuestion(game, audioResource);
var answer = boardGameService.askQuestion(
questionWithAudio, conversationId);
return answer.answerAudio();
}
与上一节版本的区别在于:不再先把音频转写为文本再调用 askQuestion(),也不再把文本答案交给 VoiceService 合成为 MP3。而是直接将 Resource 封装进 AudioQuestion,并从 AudioAnswer 中取回 byte[] 音频。
现在来试试。使用 HTTPie,可以像下面这样提交一段询问 Burger Battle 中 “Graveyard 是什么” 的 MP3:
$ http -f POST localhost:8080/audioAsk \
gameTitle="Burger Battle" \
audio@'test-audio/what_is_graveyard.mp3;type=audio/mp3' \
Accept:audio/mpeg > answer.mp3
和之前一样,你会拿到名为 answer.mp3 的语音答案。用任意播放器打开即可收听。
至此,你已经了解了如何在应用中加入语音能力。接下来让我们从“声”切到“像”,看看如何在生成式 AI 中把图像作为输入与输出来使用。
8.2 基于图像提问
在第 4 章中,你看到如何通过检索增强生成(RAG)把一个或多个文档中的文本添加到提示里,从而让应用可以“和你的文档对话”。但文本并不是 AI 应用能够对话的唯一信息形式。
许多模型也支持把图像作为上下文注入到提示中,进而就 LLM 在图像中“看到”的内容发问。支持视觉的 API/模型包括:
- OpenAI——GPT-4 with Vision、GPT-4o、GPT-4o-mini、GPT-5、GPT-5-mini、GPT-5-nano
- Ollama——Llava、Bakllava、Moondream
- Anthropic——所有 Claude 模型
- Google——所有 Gemini 模型
为展示 Spring AI 对视觉交互的支持,我们为 Board Game Buddy API 增加一个端点:它接收一张图像、一个游戏标题和一个问题。一个使用场景是:用户上传一张对局中的照片并提问。例如在 Burger Battle 中,用户可以拍下自己已出的牌,询问这个汉堡是否能免疫战斗牌,或还需要哪些配料才能获胜。
启用视觉能力首先要在 BoardGameService 接口中重载 askQuestion(),让它接收图像:
package com.example.boardgamebuddy;
import org.springframework.core.io.Resource;
public interface BoardGameService {
Answer askQuestion(Question question, String conversationId);
Answer askQuestion(Question question,
Resource image,
String imageContentType,
String conversationId);
}
下面的清单展示了在 SpringAiBoardGameService 中对这个新方法的实现。
清单 8.5 将图像作为媒体添加到用户消息
@Override
public Answer askQuestion(Question question,
Resource image, // #1
String imageContentType, // #1
String conversationId) { // #1
var gameNameMatch = String.format(
"gameTitle == '%s'",
normalizeGameTitle(question.gameTitle()));
var mediaType =
MimeTypeUtils.parseMimeType(imageContentType); // #2
return chatClient.prompt()
.user(userSpec -> userSpec
.text(question.question())
.media(mediaType, image)) // #3
.system(systemSpec -> systemSpec
.text(promptTemplate)
.param("gameTitle", question.gameTitle()))
.advisors(advisorSpec -> advisorSpec
.param(FILTER_EXPRESSION, gameNameMatch)
.param(CONVERSATION_ID, conversationId))
.call()
.entity(Answer.class);
}
这个版本与我们一直使用的另一个 askQuestion() 很像,但多了几行用于把图像随提示发送出去的代码。发送前,先用 imageContentType 创建一个 MimeType;随后在构建用户消息时,不是直接把问题字符串传给 user(),而是在 user() 的 lambda 中先 text() 设置问题文本,再用 media() 把 图像资源 + MimeType 附上。这就是把图像加入提示的方式。
其余流程与之前一致。由于 ChatClient 已经配好了用于 RAG 与多轮会话的 advisors,关于图像的问题会结合规则文档与聊天历史一并参考,而不仅仅是图像本身。
接下来在 AskController 中新增一个处理方法来调用这个重载的 askQuestion()。
清单 8.6 基于图像回答问题的控制器方法
@PostMapping(path="/visionAsk",
produces = "application/json",
consumes = "multipart/form-data")
public Answer visionAsk(
@RequestHeader(name="X_AI_CONVERSATION_ID",
defaultValue = "default") String conversationId,
@RequestPart("image") MultipartFile image, // #1
@RequestPart("gameTitle") String game,
@RequestPart("question") String questionIn) {
var imageResource = image.getResource(); // #2
var imageContentType = image.getContentType(); // #3
var question = new Question(game, questionIn);
return boardGameService.askQuestion(
question, imageResource, imageContentType, conversationId); // #4
}
某种程度上,这个 visionAsk() 与清单 8.1 的 audioAsk() 很相似:它同样接收 multipart 上传的文件。但这里接收的是图像而非音频。方法首先从 image 中取出 Resource,再取得内容类型 contentType,以供 askQuestion() 使用。然后用游戏名和问题文本构造 Question,把图像 Resource 与内容类型一起传给服务层。
还需做最后一处修改:Spring 默认 multipart 请求的最大文件大小是 1MB,而照片往往更大。请在 application.properties 中放宽限制:
spring.servlet.multipart.max-file-size=10MB
这里把上限设为 10MB,通常足够;若你的照片更大,可进一步调整。
现在可以试用了:启动应用,提交一张照片并就其提问。假设你正在玩 Burger Battle,拍到一张类似图 8.1 的对局中汉堡的照片。
使用 HTTPie 提交方式与本章前面的音频示例类似。如下所示,询问“完成该汉堡还缺哪些配料?”:
$ http -f POST :8080/visionAsk \
gameTitle="Burger Battle" \
question="What ingredients are still needed to complete this burger?" \
image@'test-images/BurgerBattle-3.jpg' -b
{
"answer": "You still need to add lettuce and teriyaki to complete The
Island burger.",
"game": "Burger Battle"
}
快速核对图片可知答案正确:The Island Burger 需要 8 种配料,但图片中只有 6 种,剩下的正是 lettuce 和 teriyaki。
现成的测试图像
考虑到你可能没有 Burger Battle 的实体卡牌,本章项目的 test-images 目录提供了若干测试图片。你也可以拍摄别的游戏的照片,只要提前把其规则载入向量库,就能围绕该游戏提问。
再试一次,这次问:对手如果打出 Burger Bomb,这份汉堡会不会被炸掉?
$ http -f POST :8080/visionAsk \
gameTitle="Burger Battle" \
question="Can a Burger Bomb be played on this burger?" \
image@'test-images/BurgerBattle-3.jpg' -b
{
"answer": "No, a Burger Bomb cannot be played on this burger because it
is protected by the Burger Force Field.",
"game": "Burger Battle"
}
回答表明 LLM 从图像中识别出 Burger Force Field 正在生效,因此汉堡不会被炸毁。
尽管尝试更多图片与问题,看看效果如何。接下来我们看看如何在 Spring AI 中生成图像,而不只是“看图”。
8.3 生成图像
许多人第一次接触生成式 AI,都是通过诸如 Midjourney 这样的图像生成工具。即便当今生成式 AI 的能力与覆盖面已延伸到日常生活的方方面面,用一段文字让模型生成一幅独一无二的艺术作品,依然是最让人愉悦的玩法之一。
借助 Spring AI,你可以为应用加入图像生成功能。Spring AI 提供了 ImageModel 接口(对 OpenAI、Azure OpenAI、Stability、智谱 ZhiPuAI 均有实现),可根据给定的提示词生成图像。
本书一直在使用 OpenAI,因此如果你选择用 OpenAI 生成图像,无需在构建中添加额外依赖。若想改用其他模型,需要在项目中加入以下任一 starter 依赖:
implementation 'org.springframework.ai:spring-ai-starter-model-azure-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-stability-ai'
implementation 'org.springframework.ai:spring-ai-starter-model-zhipuai'
随后,为确保使用正确的 ImageModel 实现,设置相应的 spring.ai.model.image 属性:
spring.ai.model.image=azure-openai
spring.ai.model.image=stabilityai
spring.ai.model.image=zhipuai
设置 spring.ai.model.image 会禁用默认的 ImageModel 自动配置,并强制启用指定实现。也就是说,你仍可像本书前文一样在其余功能上继续使用 OpenAI,同时仅在图像生成上使用其它服务商。
在构建中加入相应 API 提供商的 starter 之后,自动配置会将一个 ImageModel 放入 Spring 应用上下文。你只需在应用代码中注入并使用它即可。
为演示用法,先定义一个提交图像生成提示词的服务接口:
package com.example.boardgamebuddy;
public interface ImageService {
String generateImageForUrl(String instructions);
byte[] generateImageForImageBytes(String instructions);
}
如上,ImageService 定义了两个方法。二者都接收一段图像生成指令(String)。第一个方法返回生成图像的 URL;用浏览器打开即可查看。第二个方法返回图像字节数组,可直接保存为 PNG 文件,再用任意支持 PNG 的应用查看。
图像生成听起来复杂,但大部分工作由底层模型完成——本质上就是向提供商的 API 发送请求。Spring AI 对这些细节做了抽象。下面的清单展示了基于 ImageModel 的 ImageService 实现。
清单 8.7 基于 Spring AI ImageModel 的 ImageService 实现
package com.example.boardgamebuddy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.image.ImageOptions;
import org.springframework.ai.image.ImageOptionsBuilder;
import org.springframework.ai.image.ImagePrompt;
import org.springframework.ai.image.ImageResponse;
import org.springframework.stereotype.Service;
import java.util.Base64;
@Service
public class SpringAiImageService implements ImageService {
private static final Logger LOG =
LoggerFactory.getLogger(SpringAiImageService.class);
private final ImageModel imageModel;
public SpringAiImageService(ImageModel imageModel) {
this.imageModel = imageModel;
}
@Override
public String generateImageForUrl(String instructions) {
return generateImage(instructions, "url")
.getResult()
.getOutput()
.getUrl(); // #1
}
@Override
public byte[] generateImageForImageBytes(String instructions) {
String base64Image = generateImage(instructions, "b64_json")
.getResult()
.getOutput()
.getB64Json(); // #2
return Base64.getDecoder().decode(base64Image); // #3
}
private ImageResponse generateImage(String instructions, String format) {
LOG.info("Image prompt instructions: {}", instructions);
var options = ImageOptionsBuilder.builder()
.width(1024)
.height(1024)
.responseFormat(format)
.build(); // #4
var imagePrompt = new ImagePrompt(instructions, options); // #5
return imageModel.call(imagePrompt); // #6
}
}
SpringAiImageService 的主要工作在私有的 generateImage() 方法中完成。它先记录传入的指令,然后创建一个 ImageOptions,在其中可以指定图像生成的各类选项。至少需要指定图像的宽高;示例里将宽高都设为 1024,生成 1024×1024 的图像。
你也可以把宽高作为 Spring 配置属性设置:
spring.ai.openai.image.options.height=1024
spring.ai.openai.image.options.width=1024
除了尺寸,还要设置响应格式,这里由 generateImage() 的 format 参数决定。对于 OpenAI,支持 url 或 b64_json 两种格式:
- 当为
url时,服务方生成图像并返回一个可访问该图像的 URL; - 当为
b64_json时,直接返回Base64 编码的图像数据。
正因为响应格式不同,ImageService 才提供了两个公共方法:generateImageForUrl() 与 generateImageForImageBytes() 都会调用 generateImage() 发送请求,但二者产出的格式不同。
generateImageForUrl()传入url,随后从结果中取出输出的 URL 返回。调用方可在1 小时内对该 URL 发起 HTTP GET 请求获取图像。generateImageForImageBytes()传入b64_json,从结果中取出 Base64 字符串并解码为字节数组。调用方可以保存为文件,或(下一步)直接由控制器返回给客户端保存。
接下来把图像服务用于一个新控制器。为了更有趣、也更聚焦,我们写一个控制器:给出 Burger Battle 中某个汉堡的名字,就生成其艺术渲染图。如下的 BurgerBattleArtController 展示了做法。
清单 8.8 根据 Burger Battle 的汉堡名称生成艺术图的控制器
package com.example.boardgamebuddy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BurgerBattleArtController {
private final BoardGameService boardGameService;
private final ImageService imageService;
public BurgerBattleArtController(BoardGameService boardGameService,
ImageService imageService) { // #1
this.boardGameService = boardGameService;
this.imageService = imageService;
}
@GetMapping(path="/burgerBattleArt")
public String burgerBattleArt(@RequestParam("burger") String burger) {
var instructions = getImageInstructions(burger);
return imageService.generateImageForUrl(instructions); // #2
}
@GetMapping(path="/burgerBattleArt", produces = "image/png")
public byte[] burgerBattleArtImage(@RequestParam("burger") String burger) {
var instructions = getImageInstructions(burger);
return imageService.generateImageForImageBytes(instructions); // #3
}
private String getImageInstructions(String burger) {
var question = new Question(
"Burger Battle",
"What ingredients are on the " + burger + " burger?");
var answer = boardGameService.askQuestion(
question, "art_conversation"); // #4
return "A burger called " + burger + " " +
"with the following ingredients: " + answer.answer() + ". " +
"Style the background to match the name of the burger."; // #5
}
}
正如 ImageService 有两个方法(一个返回 URL、一个返回字节),这个控制器也提供了两个同一路径 /burgerBattleArt 的处理方法,二者都接收 burger 请求参数(汉堡名)。不同之处在于:
- 带
produces = "image/png"的burgerBattleArtImage()仅在Accept: image/png时处理请求,并调用generateImageForImageBytes()返回PNG 字节; - 否则由
burgerBattleArt()处理,调用generateImageForUrl()返回图像 URL。
二者都依赖私有方法 getImageInstructions() 来生成图像提示词。该方法会调用你在全书中构建的 BoardGameService.askQuestion(),查询指定汉堡的配料(文档中已包含汉堡及配料信息,检索应毫无问题)。
提示 这里硬编码了会话 ID:
art_conversation,因为该控制器并不关心上下文对话。
getImageInstructions() 最后返回完整的生成指令:让模型根据汉堡名与配料生成图像,并让背景风格与汉堡名称相匹配。
现在启动应用,来点“美味汉堡艺术”。例如,用 HTTPie 生成 “Cowboy” 汉堡的图像:
$ http :8080/burgerBattleArt?burger=Cowboy
稍等片刻(生成图像通常比文本问答慢一些),你会得到一个图像的 URL。该 URL 在1 小时内有效,尽快在浏览器中打开。每次渲染都会不同,你可能看到类似图 8.2 的效果。
图 8.2 Cowboy 汉堡的生成图示意
为避免“先拿 URL、再打开”的两步(且可能过期),你也可以直接请求图像本身:将 Accept 设为 image/png。例如,获取 “Sunrise” 汉堡的图片:
$ http :8080/burgerBattleArt?burger=Sunrise \
accept:image/png > sunrise.png
命令将返回的字节重定向到 sunrise.png。用你喜欢的图片查看器打开它,你可能看到类似图 8.3 的效果。
图 8.3 Sunrise 汉堡的生成图示意
可以看到,ImageModel 仅凭一段描述文字就能生成相当精彩的图像。当然,你还可以通过若干可选项进一步微调生成效果。此前我们已设置了图像宽高与响应格式;接下来将看看其它可能有用的图像生成选项。
8.3.1 指定图像生成选项
在清单 8.7 中,你已经看到了如何使用 ImageOptionsBuilder 指定生成图像的宽度、高度,以及返回格式。ImageOptionsBuilder 还提供了若干方法来设置其它选项,见下表(表 8.1)。
表 8.1 ImageOptionsBuilder 用于设置图像选项的方法
height(Integer):图像高度model(String):使用的模型名称,可选dall-e-2或dall-e-3,默认dall-e-3N(Integer):要生成的图像数量responseFormat(String):返回格式,url或b64_json,默认urlstyle(String):图像风格,vivid(更具艺术/超现实感)或natural(更接近现实/摄影风),默认vividwidth(Integer):图像宽度
在这些属性中,style() 尤其有意思。该方法用于指定是要 vivid(超写实/艺术感)还是 natural(摄影感)风格。默认是 vivid,如果你更倾向于摄影感风格,可以这样设置:
ImageOptions options = ImageOptionsBuilder.builder()
.width(1024)
.height(1024)
.responseFormat(format)
.style("natural")
.build();
应用 natural 风格后,生成的图像看起来更像照片而非绘画。图 8.4 展示了对 “Cowboy” 汉堡应用自然风格后的效果。
图 8.4 Cowboy 汉堡的自然(摄影)风渲染
除了用 ImageOptionsBuilder 构建 ImageOptions,还可以使用 OpenAiImageOptions.builder()。它能设置与 ImageOptionsBuilder 相同的选项,并额外提供一些 OpenAI 专属 的设置(见表 8.2)。
表 8.2 使用 OpenAiImageOptions 额外可设置的选项
quality(String):图像质量,standard或hd,默认standarduser(String):请求发起用户的标识字符串,OpenAI 用于监控与滥用检测
例如,下面的代码既使用 natural 风格,也选择 hd 质量,以获得细节更丰富、更加摄影化的效果:
ImageOptions options = OpenAiImageOptions.builder()
.height(1024)
.width(1024)
.responseFormat(format)
.style("natural")
.quality("hd")
.build();
当 quality 为 hd 时,通常能得到更精细、观感更佳的图像,如图 8.5 所示。
图 8.5 Cowboy 汉堡的高清摄影风渲染
这些选项同样可以通过配置属性进行设置。以下配置属性与 ImageOptionsBuilder/OpenAiImageOptions 上的同名方法等效:
spring.ai.openai.image.options.model
spring.ai.openai.image.options.n
spring.ai.openai.image.options.quality
spring.ai.openai.image.options.response-format
spring.ai.openai.image.options.size
spring.ai.openai.image.options.style
spring.ai.openai.image.options.user
spring.ai.openai.image.options.height
spring.ai.openai.image.options.width
通过配置属性设置图像选项后,它们会成为新的默认值,无需在 Java 代码里重复指定。比如,要生成 1024 × 1024、高清、摄影风 的图像,可在 application.properties 中这样写:
spring.ai.openai.image.options.height=1024
spring.ai.openai.image.options.width=1024
spring.ai.openai.image.options.style=natural
spring.ai.openai.image.options.quality=hd
若某次需要不同的值,再在构造器/Builder 中覆盖即可。
Azure OpenAI 图像选项
若你选择使用 Azure 的 OpenAI,可以使用 AzureOpenAiImageOptions(而非 OpenAiImageOptions)。它提供与 OpenAiImageOptions 相同的设置,并新增 deploymentName() 用于指定连接到 Azure OpenAI 服务时的部署名称。
例如,若部署名为 MyDeployment,可以这样创建选项对象:
ImageOptions options = AzureOpenAiImageOptions.builder()
.deploymentName("MyDeployment")
.height(1024)
.width(1024)
.responseFormat(format)
.style("natural")
.quality("hd")
.build();
同样也可通过配置属性设置这些选项:
spring.ai.azure.openai.image.options.deployment-name="My Deployment"
spring.ai.azure.openai.image.options.height=1024
spring.ai.azure.openai.image.options.width=1024
spring.ai.azure.openai.image.options.style=natural
spring.ai.azure.openai.image.options.quality=hd
可以看到,与 OpenAI 的属性名相比,Azure OpenAI 的属性名在中间多了 azure。
小结
- Spring AI 可集成支持音频与图像的模型。
- 使用 OpenAI 或 Azure OpenAI 时,转录模型让应用能“听懂”音频并生成文本。
- 同样在 OpenAI 下,可以用 SpeechModel 将文本合成音频。
- 对于支持视觉能力的模型,只需把图像 Resource加入提示词中,便可就图像内容进行问答。
- 对于支持图像生成的模型,Spring AI 也可根据文本生成图像。