SenseVoice-Small语音识别实战:Java微服务集成与调用详解

📅 发布时间:2026/7/6 13:34:53 👁️ 浏览次数:
SenseVoice-Small语音识别实战:Java微服务集成与调用详解
SenseVoice-Small语音识别实战Java微服务集成与调用详解最近在做一个客服系统的升级项目客户那边提了个挺实在的需求能不能把海量的通话录音又快又准地转成文字他们原来的做法是人工听录音、做记录效率低不说成本还高得吓人。我们团队评估了一圈最后决定用SenseVoice-Small这个语音识别模型把它集成到我们现有的Java微服务架构里。SenseVoice-Small这个模型用下来的感觉就是“轻快准”。模型不大对硬件要求相对友好但识别准确率尤其是对中文口语的识别确实让人眼前一亮。最关键的是它部署在星图GPU平台上通过标准的HTTP接口就能调用这对我们后端开发来说集成起来就方便多了。这篇文章我就来聊聊我们是怎么把SenseVoice-Small“塞”进SpringBoot微服务里的。我会重点讲清楚几个实际工程中一定会遇到的问题怎么设计一个既稳定又高效的RESTful API来封装模型调用面对大量并发的转写请求怎么用异步处理不让用户干等着还有万一网络抽风或者模型服务暂时不可用我们的重试和缓存机制该怎么设计才能保证服务不崩如果你也在琢磨怎么把AI能力落地到自己的Java项目里希望这些踩过的坑和总结的经验能帮到你。1. 项目背景与核心挑战我们接手的这个客服系统每天要处理上万通电话。按照法规和内部质检要求这些通话录音都需要转成文字存档并且要能支持关键词检索和情感分析。以前全靠人工处理一个小时的录音熟练的客服也要花上两三个小时来整理人力成本和时间成本都是巨大的瓶颈。所以自动语音转写的需求非常明确。但真要做起来挑战也不小第一是性能要求高。客服通话是实时的虽然我们不要求毫秒级的“同声传译”但至少要在通话结束后几分钟内完成转写不能让人等上半天。这意味着我们的服务必须能承受住高峰时段的并发压力。第二是稳定性必须强。这是生产环境的核心系统不能动不动就报错或者服务不可用。语音识别服务本身可能因为网络、GPU资源等问题出现波动我们的集成层必须能容错不能因为一次调用失败就导致整个流程中断。第三是要易于维护和扩展。我们团队主要是Java技术栈大家熟悉SpringBoot那一套。所以集成方案最好能无缝融入现有的微服务生态利用好我们已经有的服务发现、配置中心、监控告警等基础设施。基于这些考虑我们选择了SenseVoice-Small模型并决定采用基于HTTP的微服务集成模式。模型本身由算法团队部署和管理我们后端只需要关注如何高效、可靠地调用它。2. 技术架构与核心组件设计整个方案的架构不复杂核心思想就是“前后端分离”。模型服务专心做识别我们Java服务专心做业务集成、调度和保障。2.1 整体架构视图简单画一下数据流用户的通话录音文件通常是MP3或WAV格式上传到我们的文件服务器后会生成一个唯一的文件ID。业务服务比如工单系统会发起一个转写任务携带这个文件ID。我们的语音转写微服务接收到请求后并不会自己吭哧吭哧地去处理音频而是扮演一个“调度员”和“协调员”的角色。它的主要工作流程是这样的先检查缓存里有没有这个文件的历史转写结果避免重复计算如果没有就准备调用请求。它会读取音频文件可能做一些预处理比如格式转换、采样率调整以符合模型输入要求然后封装成一个标准的HTTP请求发给部署在星图GPU平台上的SenseVoice-Small服务。拿到返回的文本后再进行一些后处理比如添加标点、按说话人分段最后把结果存到缓存和数据库并返回给调用方。[业务服务] -- [语音转写微服务] -- [HTTP Client] -- [SenseVoice-Small on GPU] ^ | | | | | [结果缓存] -- [后处理存储] -- [响应解析] --2.2 核心Java组件选型在Java这边我们主要依赖SpringBoot生态选型都是比较主流和成熟的Web框架SpringBoot 2.7 Spring MVC。这是我们的老伙计了用来快速搭建RESTful API。HTTP客户端我们选择了OkHttp。相比传统的HttpClient或者RestTemplateOkHttp的链式调用写起来更流畅连接池、超时、重试等配置也更直观和强大性能表现也非常不错。异步处理Spring的Async注解配合自定义的线程池。对于转写这种IO密集型主要是网络等待任务异步化能极大释放主线程提高服务的吞吐量。缓存Caffeine。这是一个高性能的本地缓存库API友好性能强悍。我们用它来缓存短时间内的转写结果避免对完全相同的音频文件进行重复识别。配置管理模型的端点URL、超时时间、重试策略等我们都放在Apollo配置中心里这样可以在不重启服务的情况下动态调整参数。3. 服务集成与API封装实战理论说再多不如看代码来得实在。下面我就分步骤展示几个关键部分的实现。3.1 定义统一的请求与响应模型首先得定义好和SenseVoice-Small服务“对话”的语言。我们创建一个TranscribeRequest类来封装请求参数一个TranscribeResponse类来封装响应结果。这里用到了Lombok来减少样板代码。import lombok.Data; Data public class TranscribeRequest { /** * 音频文件的Base64编码字符串。 * 也可以设计为传递文件URL由服务端自行下载这里采用Base64简化示例。 */ private String audioBase64; /** * 音频格式如 wav, mp3, flac 等。 */ private String audioFormat; /** * 可选参数是否启用说话人分离VAD。 */ private Boolean enableSpeakerDiarization false; // 其他可能的参数如语言代码、采样率等 // private String language; // private Integer sampleRate; } Data public class TranscribeResponse { /** * 转写任务ID */ private String taskId; /** * 转写状态SUCCESS, PROCESSING, FAILED */ private String status; /** * 识别出的文本内容 */ private String text; /** * 如果启用说话人分离这里返回分段信息 */ private ListSegment segments; /** * 当status为FAILED时描述错误原因 */ private String errorMessage; Data public static class Segment { private Integer speaker; private String text; private Float startTime; private Float endTime; } }3.2 构建稳健的HTTP客户端接下来我们封装一个专门用于调用SenseVoice-Small服务的客户端。这里会用到OkHttp并集成重试和连接池等高级特性。import okhttp3.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.util.concurrent.TimeUnit; Slf4j Component public class SenseVoiceClient { Value(${sensevoice.api.endpoint}) private String apiEndpoint; Value(${sensevoice.api.timeout.seconds:30}) private int timeoutSeconds; private final OkHttpClient httpClient; private final ObjectMapper objectMapper; public SenseVoiceClient() { // 1. 创建连接池复用TCP连接提升性能 ConnectionPool connectionPool new ConnectionPool(5, 5, TimeUnit.MINUTES); // 2. 构建OkHttpClient配置超时和重试拦截器 this.httpClient new OkHttpClient.Builder() .connectTimeout(timeoutSeconds, TimeUnit.SECONDS) .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) .readTimeout(timeoutSeconds, TimeUnit.SECONDS) .connectionPool(connectionPool) .addInterceptor(new RetryInterceptor(3)) // 自定义重试拦截器 .build(); this.objectMapper new ObjectMapper(); } public TranscribeResponse transcribe(TranscribeRequest request) throws IOException { // 构建JSON请求体 String requestJson objectMapper.writeValueAsString(request); RequestBody body RequestBody.create(requestJson, MediaType.get(application/json)); // 构建HTTP请求 Request httpRequest new Request.Builder() .url(apiEndpoint) .post(body) .addHeader(Content-Type, application/json) // 可以在这里添加认证头如果模型服务需要的话 // .addHeader(Authorization, Bearer your-token) .build(); log.info(调用语音识别服务URL: {}, apiEndpoint); try (Response response httpClient.newCall(httpRequest).execute()) { if (!response.isSuccessful()) { String errorBody response.body() ! null ? response.body().string() : null; log.error(语音识别服务调用失败状态码: {}, 响应体: {}, response.code(), errorBody); throw new IOException(HTTP请求失败: response.code()); } String responseBody response.body().string(); return objectMapper.readValue(responseBody, TranscribeResponse.class); } catch (IOException e) { log.error(调用语音识别服务时发生IO异常, e); throw e; } } /** * 自定义重试拦截器 */ static class RetryInterceptor implements Interceptor { private final int maxRetries; public RetryInterceptor(int maxRetries) { this.maxRetries maxRetries; } Override public Response intercept(Chain chain) throws IOException { Request request chain.request(); Response response null; IOException exception null; // 重试逻辑 for (int i 0; i maxRetries; i) { try { response chain.proceed(request); // 只对特定的网络错误或服务器错误进行重试例如超时或5xx错误 if (response.isSuccessful() || !isRetryable(response, i)) { return response; } // 关闭不成功的响应体 if (response.body() ! null) { response.body().close(); } } catch (IOException e) { exception e; log.warn(请求失败进行第{}次重试原因: {}, i 1, e.getMessage()); } // 最后一次尝试后仍然失败则抛出异常 if (i maxRetries) { break; } // 简单的指数退避策略 try { Thread.sleep((long) (Math.pow(2, i) * 1000)); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } if (exception ! null) { throw exception; } else if (response ! null) { return response; } else { throw new IOException(未知错误重试 maxRetries 次后失败); } } private boolean isRetryable(Response response, int attempt) { // 对连接超时、读取超时、5xx服务器错误进行重试 int code response.code(); return (code 408 || code 429 || code 500) attempt maxRetries; } } }这段代码的关键点在于RetryInterceptor。网络请求总有不稳定的时候特别是跨机房调用。这个拦截器会在遇到可重试的错误如超时、服务器内部错误时自动进行最多3次重试并且每次重试之间有一个简单的指数退避等待避免对下游服务造成“惊群”效应。3.3 实现异步转写与结果缓存直接同步调用的话用户请求会被长时间阻塞。我们采用异步方式立即返回一个任务ID转写完成后通过其他方式如WebSocket、轮询通知用户。同时引入缓存避免重复计算。import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import lombok.extern.slf4j.Slf4j; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; Slf4j Service public class TranscriptionService { private final SenseVoiceClient senseVoiceClient; // 使用Caffeine构建一个本地缓存Key是音频内容的MD5或文件IDValue是转写结果 private final CacheString, TranscribeResponse transcriptionCache; public TranscriptionService(SenseVoiceClient senseVoiceClient) { this.senseVoiceClient senseVoiceClient; this.transcriptionCache Caffeine.newBuilder() .maximumSize(1000) // 缓存最多1000条结果 .expireAfterWrite(1, TimeUnit.HOURS) // 写入1小时后过期 .build(); } /** * 提交转写任务异步 * param request 转写请求 * return 任务ID这里简化处理实际可能用UUID生成 */ public String submitTranscriptionTask(TranscribeRequest request) { String cacheKey generateCacheKey(request); // 1. 检查缓存 TranscribeResponse cachedResponse transcriptionCache.getIfPresent(cacheKey); if (cachedResponse ! null SUCCESS.equals(cachedResponse.getStatus())) { log.info(缓存命中直接返回结果key: {}, cacheKey); return cachedResponse.getTaskId(); // 实际应返回缓存中的完整响应或任务ID } // 2. 无缓存生成任务ID并提交异步任务 String taskId task_ System.currentTimeMillis() _ cacheKey.hashCode(); processTranscriptionAsync(taskId, request, cacheKey); return taskId; } /** * 异步处理转写任务 */ Async(transcriptionTaskExecutor) // 使用自定义的线程池 public CompletableFutureVoid processTranscriptionAsync(String taskId, TranscribeRequest request, String cacheKey) { log.info(开始异步处理转写任务: {}, taskId); TranscribeResponse response new TranscribeResponse(); response.setTaskId(taskId); response.setStatus(PROCESSING); try { // 实际调用识别服务 TranscribeResponse apiResponse senseVoiceClient.transcribe(request); response.setStatus(apiResponse.getStatus()); response.setText(apiResponse.getText()); response.setSegments(apiResponse.getSegments()); // 如果成功放入缓存 if (SUCCESS.equals(apiResponse.getStatus())) { transcriptionCache.put(cacheKey, response); log.info(任务{}处理成功结果已缓存。, taskId); } else { response.setErrorMessage(apiResponse.getErrorMessage()); log.warn(任务{}处理失败: {}, taskId, apiResponse.getErrorMessage()); } } catch (Exception e) { response.setStatus(FAILED); response.setErrorMessage(语音识别服务调用异常: e.getMessage()); log.error(处理转写任务{}时发生异常, taskId, e); } // 这里应该将最终response存入数据库或消息队列供查询接口使用 // saveResultToDatabase(taskId, response); log.info(异步转写任务{}处理完成状态: {}, taskId, response.getStatus()); return CompletableFuture.completedFuture(null); } /** * 根据请求生成缓存键这里简单使用音频Base64的哈希值实际应考虑文件唯一标识。 */ private String generateCacheKey(TranscribeRequest request) { // 简单示例生产环境需要更健壮的方案如音频文件MD5参数组合 return String.valueOf((request.getAudioBase64() request.getAudioFormat()).hashCode()); } }注意Async(“transcriptionTaskExecutor”)注解它指定了这个异步方法使用一个名为transcriptionTaskExecutor的线程池。我们需要在配置类里定义这个线程池以避免使用默认的共享线程池导致资源竞争。import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; Configuration EnableAsync public class AsyncConfig implements AsyncConfigurer { Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); // 核心线程数 executor.setMaxPoolSize(20); // 最大线程数 executor.setQueueCapacity(100); // 队列容量 executor.setThreadNamePrefix(Transcription-Async-); executor.initialize(); return executor; } }3.4 提供RESTful查询接口最后我们提供一个简单的Controller用于提交任务和查询任务结果。import org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; RestController RequestMapping(/api/transcribe) RequiredArgsConstructor public class TranscriptionController { private final TranscriptionService transcriptionService; // 假设有一个Repository用于存储任务结果 // private final TranscriptionResultRepository resultRepository; PostMapping(/submit) public ApiResponseString submit(RequestBody TranscribeRequest request) { try { String taskId transcriptionService.submitTranscriptionTask(request); return ApiResponse.success(任务提交成功, taskId); } catch (Exception e) { return ApiResponse.error(任务提交失败: e.getMessage()); } } GetMapping(/result/{taskId}) public ApiResponseTranscribeResponse getResult(PathVariable String taskId) { // 实际应从数据库或缓存中查询 // TranscribeResponse result resultRepository.findByTaskId(taskId); TranscribeResponse mockResult new TranscribeResponse(); mockResult.setTaskId(taskId); mockResult.setStatus(SUCCESS); mockResult.setText(这是模拟的转写结果文本。); // 如果结果不存在或仍在处理中返回相应状态 // if (result null) { ... } return ApiResponse.success(mockResult); } // 统一的API响应封装 Data public static class ApiResponseT { private boolean success; private String message; private T data; // 省略静态工厂方法 success/error } }4. 部署、监控与性能调优代码写完了怎么让它稳定跑起来才是关键。部署方面我们把语音转写微服务打包成Docker镜像通过Kubernetes进行部署。通过HPA水平Pod自动伸缩配置根据CPU使用率或自定义的QPS指标自动扩容Pod实例以应对流量高峰。监控与告警这是保证服务可观测性的生命线。我们主要关注几个指标服务可用性通过定时调用一个健康检查接口监控服务的HTTP状态。接口性能使用Micrometer集成Prometheus监控/api/transcribe/submit和/api/transcribe/result接口的P99、P95响应时间以及QPS。下游依赖健康监控调用SenseVoice-Small服务的成功率、平均耗时和错误率如4xx/5xx比例。一旦错误率超过阈值比如5%立即告警。资源使用监控Pod的CPU、内存使用率以及JVM的GC情况。性能调优经验连接池OkHttp的连接池参数maxIdleConnections,keepAliveDuration需要根据实际并发量和网络情况调整。太小会频繁创建连接太大会占用过多资源。超时设置这是最容易出问题的地方。connectTimeout、writeTimeout、readTimeout需要合理设置。对于语音识别readTimeout可能需要设置得长一些比如60-120秒因为模型推理需要时间。但也要设置一个上限防止慢请求拖死线程池。异步与缓冲我们使用了Async和线程池但线程池的参数核心线程数、最大线程数、队列容量需要压测来确定。队列太长可能导致内存溢出太短又可能导致任务被拒绝。缓存策略Caffeine缓存的maximumSize和expireAfterWrite需要根据业务特点设定。对于客服录音可能1小时或1天的缓存有效期是合适的。如果音频内容唯一性很高缓存命中率会很低此时缓存的意义就不大了。5. 总结与展望这套基于SpringBoot和OkHttp的集成方案在我们当前的客服系统里跑了大半年整体上还是比较稳的。最大的好处就是解耦清晰我们的业务服务不需要关心语音模型具体在哪、怎么部署只需要调用一个简单的REST API。异步处理和缓存机制也很好地扛住了每天的业务高峰。当然过程中也遇到些小麻烦。比如早期重试策略没设计好一次网络波动导致大量请求重试差点把下游模型服务打挂。后来加上了指数退避和重试次数限制就好多了。还有缓存键的设计一开始只用文件名结果不同用户上传了同名但内容不同的文件导致缓存错乱后来改成了用文件内容哈希值。如果后续业务量再上一个量级我们考虑的方向会是引入消息队列比如RabbitMQ或Kafka来做彻底的异步解耦和流量削峰把转写任务丢到队列里由专门的Worker去消费这样前端服务的压力会更小系统的弹性也会更好。另外也可以探索一下流式识别的集成对于实时性要求更高的场景比如实时字幕生成会有更好的体验。获取更多AI镜像想探索更多AI镜像和应用场景访问 CSDN星图镜像广场提供丰富的预置镜像覆盖大模型推理、图像生成、视频生成、模型微调等多个领域支持一键部署。