最近在做一个内部工具,需要把一段段录音快速转成文字。试了几套方案,最后留在生产环境的组合挺出乎我自己意料:Whisper-Tiny 模型加一个 Go 写的 HTTP 服务,部署在一台 Ubuntu 20.04 的普通服务器上。本来担心 Tiny 识别准确率不够用,实测下来日常对话、会议录音这种场景完全能顶住,关键是 CPU 就能跑,成本比想象中低太多。这篇文章把从零到能上线服务的完整过程记录下来,包括依赖怎么装、模型怎么转、Go 怎么和模型的 C 实现对接,以及我踩过的几个坑。如果你也有音频转写需求,想在低成本条件下快速落地,这篇应该能帮你省不少时间。
先说结论:Whisper-Tiny 在 CPU 上跑,速度和性价比非常香;Go 做服务外壳,运维和部署极方便。下面按我的实际操作路径一步步说。
1. 选型逻辑:Whisper-Tiny 与 Go 为什么是黄金组合
1.1 Whisper 模型家族这么多尺寸,为什么偏偏选最小的 Tiny
很多人一听语音识别,第一反应就是"大模型准确率高",张嘴就要 Large。但对一个部署在普通服务器上的转写服务来说,准确的场景定义比参数规模重要得多。Whisper 官方家族里几个尺寸的参数量和模型体积差别非常明显:
| 模型 | 参数量 | 模型文件大小(ggml格式) | CPU上的大致速度(相对实时) |
|---|---|---|---|
| tiny | 39M | 约 75MB | 约 10x-30x |
| base | 74M | 约 140MB | 约 7x-12x |
| small | 244M | 约 460MB | 约 4x-6x |
| medium | 769M | 约 1.5GB | 约 1x-2x |
| large | 1550M | 约 2.9GB | 远慢于实时 |
注意,这里的"速度"只是一个相对参考,具体会受 CPU 型号、核心数、音频时长影响。但方向很明确:tiny 和 base 是 CPU 上能流畅跑的区间,small 开始就比较吃力了。
实际业务里我的录音主要是会议纪要、语音备忘录、客户访谈,时长一般几分钟到半小时,口音不算太杂,环境噪音也有限。这种场景下 tiny 的中文识别效果,我觉得用"能看懂"形容很贴切——偶尔会有同音字错误,比如"部署"写成"布署",但整体语义、关键词、句子结构都靠谱。如果你跑的是精确到逐字的法律文书、字幕脚本,那确实得升到 base 或者 small;如果只是先转出来给人看、搜内容、写摘要,tiny 完全够。
1.2 Go 做服务外壳的三个理由
语音转写的底层推理是 C/C++ 干的活,这没问题;问题在于服务外壳该用什么语言。我试过 Python 方案,也试过 Node.js 方案,最后留在 Go 有三个非常实际的原因:
第一,部署产物简单。Go 编译出来就一个二进制文件,加上模型文件,扔到服务器上就能跑。不需要装 Python 环境、不需要维护一堆依赖包版本、不需要担心系统库里某个 .so 被无意升级搞坏。对一台 Ubuntu 20.04 的干净服务器来说,这种"拷贝即用"的体验太省心了。
第二,并发模型适合做 API 网关。底层 whisper 推理是 C 库,同一时间只能由一个 goroutine 调用一个 context,但 Go 的 goroutine 非常适合做请求接收、任务排队、结果返回这一套流水线。层与层之间用 channel 解耦,天然就是 worker 池的形态。后面第 5 节我会详细讲怎么设计并发,这里的核心是:Go 的调度模型让"排队 + 并发处理"这件事写得特别顺手。
第三,CGO 桥接成熟。whisper.cpp 这个项目天生支持 C 接口,Go 通过 cgo 直接用 C 头文件调用,不需要额外写 RPC、也不需要起子进程。比起用 Python 调同样的 C 库,cgo 的调用损耗几乎可以忽略,反而是子进程方案每次都要加载模型,慢得让人崩溃。
2. 环境准备:Ubuntu 20.04 上一次性装齐所有依赖
2.1 基础编译工具与系统库
我习惯先更新再装包,避免早期版本的软件包冲突:
sudo apt update && sudo apt upgrade -y sudo apt install -y build-essential git ffmpeg cmake pkg-configbuild-essential会带 gcc、g++、make,编译 whisper.cpp 必须要。ffmpeg是音频重采样工具,Whisper 要求输入 16kHz 的单声道 PCM,而用户上传的往往是 44.1kHz 的双声道 mp3、m4a,这块必须靠 ffmpeg 转。cmake其实只在用 CMake 方式编译 whisper.cpp 时才用到,如果你像我一样直接用项目底下的 Makefile,不装 cmake 也能行,但装上没坏处,省得后面想编译其他 C 库时又缺工具。
装完验证一下关键组件:
ffmpeg -version gcc --version go version其中 ffmpeg 版本在 Ubuntu 20.04 上通常是 4.2.x,对咱们的需求足够。需要注意:千万别手贱装一堆libavcodec-dev、libavformat-dev之类的开发库,我们只是用 ffmpeg 命令行做转码,不需要在 Go 里链 avcodec,装多了反而可能干扰系统的库版本。
2.2 Go 安装:别用 apt 自带的版本
Ubuntu 20.04 的 apt 源里 Go 版本非常老,我记得是 1.13.x,太旧了。建议直接从官方下载最新的稳定版。写这篇文章时我用的 Go 1.22 系列,1.21 以上都没问题。
wget https://go.dev/dl/go1.22.5.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz export PATH=$PATH:/usr/local/go/bin echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc source ~/.bashrc这里有个细节:cgo 默认是开启的,但有些编译参数或环境变量会把它关掉。后面 Go 编译时我会显式设置CGO_ENABLED=1,避免踩到"明明写了 cgo 代码,却因为 CGO 关闭导致编译失败"的坑。
2.3 硬件配置底线
先说结论:最低也得 2 核 2GB 内存,最舒服的是 4 核 4GB。模型本身只占 75MB 内存,但 whisper.cpp 推理时的临时 buffer、ffmpeg 转码时的内存占用、以及 Go 运行时都要吃内存。我在一台 2 核 2GB 的小服务器上跑过,可用内存剩不到 200MB 时,系统会开始 swap,转写速度会突然下降好几倍。建议部署前先确认:
nproc free -h如果内存紧张,后面可以换用 q8_0 量化模型,模型文件能压到 40MB 左右,推理内存也会低一截。这个我在第 7 节详细说。
3. 模型获取与转换:拿到 whisper.cpp 能直接加载的 ggml 格式
3.1 为什么模型格式要从 .pt 换成 ggml
Whisper 官方开源的是 PyTorch 格式的权重,.pt文件。这个格式对服务部署非常不友好:推理时需要装 PyTorch 环境,CPU 上用 PyTorch 跑 Whisper 是出了名的慢,光是框架本身就要吃几百 MB 内存。whisper.cpp 项目把模型做了一层转换,输出为ggml格式,这是一个专门为 CPU/GPU 推理设计的轻量格式,权重布局对缓存友好,不需要 Python 运行时,单文件加载,特别适合嵌入式和服务端场景。
所以流程就成了这样:whisper.cpp 负责两件事,一是把 .pt 模型转成 .bin 格式,二是在推理时加载这个 .bin 文件。好在对于 tiny 这种小模型,转好的官方文件早就发布在仓库里了,我们大部分情况下根本不用自己动手转,直接下载就行。
3.2 直接下载官方转换好的模型
我先把 whisper.cpp 仓库拉下来,后续 Go 的 CGO 也要用它:
git clone https://github.com/ggerganov/whisper.cpp.git cd whisper.cpp bash models/download-ggml-model.sh tiny执行完会在models/目录下生成ggml-tiny.bin,大小约 75MB。这个脚本本质上是去下载已经转换好的文件,不是在你机器上现场转,所以快得很。
如果你需要自己转,比如想用某个自定义训练过的权重,官方也提供了脚本models/convert-pt-to-ggml.py,但要求本机有 PyTorch,而且版本要匹配。我的建议是:普通用户不要碰这条链路,一台只装了 tiny 模型的服务器,真没必要为了一次转换去装几百 MB 的 PyTorch。
3.3 先跑一次官方 demo 验证模型和编译环境
拿到模型后不要急着写 Go,先用 whisper.cpp 自带的命令行工具验证整条链路:
make -j$(nproc) ./main -m models/ggml-tiny.bin -f samples/jfk.wav如果输出了一段英文识别文本,说明三件事:一是模型文件没坏,二是编译环境正常,三是这台机器能跑推理。这一步花不了几分钟,却能提前排除一堆底层问题。我第一次就是跳过这步直接写 Go,结果 cgo 编译报错后分不清是模型问题还是代码问题,排查了很久。
顺带一提:make结束后,项目目录下会生成libwhisper.a,这是后续 Go cgo 链接的关键文件,没了它,Go 代码里引用的whisper_init_from_file等函数就会一直报 undefined reference。
4. Go 侧 CGO 桥接:在 Go 代码里调用 whisper.cpp
4.1 项目目录与 cgo 编译配置
我在 whisper.cpp 仓库外面建了一个独立的 Go 项目,目录结构大概是这样:
whisper-server/ ├── go.mod ├── main.go └── whisper.cpp/ # 就是上面 git clone 的仓库,Makefile 编译产物也在里面main.go 头部用 cgo 导入 whisper 的头文件和静态库:
/* #cgo CFLAGS: -I${SRCDIR}/whisper.cpp/include -I${SRCDIR}/whisper.cpp #cgo LDFLAGS: -L${SRCDIR}/whisper.cpp -lwhisper -lstdc++ -lm #include "whisper.h" */ import "C"这里的坑主要有两个,我逐个说:
坑一:头文件路径。whisper.h 实际位置在whisper.cpp/include/whisper.h,不在仓库根目录。很多初学 cgo 的人把-I指到根目录,编译时老提示找不到 whisper.h。正确做法是-I${SRCDIR}/whisper.cpp/include。${SRCDIR}是 cgo 自动注入的宏,代表当前 Go 文件所在目录,比写死绝对路径好迁移。
坑二:C++ 链接库不能少。whisper.cpp 是 C++ 写的,即使暴露的是 C 接口,静态库里依然有 C++ 符号,所以LDFLAGS里必须带上-lstdc++。我第一次没加,链接阶段直接报undefined reference to std::__cxx11::basic_string一堆错误,差点以为是 cgo 写错了。另外-lm是数学库,有些编译选项下也需要。
4.2 音频解码:ffmpeg 转成裸 PCM,不要 WAV
whisper.cpp 的whisper_full函数接受的音频输入是 16kHz、单声道、16bit PCM 的采样点数组。用户上传的 mp3、m4a 显然不满足,所以我在服务里用 ffmpeg 做了一次转码。这里有个贴士:宁可让 ffmpeg 直接输出裸 PCM,也不要让它输出 WAV。输出 WAV 的话,读文件时需要先解析 44 字节左右的 RIFF 头,还要应对不同编码器写出的头长度不一致的问题;输出裸 PCM 的话,文件内容就是连续的 16bit 小端整数,直接一把梭读取即可。
ffmpeg -y -i input.m4a -ac 1 -ar 16000 -f s16le output.pcm-ac 1强制单声道,-ar 16000强制采样率,-f s16le输出裸 16bit 有符号小端 PCM。在 Go 里用os/exec调用这条命令。
读出来的字节流转成[]float32,这是 whisper_full 需要的采样点类型。转换公式很简单:每个 16bit 整数除以 32768,得到 -1.0 到 1.0 之间的浮点数。
func pcmBytesToFloats(data []byte) ([]float32, error) { if len(data)%2 != 0 { return nil, fmt.Errorf("PCM bytes length is odd: %d", len(data)) } samples := make([]float32, len(data)/2) for i := 0; i < len(samples); i++ { raw := int16(data[2*i]) | int16(data[2*i+1])<<8 samples[i] = float32(raw) / 32768.0 } return samples, nil }注意这里是小端字节序:低字节在前。int16(data[2*i]) | int16(data[2*i+1])<<8这行,是因为 Go 里data[2*i]是 byte 类型,直接转 int16 只能拿到 0-255,需要把高字节左移 8 位再或起来。也可以用encoding/binary包的LittleEndian.Uint16,但手写这个也够用了。
4.3 核心 API 调用流程与参数
初始化、推理、取结果,完整流程分四步,对应四个核心函数。
第一步,初始化模型上下文:
modelPath := C.CString("/opt/models/ggml-tiny.bin") defer C.free(unsafe.Pointer(modelPath)) ctx := C.whisper_init_from_file(modelPath) if ctx == nil { return fmt.Errorf("failed to init whisper from %s", modelPath) } defer C.whisper_free(ctx)注意C.CString在 C 堆上分配内存,用完必须 free,否则每次请求都会泄漏一点,跑几天后内存就爆了。我一开始没注意,压测到几百次请求后进程直接 OOM。
第二步,设置推理参数:
params := C.whisper_full_default_params(C.WHISPER_SAMPLING_GREEDY) params.n_threads = 4 params.translate = C.bool(false) params.single_segment = C.bool(true)WHISPER_SAMPLING_GREEDY是贪心采样,对比 beam search 速度快、结果稳定,对中文场景这种生成式任务够了。translate控制是否翻译成英文,我们只转写不翻译,必须设 false。single_segment设为 true 会把整段音频当作一个片段处理,适合短录音;如果处理带停顿的完整会议记录,建议留在 false,让 whisper 自己切分片段,后面的结果遍历会拿到多段文本。
第三步,调用推理:
samplesPtr := (*C.float)(unsafe.Pointer(&samples[0])) ret := C.whisper_full(ctx, params, samplesPtr, C.int(len(samples))) if ret != 0 { return fmt.Errorf("whisper_full failed: %d", ret) }第四步,遍历结果:
n := int(C.whisper_n_text_segments(ctx)) var b strings.Builder for i := 0; i < n; i++ { segText := C.whisper_full_get_segment_text(ctx, C.int(i)) b.WriteString(C.GoString(segText)) }C.whisper_full_get_segment_text返回的是 C 字符串指针,必须用C.GoString转成 Go string,否则拿到的是一串内存地址。这里也不用手动 free,这个指针的生命周期由 whisper context 管理。
4.4 一个关键的工程细节:context 全局复用
前面的代码片段把"初始化-推理-释放"写成了一个函数,看起来简单,但如果你真的每次请求都whisper_init_from_file一次,服务性能会烂到没法用。模型文件 75MB,光加载就要几百毫秒甚至一秒,加上每次 init 都要重新分配一堆内部 buffer,等于每次请求都要付一次冷启动代价。
正确做法是:进程启动时初始化一次 context,全局保存,后续所有请求复用同一个ctx。但这样又会带来并发安全问题——多个 goroutine 同时调whisper_full(ctx, ...)会数据竞争甚至段错误。这个问题就是我第 5 节要讲的并发设计核心。
5. HTTP 服务实现与并发防护
5.1 接口设计与响应格式
服务端只需要一个接口:接收音频文件,返回转写文本。我用的是标准POST /api/transcribe,请求体为multipart/form-data,文件字段名叫file。
type TranscribeResponse struct { Code int `json:"code"` Message string `json:"message"` Data struct { Text string `json:"text"` Duration float64 `json:"duration"` } `json:"data"` }前端上传时,顺带带上录音时长duration,服务端可以用来做超时限制,也可以返回给前端做日志统计。响应用 JSON,text字段就是转写结果。
5.2 为什么要做 worker 池而不只是加锁
前面提到 whisper context 不能并发调用。最简单的方案是给全局 context 加一把sync.Mutex,谁抢到谁推理。这个方案实现成本最低,但有个致命问题:一个 4 核的服务器,只有 1 个核在跑推理,其他 3 个核闲着。Whisper-Tiny 本身推理速度很快,单任务延迟能接受,但多用户并发时吞吐量直接瘫痪。
所以我选择了 worker 池方案,核心思路是:每个 worker 持有自己的 whisper context,worker 之间互相独立,天然并行。假设服务器有 4 核,我就启动 3 个 worker,留 1 个核给操作系统、Go 调度、ffmpeg 转码和其他杂活。
一个简化版的 worker 结构:
type worker struct { id int ctx unsafe.Pointer // 指向 C 的 whisper context } var taskCh = make(chan *transcribeTask, 16) func (w *worker) loop() { for task := range taskCh { text, err := runWhisper(w.ctx, task.samples) task.result <- transcribeResult{text: text, err: err} } }启动 worker 后,HTTP handler 只负责接收上传、转码、切好[]float32,然后塞进taskCh,在task.result上等结果。这个模型下,多个请求会被自动排队到不同 worker,CPU 利用率能接近满载。
5.3 请求全链路处理流程
完整流程我分七步,每一步都有值得注意的细节:
第一步,限制请求体大小。不加限制的话,一个几十 MB 的文件就能把内存打爆。用http.MaxBytesReader限制到 20MB:
r.Body = http.MaxBytesReader(w, r.Body, 20<<20)第二步,保存上传文件到临时目录。不要直接在内存里读整个文件,mp3 解码前的原始数据虽然不大,但后面还要转 PCM,内存峰值会翻好几倍。用os.CreateTemp落盘,处理完再删。
第三步,ffmpeg 转码。注意 ffmpeg 在转码时要设置超时,防止恶意文件或异常格式让它卡死。exec.CommandContext配合context.WithTimeout,超时直接杀掉进程。
第四步,读 PCM 转[]float32。这一步前面讲过了,唯一要注意的是文件读完立刻删掉临时文件,避免磁盘被垃圾撑爆。
第五步,把任务提交到taskCh。这一步是异步的,注意taskCh满了要能阻塞等待,而不是无限堆积。我给 channel 容量设了 16,超过就返回 503,让客户端稍后重试。
第六步,等待结果。我给结果 channel 也加了超时,比如 30 秒,防止某个音频太离谱让 worker 卡住。
第七步,返回 JSON。记录延迟、音频时长、模型型号这些元信息,方便后面做监控。
5.4 关于 ffmpeg 转码的资源占用提醒
ffmpeg 是独立进程,转码大文件时 CPU 占用忽高忽低。在 4 核服务器上,如果 3 个 worker 同时跑推理,ffmpeg 再占 1 核,系统负载就会偏高。所以我在实现里设置了一个maxConcurrentFfmpeg信号量,限制同时最多 2 个 ffmpeg 进程转码。实测这样能避免偶发的高峰期 CPU 过载。
6. systemd 守护与生产环境部署
6.1 编译产物与依赖关系
Go 代码写好后,用下面的命令编译出生产二进制:
CGO_ENABLED=1 go build -trimpath -ldflags "-s -w" -o whisper-server main.go-ldflags "-s -w"去掉调试信息和符号表,能瘦身 30% 左右。编译出来的whisper-server就是最终部署单元,配合ggml-tiny.bin模型文件,两个文件就能撑起一个服务。
有一点要提前说明:因为 cgo 链接的是静态库libwhisper.a,Go 二进制本身已经把 whisper.cpp 编译进去了,但 C++ 标准库仍然是动态依赖。所以目标服务器上至少要有libstdc++.so.6,Ubuntu 20.04 默认带,不需要额外处理。你可以用ldd whisper-server检查依赖,看到libstdc++.so.6心里就有数了。
6.2 systemd service 文件写法
我用一个独立的whisper系统用户运行服务,不推荐 root 直接跑,万一服务有漏洞,权限太大很危险。
[Unit] Description=Whisper Transcription Service After=network.target [Service] User=whisper Group=whisper WorkingDirectory=/opt/whisper-server ExecStart=/opt/whisper-server/whisper-server Restart=always RestartSec=3 Environment=WHISPER_MODEL=/opt/models/ggml-tiny.bin Environment=WHISPER_PORT=8080 Environment=WHISPER_THREADS=3 LimitNOFILE=65535 [Install] WantedBy=multi-user.target关键点:
Environment里放配置,比在 Go 代码里硬编码路径好维护。模型路径、端口、线程数都用环境变量注入。Restart=always保证进程崩溃后能自动拉起。whisper.cpp 这种 C 库和 cgo 打交道的代码,偶发段错误也不是完全没可能,自动重启比半夜被监控叫醒强。WorkingDirectory要设置到可执行文件所在目录,防止有些相对路径逻辑出问题。
启动命令:
sudo useradd -r -s /bin/false whisper sudo mkdir -p /opt/whisper-server /opt/models sudo mv whisper-server /opt/whisper-server/ sudo mv ggml-tiny.bin /opt/models/ sudo chown -R whisper:whisper /opt/whisper-server /opt/models sudo systemctl daemon-reload sudo systemctl enable --now whisper查看日志用:
journalctl -u whisper -f6.3 模型路径与权限自查清单
部署完成后,按这个顺序自查:
journalctl -u whisper -n 20看启动日志,确认模型加载成功,没有报failed to init whisper。curl -X POST http://localhost:8080/api/transcribe -F "file=@test.mp3"跑一个真实文件。ps aux | grep whisper-server看内存占用,确认没有异常飙升。- 检查
/opt/whisper-server和/opt/models目录权限,确保 whisper 用户可读。很多人一开始用 root 部署完没问题,后面换了用户就各种 Permission denied,根源就是模型文件权限没放开。
如果要在外网提供服务,建议前面加一层 nginx 做 TLS 终结和负载均衡,不要把 Go 服务的端口直接暴露公网。nginx 里需要调大client_max_body_size,默认 1MB 连一个长录音都传不上去。
7. 实测数据、调优方向与踩坑记录
7.1 实测性能数据
我在一台 4 核 2.4GHz、4GB 内存的服务器上跑了完整压测,模型为官方ggml-tiny.bin,线程数设为 4,测试音频是一段 32 秒的中文会议录音。
| 项目 | 实测值 |
|---|---|
| 模型加载耗时 | 约 600ms(进程启动时一次性) |
| 单次推理耗时(32秒音频) | 约 2.1s |
| 实时倍数 | 约 15x |
| 进程常驻内存 | 约 280MB |
| 并发 6 个请求,全部完成时间 | 约 8s(3 个 worker 并行) |
注意单次推理耗时是"推理"本身,不包括 ffmpeg 转码的 200-400ms。整体体感是:一段 30 秒的录音,用户点击上传到看到文字,大约 2.5 秒。这个延迟对会议纪要、语音笔记、内容检索场景完全够用。
如果你经常处理 10 分钟以上的长录音,建议让 ffmpeg 先把音频切成 1 分钟以内的片段,再逐段推理,最后拼接结果。whisper 对长音频的内存占用会显著上升,切片还能顺便降低单次请求的失败率。
7.2 场景化调优方向
方向一:模型量化。whisper.cpp 自带量化工具,能进一步压缩模型体积和推理开销。我最常用的是q8_0量化,实测中文识别准确率几乎不掉,模型文件从 75MB 降到 40MB,推理速度快 10% 左右。操作方式:
./quantize models/ggml-tiny.bin models/ggml-tiny-q8_0.bin q8_0方向二:线程数不是越多越好。对 tiny 这种小模型,n_threads我建议设置成 CPU 物理核心数,再多不仅没提升,反而因为线程同步开销导致延迟变高。在 4 核机器上设 4,实测最好;设 8 反而慢了 20%。
方向三:音频前置处理。录音里有环境噪音时,可以给 ffmpeg 加一组简单的滤波参数:
ffmpeg -y -i input.m4a -af highpass=f=200,lowpass=f=8000 -ac 1 -ar 16000 -f s16le output.pcmhighpass切掉 200Hz 以下的低频隆隆声,lowpass切掉 8kHz 以上的高频噪声,对空调声、风扇声有效。有轻微背景人声的话,还可以加个afftdn=nf=-20降噪,但这个会轻微损伤语音清晰度,要不要用自己权衡。
方向四:启动预热。服务启动后,whisper context 刚加载时很多内部状态还没热起来,第一次推理有时会偏慢。我习惯在main函数里,初始化完成后跑一段 1 秒的静音音频做预热,让模型 buffer 全部填充好再对外提供服务。实测预热后第一次请求能省 100-200ms。
7.3 值得记录的五个坑
坑一:cgo 编译报 undefined reference。前面提过一次,具体是undefined reference to whisper_init_from_file。核心原因是libwhisper.a还没生成。解决办法是先在 whisper.cpp 目录跑make -j$(nproc),确保静态库存在后再 go build。另外-lstdc++缺少会导致一堆 C++ 符号报错,别漏。
坑二:多 goroutine 复用同一个 context 导致段错误。现象是服务跑着跑着突然崩溃,日志里没有 Go panic,只有 SIGSEGV。排查了很久,最后确定是并发调whisper_full。解决方案就是 worker 池,每个 worker 独立 context。这个坑属于 C 库的固有设计,whisper.h 里没有明确写"not thread safe",但你得默认它不安全。
坑三:ffmpeg 输出 WAV 导致音频多出 44 字节头。如果你用-f wav输出,Go 读取时不做头偏移,转出来的音频前面会有噪音,识别结果开头会出现奇怪的字符。我改成-f s16le输出裸 PCM 后,这个问题彻底消失。
坑四:临时文件不清理。我的测试阶段,请求一多,/tmp被临时 WAV 撑满,磁盘告警后所有转写全部失败。后来所有临时文件都用 defer 清理,并且服务里加了定期清理兜底,再没出过问题。
坑五:忽略请求体大小限制。有人上传了一个 200MB 的录音文件,压测时直接把服务器的内存吃爆,Go 进程被系统杀掉。加了http.MaxBytesReader后,超过 20MB 的请求直接 413,安全多了。实际业务里前端也应该切成 1 分钟以内的片段再传,一举两得。
我这套服务上线运行了大概一个多月,期间只因为一次磁盘写满挂过一次,其余时间非常稳定。如果你也想搭一套语音转写服务,我建议从 Whisper-Tiny 加 Go 这个组合起步。Tiny 跑起来快,试错成本低,等真的遇到准确率不够的场景,再平滑切换到 base 或者 small——whisper.cpp 的模型文件换一个就行,代码完全不用动,这种可替换性才是最舒服的。
最后再分享一个小技巧:在 Go 服务里加一个/api/health接口,返回当前 worker 数量、排队任务数、最近一次推理耗时。配合 systemd 的监视,能第一时间发现服务异常。这个小东西帮我省了不少事,推荐你也加上。