ENZH
和 AI 讨论这篇文章
ChatGPTClaude

豆包 STT 接入手册

📊 幻灯片

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

这个系列是什么:完整的技术 runbook——不讲故事,只讲怎么把一个服务接好。照着步骤走,把占位符换成自己的值就能跑。

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

我需要给一个项目加中文语音识别。一开始用的是 Whisper,OpenAI 那个,文档和社区都很成熟,接起来也快。但拿中文录音一测就发现不太行:口语化的表达识别得很差,同音字经常认错,有些句子直接吞掉半截。正式一点的场景 Whisper 还能凑合,日常聊天、方言口音、语气词多的场景,差距就很明显了。

后来换了火山引擎的 Seed ASR 大模型,也就是豆包背后那套语音识别,准确率直接上了一个台阶。这篇把整个接入过程整理出来:控制台怎么配、提交-轮询的异步 API 怎么调、生产环境的降级链路、成本追踪,还有实际调试踩出来的 8 个坑。


目录

  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,直接走链路里的下一个。降级链路怎么搭,第 6 节讲。


4. API 详解:提交-轮询流程

大模型文件识别是一个 两步异步流程:先提交音频,再轮询等结果。两步用的是同一个 request ID,这一点下面会反复提到。

提交-轮询异步流程:提交后只返回空 {},你自己生成的 UUID 既是请求 ID 也是任务 ID,用它反复轮询直到吐出识别文本提交-轮询异步流程:提交后只返回空 {},你自己生成的 UUID 既是请求 ID 也是任务 ID,用它反复轮询直到吐出识别文本

接口地址

步骤方法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。这个设计挺反直觉的,我一开始以为是提交失败了,反复检查了好几遍。

查询请求体

{}

查询的 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/oggoggTelegram 语音、WebM 音频
audio/mpegaudio/mp3mp3标准音频文件
audio/wavaudio/x-wavwav原始录音、浏览器 MediaRecorder
audio/mp4audio/m4aaudio/x-m4am4aWhatsApp 音频、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_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.

和 AI 讨论这篇文章
ChatGPTClaude
AI 随想第 19 篇 · 共 28 篇

订阅更新

新文章发布时发到你的邮箱,不发别的。


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