第24章:ASR 客户端:提交与轮询
本章分析 internal/pkg/asr/client.go:豆包 BigModel 录音文件识别的封装——提交任务、状态码驱动的轮询循环、进度回调、格式探测。这是一个「外部异步 API 客户端」的完整范本:超时分层、幂等提交、进度估算,全部模式可平移到任何轮询型第三方服务。
流程
TranscribeWithProgress(ctx, audioURL, onProgress) 的完整流程:
- 前置校验 APIKey;
DetectFormat(audioURL)从 URL 后缀推断音频格式(asr/format.go); - 生成本地 taskID(UUID,用作提交幂等键);
submit:POST 提交载荷(user/audio/request 三段),请求参数硬编码最优组合:show_utterances=true(分句+词级时间戳)、enable_itn=true(数字规整,如「一百二」→「120」)、enable_punc=true(标点)、enable_speaker_info=true(说话人分离)——这四个开关正是后续切片精度的数据基础(第 26 章);poll:以PollInterval(默认 10 秒)间隔查询结果,按响应头X-Api-Status-Code分流:20000000成功返回完整 JSON;20000001/20000002进行中,回调onProgress(5~95)后继续;其他码即失败带 message;- 上限
MaxPolls=360(约 60 分钟)后超时失败。
输入是一个公网可访问的音频 URL(不是本地文件)——所以第 28 章的音频预处理器必须先把本地 MP3 上传对象存储。输出是豆包的完整原始 JSON(RawMessage),调用方再按需解析(时长、分句等,第 26 章)。
实现
实现上的分层细节:
- 两级超时:
http.Client{Timeout: 30s}管单次 HTTP 请求(提交、每次轮询都是短请求);总时长由MaxPolls × PollInterval控制(默认 3600 秒)。二者独立配置,互不耦合——「单请求超时」与「任务总超时」是两个概念,混用是常见错误。 - 状态码走响应头:豆包用
X-Api-Status-Code/X-Api-Message头表达业务状态(常量statusSuccess="20000000"等),body 才是结果数据——客户端严格按此分流,不做 body 猜测。 - 进度回调区间 5~95:刻意避开 0 与 100——进度 0 表示未开始、100 表示完成(由调用方在前后设置),轮询期间的估算只占中间区间,语义不越界。素材表的 asr_progress 因此单调且有明确语义。
- 本地 taskID 而非服务端 ID:提交时自己生成 UUID 作为请求标识,豆包按此返回查询句柄——客户端对「身份」的控制权更大,重试场景可以复用同一 ID 排查。
- 匿名结构体反序列化:
ParseDurationMs等解析函数用内联 struct 只取需要的字段(audio_info.duration),不为完整响应建模巨型结构——按需解析降低耦合,豆包响应格式变化时影响面最小。 client_test.go用 httptest 假服务器覆盖提交成功、轮询中、失败、超时分支——外部依赖的测试解法与 LLM/capcutmate 包一致。
📌 设计决策
- 轮询而非 Webhook 回调:豆包该接口为轮询模型;服务端回调需要公网可达的回调端点与鉴权设计,对私有化部署不友好。
- 同步方法(Transcribe 一次调用跑到完成)而非返回句柄由调用方轮询:调用方是 ASR Worker(第 29 章),它有自己的心跳机制(每轮回调刷新进度与 asr_updated_at),把轮询折叠进一次调用让 Worker 的循环最简单。
- 10 秒间隔 × 360 次:豆包长音频任务典型耗时数分钟到数十分钟,10 秒粒度既不过压(单任务 360 次请求)又能让进度条平滑。
代码示例
提交载荷的最优参数组合:
// internal/pkg/asr/client.go(节选)
type submitPayload struct {
User struct {
UID string `json:"uid"` // "live-mixer"
} `json:"user"`
Audio struct {
Format string `json:"format"` // DetectFormat 从 URL 推断
URL string `json:"url"`
} `json:"audio"`
Request struct {
ModelName string `json:"model_name"` // "bigmodel"
ShowUtterances bool `json:"show_utterances"` // 分句+词级时间戳
EnableITN bool `json:"enable_itn"` // 数字规整
EnablePunc bool `json:"enable_punc"` // 标点
EnableSpeakerInfo bool `json:"enable_speaker_info"` // 说话人
} `json:"request"`
}
进度回调与状态码分流:
// ProgressCallback 轮询进度回调,progress 取值 5~95。
type ProgressCallback func(progress int)
// TranscribeWithProgress 同步转写并在轮询时回调估算进度。
func (c *Client) TranscribeWithProgress(ctx context.Context,
audioURL string, onProgress ProgressCallback) (json.RawMessage, error) {
if strings.TrimSpace(c.cfg.APIKey) == "" {
return nil, fmt.Errorf("ASR API Key 未配置")
}
format, err := DetectFormat(audioURL)
if err != nil {
return nil, err
}
taskID := uuid.NewString()
if err := c.submit(ctx, taskID, audioURL, format); err != nil {
return nil, err
}
return c.poll(ctx, taskID, onProgress) // 内部按 X-Api-Status-Code 分流
}
小结
- ASR 客户端 = 提交(四开关最优参数)+ 头部状态码驱动的轮询 + 5~95 进度回调。
- 两级超时、按需解析、httptest 假服务器三件套值得平移到任何轮询型 API。
- 输入是公网 URL——上游必须先完成「抽音频 + 上传」预处理。
思考题
- 轮询间隔若改为自适应(首次 5 秒,逐次退避到 30 秒),能省多少请求?进度条平滑性如何补偿?
- 提交成功但轮询进程崩溃,重新 Transcribe 会导致重复计费——如何利用本地 taskID 实现断点续查?
项目信息
- GitHub仓库:Chyona/live-mixer