使用 OpenAI ASR 实现语音识别接口(Java 后端示例)
本文介绍如何在 Java 后端快速实现一个“上传音频文件 → 调用 OpenAI ASR(Whisper)→ 返回识别文本”的接口,并加入缓存机制避免重复识别,降低成本与延迟。
整体流程可以理解为四步:
- 客户端通过
POST /api/v1/asr上传音频文件(表单字段名为file)。 - 后端接收文件后,先对文件内容做 MD5,去数据库查缓存。
- 如果缓存命中,直接返回历史识别结果。
- 如果未命中,则上传音频到对象存储(用于留档/追溯),随后调用 OpenAI Whisper 转写,把结果写入数据库缓存,再返回给客户端。
接口定义
- 请求方式:
POST /api/v1/asr - 上传字段:
file: binary - 返回:统一 JSON 结构,
data.result为识别文本
请求示例(概念描述):
POST /api/v1/asr
file:binary
响应示例:
{
"data": {
"id": null,
"result": "......\n"
},
"code": 1,
"msg": null,
"ok": true,
"error": null
}
核心实现思路(通俗版)
1)Handler 负责“接收请求 + 转交服务 + 返回响应”
Handler 做的事情很简单:从请求里把上传文件取出来,交给 ASR 服务处理,然后把结果包装成 HTTP 响应返回。
2)Service 负责“去重缓存 + 并发控制 + 调用 ASR + 落库”
Service 主要解决三个实际问题:
- 重复识别浪费钱:同一个音频反复上传不应每次都调用 ASR,所以用 MD5 做缓存键。
- 并发时重复调用:比如同一时间多个请求上传同一个文件,如果不控制,会同时调用 ASR 多次。这里用
Striped<Lock>按 md5 做分段锁,保证同一个 md5 在同一时间只会有一个线程真正去调用 ASR。 - 结果持久化:识别完成后保存到表
ai_audio_asr_cache,下次直接命中缓存。
后端代码
下面是完整代码(保持原样展示,不拆分)。
package com.litongjava.llm.handler;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.llm.service.AiAudioAsrService;
import com.litongjava.model.body.RespBodyVo;
import com.litongjava.tio.boot.http.TioRequestContext;
import com.litongjava.tio.http.common.HttpRequest;
import com.litongjava.tio.http.common.HttpResponse;
import com.litongjava.tio.http.common.UploadFile;
import com.litongjava.tio.http.server.handler.HttpRequestHandler;
public class ApiAsrHandler implements HttpRequestHandler {
@Override
public HttpResponse handle(HttpRequest httpRequest) throws Exception {
UploadFile uploadFile = httpRequest.getUploadFile("file");
RespBodyVo vo = Aop.get(AiAudioAsrService.class).asr(uploadFile);
HttpResponse response = TioRequestContext.getResponse();
return response.body(vo);
}
}
package com.litongjava.llm.service;
import java.util.concurrent.locks.Lock;
import com.google.common.util.concurrent.Striped;
import com.litongjava.db.activerecord.Db;
import com.litongjava.db.activerecord.Row;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.llm.consts.AgentTableNames;
import com.litongjava.model.TaskResponse;
import com.litongjava.model.body.RespBodyVo;
import com.litongjava.model.http.response.ResponseVo;
import com.litongjava.openai.whisper.WhisperClient;
import com.litongjava.openai.whisper.WhisperResponseFormat;
import com.litongjava.tio.boot.admin.services.storage.AliyunStorageService;
import com.litongjava.tio.boot.admin.vo.UploadResultVo;
import com.litongjava.tio.http.common.UploadFile;
import com.litongjava.tio.utils.crypto.Md5Utils;
import com.litongjava.tio.utils.snowflake.SnowflakeIdUtils;
public class AiAudioAsrService {
private static final Striped<Lock> locks = Striped.lock(1024);
public RespBodyVo asr(UploadFile uploadFile) {
byte[] data = uploadFile.getData();
String md5Hex = Md5Utils.md5Hex(data);
String sql = "select content from %s where md5=? and deleted=0";
sql = String.format(sql, AgentTableNames.ai_audio_asr_cache);
String content = Db.queryStr(sql, md5Hex);
if (content == null) {
Lock lock = locks.get(md5Hex);
lock.lock();
try {
content = Db.queryStr(sql, md5Hex);
if (content == null) {
content = parse0(uploadFile, md5Hex);
}
} finally {
lock.unlock();
}
}
TaskResponse taskResponse = new TaskResponse(content);
return RespBodyVo.ok(taskResponse);
}
public String parse0(UploadFile uploadFile, String md5Hex) {
UploadResultVo uploadFileResult = Aop.get(AliyunStorageService.class).uploadFile("audio", uploadFile);
Long fileId = uploadFileResult.getId();
byte[] data = uploadFile.getData();
String name = uploadFile.getName();
ResponseVo transcriptions = WhisperClient.transcriptions(name, data, WhisperResponseFormat.text);
String content = transcriptions.getBodyString();
long id = SnowflakeIdUtils.id();
Row row = Row.by("id", id).set("md5", md5Hex).set("file_id", fileId)
//
.set("content", content);
Db.save(AgentTableNames.ai_audio_asr_cache, row);
return content;
}
}
关键点解释(更容易踩坑的地方)
1)上传字段名必须一致
你的 handler 用的是:
httpRequest.getUploadFile("file")
所以客户端表单字段一定要叫 file,否则会取不到文件。
2)缓存表结构要能支撑查询条件
代码查询条件是:
where md5=? and deleted=0
因此表里至少需要 md5、content、deleted 字段(以及你保存的 file_id、id 等)。另外建议对 md5 建索引,不然音频多了会慢。
3)并发去重的“二次检查”很重要
你在加锁前查一次,锁内又查一次,这是典型的“双重检查”写法,目的就是避免:
- 第一个线程锁内已经写入缓存
- 第二个线程拿到锁后不再重复调用 ASR
这能显著降低高并发时的重复调用。
4)返回格式与前端约定
最终返回是:
RespBodyVo.ok(new TaskResponse(content))
所以前端拿到结果一般在 data.result,与你示例响应一致。
如何调用(简单说明)
用任意支持 multipart 的方式上传即可:
- Postman:选择
form-data,key 填file,类型选 File,上传音频。 - curl:
-F "file=@xxx.wav"
