ASR 语音识别
在一个后端程序中,Java 服务器代码并不提供语音识别功能,其功能往往通过调用第三方接口实现。常见的识别 API 提供商有阿里云、百度、字节、科大讯飞等。
文件传入
一般而言,传入有两种方式:一种是通过 HTTP POST 请求,获取音频数据;一种是直接从本地上传。前者需要传入的参数为已经编码好(如 base64 编码)的音频文件,而本地传输则通过 MultipartFile 对象传输文件本身。然后将数据统一送给工厂类提供的对象中。
为了兼容两种格式的传入,传输的 DTO 应该包含录音文件 url、来源、租户 id、渠道(不能为空)、会话 id、坐席 id、接口指向、回调 url、录音文件名、语速、开始时间、结束时间、录音时长、声道(单声道为 mono,双声道为 stereo),而本地传入应该多加一个 MultipartFile 对象。
工厂类提供对象
对于不同的 API,我们理应实现多个不同的实现类,在某个 API 存在时则加载对应的实现类。如果工厂类找不到对应的实现类,则直接报错。
不同的实现类,通过多态实现不同 Impl 类的动态加载。
1 2 3 4 5 6 7 8 9 10 11 12 13
| private final Map<String, AsrRequestService> asrServices;
private AsrRequestService transcribe(String vendor) { return asrServices.getOrDefault(vendor, null); }
AsrRequestService service = transcribe(request.getVendor()); if (service == null) { log.error("不支持的厂商: {}", request.getVendor()); return new AsrResponse("-1", "不支持的厂商: " + request.getVendor()); }
|
此处应实现如下功能:
按照配置文件中的配置,若配置存在则加载对应的实现类。
这一点可以通过给每个类添加 @Condition() 注解实现。以讯飞的 Impl 为例。
1 2 3 4 5 6
| @ConditionalOnProperty( prefix = "asr.vendors", name = "enabled", havingValue = "xunfei", matchIfMissing = false )
|
对每一个 Impl 类都存到 asrServices 的 map 中。
这一个可以在这个 map 所在类中通过 @PostConstruct 注解定义初始化方法,并通过 applicationContext.getBeansOfType(AsrRequestService.class) 在 applicationContext 中获取所有的实现类。
但我并没有在源码中看到相关操作。
如果传入的参数为一个,那么就是 HTTP 传输,若为两个(多了 MultipartFile)就是本地传输。然后调用对应的 transcribe 方法。
在这个工厂类中,还需要校验转写并发是否超过限制,等等。
实现类
此处的实现如下
- 定义接口,其中有两个
transcribe 方法,对应 HTTP 文件和本地文件。
- 用抽象类实现这个接口,用于提供完整的
transcribe 方法实现,并额外添加其全流程(前置处理、构建请求、发出请求、后置处理)的方法实现。在前置处理中,将该次会话相关的 session 存到 redis 中。
- 不同的实现类都在继承这个抽象类,并对不同的业务特点进行特定流程的重写。同时,实现类还需要注入相关配置。
回调
接收到 ASR 服务后,外部还会给服务器发一个回调数据。通过与上述实现类对应的回调实现类,可以获取 ASR 的响应结果。
AI 接口
此处的大部分代码由我和 Claude 一起实现,是这个工厂模式的更激进的做法,同时尽量不动原有的会话管理功能。在这个实现中,所有的模型接口均只由一个 Controller 接收。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| @RestController @Slf4j @RequestMapping(value = "/v1/llm") @Tag(name = "LLM统一接口") public class LlmController {
@Autowired private LlmServiceFactory llmServiceFactory;
@Operation(description = "发送消息到指定模型") @PostMapping(value = "/sendMessage/{modelName}") public ApiResponse<JSONObject> sendMessage( @Parameter(description = "模型名称") @PathVariable String modelName, @RequestBody @Validated JSONObject message) { try { log.info("sendMessage request - modelName: {}, message: {}", modelName, message);
LlmService llmService = llmServiceFactory.getLlmService(modelName); JSONObject result = llmService.sendMessage(modelName, message);
if (result != null && !result.containsKey("msg")) { return ApiResponse.data(200, result, "消息发送成功"); } else { String errorMsg = result != null ? result.getString("msg") : "未知错误"; return ApiResponse.fail("消息发送失败: " + errorMsg); } } catch (Exception e) { log.error("sendMessage error - modelName: {}, message: {}", modelName, message, e); return ApiResponse.fail("消息发送异常: " + e.getMessage()); } }
@Operation(description = "获取指定模型的会话历史信息") @GetMapping(value = "/session/{modelName}/{sessionId}") public ApiResponse<JSONObject> getSessionInfo( @Parameter(description = "模型名称") @PathVariable String modelName, @Parameter(description = "会话ID") @PathVariable String sessionId, @Parameter(description = "获取最后几轮对话") @RequestParam(defaultValue = "5") int lastNum) { try { log.info("getSessionInfo request - modelName: {}, sessionId: {}, lastNum: {}", modelName, sessionId, lastNum);
JSONObject result = new JSONObject(); result.put("modelName", modelName); result.put("sessionId", sessionId); result.put("lastNum", lastNum); result.put("message", "会话信息获取功能需要配合具体模型配置使用");
return ApiResponse.data(200, result, "会话信息获取成功"); } catch (Exception e) { log.error("getSessionInfo error - modelName: {}, sessionId: {}", modelName, sessionId, e); return ApiResponse.fail("获取会话信息异常: " + e.getMessage()); } } }
|
此处的请求转发到统一的工厂类中。工厂类按照传入的 modelName 参数,去匹配是否存在这样的 Impl 类,没有就报错,有就去找对应的实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| @Component @Slf4j public class LlmServiceFactory {
private final Map<String, LlmService> llmServices;
@Autowired public LlmServiceFactory(Map<String, LlmService> llmServices) { this.llmServices = llmServices; }
public LlmService getLlmService(String serviceType) { LlmService service = llmServices.getOrDefault(serviceType + "LlmServiceImpl", null); if (service == null) { log.error("不支持的LLM服务类型: {}, 可用服务: {}", serviceType, llmServices.keySet()); return getDefaultLlmService(); } log.debug("选择LLM服务: {} for type: {}", service.getClass().getSimpleName(), serviceType); return service; }
public LlmService getDefaultLlmService() { LlmService defaultService = llmServices.get("deepseekLlmServiceImpl"); if (defaultService == null) { log.warn("默认deepseek服务不可用,使用第一个可用服务"); if (llmServices.isEmpty()) { throw new IllegalStateException("没有可用的LLM服务实现"); } defaultService = llmServices.values().iterator().next(); } return defaultService; } }
|
此处的
1 2 3 4
| @Autowired public LlmServiceFactory(Map<String, LlmService> llmServices) { this.llmServices = llmServices; }
|
Spring 会自动按照值的类型批量注入,将所有存在的 Impl 统一装配到这个 map 中。这个 map 的键就是这个 Impl 类的名字。
然后对于一个 Impl 的具体实现(此处以 Deepseek 为例),最理想的状态就是在查询到有对应配置后才开始加载,否则不加载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| @Service @Slf4j @ConditionalOnProperty(prefix = "llm.models.deepseek", name = "enabled", havingValue = "true") public class DeepseekLlmServiceImpl implements LlmService {
private static final String REDIS_SESSION_PREFIX = "thdai:deepseek:chat:session:";
private final String apiKey;
private final String name;
private final String url;
private final int sessionTimeout;
private final StringRedisTemplate redisTemplate;
private DeepSeekService deepSeekService;
public DeepseekLlmServiceImpl(@Autowired StringRedisTemplate redisTemplate, @Value("${llm.models.deepseek.apiKey}") String apiKey, @Value("${llm.models.deepseek.name}") String name, @Value("${llm.models.deepseek.url}") String url, @Value("${llm.models.deepseek.sessionTimeout}") int sessionTimeout) { this.redisTemplate = redisTemplate; this.apiKey = apiKey; this.name = name; this.url = url; this.sessionTimeout = sessionTimeout; }
@PostConstruct public void init() { AIConfig config = new AIConfigBuilder(ModelName.DEEPSEEK.getValue()) .setApiKey(apiKey) .setModel(name) .setApiUrl(url) .build(); deepSeekService = AIServiceFactory.getAIService(config, DeepSeekService.class); }
}
|
这就是一条配置化代码的实现流程。