Prhub

#32611 Fix transcription & audio-understanding for ASR/audio/speech models

原始 PR 作者 SKRohit 合并时间 2026-08-19 18:44 文件变更 10 提交数 5 评论 9 代码增减 +610 / -16

执行摘要

为 Qwen2-Audio / GLM-ASR / Granite Speech 补齐真实 ASR 转录

PR body 明确指出:此前这三个 decoder-only 语音模型在转录端点会“falling back to the Whisper adapter”,而 Whisper 专用参数(_detect_language、强制语言/任务前缀)这些模型并不支持;同时 /v1/audio/transcriptions 发送的 text="" 不携带音频占位符,导致编码器特征无处填充、提示构造失败。因此需要为每个模型实现专属适配器与提示兜底逻辑,才能提供真实可用的 ASR 服务。

值得精读。该 PR 展示了在多模态服务框架中为“不同架构的同类任务”做适配器隔离的典型做法:适配器注册 + 契约方法(build_sampling_params / build_verbose_response)+ 空提示兜底 + 时长预算缩放。尤其建议关注 resolve_adapter 的子串匹配风险、以及 Qwen2-Audio 固定窗口截断这类“上游约束下放”到 warning 的设计取舍。

讨论亮点

核心讨论集中在三处:

  1. GLM-ASR 输出前缀:mickqian 建议“Could we override postprocess_text to mirror HF's strip_prefix=True? Otherwise GLM-ASR may return 'The spoken content of the audio is "..."' instead of the raw transcript, which hurts WER” ,SKRohit 回应已按 qwen3_asr 的方式处理,最终提交里加入了 _ASSISTANT_PREFIXES 剥离。

  2. Qwen2-Audio 30 秒截断:mickqian 指出强制 truncation=True 后超过 30 秒的音频只会转录前 30 秒,SKRohit 说明该限制来自 transformers 的 Qwen2AudioProcessor 固定窗口,并补充截断警告日志作为缓解。

  3. verbose 响应语言字段:mickqian 质疑适配器在 supports_language_detection=False 时回显用户语言是在“声称从未被兑现的语言”,建议返回 None 或真正传递 hint;最终统一改为 language=None,并由测试固定该契约。

实现拆解

实现按以下 5 个步骤展开:

  1. 新增转录适配器注册:新建 python/sglang/srt/entrypoints/openai/transcription_adapters/glmasr.py、granite_speech.py、qwen2_audio.py,分别以 GlmAsr、GraniteSpeech、Qwen2Audio 为注册键并实现 supports_language_detection=False;每个适配器实现 build_sampling_params(temperature + 按音频时长缩放且带 448 token 下限的 max_new_tokens)与 build_verbose_response(language=None、空 segments);GLM-ASR 额外实现 postprocess_text 剥离模型输出中的 assistant 前缀与引号,降低 WER。transcription_adapters/init.py 同步完成导出与注册装配。

  2. 空文本提示兜底:针对 /v1/audio/transcriptions 的 text="" 场景,在 qwen_audio.py 与多模态 processors/glmasr.py 中新增 _build_transcription_prompt(),通过 apply_chat_template 渲染带音频占位符的对话模板;transformers_auto.py 新增 audio 分支与 _build_audio_prompt(),并用 audio_token_index 扩展音频 token 查找,保证 Granite Speech 路径也能插入 <|audio|> 占位符。

  3. 处理器兼容性修复:base_processor.py 将 GraniteSpeechProcessor 加入 audio= 关键字白名单,因为它使用的是新式 audio= 参数而非已废弃的 audios=。

  4. 模型侧音频特征对齐:重写 python/sglang/srt/models/glmasr.py 中 GlmAsrForConditionalGeneration.get_audio_feature,按 30s 窗口重组编码器输出、依据 input_features_mask 经 conv 子采样与 merge_factor 降采样保留有效嵌入,使嵌入数量与音频占位符 token 数一致;Qwen2-Audio processor 强制 truncation=True 将超过 30s 的音频裁剪到固定 3000 mel 窗口,并新增 _warn_if_audio_exceeds_window 警告日志。

  5. 测试配套:新增 test/registered/unit/entrypoints/openai/test_transcription_adapters.py,注册到 CPU CI(suite=base-a-test-cpu,est_time=4),覆盖适配器解析与 Whisper 回退、语言检测关闭、max_new_tokens 下限与时长缩放边界,以及 verbose-response 契约。

文件 模块 状态 重要度
python/sglang/srt/entrypoints/openai/transcription_adapters/glmasr.py 转录适配器 added 8.74
python/sglang/srt/entrypoints/openai/transcription_adapters/granite_speech.py 转录适配器 added 8.19
python/sglang/srt/entrypoints/openai/transcription_adapters/qwen2_audio.py 转录适配器 added 8.18
python/sglang/srt/entrypoints/openai/transcription_adapters/__init__.py 适配器注册 modified 5.4
test/registered/unit/entrypoints/openai/test_transcription_adapters.py 适配器测试 added 7.62
python/sglang/srt/multimodal/processors/qwen_audio.py 音频处理器 modified 7.78
python/sglang/srt/multimodal/processors/transformers_auto.py 通用处理器 modified 7.19
python/sglang/srt/models/glmasr.py 语音模型 modified 7.03
python/sglang/srt/multimodal/processors/glmasr.py 音频处理器 modified 6.72
python/sglang/srt/multimodal/processors/base_processor.py 处理器基类 modified 4.18

关键符号

GlmAsrAdapter.build_sampling_params GlmAsrAdapter.postprocess_text GlmAsrAdapter.build_verbose_response GraniteSpeechAdapter.build_sampling_params GraniteSpeechAdapter.build_verbose_response Qwen2AudioAdapter.build_sampling_params Qwen2AudioAdapter.build_verbose_response Qwen2AudioMultimodalProcessor._build_transcription_prompt Qwen2AudioMultimodalProcessor._warn_if_audio_exceeds_window GlmAsrProcessor._build_transcription_prompt AutoMultimodalProcessor._build_audio_prompt AutoMultimodalProcessor.process_mm_data_async GlmAsrForConditionalGeneration.get_audio_feature

关键源码片段

python/sglang/srt/entrypoints/openai/transcription_adapters/glmasr.py core-logic

新增 GLM-ASR 专用转录适配器,核心包含按时长缩放 max_new_tokens、postprocess_text 前缀剥离与 verbose 响应契约,是 PR 主干逻辑之一。

# 转录适配器:GLM-ASR
# 注册键 GlmAsr 会被 resolve_adapter() 按架构字符串子串匹配命中。
# 语音大约 2~3 词 / 秒,而 tokenizer 每个词会拆出多于 1 个 token,
# 因此 15 token/s 的预算既留出充足余量,又限制了失控生成。
_MAX_NEW_TOKENS_PER_SECOND = 15
# 生成长度下限取 448,保证 ~30s 的短音频也能完整转录。
_DEFAULT_MAX_NEW_TOKENS = 448
​
​
@register_transcription_adapter("GlmAsr")
class GlmAsrAdapter(TranscriptionAdapter):
    # GLM-ASR 是 decoder-only 语音模型,处理器插入
    # <|begin_of_audio|>...<|end_of_audio|> 占位符后自由生成文本,
    # 没有 Whisper 风格的强制语言 / 任务前缀,所以关闭语言检测。
    _ASSISTANT_PREFIXES = (
        "The spoken content of the audio is",
        "The transcription of the audio is",
        "The content of the input audio is",
    )
​
    def build_sampling_params(self, request: TranscriptionRequest) -> dict:
        # /v1/audio/transcriptions 请求侧没有长度字段,只能由适配器控制:
        # 短音频用下限,长音频按时长缩放,避免转录被静默截断。
        duration_s = request.audio_duration_s or 0.0
        return {
            "temperature": request.temperature,
            "max_new_tokens": max(
                _DEFAULT_MAX_NEW_TOKENS,
                int(duration_s * _MAX_NEW_TOKENS_PER_SECOND),
            ),
        }
​
    def postprocess_text(self, text: str) -> str:
        # 镜像 HF GlmAsrProcessor.decode(strip_prefix=True),
        # 剥离模型在转写前后包装的 assistant 前缀与引号,降低 WER。
        stripped = text.strip()
        for prefix in self._ASSISTANT_PREFIXES:
            if stripped.startswith(prefix):
                stripped = stripped[len(prefix):].strip()
                break
        if stripped.endswith("."):
            stripped = stripped[:-1].strip()
        if (
            len(stripped) >= 2
            and stripped[0] == stripped[-1]
            and stripped[0] in {"'", '"'}
        ):
            stripped = stripped[1:-1].strip()
        return stripped
​
    def build_verbose_response(self, request, text, ret, tokenizer, usage):
        # GLM-ASR 内部自行推断语言,前端不声称支持语言检测,
        # 因此 language 置 None,不返回时间戳 segments。
        return TranscriptionVerboseResponse(
            language=None,
            duration=round(request.audio_duration_s, 2),
            text=text,
            segments=[],
            usage=usage,
        )
python/sglang/srt/entrypoints/openai/transcription_adapters/granite_speech.py core-logic

新增 Granite Speech 专用转录适配器,实现相同的时长缩放采样参数与 verbose 契约,是三类模型支持的关键组成。

# Granite Speech 是 English-only 的 decoder-only 语音模型,
# 音频编码器特征合并进 <|audio|> 占位符后自由生成转写文本。
# 它同样没有 Whisper 风格的强制语言 / 任务前缀,因此关闭语言检测。
@register_transcription_adapter("GraniteSpeech")
class GraniteSpeechAdapter(TranscriptionAdapter):
    def build_sampling_params(self, request: TranscriptionRequest) -> dict:
        # 转录端点没有请求侧长度字段,只能由适配器按音频时长控制
        # max_new_tokens:短音频取下限 448,长音频按时长线性缩放。
        duration_s = request.audio_duration_s or 0.0
        return {
            "temperature": request.temperature,
            "max_new_tokens": max(
                _DEFAULT_MAX_NEW_TOKENS,
                int(duration_s * _MAX_NEW_TOKENS_PER_SECOND),
            ),
        }
​
    def build_verbose_response(self, request, text, ret, tokenizer, usage):
        # language 置 None:模型会从音频中自行推断语言,
        # 适配器不声明它没有真正兑现的语言字段。
        return TranscriptionVerboseResponse(
            language=None,
            duration=round(request.audio_duration_s, 2),
            text=text,
            segments=[],
            usage=usage,
        )
python/sglang/srt/multimodal/processors/qwen_audio.py core-logic

为 Qwen2-Audio 处理器增加空文本转录提示兜底与 30s 截断警告,是该模型在转录端点可用的关键配套修改。

# Qwen2-Audio 的音频塔要求恰好 3000 mel 帧(固定 30s 窗口),
# BaseMultimodalProcessor 默认传 truncation=False(分块编码器需要),
# 超过 30s 的片段会超出窗口导致音频塔报错,因此这里强制截断。
self.audio_config = {**self.audio_config, "truncation": True}# 转录专用对话模板:chat template 只匹配裸 audio 键,
# 严格指令可避免模型输出 "The content of this audio is:" 前缀,
# 从而控制 WER 膨胀。
_TRANSCRIPTION_CONVERSATION = [
    {
        "role": "user",
        "content": [
            {"type": "audio", "audio": ""},
            {
                "type": "text",
                "text": (
                    "Transcribe the audio. Output only the exact transcription, "
                    "with no preamble, prefix, commentary, or quotation marks."
                ),
            },
        ],
    }
]def _build_transcription_prompt(self, input_text) -> str:
    # /v1/audio/transcriptions 端点发送空文本(input_ids 也为空),
    # 没有音频占位符可供编码器特征填充;这里渲染带一个 audio span
    # 的聊天提示作为兜底,否则走调用方传入的原文。
    if isinstance(input_text, list):
        input_text = (
            self._processor.tokenizer.decode(input_text) if input_text else ""
        )
    if input_text and input_text.strip():
        return input_text
    return self._processor.apply_chat_template(
        self._TRANSCRIPTION_CONVERSATION,
        add_generation_prompt=True,
        tokenize=False,
    )def _warn_if_audio_exceeds_window(self, audios) -> None:
    # Qwen2-Audio 编码器是单个固定 30s 窗口,超长输入只转录前 30s,
    # 这里按采样率与 chunk_length 换算最大样本数并给出显式警告。
    feature_extractor = self._processor.feature_extractor
    max_samples = int(
        feature_extractor.sampling_rate * feature_extractor.chunk_length
    )
    for audio in audios:
        if isinstance(audio, np.ndarray) and audio.shape[-1] > max_samples:
            logger.warning(
                "Qwen2-Audio input is %.1fs but the encoder window is %ds; "
                "only the first %ds will be transcribed (audio truncated).",
                audio.shape[-1] / feature_extractor.sampling_rate,
                feature_extractor.chunk_length,
                feature_extractor.chunk_length,
            )

评论区精华

Qwen2-Audio 强制 truncation 导致超过 30s 音频只转录前 30s 正确性

mickqian 在 qwen_audio.py 第 43 行指出:设置 truncation=True 后,超过 30 秒的音频只会转录前 30 秒。SKRohit 回应:30 秒限制来自 transformers 的 Qwen2AudioProcessor 固定窗口,已增加截断警告日志。

结论:保留强制截断以规避音频塔报错,同时在 _warn_if_audio_exceeds_window 中输出显式警告;这是上游处理器约束下的折中方案。 · 已解决

GLM-ASR 输出带 assistant 前缀,需要 postprocess_text 剥离 正确性

mickqian 建议覆盖 postprocess_text 以镜像 HF 的 strip_prefix=True,否则 GLM-ASR 会返回 'The spoken content of the audio is "..."' 而非原始转写,损害 WER。SKRohit 回应已按 qwen3_asr 的既有方式处理。

结论:新增 _ASSISTANT_PREFIXES 前缀剥离与引号清理,最终提交 'Address review: GLM prefix strip' 落实该修改。 · 已解决

verbose 响应回显请求语言字段是否真实兑现 正确性

mickqian 在测试文件指出:这些适配器 supports_language_detection=False,提示与采样参数都未使用 request.language,回显 fr 等语言字段是虚假声明;建议返回 None 或真正把 hint 传给模型。

结论:三个适配器的 build_verbose_response 统一改为 language=None,并通过测试契约固定该行为。 · 已解决

风险与影响

主要风险点如下:

  • 子串匹配解析碰撞:resolve_adapter() 基于架构字符串子串匹配,新增 GlmAsr、GraniteSpeech、Qwen2Audio 键后,若未来出现名称相近的架构可能误配;测试覆盖了未知架构回退 Whisper,但真实模型名与注册键的映射仍需持续关注。
  • Qwen2-Audio 30 秒静默截断:truncation=True 使长音频只转录前 30 秒,即使有 warning 日志,对生产用户仍是信息丢失;且该行为由 HF 处理器强约束,无法在适配器层绕过。
  • 模型侧嵌入对齐重写:get_audio_feature 的 30s 窗口重组与 input_features_mask 降采样逻辑较复杂,若 conv 子采样或 merge_factor 与占位符 token 数失配,可能引发 token/embedding 数量不一致或崩溃;该路径缺乏 GPU e2e 测试。
  • 多模态处理器回归面:qwen_audio.py、transformers_auto.py、base_processor.py 均在共享提示构造链路中改动,聊天路径与转录路径共用 processor,提示兜底逻辑的触发条件(空文本)若被其他入口误触发可能改变行为。

影响范围集中在 OpenAI 转录入口、多模态处理器与 GLM-ASR 模型前向:

  • 用户侧:Qwen2-Audio-7B、GLM-ASR-Nano、Granite Speech 3.3-8B 三类模型的 /v1/audio/transcriptions 从不可用/回退 Whisper 变为真实 ASR;时长缩放 max_new_tokens 避免长片段静默截断与短片段浪费生成预算。
  • 系统侧:新增 3 个适配器文件、2 个 processor 提示兜底逻辑、1 个模型前向重写,并新增 CPU 单测套件;对既有 Whisper 路径不改变行为(Whisper 仍是默认回退)。
  • 团队侧:确立了解码器语音模型转录适配器的扩展模式,后续新 ASR 模型可复用注册、契约测试与按时长算 token 预算的策略。
适配器注册键子串匹配易碰撞 Qwen2-Audio 超过 30s 静默截断 模型侧嵌入对齐重写缺 GPU e2e 测试 共享多模态处理器提示链路回归面

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论