豆包 STT 接入手册
语音转文字处理管线示意图
这个系列是什么:完整的技术 runbook——不讲故事,只讲怎么把一个服务接好。照着步骤走,把占位符换成自己的值就能跑。
豆包 STT:火山引擎 Seed ASR 中文语音转文字接入
我需要给一个项目加中文语音识别。一开始用的是 Whisper,OpenAI 那个,文档和社区都很成熟,接起来也快。但拿中文录音一测就发现不太行:口语化的表达识别得很差,同音字经常认错,有些句子直接吞掉半截。正式一点的场景 Whisper 还能凑合,日常聊天、方言口音、语气词多的场景,差距就很明显了。
后来换了火山引擎的 Seed ASR 大模型,也就是豆包背后那套语音识别,准确率直接上了一个台阶。这篇把整个接入过程整理出来:控制台怎么配、提交-轮询的异步 API 怎么调、生产环境的降级链路、成本追踪,还有实际调试踩出来的 8 个坑。
目录
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,直接走链路里的下一个。降级链路怎么搭,第 6 节讲。
4. API 详解:提交-轮询流程
大模型文件识别是一个 两步异步流程:先提交音频,再轮询等结果。两步用的是同一个 request ID,这一点下面会反复提到。
提交-轮询异步流程:提交后只返回空 {},你自己生成的 UUID 既是请求 ID 也是任务 ID,用它反复轮询直到吐出识别文本
接口地址
| 步骤 | 方法 | 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。这个设计挺反直觉的,我一开始以为是提交失败了,反复检查了好几遍。
查询请求体
{}
查询的 body 就是空的 {}。任务靠 header 里的 X-Api-Request-Id 来定位,跟提交时用同一个 UUID。
查询响应
还在处理中的时候返回空的 {},处理完了才给结果:
{
"audio_info": {
"duration": 4300
},
"result": {
"text": "你好,今天天气怎么样",
"utterances": [
{ "text": "你好,今天天气怎么样", "start_time": 0, "end_time": 4300 }
]
}
}
audio_info.duration 的单位是毫秒。utterances 里是逐句的文本加 start/end 时间戳。
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 的方式,异步去记。记失败了就打一条 warn,不影响正常转写。
9. 踩坑记录——先读完再写代码
下面 8 条全是实际调试的时候踩出来的。先通读一遍再动手,大概能省你一天。
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.

