FireRedASR-AED-L语音识别实战:Java后端服务集成与调用

最近在做一个企业客服系统的升级项目,客户反馈最强烈的一个痛点就是通话录音的转写。传统的方案要么是调用第三方API,延迟高、成本贵;要么是本地部署一些开源模型,但准确率又不太理想,尤其是在一些带口音或者背景嘈杂的录音上,效果差强人意。

后来我们注意到了FireRedASR-AED-L这个模型,听说在中文语音识别上表现不错,特别是对长音频和复杂场景的适应性比较好。正好星图平台提供了预置的镜像,部署起来比较方便。我们就琢磨着,能不能把它集成到我们现有的Java微服务架构里,做成一个稳定、高效、可扩展的内部语音转写服务。

今天这篇文章,我就来分享一下我们整个集成和调用的实战过程。这不是一个简单的“Hello World”教程,而是聚焦在如何将一个AI模型能力,以工程化的方式封装成企业级后端服务。我们会聊到SpringBoot服务的封装设计、如何优雅地处理异步调用和长耗时任务、结果缓存与错误重试这些实际开发中肯定会遇到的“坑”。如果你也在为类似的需求寻找解决方案,希望这篇分享能给你带来一些直接的参考。

1. 场景与方案总览

在深入代码之前,我们先明确一下要解决的具体问题和我们设计的整体方案。

我们的客服系统每天会产生大量的通话录音文件。运营和质检团队需要将这些录音转换成文字,用于话术分析、质量检查、投诉溯源和新人培训。之前的方式是人工抽查听录,效率极低;后来尝试过一些商业ASR服务,按量计费成本攀升很快,而且在网络波动时,响应延迟很不稳定。

核心需求可以归纳为三点:

  1. 高准确率:针对客服场景的常见词汇、产品名称、以及可能的用户口音,识别要准。
  2. 低延迟:虽然不是严格“实时”,但转写任务提交后,最好能在分钟级内返回结果,不影响后续业务流程。
  3. 高可靠与可扩展:服务要稳定,能应对批量任务并发,并且方便在我们自己的私有化环境中部署和管理。

基于这些需求,我们选择了 FireRedASR-AED-L + 星图GPU镜像 + Java后端微服务 的技术栈。

  • FireRedASR-AED-L:作为识别引擎,负责核心的语音到文本转换。
  • 星图GPU镜像:提供了开箱即用的模型运行环境,省去了我们自己搭建CUDA环境、处理模型依赖的麻烦,一键部署,资源隔离性好。
  • Java SpringBoot微服务:作为业务集成层,负责任务调度、状态管理、结果缓存、与上游客服系统对接,并提供统一的RESTful API。

整体的工作流程是这样的:客服系统上传录音文件到文件服务器,然后向我们的语音转写服务发起一个转写请求。Java服务将这个请求异步化,去调用部署在星图平台上的FireRedASR-AED-L模型API,拿到转写结果后,先缓存起来,再通知客服系统来获取。这样做的好处是,将耗时的AI计算与核心业务逻辑解耦,保证了主业务流程的响应速度。

2. 核心服务设计与搭建

这一部分,我们来看看Java后端服务是如何设计和搭建的。我们采用经典的SpringBoot框架,整体结构清晰,便于维护。

2.1 项目结构与依赖

首先,创建一个标准的SpringBoot项目。核心的依赖除了SpringBoot Web Starter,我们还需要用于HTTP客户端、JSON处理、任务调度和缓存的库。

<!-- pom.xml 关键依赖 -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 用于调用星图模型API -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    <!-- 异步任务与缓存 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <!-- 工具类 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

项目的主要目录结构如下:

src/main/java/com/example/asrservice/
├── AsrServiceApplication.java          // 启动类
├── config/
│   ├── WebClientConfig.java           // HTTP客户端配置
│   └── RedisConfig.java               // 缓存配置
├── controller/
│   └── AsrTaskController.java         // 提供REST API
├── service/
│   ├── AsrCoreService.java            // 调用AI模型的核心服务
│   └── TaskManageService.java         // 任务状态管理服务
├── model/
│   ├── dto/                           // 请求与响应对象
│   └── entity/                        // 实体类(如任务记录)
└── task/
    └── AsyncTaskExecutor.java         // 异步任务执行器

2.2 模型API调用封装

这是与FireRedASR-AED-L模型交互的核心。我们假设星图镜像部署后,提供了一个HTTP API端点,例如 http://your-mirror-address:port/v1/asr,它接受一个包含音频文件URL或二进制数据的请求,返回识别文本。

我们创建一个 AsrCoreService,使用Spring的 WebClient(响应式、非阻塞)进行调用。

@Service
@Slf4j
public class AsrCoreService {

    private final WebClient webClient;
    private final String asrApiUrl = "http://YOUR_MIRROR_IP:PORT/v1/asr"; // 配置在application.yml中

    public AsrCoreService(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.baseUrl(asrApiUrl).build();
    }

    /**
     * 调用远程ASR模型进行语音转写
     * @param audioUrl 可公开访问的音频文件URL
     * @return 转写后的文本
     */
    public Mono<String> transcribeAudio(String audioUrl) {
        // 构建请求体,根据模型API的实际要求调整
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("audio_url", audioUrl);
        requestBody.put("language", "zh-CN");
        // 可能还有其他参数,如采样率、模型类型等

        return webClient.post()
                .uri("/transcribe") // 具体的API路径
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(requestBody)
                .retrieve()
                .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
                         response -> {
                             log.error("ASR API调用失败,状态码: {}, 音频: {}", response.statusCode(), audioUrl);
                             return Mono.error(new RuntimeException("ASR服务调用异常"));
                         })
                .bodyToMono(JsonNode.class) // 假设返回JSON
                .map(response -> {
                    // 解析响应,提取文本字段,根据实际API响应结构调整
                    String text = response.path("text").asText("");
                    log.info("音频转写成功,URL: {}, 文本长度: {}", audioUrl, text.length());
                    return text;
                })
                .timeout(Duration.ofSeconds(60)) // 设置超时时间
                .doOnError(e -> log.error("音频转写失败,URL: {}", audioUrl, e));
    }
}

关键点说明

  1. 使用WebClient:相比传统的RestTemplate,WebClient支持非阻塞IO,更适合高并发场景下的外部服务调用。
  2. 错误处理:通过 onStatusdoOnError 对HTTP错误和网络异常进行统一处理,并记录日志。
  3. 超时控制:语音识别可能耗时较长,通过 timeout 设置一个合理的超时时间,避免线程长时间阻塞。
  4. 配置化:API地址、超时时间等应放在配置文件中,便于不同环境切换。

3. 异步处理与任务管理

直接同步调用AI模型API会阻塞HTTP线程,导致服务无法处理其他请求。因此,我们必须引入异步机制。

3.1 异步任务执行器

我们创建一个 AsyncTaskExecutor,利用Spring的 @Async 注解和线程池来执行耗时的转写任务。

@Component
@Slf4j
public class AsyncTaskExecutor {

    @Autowired
    private AsrCoreService asrCoreService;
    @Autowired
    private TaskManageService taskManageService;

    /**
     * 自定义线程池,避免使用默认的共享线程池
     */
    @Bean("asrTaskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5); // 核心线程数
        executor.setMaxPoolSize(20); // 最大线程数
        executor.setQueueCapacity(100); // 队列容量
        executor.setThreadNamePrefix("asr-task-");
        executor.initialize();
        return executor;
    }

    /**
     * 异步执行转写任务
     * @param taskId 任务ID
     * @param audioUrl 音频地址
     */
    @Async("asrTaskExecutor")
    public void executeTranscribeTask(String taskId, String audioUrl) {
        log.info("开始执行异步转写任务,任务ID: {}, 音频URL: {}", taskId, audioUrl);
        taskManageService.updateTaskStatus(taskId, TaskStatus.PROCESSING);

        try {
            // 调用核心转写服务
            String transcribedText = asrCoreService.transcribeAudio(audioUrl).block(); // 注意:在异步方法内阻塞是合理的
            // 存储结果
            taskManageService.saveTaskResult(taskId, transcribedText);
            taskManageService.updateTaskStatus(taskId, TaskStatus.SUCCESS);
            log.info("异步转写任务完成,任务ID: {}", taskId);
        } catch (Exception e) {
            log.error("异步转写任务失败,任务ID: {}", taskId, e);
            taskManageService.updateTaskStatus(taskId, TaskStatus.FAILED);
            // 这里可以加入重试逻辑(见下一节)
        }
    }
}

3.2 任务状态管理与API设计

我们需要一个 TaskManageService 来管理任务的生命周期(创建、查询、更新状态、存储结果)。通常,我们会将任务信息存入数据库(如MySQL)和缓存(如Redis)。

@Service
public class TaskManageService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    // @Autowired 数据库Repository (假设使用JPA)

    private static final String TASK_RESULT_KEY_PREFIX = "asr:result:";
    private static final long RESULT_EXPIRE_HOURS = 24; // 结果缓存24小时

    /**
     * 创建转写任务
     */
    public String createTask(String audioUrl) {
        String taskId = UUID.randomUUID().toString();
        // 1. 存入数据库(持久化)
        // AsrTask task = new AsrTask(taskId, audioUrl, TaskStatus.PENDING);
        // taskRepository.save(task);
        // 2. 初始化缓存状态
        redisTemplate.opsForValue().set(getTaskStatusKey(taskId), TaskStatus.PENDING.name());
        log.info("创建转写任务,ID: {}, URL: {}", taskId, audioUrl);
        return taskId;
    }

    /**
     * 查询任务结果
     */
    public String getTaskResult(String taskId) {
        // 优先从缓存获取
        String result = redisTemplate.opsForValue().get(getTaskResultKey(taskId));
        if (result != null) {
            return result;
        }
        // 缓存未命中,从数据库获取
        // AsrTask task = taskRepository.findById(taskId).orElse(null);
        // return task != null && task.getStatus() == TaskStatus.SUCCESS ? task.getResultText() : null;
        return null; // 简化示例
    }

    /**
     * 更新任务状态
     */
    public void updateTaskStatus(String taskId, TaskStatus status) {
        redisTemplate.opsForValue().set(getTaskStatusKey(taskId), status.name());
        // 同时更新数据库
    }

    /**
     * 保存任务结果
     */
    public void saveTaskResult(String taskId, String result) {
        String resultKey = getTaskResultKey(taskId);
        redisTemplate.opsForValue().set(resultKey, result, RESULT_EXPIRE_HOURS, TimeUnit.HOURS);
        // 同时保存到数据库
    }

    private String getTaskStatusKey(String taskId) {
        return "asr:status:" + taskId;
    }
    private String getTaskResultKey(String taskId) {
        return TASK_RESULT_KEY_PREFIX + taskId;
    }
}

最后,我们提供一个简单的REST API控制器。

@RestController
@RequestMapping("/api/asr")
@Slf4j
public class AsrTaskController {

    @Autowired
    private TaskManageService taskManageService;
    @Autowired
    private AsyncTaskExecutor asyncTaskExecutor;

    /**
     * 提交语音转写任务
     */
    @PostMapping("/submit")
    public ResponseEntity<Map<String, String>> submitTask(@RequestBody Map<String, String> request) {
        String audioUrl = request.get("audio_url");
        if (StringUtils.isEmpty(audioUrl)) {
            return ResponseEntity.badRequest().body(Map.of("error", "audio_url is required"));
        }

        String taskId = taskManageService.createTask(audioUrl);
        // 触发异步执行
        asyncTaskExecutor.executeTranscribeTask(taskId, audioUrl);

        return ResponseEntity.accepted().body(Map.of(
            "task_id", taskId,
            "status", "PENDING",
            "message", "Task submitted successfully"
        ));
    }

    /**
     * 查询任务结果
     */
    @GetMapping("/result/{taskId}")
    public ResponseEntity<?> getResult(@PathVariable String taskId) {
        String result = taskManageService.getTaskResult(taskId);
        if (result != null) {
            return ResponseEntity.ok(Map.of("task_id", taskId, "status", "SUCCESS", "text", result));
        }
        // 检查任务状态
        // 如果还在处理中或失败,返回对应状态
        return ResponseEntity.ok(Map.of("task_id", taskId, "status", "PROCESSING", "message", "Task is still in progress"));
    }
}

这样,上游系统调用 /submit 接口会立即返回一个任务ID,然后通过轮询 /result/{taskId} 接口来获取最终转写文本。这是一种经典的异步任务处理模式。

4. 稳定性与性能优化

在实际生产环境中,仅仅实现基本功能是不够的。网络抖动、模型服务重启、瞬时高并发都是挑战。这里分享我们做的几个关键优化。

4.1 错误重试与降级策略

对于调用外部模型API,网络超时或服务暂时不可用很常见。我们使用Spring Retry来实现自动重试。

@Service
public class AsrCoreService {
    // ... 其他代码 ...

    @Retryable(value = {RuntimeException.class}, // 对哪些异常重试
               maxAttempts = 3, // 最大重试次数
               backoff = @Backoff(delay = 2000, multiplier = 1.5)) // 退避策略:首次等2秒,后续乘1.5倍
    public Mono<String> transcribeAudioWithRetry(String audioUrl) {
        return transcribeAudio(audioUrl); // 调用原始方法
    }

    @Recover // 重试全部失败后的降级处理
    public Mono<String> recoverTranscribe(RuntimeException e, String audioUrl) {
        log.error("ASR转写重试全部失败,音频: {}, 启用降级策略", audioUrl, e);
        // 降级策略:1. 返回空文本并记录。2. 转发到备用识别服务。
        return Mono.just(""); // 示例:返回空字符串
    }
}

4.2 结果缓存与限流

为了避免对同一音频重复转写(比如用户重复查询),我们使用Redis缓存结果。同时,为了保护后端模型服务不被突发流量冲垮,我们在Java服务层做了简单的限流。

@Component
public class RateLimiterService {
    // 使用Guava RateLimiter做简单应用层限流
    private final RateLimiter rateLimiter = RateLimiter.create(10.0); // 每秒10个请求

    public boolean tryAcquire(String taskId) {
        if (rateLimiter.tryAcquire()) {
            return true;
        } else {
            log.warn("触发限流,任务ID: {}", taskId);
            return false;
        }
    }
}

// 在AsyncTaskExecutor中调用前检查
public void executeTranscribeTask(String taskId, String audioUrl) {
    if (!rateLimiterService.tryAcquire(taskId)) {
        taskManageService.updateTaskStatus(taskId, TaskStatus.FAILED, "系统繁忙,请稍后重试");
        return;
    }
    // ... 原有逻辑 ...
}

4.3 监控与日志

完善的日志和监控是线上服务的眼睛。我们记录了任务生命周期的关键事件,并集成了Micrometer将指标(如任务排队数、处理耗时、成功率)暴露给Prometheus。

# application.yml 日志配置示例
logging:
  level:
    com.example.asrservice: INFO
  file:
    name: logs/asr-service.log
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

5. 总结与后续思考

经过一段时间的开发和线上试运行,这套基于FireRedASR-AED-L和Java微服务的语音转写方案,基本达到了我们预期的目标。准确率相比旧方案有显著提升,特别是在客服领域的专业术语识别上;通过异步化和缓存设计,接口响应速度很快,用户体验良好;重试和限流机制也帮助我们平稳度过了几次小的流量波动。

当然,没有完美的方案。在实际运行中,我们也发现了一些可以继续优化的点。比如,目前的任务状态查询还是靠轮询,对客户端不太友好,后续可以考虑引入WebSocket或者消息队列来实现服务端主动推送。另外,音频文件的预处理(格式转换、降噪、分片)如果放在Java服务里做,会消耗不少CPU资源,也许可以拆分成一个独立的前置处理服务。

技术选型上,SpringBoot的生态让我们快速搭建了稳健的后端框架,而星图平台的镜像则让我们免去了深度学习环境部署的繁琐,能把精力集中在业务集成上。如果你团队的技能栈主要是Java,并且有类似的AI能力集成需求,希望这个实践案例能提供一个可行的思路。最重要的是,根据自己业务的实际流量和容错要求,去调整线程池参数、重试策略和降级方案,这些才是系统稳定性的关键。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐