ENZH

豆包 STT 接入手册

语音转文字处理管线示意图语音转文字处理管线示意图

这个系列是什么:完整的技术 runbook——不讲故事,只讲怎么把一个服务接好。一步一步跟着来,替换占位符,直接能跑。

豆包 STT:火山引擎 Seed ASR 中文语音转文字接入

我需要给一个项目加中文语音识别。

第一反应当然是 Whisper——OpenAI 的,大品牌,文档齐全,社区成熟。但实际拿中文录音一测,问题就来了。口语化的表达识别得稀烂,同音字乱飞,有些句子直接吞掉半截。

说白了,Whisper 的中文不是不能用,是不够好。尤其碰到日常聊天、方言口音、语气词多的场景,差距很明显。

然后我找到了火山引擎的 Seed ASR 大模型——就是豆包背后那套语音识别。一换上去,准确率直接上了一个台阶。

这篇把我接入过程中学到的东西全部整理出来:控制台配置、两步异步 API、生产环境的降级链路、成本追踪,还有 8 个会让你多调一天 bug 的坑。


目录

  1. 为什么选豆包而不是 Whisper
  2. 控制台配置
  3. 环境变量
  4. API 详解:提交-轮询流程
  5. 完整 TypeScript 实现
  6. 链式降级模式
  7. 支持的音频格式
  8. 成本追踪
  9. 踩坑记录(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 产品一堆。

  1. 顶部导航找 豆包语音(或者直接搜索)
  2. 进去找 API服务中心
  3. 录音文件识别大模型

千万别选错:

  • 录音文件识别 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
提交POSThttps://openspeech.bytedance.com/api/v3/auc/bigmodel/submit
查询POSThttps://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/oggoggTelegram 语音、WebM 音频
audio/mpegaudio/mp3mp3标准音频文件
audio/wavaudio/x-wavwav原始录音、浏览器 MediaRecorder
audio/mp4audio/m4aaudio/x-m4am4aWhatsApp 音频、iOS 录音
其他mp3(默认兜底)

Telegram 语音是 audio/ogg,直接能用。WhatsApp 通常是 audio/oggaudio/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_commonvolcengine_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 ID
  • volc.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.


© Xingfan Xia 2024 - 2026 · CC BY-NC 4.0