想把一个带角色人设的 AI 语音聊天软件从想法变成能装进手机的应用,第一关不是模型能力,而是链路管理。用户对着手机说一句话,要经过语音识别转成文字、大模型按角色人设生成回复、语音合成播报出来,同时还要有一层记忆功能,让应用在下一轮对话里记住用户是谁、聊过什么。这样拆开看,开发一个陪伴型聊天软件难的不是某一个环节,而是把语音、模型、记忆几条线串起来后还能保持稳定。下面以“和魔法使伊蕾娜语音对话”这类陪伴型应用为例,拆解链路设计、Java 技术栈落地、人设控制方法和记忆功能的分层实现。内容组织上,先讲链路和选型,再给环境与代码,然后落到验证和排错,最后补生产建议,适合刚接触大模型应用开发、想了解 AI 语音对话项目如何搭起来的开发者阅读。
1. 陪伴型 AI 语音聊天应用的链路:不是“接个大模型”那么简单
1.1 从语音输入到语音输出,要过四段处理
很多人第一次做这类应用时,第一反应是“调用一个大模型 API,让它聊天不就行了”。但实际产品里,用户输入的是语音,不是文字;AI 返回的也是语音,不是屏幕上的文本。于是中间至少多了语音识别和语音合成两段。再加上“陪伴型”这个定位,用户会期待 AI 记得自己刚才说过什么、昨天聊过什么,因此还需要一段完整的记忆读写逻辑。
完整的处理链路可以分成四段:
- ASR(Automatic Speech Recognition,自动语音识别):把手机采集到的语音转为文本。这一段做不好,后面的模型再强也白搭,因为输入已经是错的。
- 对话生成:把识别出的文本、角色人设、记忆上下文一起交给大模型,让它生成符合角色性格的回复文本。
- TTS(Text To Speech,语音合成):把模型生成的文本转为语音,返回给手机播放。
- 记忆读写:在对话前后记录关键信息、更新用户画像、加载相关历史,让对话在多次会话之间保持连续。
四段之间的依赖关系很直接:ASR 的输出是对话生成的输入,对话生成的输出是 TTS 的输入,而记忆模块同时影响对话生成的输入和后续存储。任何一段延迟都会直接叠加到用户感受到的“响应速度”上。通常来说,一段语音对话的完整往返预算是 2 到 4 秒:ASR 占用几百毫秒,大模型生成占用 1 到 3 秒,TTS 占用几百毫秒到 1 秒。超出这个范围,用户会明显觉得“卡”。
1.2 记忆不是全文存档,而是三种层次配合
“记忆功能”这个词很容易被理解成“把聊天记录全部保存下来”。实际上,只保存聊天记录是不够的,因为大模型的上下文窗口有长度限制,把几天甚至几个月的记录全部塞进去,既浪费 token,也会让模型抓不住重点。更合理的做法是把记忆分成三层。
第一层是短期记忆,指当前会话内最近若干轮对话。它决定 AI 能不能接住“我刚才不是说过了吗”这种话。实现上是一个滑动窗口,只保留最近 N 轮。
第二层是长期记忆,指跨越多次会话仍然需要保留的事实性信息,比如用户养了一只猫、喜欢喝美式咖啡、上次聊到下周要出差。这层信息不是全部聊天记录,而是从聊天里抽取出来的关键条目,通常存到向量数据库里,在对话前按相似度召回最相关的一两条。
第三层是用户画像,指相对稳定的用户属性,比如用户希望 AI 怎么称呼自己、偏好什么风格。它单独存储,每次对话都固定加载,不依赖相似度检索。
用一句话概括:短期记忆解决“接得上话”,长期记忆解决“记得住事”,用户画像解决“知道你是谁”。三层配合起来,才是完整的记忆功能。
1.3 手机端和服务器端的分工
这类应用的移动端不能只做一个“录音加播放”的空壳,也不能把所有计算都塞进手机。比较常见的做法是分层分工。
手机端负责音频采集、音频播放、界面交互,以及可选的本地预处理。如果使用 Vosk 离线识别,也可以选择在手机侧完成 ASR,只把文本发给服务器,这样语音数据不出设备,隐私性更好,但识别效果和模型更新受限于端侧模型。
服务器端负责大模型调用、记忆存储、TTS 合成、用户状态管理。选择把对话生成放在服务器而不是手机上的原因很直接:大模型需要显卡或 API 能力,手机上跑小模型可以,但角色扮演质量和知识广度通常不如云端模型。另外,长期记忆需要数据库支撑,放在服务器端更容易做统一的用户维度和数据备份。
还有一种折中方案:ASR 在端侧离线做,LLM 和 TTS 在云端做。这样既减少了语音数据上传的隐私风险,又保留了云端模型的质量优势。后面的实现会以这种“端侧识别加云端生成”的混合结构为例。
2. 技术选型:Vosk 离线识别、Spring AI 接入大模型、TTS 服务化
2.1 语音识别三种方案的取舍
先说 ASR 的选型。市面上可用的方案大致有三类:商用云端语音识别、开源离线识别、端侧推理框架。
| 方案 | 典型代表 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 商用云端 ASR | 阿里云、百度、讯飞 | 识别准确率高、支持方言、接口稳定 | 按次数付费、依赖网络、音频需上传 | 对准确率要求高、网络稳定的正式产品 |
| 开源离线 ASR | Vosk、Whisper 本地部署 | 免费、可离线、数据不出服务器 | 准确率低于商用方案、中文模型需要挑选调参、CPU 资源占用 | 学习验证、隐私敏感场景、低成本原型 |
| 端侧推理 ASR | whisper.cpp、端侧 onnx 模型 | 真正离线、延迟低 | 手机性能和内存受限、模型迭代麻烦、识别效果受设备影响 | 纯离线 App、隐私要求极高的场景 |
实际选型时要注意一个容易被忽略的问题:商用 ASR 的计费和对网络的要求。如果是学习项目或内部原型,Vosk 是更合适的起点,因为模型文件可以本地下载,代码完全可控,不依赖外部配额。
2.2 Vosk 为什么适合作为离线识别起点
Vosk 是一个开源语音识别工具包,支持中文、英文等二十多种语言,Java 端有官方库com.alphacephei:vosk,输入需要 16kHz 采样率、单声道、16 位 PCM 格式的音频。它提供两种使用方式:一种是流式识别,边输入音频边出结果,适合实时对话;另一种是文件识别,一次性给完整音频,适合录音后处理。
对陪伴型语音对话来说,Vosk 的定位不是替代云端 ASR,而是让开发者先用一个免费、可离线的方案把整条链路跑通。等验证了产品形态,再根据准确率要求决定是否切换商用 ASR。这样不会在项目最初阶段就被 API 配额或账单卡住。
使用 Vosk 需要单独下载中文模型,常用模型是vosk-model-small-cn-0.22(小模型,体积小、速度较快)和vosk-model-cn-0.22(大模型,准确率更高、资源占用更大)。小模型适合验证流程,大模型适合追求识别效果。模型文件需要解压到项目目录或指定路径,并把路径配置到应用里。
2.3 Spring AI 在大模型接入层解决什么问题
如果直接使用大模型厂商的 SDK,项目里会出现一坨与具体厂商绑定的调用代码。换模型、加记忆、改提示词时,都容易牵连业务代码。Spring AI 是 Spring 生态用来统一大模型接入的框架,它做的事情和 Spring Data 类似:定义一套通用接口,把不同大模型厂商的差异封装在适配层后面。
对本文场景,Spring AI 主要带来三个价值。第一是ChatClient统一调用入口,业务代码不直接依赖具体厂商 SDK;第二是消息抽象,SystemMessage、UserMessage、AssistantMessage可以把人设提示词、长期记忆、对话历史都组织成结构化的消息序列,而不是拼一个大字符串;第三是VectorStore接口,可以用同一套 API 对接内存向量库、PostgreSQL、Redis 等不同存储实现。
需要特别注意,Spring AI 的版本更新比较快,API 在不同版本之间有调整。写代码前要确认当前使用的 Spring Boot 版本对应哪个 Spring AI 版本,以官方文档为准。下面示例用的是较新的ChatClientAPI,如果版本不同,按项目实际 API 调整即可。
2.4 语音合成方案选择
TTS 部分是整条链路里最容易出现“音色不对”的环节。选择方案时,重点看三件事:音色是否适合角色、合成延迟是否可控、以及是否可以商用。
如果使用云端 TTS,阿里云、微软 Azure、OpenAI TTS 等方案音质较好,支持多种音色,但按字符或按次计费,且合成请求同样依赖网络。如果追求完全免费和本地化,可以考虑开源 TTS,例如 CosyVoice、GPT-SoVITS 等,但它们通常需要一定的 GPU 资源,普通服务器 CPU 上跑实时合成会比较吃力。
在最小闭环阶段,建议直接使用一个可用的云端 TTS API,把 TTS 封装成独立的TtsService接口。这样后续替换本地 TTS 时,只需要改实现类,不需要动 controller 和业务逻辑。TTS 的输出建议直接返回音频文件 URL 或音频字节流,由手机端播放。
3. 环境准备、依赖配置与项目结构
3.1 学习环境最小清单
先把环境列清楚,避免后面代码跑起来才发现缺少依赖。
| 组件 | 版本建议 | 用途 |
|---|---|---|
| JDK | 17 或 21 | 运行 Spring Boot 3 |
| Maven | 3.8+ | 依赖管理 |
| Spring Boot | 3.2 及以上 | Web 服务基础 |
| Vosk 模型 | vosk-model-small-cn-0.22 | 离线中文识别 |
| 数据库 | MySQL 8 或 PostgreSQL | 记忆与历史记录 |
| Redis | 可选 | 短期记忆缓存、会话状态 |
| 大模型 API | OpenAI 兼容接口即可 | 对话生成 |
| TTS API | 按所选服务申请 | 语音合成 |
如果只是先跑通代码,MySQL 和 Redis 可以在本地用 Docker 起,或者先用 Spring AI 自带的 SimpleVectorStore 和内存存储验证,等确认链路没问题再引入数据库。
3.2 Maven 依赖与关键配置
在pom.xml中加入以下依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.alphacephei</groupId> <artifactId>vosk</artifactId> <version>0.3.47</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0-M6</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>Vosk 的 Java 版本在 0.3.x 系列,模型路径和采样率通过配置管理。Spring AI 相关依赖需要声明它的仓库地址,或者使用已经带 BOM 的 Spring Boot 依赖管理。实际项目中以官方最新版本为准,不要直接照搬这里写死的版本号。
注意:Spring AI 版本更新较快,不同版本的 API 可能有差异。落地前先确认与 Spring Boot 和 JDK 的版本对应关系,以官方文档为准。
application.yml配置示例:
server: port: 8080 spring: application: name: elaina-voice-chat ai: openai: base-url: ${LLM_BASE_URL:https://api.example.com/v1} api-key: ${LLM_API_KEY:} chat: options: model: ${LLM_MODEL:qwen-plus} temperature: 0.8 max-tokens: 300 app: vosk: model-path: ./models/vosk-model-small-cn-0.22 sample-rate: 16000 tts: endpoint: ${TTS_ENDPOINT:} token: ${TTS_TOKEN:}这里用环境变量占位符管理密钥,避免把 API Key 写死在配置文件里。temperature在角色陪伴场景一般设置在 0.7 到 0.9,太低会像机器人在背台词,太高会容易跑出人设。max-tokens限制单次回复长度,语音对话场景通常 300 以内就够了,太长 TTS 播放也累赘。
3.3 项目目录结构
一个清晰的包结构能减少后续混乱:
src/main/java/com/example/elaina/ ├── ElainaVoiceChatApplication.java ├── controller/ │ ├── ChatController.java │ └── VoiceController.java ├── service/ │ ├── AsrService.java │ ├── VoskAsrService.java │ ├── ChatService.java │ ├── TtsService.java │ └── MemoryService.java ├── prompt/ │ └── ElainaCharacterPrompt.java ├── entity/ │ ├── MemoryItem.java │ └── UserProfile.java └── repository/ ├── MemoryItemRepository.java └── UserProfileRepository.javaAsrService接口和 Vosk 实现分离,后续换成云端 ASR 只改实现类。controller 只负责接收请求和返回结果,业务逻辑放到 service。
4. 实现语音对话闭环
4.1 移动端音频采集与格式转换
手机录音得到的最常见格式是 AAC、WebM/Opus 或 M4A,而 Vosk 需要的是 16kHz、单声道、16 位 PCM 裸数据。这一步不处理,后面识别结果一定是空的或乱码。
有两种处理方式。一种是在手机端完成转码,例如 Web 端用 WebRTC 采集 16kHz 单声道音频直接上传;另一种是服务器端转码,收到原始音频后用 FFmpeg 转换:
ffmpeg -i input.webm -ar 16000 -ac 1 -f s16le output.pcm参数含义:
-ar 16000:重新采样为 16kHz-ac 1:转成单声道-f s16le:输出 16 位有符号小端序 PCM
在服务器端转码的好处是手机端逻辑简单,缺点是增加一道处理延迟和 CPU 开销。如果是实时对话,推荐手机端直接输出 16kHz 单声道 PCM,省掉转码环节。
4.2 基于 Vosk 的 Java 离线语音识别
写一个VoskAsrService,启动时加载模型,请求到来时把 PCM 字节数组交给 Recognizer:
@Component public class VoskAsrService implements AsrService { private final ObjectMapper objectMapper = new ObjectMapper(); private Model model; @Value("${app.vosk.model-path}") private String modelPath; @Value("${app.vosk.sample-rate}") private int sampleRate; @PostConstruct public void init() { // 模型加载较耗时,放在启动阶段,不要每次请求都 new Model model = new Model(modelPath); } @Override public String recognize(byte[] pcmData) { try (Recognizer recognizer = new Recognizer(model, sampleRate)) { boolean complete = recognizer.acceptWaveForm(pcmData, pcmData.length); String json = complete ? recognizer.getResult() : recognizer.getFinalResult(); return extractText(json); } catch (Exception e) { throw new RuntimeException("ASR 识别失败", e); } } private String extractText(String json) throws JsonProcessingException { JsonNode node = objectMapper.readTree(json); return node.path("text").asText(""); } }这里有两个关键点。第一,Model对象加载很耗时,必须放在@PostConstruct里一次性初始化,不能放进每次识别的代码中。第二,Recognizer是非线程安全的,高并发时需要对它做对象池管理,否则多个请求同时使用会出问题。学习阶段可以先这样写,生产环境要改成池化。
acceptWaveForm返回true表示模型认为一句话已经结束,可以从getResult()里取完整结果;返回false表示还在识别中,最后要用getFinalResult()强制取最终结果。对一次性上传整段音频的场景,直接判断返回值即可。
注意:Recognizer 不是线程安全的,生产环境高并发场景必须使用对象池,不能每个请求都 new 一个,更不能让多个线程共用一个实例。
4.3 通过 Spring AI 生成角色回复
有了识别文本,下一步是把人设、记忆和文本一起交给大模型。ChatService中组织消息序列:
@Service public class ChatService { private final ChatClient chatClient; private final MemoryService memoryService; public ChatService(ChatClient.Builder builder, MemoryService memoryService) { this.chatClient = builder.build(); this.memoryService = memoryService; } public String reply(String userId, String userText) { List<Message> messages = new ArrayList<>(); // 第一层:角色人设 messages.add(new SystemMessage(ElainaCharacterPrompt.TEMPLATE)); // 第二层:长期记忆和用户画像 String memoryContext = memoryService.buildMemoryContext(userId); if (StringUtils.hasText(memoryContext)) { messages.add(new SystemMessage("以下是这个用户的长期记忆:" + memoryContext)); } // 第三层:当前会话的短期历史 List<ChatRecord> history = memoryService.loadRecentHistory(userId, 10); for (ChatRecord record : history) { messages.add(new UserMessage(record.getUserText())); messages.add(new AssistantMessage(record.getAssistantText())); } messages.add(new UserMessage(userText)); return chatClient.prompt() .messages(messages) .call() .content(); } }这里ChatRecord是保存一轮对话的简单对象,实际项目里可以用 Java record 或实体类。消息组织顺序很重要。SystemMessage 放在最前面,表示这是系统级约束;用户历史按时间正序排列在最新用户消息之前;最新输入放到最后。如果顺序颠倒,模型可能分不清哪些是历史、哪些是当前输入。
短期历史只加载最近 10 轮,这是为了避免上下文过长。10 轮在语音对话场景大约对应几千 token,对多数模型都比较安全。具体轮数要根据模型上下文长度和单轮平均 token 数调整。
4.4 语音合成与音频返回
TTS 部分封装成接口:
public interface TtsService { byte[] synthesize(String text); }云端实现类内部调用 TTS HTTP 接口,把返回的音频字节流直接交给 controller:
@RestController @RequestMapping("/api") public class VoiceController { private final AsrService asrService; private final ChatService chatService; private final TtsService ttsService; @PostMapping("/voice-chat") public VoiceChatResponse voiceChat(@RequestBody byte[] pcmAudio) { String userText = asrService.recognize(pcmAudio); String replyText = chatService.reply("u1001", userText); byte[] replyAudio = ttsService.synthesize(replyText); return new VoiceChatResponse(userText, replyText, replyAudio); } }这个接口把语音输入、对话生成、语音输出串成了最小闭环。这里把userId写死是为了演示,实际项目应从登录态或请求参数中获取。userText回传给客户端还有一个好处:用户能看到 AI 是否正确理解了自己的话,便于调试 ASR 环节。
需要注意,synthesize如果是同步阻塞调用,TTS 接口耗时会被算进请求总时长。生产环境应该改成先返回回复文本和音频 URL,再异步合成;或者用 SSE 分流式返回。学习阶段用同步方式没问题。
5. 角色人设控制:让 AI 真正“像伊蕾娜”
5.1 人设的本质是对回复空间的约束
“让 AI 像伊蕾娜”不是给模型讲一个故事,而是通过系统提示词约束它的回复空间。没有约束时,大模型会默认变成一个客服式的助手,回复大多是“好的”“没问题”“请问还有什么需要帮助的吗”。这种回复放在陪伴型应用里立刻出戏。
人设提示词要完成四件事:定义角色身份、规定说话风格、划定知识边界、设定互动规则。前两件事决定“像不像”,后两件事决定“稳不稳”。很多角色扮演项目只写前两件,结果模型会自己编造用户的信息,甚至在用户问不确定的问题时直接答错。后两件才是生产环境里真正要花时间调的。
5.2 角色提示词的组成与示例
下面是一份面向伊蕾娜角色的示例提示词,用于说明写法,实际项目需要根据角色设定和产品定位持续迭代:
你正在扮演《魔女之旅》中的魔女伊蕾娜。 【身份】 你是一位独自旅行的年轻魔女,去过很多国家,见过形形色色的人和事。 【说话风格】 自信、从容,带着一点旅行者特有的漫不经心。 偶尔会小小得意一下,但不傲慢。 对熟悉的人会放松一些,对陌生人保持礼貌的距离。 【知识边界】 只聊旅行见闻、魔女生活、日常小事、伙伴和写信这些话题。 遇到不确定的事,用“我没有亲眼见过,就不乱说了”来回应,不要编造确定答案。 不要假装认识用户生活中的具体人物或地点,除非用户刚才提到。 【互动规则】 回复长度控制在三到五句话,口语化,像真正在对话。 不重复用户的问题,不总结用户的话。 如果用户提到姓名、喜好、重要日期,自然地记住并以后用得时候带过。提示词里的每一项都在干一件事:减少模型的猜测空间。“不要假装认识用户生活中的具体人物或地点”这条看起来简单,实际上能显著减少记忆功能带来的幻觉问题。
5.3 人设与记忆配合时的边界
人设和记忆不是两个独立模块。长期记忆如果没有过滤,可能把用户闲聊中随口说的话当成事实塞进提示词,模型就可能说出“我记得你下周三去医院”,而用户并不想让 AI 知道这件事。这里要注意三点:
第一,写入长期记忆前先按“事实性、重要性、私密性”初步判断,不要什么都存。第二,召回记忆后,只把与当前问题相关的条目放进消息,相关度低的不要加。第三,角色提示词里要约定“自然使用记忆”而不是“复述记忆”。用户不会喜欢 AI 每句话都带一句“还记得吗”,这种回复会立刻出戏。
6. 记忆功能实现:短期、长期、用户画像
6.1 短期记忆:会话内滑动窗口
短期记忆的实现在 4.3 已经出现过:从数据库加载最近 N 轮对话,按顺序放进消息。这层要注意两个问题。
一个是窗口大小。窗口太大会浪费 token,太小会接不上话。语音对话场景,建议先取最近 10 轮,观察实际 token 消耗再调整。另一个是存储位置。MySQL 或 PostgreSQL 存历史记录没问题,但高频会话中每次都查最近 10 轮,数据库压力会上升。学习阶段直接在库里查,生产环境可以引入 Redis,把最近对话缓存起来,数据库只做持久化备份。
6.2 长期记忆:向量存储与相似度召回
长期记忆的思路是把聊天中抽取出的关键事实向量化,等下一次对话时用当前问题做相似度检索,取最相关的几段记忆加入上下文。Spring AI 的VectorStore接口可以屏蔽不同向量库的差异。
示例代码:
@Service public class MemoryService { private final VectorStore vectorStore; private final MemoryItemRepository memoryItemRepository; public String buildMemoryContext(String userId, String query) { SearchRequest request = SearchRequest.query(query) .withTopK(3); List<Document> docs = vectorStore.similaritySearch(request); return docs.stream() .filter(doc -> userId.equals(doc.getMetadata().get("userId"))) .map(Document::getText) .collect(Collectors.joining("\n")); } }这里的关键参数是topK。取 3 条左右比较常见,取太多会冲淡当前消息的重要性,取太少又可能漏掉关键记忆。向量检索出的结果还要按用户维度过滤,不要让用户 A 的记忆进入用户 B 的上下文。最简单的做法是把userId写进 Document 的 metadata,检索后逐条校验。
新记忆的来源需要单独处理。比较实用的做法是:每轮对话结束后,用大模型对当前对话做一次“是否值得记录”的抽取,把结果异步写入向量库。这样不会阻塞主流程,也能过滤掉大部分闲聊内容。简单实现可以用预设规则判断,比如包含“我喜欢”“我下周”“我叫”等句式时记录。
6.3 用户画像:稳定属性的独立存储
用户画像和长期记忆的区别在于更新频率和使用方式。用户画像适合单独表存储,每次对话直接加载,不需要检索。画像内容建议保持精简:称呼方式、避免的话题、对话语气偏好、固定日程等。如果每次对话都把几十条画像塞进提示词,人设会被淹没,模型反而抓不住重点。
6.4 数据表设计与写入时机
三张核心表的 DDL 如下:
CREATE TABLE t_user_profile ( user_id VARCHAR(64) PRIMARY KEY, call_name VARCHAR(64), preferences TEXT, avoid_topics TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE t_dialog_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id VARCHAR(64) NOT NULL, user_id VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at) ); CREATE TABLE t_memory_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id VARCHAR(64) NOT NULL, content TEXT NOT NULL, importance TINYINT DEFAULT 5, is_active TINYINT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) );写入时机按“不强求实时”的原则设计。对话历史可以在每次请求结束时写入,但长期记忆的抽取和写入应该异步执行。推荐用 Spring 的@Async或消息队列,让记忆写入失败也不影响主回复链路。t_memory_item里的is_active字段用于软删除,用户明确说“忘掉这件事”时,应支持把对应记忆标记为失效,而不是物理删除后无法追溯。
7. 运行验证、常见问题与排查路径
7.1 分阶段验证链路
整个链路一次调通的可能性很低,建议按“ASR、对话生成、TTS、记忆”四个阶段分别验证。
先验证 ASR。准备一段 16kHz 单声道 PCM 音频,请求识别接口:
curl -X POST http://localhost:8080/api/asr \ -H "Content-Type: application/octet-stream" \ --data-binary @voice.pcm预期返回 JSON 中包含识别出的文本。如果 text 为空,先检查音频格式和模型路径。
再验证对话生成:
curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"userId":"u1001","text":"你好,伊蕾娜"}'预期返回三到五句符合角色风格的回复。
验证记忆时,先发送一条包含用户信息的文本,例如“我喜欢喝美式咖啡”,然后查看数据库:
SELECT user_id, content FROM t_memory_item WHERE user_id = 'u1001';再开一个新会话问“我喜欢喝什么”,看模型能否回答出来。这一步能同时验证长期记忆的写入和召回是否正常。
注意:不要只验证程序能启动,还要验证输入、输出、异常分支和日志是否符合预期。
7.2 常见问题与排查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 识别结果为空 | 音频不是 16kHz 单声道 PCM;模型路径错误 | 用 ffprobe 查看音频参数;检查启动日志模型加载是否成功 | 统一转码为 s16le 16kHz 单声道;确认模型解压路径 |
| 回复不像角色 | 人设提示词太弱;temperature 太高;用户消息压过 SystemMessage | 打印最终发给模型的 messages 内容 | 加强人设约束;temperature 调到 0.7 到 0.8;确认 SystemMessage 在最前 |
| 记忆没生效 | 写入和召回时机不对;检索结果未按 userId 过滤 | 查 t_memory_item 是否有记录;打印 buildMemoryContext 返回值 | 明确异步写入逻辑;检索后按 metadata 过滤 userId |
| 上下文过长报错 | 短期历史窗口设置过大 | 查看模型返回的错误信息和 token 统计 | 减少窗口轮数;对历史消息做摘要 |
| 整段请求耗时长 | ASR、LLM、TTS 同步串行 | 分别打印三段耗时 | TTS 改异步;LLM 开启流式;ASR 换小模型 |
一个很常见的坑是:修改了配置或提示词后,结果没有任何变化。这种情况优先检查是不是改错了环境,再检查应用是否真的重启加载了新配置,最后看日志里有没有加载到旧模型缓存。
7.3 推荐排查顺序
遇到问题先按这个顺序定位,不要直接去改代码:
- 输入是否正确:音频格式、文本内容、userId 是否传对。
- 路径和命名:Vosk 模型路径、配置项名称、环境变量是否生效。
- 依赖版本:Spring AI 版本与 Spring Boot 是否匹配,Vosk 模型与库版本是否兼容。
- 配置参数:采样率、temperature、topK、max-tokens 是否符合预期。
- 日志关键字:
model loaded、ASR 识别失败、LLM response、memory saved。 - 数据库状态:历史记录是否写入,记忆条目是否存在,userId 是否一致。
- 最后再考虑框架限制:Spring AI 版本 API 差异、Vosk 对特定方言支持不足。
按这个顺序排查,大部分问题都能在不动业务代码的前提下找到方向。
8. 从学习环境到生产环境:六项落地保障与扩展方向
8.1 学习环境与生产环境的差异
学习环境的目标是“链路能通”,生产环境的目标是“用户能用”,两者差异集中在表格里:
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 配置 | 写死在 application.yml | 环境变量、配置中心、密钥管理 |
| 记忆存储 | SimpleVectorStore、本地表 | PostgreSQL/PGVector、Redis 缓存、备份 |
| ASR 并发 | 单个 Recognizer | Recognizer 对象池、云端 ASR 兜底 |
| TTS | 同步返回音频 | 异步合成、音频 CDN 分发 |
| 日志 | 控制台打印 | 结构化日志、监控告警 |
| 内容安全 | 不处理 | 输出内容安全审核、敏感话题兜底 |
8.2 发布前检查清单
下面这份清单可以在每次发布前过一遍:
- 密钥是否已从代码和仓库中清除,是否改成环境变量。
- 大模型 API 密钥是否配置了限额和告警。
- 语音数据是否有留存策略,是否需要用户同意后保存。
- 短期记忆窗口大小是否在目标模型上下文限制内。
- 长期记忆召回是否正确按 userId 隔离。
- ASR 和 TTS 服务是否有超时和失败兜底。
- 是否有请求频率限制,异常流量是否会被识别。
- 角色提示词版本是否记录,能否在线上快速回滚。
- 是否配置了结构化日志,关键链路是否可追踪。
- 是否具备模拟高并发压测的数据和脚本。
提示:这份清单不是一次性的,角色提示词和记忆策略只要改一次,就要重新过一遍相关项。
8.3 后续扩展方向
链路跑通后,可以按优先级推进这些方向。
第一优先级是延迟优化。把 LLM 调用改成流式输出,边生成边合成语音,用户听到第一个字的等待时间能从秒级降到亚秒级。第二优先级是语音质量,接入支持音色定制的 TTS 服务,解决“声音不像角色”的用户反馈。第三优先级是记忆质量,把简单规则抽取改成基于大模型的记忆提炼,并增加记忆合并和遗忘机制。
再往后,可以把记忆、人设、工具调用整合成一个完整的 AI Agent,让角色不仅能聊,还能在用户允许的前提下完成查天气、记日程、定闹钟这类操作。也可以扩展情绪识别、角色动画、多人会话、多角色切换等产品能力。
从工程角度看,这类项目最有价值的地方不是某个单一技术,而是把 ASR、大模型、TTS、记忆四段链路串成一个稳定系统的能力。先把最小闭环跑通,再用真实对话数据持续调优人设和记忆策略,比一开始就追求复杂架构更稳妥。