豆包 STT 接入手册
语音转文字处理管线示意图
这个系列是什么:完整的技术 runbook——不讲故事,只讲怎么把一个服务接好。一步一步跟着来,替换占位符,直接能跑。
豆包 STT:火山引擎 Seed ASR 中文语音转文字接入
我需要给一个项目加中文语音识别。
第一反应当然是 Whisper——OpenAI 的,大品牌,文档齐全,社区成熟。但实际拿中文录音一测,问题就来了。口语化的表达识别得稀烂,同音字乱飞,有些句子直接吞掉半截。
说白了,Whisper 的中文不是不能用,是不够好。尤其碰到日常聊天、方言口音、语气词多的场景,差距很明显。
然后我找到了火山引擎的 Seed ASR 大模型——就是豆包背后那套语音识别。一换上去,准确率直接上了一个台阶。
这篇把我接入过程中学到的东西全部整理出来:控制台配置、两步异步 API、生产环境的降级链路、成本追踪,还有 8 个会让你多调一天 bug 的坑。
目录
1. 为什么不用 Whisper
| 维度 | 豆包(Seed ASR) | OpenAI Whisper |
|---|---|---|
| 中文准确率 | 很强——口语、语气词、方言口音都能扛 | 正式场景还行,口语化就拉了 |
| 方言支持 | 覆盖面广 | 有限 |
| 时间戳 | 有——逐句 start/end | 有——按段 |
| 价格 | 比 Whisper 便宜约 2.5 倍(详见火山引擎控制台) | 标准 Whisper 价格 |
| 延迟 | 短音频 2–4 秒(异步轮询) | 实时流式或批量 |
| API 风格 | 异步提交-轮询(REST) | 同步或流式 |
光价格就便宜了约 2.5 倍(具体价格看火山引擎控制台)。
但真正的差距在准确率。你拿一段日常中文对话去测,豆包和 Whisper 的差距,不是"稍微好一点",是"能用"和"凑合用"的区别。
当然,不能把鸡蛋放一个篮子里。豆包挂了怎么办?后面会讲链式降级——自动切 OpenAI,再切 Gemini。
2. 控制台配置
2.1 注册登录
去 console.volcengine.com,用手机号或邮箱注册登录。
2.2 找到正确的服务
这一步最容易走错。控制台里长得像的 ASR 产品一堆。
- 顶部导航找 豆包语音(或者直接搜索)
- 进去找 API服务中心
- 选 录音文件识别大模型
千万别选错:
- 录音文件识别 2.0 —— 老版本,鉴权体系完全不同
- 流式语音识别 —— 流式服务,用的是不同的 resource ID 和 WebSocket 协议
- 任何标着 "旧版" 的东西
选错了你后面所有的鉴权都会报错,而且错误信息完全看不出是选错了服务。
2.3 开通服务
没开通的话,点 开通服务。
2.4 购买时长包
大模型识别按音频时长计费。去 时长包 页面买一个适合你用量的包。这个 API 没有免费额度。
2.5 拿到你的凭证
在豆包语音产品页里找到 服务接口认证信息,你需要:
- APP ID → 对应环境变量
DOUBAO_STT_APP_ID - Access Token → 对应环境变量
DOUBAO_STT_ACCESS_KEY
控制台还会显示一个 "Secret Key"。不需要它。 大模型 v3 API 只用 APP ID + Access Token 鉴权。(详见坑 #6。)
3. 环境变量
# 必填
DOUBAO_STT_APP_ID=<控制台里的 APP ID>
DOUBAO_STT_ACCESS_KEY=<控制台里的 Access Token>
# 不需要——只有流式 WebSocket 才用
# DOUBAO_STT_CLUSTER=volcengine_streaming_common
调用前做个可用性检查:
function isDoubaoAvailable(): boolean {
return !!(process.env.DOUBAO_STT_APP_ID && process.env.DOUBAO_STT_ACCESS_KEY)
}
任一变量缺失,就跳过这个 provider,走下一个。
4. API 详解:提交-轮询流程
大模型文件识别是一个 两步异步流程:先提交音频,再轮询等结果。
接口地址
| 步骤 | 方法 | URL |
|---|---|---|
| 提交 | POST | https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit |
| 查询 | POST | https://openspeech.bytedance.com/api/v3/auc/bigmodel/query |
鉴权 Headers
所有请求(提交和查询)都要带:
X-Api-App-Key: <DOUBAO_STT_APP_ID>
X-Api-Access-Key: <DOUBAO_STT_ACCESS_KEY>
X-Api-Resource-Id: volc.seedasr.auc
X-Api-Request-Id: <你生成的 UUID>
X-Api-Request-Id 是你自己生成的 UUID。它同时也是 任务 ID——后面轮询用的就是这个值。
提交请求体
{
"user": { "uid": "your-app-name" },
"audio": {
"data": "<base64 编码的音频数据>",
"format": "ogg"
}
}
提交响应
成功提交返回 HTTP 200,body 是空的 {}。
没有任务 ID 在响应里——你发出去的 X-Api-Request-Id 就是任务 ID。
这个设计挺反直觉的。我一开始以为提交失败了,反复检查了好几遍。
查询请求体
{}
查询靠 header 里的 X-Api-Request-Id 来定位任务(跟提交时同一个 UUID)。
查询响应
还在处理中的时候,返回空的 {}。处理完了:
{
"audio_info": {
"duration": 4300
},
"result": {
"text": "你好,今天天气怎么样",
"utterances": [
{ "text": "你好,今天天气怎么样", "start_time": 0, "end_time": 4300 }
]
}
}
audio_info.duration 单位是毫秒。
5. 完整 TypeScript 实现
独立可运行的实现,没有框架依赖——只用 node:crypto 生成 UUID 和全局 fetch。
import { randomUUID } from 'node:crypto'
// ── 常量 ───────────────────────────────────────────────────
const SUBMIT_URL = 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit'
const QUERY_URL = 'https://openspeech.bytedance.com/api/v3/auc/bigmodel/query'
const RESOURCE_ID = 'volc.seedasr.auc'
const MAX_POLLS = 30 // 30 次 × 2s = 最多等 60s
const POLL_INTERVAL = 2000 // 每 2 秒轮询一次
// ── MIME → 豆包格式映射 ────────────────────────────────────
const MIME_TO_FORMAT: Record<string, string> = {
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3',
'audio/mp3': 'mp3',
'audio/wav': 'wav',
'audio/x-wav': 'wav',
'audio/mp4': 'm4a',
'audio/m4a': 'm4a',
'audio/x-m4a': 'm4a',
}
function mimeToFormat(mimeType: string): string {
return MIME_TO_FORMAT[mimeType] ?? 'mp3'
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
// ── 主函数 ─────────────────────────────────────────────────
export async function transcribeWithDoubao(
audio: Buffer,
mimeType: string,
userId?: string,
): Promise<string> {
const appId = process.env.DOUBAO_STT_APP_ID
const accessKey = process.env.DOUBAO_STT_ACCESS_KEY
if (!appId || !accessKey) {
throw new Error('Doubao STT credentials not configured')
}
const reqId = randomUUID()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Api-App-Key': appId,
'X-Api-Access-Key': accessKey,
'X-Api-Resource-Id': RESOURCE_ID,
'X-Api-Request-Id': reqId,
}
// ── 第一步:提交 ──
const submitRes = await fetch(SUBMIT_URL, {
method: 'POST',
headers,
body: JSON.stringify({
user: { uid: userId ?? 'app' },
audio: {
data: audio.toString('base64'),
format: mimeToFormat(mimeType),
},
}),
})
if (!submitRes.ok) {
const errText = await submitRes.text()
throw new Error(`Doubao STT submit failed (${submitRes.status}): ${errText}`)
}
// submitRes body 是 {} —— 不要从里面找任务 ID
// ── 第二步:轮询 ──
for (let i = 0; i < MAX_POLLS; i++) {
await sleep(POLL_INTERVAL)
const queryRes = await fetch(QUERY_URL, {
method: 'POST',
headers, // 同样的 headers,同样的 reqId
body: '{}',
})
const body = await queryRes.text()
if (!body || body === '{}') continue // 还在处理中
const result = JSON.parse(body)
if (result.result?.text) {
const audioDurationMs = result.audio_info?.duration ?? 0
const audioDurationSec = audioDurationMs / 1000
console.log(`Doubao STT: ${audioDurationSec}s audio transcribed`)
return result.result.text
}
}
throw new Error(`Doubao STT: no result after ${MAX_POLLS * POLL_INTERVAL / 1000}s`)
}
实测短语音消息(几秒到十几秒),通常第一次或第二次轮询就能拿到结果,总耗时 2–4 秒。
6. 链式降级模式
生产环境别只接一个 STT 服务。网络抖动、API 故障、限流——都会发生。链式降级让你自动切换备用方案。
思路
按语言注册 provider 优先级链。依次尝试。凭证缺失的跳过。报错的记日志然后试下一个。
// ── Provider 接口 ──────────────────────────────────────────
interface STTProvider {
name: string
isAvailable(): boolean
transcribe(audio: Buffer, mimeType: string, userId?: string): Promise<string>
}
// ── 链注册 ─────────────────────────────────────────────────
const chains = new Map<string, STTProvider[]>()
let defaultChain: STTProvider[] = []
function registerSTTChain(language: string, providers: STTProvider[]): void {
chains.set(language, providers)
}
function setDefaultSTTChain(providers: STTProvider[]): void {
defaultChain = providers
}
// ── 链执行 ─────────────────────────────────────────────────
async function transcribeWithChain(
language: string,
audio: Buffer,
mimeType: string,
userId?: string,
): Promise<string> {
const chain = chains.get(language) ?? defaultChain
for (const provider of chain) {
if (!provider.isAvailable()) continue
try {
const text = await provider.transcribe(audio, mimeType, userId)
if (text) return text
} catch (err) {
console.warn(`STT provider ${provider.name} failed, trying next:`, err)
}
}
return '[语音消息无法识别]'
}
注册示例
// 中文:豆包优先 → OpenAI 备用 → Gemini 兜底
registerSTTChain('zh', [doubaoProvider, openaiProvider, geminiProvider])
// 英文:OpenAI
registerSTTChain('en', [openaiProvider])
// 默认(未知语言):OpenAI → 豆包 → Gemini
setDefaultSTTChain([openaiProvider, doubaoProvider, geminiProvider])
这个模式不限于 STT——任何有多个供应商的服务都能用。核心在于 isAvailable() 基于环境变量判断,不同环境配不同凭证,链路自动适配。
7. 支持的音频格式
MIME 类型需要映射成豆包的格式字符串再提交:
| MIME 类型 | 豆包格式 | 常见来源 |
|---|---|---|
audio/ogg | ogg | Telegram 语音、WebM 音频 |
audio/mpeg、audio/mp3 | mp3 | 标准音频文件 |
audio/wav、audio/x-wav | wav | 原始录音、浏览器 MediaRecorder |
audio/mp4、audio/m4a、audio/x-m4a | m4a | WhatsApp 音频、iOS 录音 |
| 其他 | mp3(默认兜底) | — |
Telegram 语音是 audio/ogg,直接能用。WhatsApp 通常是 audio/ogg 或 audio/mp4,也没问题。
8. 成本追踪
定价
模型 ID: doubao-seedasr
价格: 具体看火山引擎控制台——比 Whisper 便宜约 2.5 倍
具体价格去控制台查最新的。写这篇的时候,豆包比 Whisper 便宜约 2.5 倍。
Token 估算
API 会返回音频时长(毫秒),转成 token 数方便统一计费:
// 从音频时长估算 token 数(用于成本追踪)
const estimatedTokens = Math.ceil(audioDurationSec * 200)
|| Math.ceil((audio.length / 32000) * 200) // 兜底:按 buffer 大小估算
- 主路径:
audioDurationSec * 200——用 API 返回的实际时长 - 兜底:
(audio.length / 32000) * 200——时长缺失时按 ~32kbps 码率估算
记录成本
// 异步记录,不阻塞转写流程
recordCost({
userId,
operationType: 'transcription',
modelId: 'doubao-seedasr',
inputTokens: estimatedTokens,
}).catch(err => console.warn('Cost tracking failed:', err))
成本记录用 fire-and-forget 模式。记失败了不影响正常转写。
9. 踩坑记录——先读完再写代码
以下每一条都是实际调试中踩出来的。通读一遍,能省你一天。
1. v2 和 v3 压根不是一个东西
火山引擎有一套老的 ASR API(v2),走 WebSocket,用 cluster 标识符,鉴权方式是 Bearer token + Secret Key 签名。跟 v3 完全不一样:
- 不同的鉴权 headers
- 不同的接口地址
- 不同的 cluster ID(
volcengine_streaming_common、volcengine_input_common等等)
你在网上搜到的很多教程和论坛帖子都是 v2 的。看到 cluster 或者 Bearer token,直接关掉,别跟着走。
2. v3 WebSocket 接口全部返回 400
v3 也有 WebSocket 变体。我测遍了所有凭证和 header 组合,全部返回 400。能用的只有 REST 文件识别接口。除非火山引擎发了新文档,别在 WebSocket 上浪费时间。
3. Resource ID 必须是 volc.seedasr.auc
文档和论坛里常见的错误值:
volc.bigasr.auc—— 错的,返回鉴权失败volcengine_streaming_common—— 这是流式的 cluster ID,不是 resource IDvolc.bigasr.sauc—— 流式变体,接口都对不上
正确值就是 volc.seedasr.auc,一个字都不能错。
4. 提交返回 {}——你没看错,就是空的
大多数异步 API 提交后会在响应体里返回一个任务 ID。这个 API 不会。响应就是空的 {}。
任务 ID 是你请求时发的 X-Api-Request-Id。后面轮询的时候用同一个 UUID。
我一开始以为提交失败了,反复调了好久。
5. 查询也返回 {} 直到处理完成
查询响应只有两种状态:
{}—— 排队中或处理中(继续轮询)- 完整结果 —— 处理完成
没有中间状态。没有 "processing"、"in-progress" 之类的状态字段。从 {} 到完整结果是瞬间切换的。
6. 你不需要 Secret Key
控制台显示三个凭证:APP ID、Access Token、Secret Key。大模型 v3 REST API 只用 APP ID + Access Token。Secret Key 是火山引擎其他服务签名用的。这个 API 不需要。
7. 新版旧版控制台,鉴权完全不通用
火山引擎正在把服务迁移到新版控制台。新版用 X-Api-* headers(就是本文介绍的方式)。旧版用 Authorization: Bearer <token>,凭证体系也不一样。
搜到的论坛帖子或老文档里如果出现 Authorization: Bearer,那就是旧版的方式——在大模型接口上 不能用。
8. 流式服务和文件识别是两个独立产品
就算你的火山引擎账号已经开通了流式 ASR,文件识别也是另一个独立的服务,有自己的开通流程和计费。必须单独开通 录音文件识别大模型 并购买时长包。开了流式不等于能用文件识别。
速查
Headers 模板
X-Api-App-Key: YOUR_APP_ID
X-Api-Access-Key: YOUR_ACCESS_TOKEN
X-Api-Resource-Id: volc.seedasr.auc
X-Api-Request-Id: <生成的 UUID>
Content-Type: application/json
接口地址
提交: POST https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit
查询: POST https://openspeech.bytedance.com/api/v3/auc/bigmodel/query
凭证一览
| 凭证 | 在哪找 | 环境变量 |
|---|---|---|
| APP ID | 豆包语音 → API服务中心 → 服务接口认证信息 | DOUBAO_STT_APP_ID |
| Access Token | 同上 | DOUBAO_STT_ACCESS_KEY |
| Secret Key | 同上——不需要 | — |
决策树
需要中文 STT?
├── 是 → 豆包(Seed ASR 大模型)
│ ├── 可用? → 用它
│ └── 不可用/失败? → 降级到 OpenAI → Gemini
└── 否 → OpenAI Whisper(多语言覆盖更好)
This post is also available in English.