执行摘要
- 一句话:修复 Whisper 长音频静默截断,新增能量感知分块转写
- 推荐动作:值得精读。该 PR 是一个完整的"适配器扩展点 + 服务层通用编排"范例,核心看点:①能量感知切分与 vLLM 逐位对齐的兼容策略(切分点可复现);②
strip=False 保留模型边界空白以正确处理无空格脚本的拼接语义;③_build_chunk_request 每块重建 sampling_params 以适配多模态处理器"消费式"取键的约定;④reviewer 对失败路径、并发上限、语言选择的三轮纠偏展示了生产级审查应当关注的边界。对要扩展新 ASR 适配器的工程师,这是必读示例。
功能与动机
PR body 明确说明:Whisper 的 feature extractor 会把输入填充或截断到固定的 3000 个 mel 帧(30 秒)编码窗口,因此当服务层把长文件作为一次生成发送时,/v1/audio/transcriptions 会静默丢弃 30 秒之后的所有内容。切分点选择对齐旧 vLLM speech-to-text 行为(每个块最后 1 秒内最安静的 100 ms RMS 窗口),并保证 Qwen3-ASR 等其他适配器不受影响。
实现拆解
本 PR 按以下 5 步完成长音频分块转写能力:
-
新增能量感知分块模块 audio_chunking.py:新增 find_split_point 与 split_audio_energy_aware 两个函数。前者在指定区间内按 100 ms(16 kHz 下 1600 个采样)步长计算 RMS 能量,返回最安静窗口的起始下标;后者先用 load_audio 解码并重采样到 16 kHz 单声道,再按 max_clip_s 步长行走,在每个步长末尾 1 秒搜索窗内寻找低能量切点,返回连续不重叠的 WAV 字节块及各块起始偏移。循环边界刻意与 vLLM 的 _find_split_point 逐位一致,保证切分点与旧 vLLM 转写端点完全一致。
-
在 serving_transcription.py 的 create_transcription 入口接入分块:_get_audio_duration 增加 soundfile 解析失败时的全量解码 fallback,并通过 asyncio.to_thread 把时长计算与分块解码放入线程池,避免长文件阻塞事件循环。当 adapter.max_audio_clip_s 存在且音频超窗时调用 split_audio_energy_aware;分块失败或结果非法(空块、单块、偏移数不匹配)直接返回 400 错误,不再回退到明知会截断的原始音频。分块结果通过 request._audio_chunks / request._chunk_offsets_s 挂载。
-
分块请求编排与拼接:_handle_non_streaming_request 检测到 _audio_chunks 后转入 _handle_chunked_non_streaming_request,顺序派发各块(测试断言 max_active_dispatches == 1,回应 reviewer 对并发扇出的担忧)。_build_chunk_request 为每块克隆 GenerateReqInput 并重建独立的 sampling_params dict——多模态处理器在构造 decoder prompt 时会 pop 掉 transcription 级键,复用同一 dict 会导致后续块参数被消费。_finalize_text 抽取原内联的 fused 解析逻辑并新增 strip 参数:strip=False 保留模型发出的边界空白,无空格脚本(zh/ja/th)不注入 ASCII 空格;语言仅在 visible.strip() 非空且未设置时记录(首个非空块胜出)。_abort_chunk_requests 按 rid 中止已派发请求,先处理已分配 rid,再在 dispatch 窗口后二次中止未分配 rid 的请求。
-
适配器抽象扩展:transcription_adapters/base.py 新增默认 max_audio_clip_s(返回 None,禁用分块)与 build_verbose_response_chunked 默认实现(返回拼接文本、空段列表,适配器可覆写);whisper.py 声明 max_audio_clip_s = 30.0,实现 build_verbose_response_chunked:直接使用 serving 层已拼接的文本(避免二次 ASCII join 破坏无空格脚本),仅从每块 output_ids 解析段,_parse_segments 新增 time_offset_s 与 seg_id_start 参数,对每块时间戳偏移、段 id 跨块连续编号。common.py 的 load_audio 入参类型从 str 扩展为 Union[str, bytes]。
-
测试配套:新增 test_audio_chunking.py(安静窗口选择、均匀能量回退、连续性、不重叠、波形保持、无静音仍推进);test_serving_transcription.py 新增 _MockChunkTokenizerManager(模拟 rid 分配、abort 记录、并发计数)与 TestLongAudioChunkedNonStreaming(顺序拼接、块失败中止、分割失败返回 400、短音频不分块、fused 自动检测首块语言胜出、无空格脚本拼接);test_whisper_adapter.py 验证段偏移与单块零偏移等价的语义;GPU 端到端 test_serving_transcription.py 用 30 秒静音 + 10 秒语音的 40 秒 WAV 覆盖 JSON 转录、verbose_json 段时间戳超过 30 秒、流式三条路径。
关键文件:
python/sglang/srt/entrypoints/openai/serving_transcription.py(模块 转录服务;类别 source;类型 core-logic;符号 create_transcription, _handle_chunked_non_streaming_request, _finalize_text, _build_chunk_request): 核心编排文件:接入分块入口、顺序派发、文本拼接、失败中止、流式处理全部在此完成,是本次变更的主路径。
python/sglang/srt/entrypoints/openai/audio_chunking.py(模块 音频切分;类别 source;类型 core-logic;符号 find_split_point, split_audio_energy_aware): 新增的能量感知分块模块,定义切点搜索与整段切分算法,切分行为与 vLLM 旧转写端点逐位对齐。
python/sglang/srt/entrypoints/openai/transcription_adapters/whisper.py(模块 转写适配;类别 source;类型 core-logic;符号 max_audio_clip_s, build_verbose_response_chunked, _parse_segments, parse_fused_output): Whisper 适配器声明 30 秒窗口上限、实现分块 verbose_json 响应与带偏移的段解析,是分块能力的模型侧核心。
python/sglang/srt/entrypoints/openai/transcription_adapters/base.py(模块 适配基类;类别 source;类型 data-contract;符号 max_audio_clip_s, build_verbose_response_chunked, parse_fused_output): 定义适配器分块契约:max_audio_clip_s 默认 None 禁用分块,build_verbose_response_chunked 默认实现保证第三方适配器行为不回归。
test/registered/unit/entrypoints/openai/test_serving_transcription.py(模块 转录服务;类别 test;类型 test-coverage;符号 _MockChunkTokenizerManager, generate_request, abort_request, _long_wav_bytes): 核心编排的 CPU 单测:mock TokenizerManager 模拟 rid 分配、abort 记录与并发计数,覆盖顺序拼接、失败中止、分割失败、fused 语言与无空格脚本场景。
test/registered/unit/entrypoints/openai/test_audio_chunking.py(模块 音频切分;类别 test;类型 test-coverage;符号 TestFindSplitPoint, TestSplitAudioEnergyAware, _tone_with_silences): 分块算法本身的 CPU 单测:验证安静窗口选择、均匀能量回退、连续性、不重叠、波形保持与无静音推进。
test/registered/openai_server/basic/test_serving_transcription.py(模块 端到端;类别 test;类型 test-coverage;符号 long_audio_wav_bytes, _assert_keywords, test_long_audio_transcribes_past_30s, test_long_audio_verbose_json_segment_offsets): GPU 端到端验证:40 秒 WAV(30 秒静音 + 10 秒语音)保证语音完全落在 Whisper 窗口之外,覆盖 JSON、verbose_json 与流式三条路径。
test/registered/unit/entrypoints/openai/test_whisper_adapter.py(模块 转写适配;类别 test;类型 test-coverage;符号 _FakeTokenizer, TestWhisperChunkedVerboseResponse): 验证分块 verbose_json 的段偏移与跨块 id 连续性,以及单块零偏移与未分块路径语义一致。
python/sglang/srt/utils/common.py(模块 公共工具;类别 source;类型 dependency-wiring;符号 load_audio): load_audio 入参类型扩展为支持 bytes,是分块解码与时长 fallback 的公共基础。
关键符号:find_split_point, split_audio_energy_aware, OpenAIServingTranscription.create_transcription, OpenAIServingTranscription._handle_chunked_non_streaming_request, OpenAIServingTranscription._finalize_text, OpenAIServingTranscription._build_chunk_request, OpenAIServingTranscription._abort_chunk_requests, OpenAIServingTranscription._generate_long_audio_stream, TranscriptionAdapter.max_audio_clip_s, TranscriptionAdapter.build_verbose_response_chunked, WhisperAdapter.max_audio_clip_s, WhisperAdapter.build_verbose_response_chunked, WhisperAdapter._parse_segments, WhisperAdapter.parse_fused_output, load_audio
关键源码片段
python/sglang/srt/entrypoints/openai/audio_chunking.py
新增的能量感知分块模块,定义切点搜索与整段切分算法,切分行为与 vLLM 旧转写端点逐位对齐。
"""Energy-aware audio chunking for ASR models with a bounded input window.
Whisper 的编码器只接受固定 30 秒窗口(3000 个 mel 帧),feature extractor
会把超长输入静默截断,所以超长音频必须先切成独立块。若盲目在窗口边界
下刀,可能切到单词中间,把缝两侧的转录都弄坏;因此在每个窗口尾部
1 秒的搜索区间里找 RMS 能量最低(最安静)的位置切。
行为刻意与 vLLM 的 ``OpenAISpeechToText._split_audio`` / ``_find_split_point``
对齐:块连续、不重叠,拼接回来就是完整波形,切分点与旧 vLLM 端点
逐位一致。
"""
# 每个 max-length 窗口尾部用于搜索低能量切点的区域长度(秒)。
SPLIT_SEARCH_WINDOW_S = 1.0
# RMS 能量按 100 ms(16 kHz 下 1600 个采样)为步长评估,
# 搜索区间内最安静的一个步长的起点即切点。
MIN_ENERGY_WINDOW_SIZE = 1600
def find_split_point(wav: np.ndarray, start_idx: int, end_idx: int) -> int:
"""返回 ``wav[start_idx:end_idx]`` 内最安静能量窗口的起始下标(绝对值)。
循环边界故意不评估搜索区间的最后一个步长:这是照抄 vLLM 的
``_find_split_point``,从而保证切分点与旧 vLLM 转写端点完全一致。
"""
segment = wav[start_idx:end_idx]
min_energy = math.inf
quietest_idx = start_idx
for i in range(0, len(segment) - MIN_ENERGY_WINDOW_SIZE, MIN_ENERGY_WINDOW_SIZE):
window = segment[i : i + MIN_ENERGY_WINDOW_SIZE]
energy = (window**2).mean() ** 0.5
if energy < min_energy:
quietest_idx = i + start_idx
min_energy = energy
return quietest_idx
def split_audio_energy_aware(
audio_data: bytes,
max_clip_s: float,
sample_rate: int = 16000,
) -> Tuple[List[bytes], List[float]]:
"""把音频切成不超过 ``max_clip_s`` 秒的连续 WAV 块。
解码并重采样到 16 kHz 单声道后,按 ``max_clip_s`` 步长前进,在每个
步长末尾的 ``SPLIT_SEARCH_WINDOW_S`` 内找最低能量点作为切点,保证
切在停顿处而非单词中间。返回 ``(chunk_bytes, offsets_s)``,
``offsets_s[i]`` 是第 i 块在原始音频中的起始时间。
"""
if not audio_data:
raise ValueError("audio_data is empty")
audio = load_audio(audio_data, sr=sample_rate, mono=True)
chunk_size = int(sample_rate * max_clip_s)
search_size = int(sample_rate * SPLIT_SEARCH_WINDOW_S)
total = audio.shape[-1]
raw_chunks: List[np.ndarray] = []
offsets_s: List[float] = []
i = 0
while i < total:
offsets_s.append(i / sample_rate)
if i + chunk_size >= total:
raw_chunks.append(audio[i:]) # 剩余不足一个窗口,整段收尾
break
search_start = i + chunk_size - search_size
search_end = min(i + chunk_size, total)
split_point = find_split_point(audio, search_start, search_end)
raw_chunks.append(audio[i:split_point])
i = split_point # 切点成为下一块起点,块与块无缝衔接
chunks: List[bytes] = []
for chunk in raw_chunks:
buf = io.BytesIO()
sf.write(buf, chunk, sample_rate, format="WAV")
chunks.append(buf.getvalue())
return chunks, offsets_s
python/sglang/srt/entrypoints/openai/transcription_adapters/whisper.py
Whisper 适配器声明 30 秒窗口上限、实现分块 verbose_json 响应与带偏移的段解析,是分块能力的模型侧核心。
def build_verbose_response_chunked(
self,
request: TranscriptionRequest,
text: str,
rets: List[dict],
chunk_offsets_s: List[float],
tokenizer,
usage: TranscriptionUsage,
) -> TranscriptionVerboseResponse:
"""由多个块的结果组装 verbose_json 响应。
Whisper 的时间戳 token 相对各自块的 30 秒窗口,所以每个块的段
都要按块在原始音频中的起始时间偏移;segment id 跨块连续编号。
``text`` 直接用 serving 层已拼接好的文本——若在这里用 output_ids
重新解码再用 ASCII 空格 join,会破坏中文、日文、泰文等无空格
脚本(模型本来就没输出空格)。
"""
segments: list[TranscriptionSegment] = []
for ret, offset_s in zip(rets, chunk_offsets_s):
# _parse_segments 对每一块的 timestamp token 加上 time_offset_s,
# 并把起始 seg_id 接在已累积段数之后。
_, part_segments = self._parse_segments(
ret.get("output_ids", []),
tokenizer,
time_offset_s=offset_s,
seg_id_start=len(segments),
)
segments.extend(part_segments)
return TranscriptionVerboseResponse(
language=request.language,
duration=round(request.audio_duration_s, 2),
text=text,
segments=segments,
usage=usage,
)
评论区精华
合并者 JustinTong0323 的审查贯穿了从实现到测试的多个关键决策点,且多数意见已在最终提交中落实:
-
并发扇出风险(性能):"Serialize these chunk generations or enforce an explicit configurable bound; one task per user-controlled 30-second chunk lets a single long upload fan out into arbitrarily many tokenizer/GPU requests, and the route has no duration limit." 最终实现改为顺序派发(测试断言 max_active_dispatches == 1),缓解了并发放大,但未引入总时长/块数上限配置。
-
分割失败回退(正确性):"Return an error when splitting fails instead of sending the original over-window audio; this fallback knowingly re-enters Whisper's 30-second truncation path and can return HTTP 200 with an incomplete transcript." 最终实现改为返回 400 错误,不再静默回退。
-
无空格脚本拼接(正确性):"Use the already stitched text here and parse output_ids only for segments; joining chunk texts with an ASCII space corrupts spaceless scripts such as Chinese, Japanese, and Thai." 最终 build_verbose_response_chunked 直接用 serving 层已拼接文本,_finalize_text 通过 strip=False 保留模型边界空白。
-
语言选择语义(正确性):"Select the reported language from the first chunk with non-empty visible text instead of the first parsed prefix; a leading-silence chunk can otherwise lock verbose_json.language to an arbitrary language." 最终 _finalize_text 用 visible.strip() 判断非空,首个非空文本块的语言胜出。
-
测试 mock 缺陷(测试):"Set asr_max_concurrent_sessions to an integer on this mock; OpenAIServingTranscription passes it to asyncio.Semaphore, so the registered CPU test currently raises TypeError before any chunk orchestration runs." 已修复为 asr_max_concurrent_sessions=32。
-
设计复核(设计):"This is a design choice, what's your opinion? @shenxiul"——reviewer 在语言选择策略上主动征询作者意见。最终批准意见:"CI base green; extra-CI gptq failure is a pre-existing main regression unrelated to this PR. LGTM."
- 分块生成的并发扇出风险 (performance): 最终实现改为顺序派发(单测断言 max_active_dispatches == 1),并发放大被消除,但未引入总时长/块数上限配置。
- 分割失败时回退发送原始音频 (correctness): 最终实现改为返回 400 错误响应,不再回退到原始音频。
- 无空格脚本的拼接语义 (correctness): 最终 build_verbose_response_chunked 直接用已拼接文本,_finalize_text 以 strip=False 保留模型边界空白。
- 自动检测语言应取首个非空文本块 (correctness): 最终 _finalize_text 以 visible.strip() 判断非空,首个非空文本块的语言胜出。
- 测试 mock 的 Semaphore 类型错误 (testing): 已修复为 asr_max_concurrent_sessions=32。
- 语言选择策略的设计征询 (design): 采用首个非空可见文本块的语言;整体审查最终以 LGTM 批准。
风险与影响
- 风险:
- 资源放大无上限(性能/可用性):虽然块改为顺序派发,但单个长上传仍会生成 ceil(时长/30) 个独立 tokenizer/GPU 请求(如 1 小时音频产生 120 个块),总延迟与计算成本随时长线性放大,路由层无总时长或块数上限。reviewer 在讨论中明确指出这一点,属于部分缓解。
- 核心路径变更(回归):
create_transcription 与 _handle_non_streaming_request 是 /v1/audio/transcriptions 的主路径;_finalize_text 抽取后 strip=True 时必须保持与原内联逻辑完全一致,否则非分块请求的文本/语言行为会漂移。_parse_segments 新增参数为默认值,非分块路径不受影响。
- 适配器兼容性(接口):
TranscriptionAdapter 新增 build_verbose_response_chunked 默认实现(空段列表)。第三方自定义适配器若未覆写,在超窗音频且 max_audio_clip_s 非 None 时会丢失段时间戳;默认 None 禁用了该路径,风险可控。
- 音频解码稳定性:
_get_audio_duration 的 fallback 与分块都会全量解码音频,asyncio.to_thread 已规避事件循环阻塞,但线程池饱和可能在高并发下排队;sf.write 重编码为 PCM16 WAV 存在量化误差(测试以 atol=2/32768 接受)。
- 测试稳定性(CI):GPU 端到端测试依赖远程下载 sgl-test-files 的 mp3 并拼接 40 秒 WAV,网络抖动可能导致 flaky;
est_time 从 60 提升到 90 秒。
- 安全:无新攻击面,但长音频上传可放大 GPU 资源消耗(reviewer 已关注),路由层缺少时长限制的现状仍在。
- 影响:对用户:Whisper 模型下超过 30 秒的音频从"HTTP 200 但内容被静默截断"变为完整转录;verbose_json 段时间戳正确反映原始音频时间轴(可超过 30 秒);流式路径同步可用;分割失败时获得显式 400 错误而非静默丢失。对系统:转录路径新增 CPU 侧解码/分块/重编码开销,长音频按块多次进入多模态编码与解码,计算成本与时长线性相关,是此前"一次编码但丢内容"之外的增量成本。对团队:TranscriptionAdapter 抽象形成"适配器声明窗口上限 → serving 层通用分块 → 适配器负责段偏移与文本拼接"的扩展模式,Qwen3-ASR 等其他适配器只需声明 max_audio_clip_s 即可复用分块能力。
- 风险标记:核心路径变更, 长音频资源放大无上限, 多适配器接口扩展, 测试依赖外部音频资源
关联脉络
- PR #34892 feat: add safeguards for remote media URLs: 同属 multimodal 输入侧防御/审计方向,且都触及 python/sglang/srt/utils/common.py 的公共工具面;本 PR 扩展 load_audio 支持 bytes 输入,为分块音频的 CPU 侧处理提供基础。
参与讨论