Spring AI 实战——语音与图像生成

91 阅读14分钟

本章内容

  • 将音频转写为文本
  • 从文本合成语音
  • 将图像作为提示上下文
  • 生成图像

自古以来,人类发展出多种彼此沟通的方式。或许最古老的形式就是“以声传意”,人们通过说和听来交流。文字交流也演化出多种形态:从早期象形文字、腓尼基人创造的字母,到书信、电子邮件、短信等。有时“一图胜千言”,艺术作品和照片所带来的表达力量,是文字与语音难以企及的。

到目前为止,我们的项目一直围绕与 Board Game Buddy 应用的文本交互展开:提问以文本提交,回答也以文本返回。既然最终与应用互动的是人类,那么提供更贴近人类的交流方式就很有意义。

本章我们将使用 Spring AI 跳出纯文本交互,给应用同时加入语音与图像的输入/输出能力。先从如何为应用“加上声音”开始。

8.1 使用语音

计算机的语音交互长期活在科幻作品里——从《星际迷航》中企业号的计算机,到钢铁侠的 JARVIS。近年来,随着 Siri、Alexa 等助手的普及,语音交互逐渐走向主流,带来免手操作的语音驱动体验。语音是一种自然、丰富且常常高效的交互方式,很多时候比敲字、点按或点击更清晰直观。毕竟,语音作为沟通手段的历史,比计算机要早上好几个千年。

借助生成式 AI,你的 Spring 应用可以具备“聆听”的能力:对音频文件进行“所闻即所写”的转写,帮助应用更自然地与用户互动。反过来,将文本合成为语音,可以让应用像用户与它说话那样“开口”回应。

接下来我们为 Board Game Buddy 添加语音能力,先实现“语音提问 -> 文本转写 -> 回答”的流程。

8.1.1 语音转写(Transcribing speech)

“转写”是把口语音频转换为文本的过程。Spring AI 通过 TranscriptionModel 接口提供转写支持,目前有两个实现:OpenAiAudioTranscriptionModelAzureOpenAiAudioTranscriptionModel。虽然这意味着你只能用 OpenAI 或 Azure OpenAI,但这对我们的项目没影响——我们一直在使用 OpenAI,而且无需在构建中额外添加依赖。

如果你选用的并非 OpenAI 相关 API,也并非完全无解。你需要:

  1. 按第 1 章所述,把 OpenAI Starter 依赖加入构建。
  2. 在应用配置里设置 OpenAI API Key(同样见第 1 章)。
  3. 禁用 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() 很像,但有几点重要差异:

  • 处理的是 /audioAskPOST 请求,而非 /ask
  • 不再从请求体接收 Question,而是通过 MultipartFile 接收音频(假定其中包含口述问题);
  • 因为没有 Question 对象,所以通过额外的 gameTitle 字符串参数明确是针对哪款游戏发问。

在方法内部,从 audioBlob 获取 Resource,交给 voiceService.transcribe() 做转写。得到文本后,与 gameTitle 一起构造 Question,再交给 boardGameService.askQuestion() 获取答案并返回。

按理,一个前端应用会负责录音并把音频提交给 API。为避免偏离本书重点,你需要用任一音频工具自行录制音频文件,比如 Audacity、Windows 录音机或 QuickTime。无论使用何种工具,请确保能保存为以下格式之一:MP3MP4MPEGMPGAM4AWAVWEBM——这是 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-1tts-1-hd 两个模型(默认 tts-1tts-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 而非 QuestionAudioQuestion 记录类型如下:

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 的对局中汉堡的照片。

image.png

使用 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 种,剩下的正是 lettuceteriyaki

现成的测试图像

考虑到你可能没有 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 对这些细节做了抽象。下面的清单展示了基于 ImageModelImageService 实现。

清单 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,支持 urlb64_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 的效果。

image.png

图 8.2 Cowboy 汉堡的生成图示意

为避免“先拿 URL、再打开”的两步(且可能过期),你也可以直接请求图像本身:将 Accept 设为 image/png。例如,获取 “Sunrise” 汉堡的图片:

$ http :8080/burgerBattleArt?burger=Sunrise \
  accept:image/png > sunrise.png

命令将返回的字节重定向到 sunrise.png。用你喜欢的图片查看器打开它,你可能看到类似图 8.3 的效果。

image.png

图 8.3 Sunrise 汉堡的生成图示意

可以看到,ImageModel 仅凭一段描述文字就能生成相当精彩的图像。当然,你还可以通过若干可选项进一步微调生成效果。此前我们已设置了图像宽高响应格式;接下来将看看其它可能有用的图像生成选项。

8.3.1 指定图像生成选项

在清单 8.7 中,你已经看到了如何使用 ImageOptionsBuilder 指定生成图像的宽度、高度,以及返回格式。ImageOptionsBuilder 还提供了若干方法来设置其它选项,见下表(表 8.1)。

表 8.1 ImageOptionsBuilder 用于设置图像选项的方法

  • height(Integer):图像高度
  • model(String):使用的模型名称,可选 dall-e-2dall-e-3,默认 dall-e-3
  • N(Integer):要生成的图像数量
  • responseFormat(String):返回格式,urlb64_json,默认 url
  • style(String):图像风格,vivid(更具艺术/超现实感)或 natural(更接近现实/摄影风),默认 vivid
  • width(Integer):图像宽度

在这些属性中,style() 尤其有意思。该方法用于指定是要 vivid(超写实/艺术感)还是 natural(摄影感)风格。默认是 vivid,如果你更倾向于摄影感风格,可以这样设置:

ImageOptions options = ImageOptionsBuilder.builder()
    .width(1024)
    .height(1024)
    .responseFormat(format)
    .style("natural")
    .build();

应用 natural 风格后,生成的图像看起来更像照片而非绘画。图 8.4 展示了对 “Cowboy” 汉堡应用自然风格后的效果。

image.png

图 8.4 Cowboy 汉堡的自然(摄影)风渲染

除了用 ImageOptionsBuilder 构建 ImageOptions,还可以使用 OpenAiImageOptions.builder()。它能设置与 ImageOptionsBuilder 相同的选项,并额外提供一些 OpenAI 专属 的设置(见表 8.2)。

表 8.2 使用 OpenAiImageOptions 额外可设置的选项

  • quality(String):图像质量,standardhd,默认 standard
  • user(String):请求发起用户的标识字符串,OpenAI 用于监控与滥用检测

例如,下面的代码既使用 natural 风格,也选择 hd 质量,以获得细节更丰富、更加摄影化的效果:

ImageOptions options = OpenAiImageOptions.builder()
    .height(1024)
    .width(1024)
    .responseFormat(format)
    .style("natural")
    .quality("hd")
    .build();

qualityhd 时,通常能得到更精细、观感更佳的图像,如图 8.5 所示。

image.png

图 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 也可根据文本生成图像