这份文档把仓库里的推理代码路径拆成八张图:音频怎么切、请求怎么长、模型内部怎么压缩、 什么时候该说、说漏了怎么补、上下文什么时候撑爆。每一步都给出可回查的代码位置与可复算的数字。
这套推理的本质不是"流式 ASR + 流式 MT"的级联,而是把同声传译当成一场多轮对话: 每 1.6 秒音频就是用户发来的一条新消息,模型回答"这一块我现在能确定的那部分译文",说完就停。
一次请求要穿过五个阶段。左侧三段的职责是把连续音频整理成模型能吃的离散输入, 右侧两段负责把模型的稀疏输出还原成连续的说话节奏。
首块长度在代码里被硬校验:first_chunk_ms 必须等于 1600,后续块只能是 800 或 1600
(config.py __post_init__)。这不是随意取的数——1.6 秒大约是一个短句的最小完整单元,
首块太短模型无从下手,太长则开场就要等两秒。
# simulinfer/translation.py:376 —— 按首块/后续块不同长度切流 size = (self.settings.first_chunk_ms if self.index == 0 else self.settings.chunk_ms) * 16 if len(self.buffer) < size: break chunk, self.buffer = self.buffer[:size], self.buffer[size:] yield await self.step(chunk, size)
切块之后并非每块都会触发推理。VAD 会给每一块打上"是否静音"的标签,
静音块直接被丢弃而不产生任何模型调用(skip_initial_silence 默认开启)。
这次省下的不只是一次前向,而是一整个往返。
| 职责 | 具体行为 | 配置项 |
|---|---|---|
| 门控 | 判静音的块不送模型,省一次往返 | skip_initial_silence |
| 给 ST 提供人声区间 | 补偿重译时按人声区间裁剪,前后各补 240 ms,避免切掉字头字尾 | st_crop_speech · st_segment_pad_ms |
| 判句末 | 连续静音达 2.4 s 视为一句说完,触发未决音频的收尾翻译 | trailing_silence_seconds |
两个细节值得注意。第一,VAD 的状态不随对话窗口重置而重置—— 代码注释写得很直白:"only real source samples advance VAD and the progress cursor", 源时间轴与模型对话轮次是两套独立的时钟。第二,人声区间裁剪只影响送给模型的音频, 不影响源时间轴上的记账,所以被裁掉的静音不会被误算成"漏译"。
到这里,输入已经变成"每 1.6 秒一个、带静音标记的音频块"。接下来的问题是: 这些块怎么变成模型能读的上下文?
这是整套机制最反直觉的地方:没有增量状态,每一轮都把全部历史重发一次。
会话里维护一个 messages 列表,每处理一块音频就往后追加两条——一条 user(音频块)、
一条 assistant(这一块的译文)。下一轮把这个列表原样再发一遍,末尾再加上新的一块音频。
user_turn() 里的 if not self.messages 分支),
后两轮的 user 消息里只有音频。这也是为什么历史被清空后,第一句话必须重新带上指令。
光有 messages 还不够,关键在 chat 模板怎么渲染。模板给每个音频 part 展开成三个特殊标记, 给每条 assistant 历史补上角色头与结束符,最后让模型停在"接着说"的位置:
<|hy_begin▁of▁sentence|><|reasoning_mode|>reasoning_effort:no_think <|hy_User|>请完成下面的同声传译任务……不必等待整句结束。 <|hy_place▁holder▁no▁6|><|hy_place▁holder▁no▁5|><|hy_place▁holder▁no▁8|> ← 音频占位,中间那个会展开成 11 个语音词元 <|hy_Assistant|><think></think>所以关键的想法是<|hy_EOT|> <|hy_User|> <|hy_place▁holder▁no▁6|><|hy_place▁holder▁no▁5|><|hy_place▁holder▁no▁8|> <|hy_Assistant|><think></think> ← 模型从这里继续往下写
请求侧怎么把它接上?分两种情形。只出译文的模式走标准生成提示——模板在末尾补一个空的 assistant 角色头,
模型从空白处开始写。需要续写已有内容时(Interleave 模式要先写源文、ST 补偿时要接上旧译文),
改用 continue_final_message:把一段 assistant 前缀塞进 messages,让模板不补生成提示,
模型从这段前缀的末尾继续往下写。
# simulinfer/translation.py:61-83 —— 极简版 continuation = prefix if prefix is not None else (SRC if mode == 'source_target' else None) fields = {'messages': wire, 'add_generation_prompt': continuation is None, 'continue_final_message': continuation is not None, 'add_special_tokens': False} if continuation is not None: wire.append({'role': 'assistant', 'content': [{'type': 'text', 'text': continuation}]})
标准 add_generation_prompt 路径,模型直接写译文。前端看不到源文转写。
用于 Hy-SimulMT-30B-A3B 系列。
强制以 place▁holder▁no▁790 起头,先写源文,遇到 place▁holder▁no▁791 再写译文,上层用 split_reply() 拆开。
用于 Interleave 系列,可以同时显示双语字幕。
音频不是以"文本转写"的形式进入 LLM 的。它先被压成一条很短的离散序列, 再从词嵌入表旁边"借道"插进语言模型里。整条链上有两次压缩,合起来把 160 个 mel 帧压成 11 个词元, 时间轴上每个词元代表约 145 ms。
这几个数字不是估的,是 budget.py 里三行算术算出来的,可以直接复跑:
# simulinfer/budget.py:27-34 def speech_tokens(num_samples): frames = (num_samples + 159) // 160 # 16 kHz / hop 160 → mel 帧 tail = (frames % 100 + 7) // 8 # 尾块每 8 帧出 1 帧 return (frames // 100 * 13 + tail + 1) // 2 # 每满百帧出 13 帧,再按 FSK k=2 折半
| 音频块 | 采样数 | Mel 帧 | 编码器帧 | 语音词元 | 每词元时长 |
|---|---|---|---|---|---|
| 首块 / 标准块 1600 ms | 25 600 | 160 | 21 | 11 | 145 ms |
| 短分块 800 ms | 12 800 | 80 | 10 | 5 | 160 ms |
| 单条音频上限 30 s | 480 000 | 3000 | 390 | 195 | 154 ms |
请求里音频只体现为一个占位符,但服务端算出的 prompt token 数必须把 11 个位置算进去。
render() 在渲染模板之后做了一个偏移修正:每出现一个音频 part,就加上
speech_tokens(n) − 1,因为占位符本身已经被计过一次。
这个数在生成后还会被核对一遍——如果 usage.prompt_tokens 不等于预算值,
说明模型侧的音频模板与推理层不匹配,直接抛错而不是"带病继续译"。
# 真实响应里的计数校验(translation.py:263) if budget is not None and actual != budget.prompt_tokens: raise RuntimeError('模型音频token计数与SimulInfer模板不一致')
模型返回之后,会话层只做一件事——看这段文本是不是空的。这个判断看着太简单, 但它是整条链路里唯一决定"用户什么时候听到声音"的地方。
if reply.text.strip():。
下面用一句 8 秒的英文口语走一遍,假设方向是英译中。每条译文都是示意, 用来展示节奏,不是模型实测输出。
| 块 | 源音频(示意) | 模型输出 | 动作 | 此刻的上下文里有什么 |
|---|---|---|---|---|
| #1 · 0–1.6 s | So the key idea here | 所以关键的想法是 | EMIT | 只有指令 + 音频₁ |
| #2 · 1.6–3.2 s | is that we translate | 我们要翻译 | EMIT | 指令 + 音频₁ + 译文₁ + 音频₂ |
| #3 · 3.2–4.8 s | while the speaker | (空) | WAIT | 从句还没说完,先说会拧;模型选择等 |
| #4 · 4.8–6.4 s | is still talking | 在说话人还在讲话的时候 | EMIT | 块 3 的音频仍在上下文里,只是当时没产出文字 |
| #5 · 6.4–8.0 s | and that's why | 这就是为什么 | EMIT | 五块音频 + 四段译文 |
"让模型自己决定"不等于放任。采样端挂了四个约束,防止它在长上下文里跑偏:
no_repeat_ngram_size = 3。同一轮里重复过的三词组合被置为 −∞。同传模式下只看当前块,历史译文不参与统计——否则模型会因为"上一轮说过"而不敢复用常用搭配。
repetition_penalty 同传 1.0、整段重译 1.05。整段重译更容易陷入"这句我译过"的死循环,所以略微加压。
stop = [EOT, EOS],且 skip_special_tokens = false——保留结束符字面,方便上层核对到底是"正常收尾"还是"被截断"。
中译英时打开 no_chinese_target,把中文 token 全部屏蔽。用于压制"听着英文说着中文"的串语。
模型"忍一下"是有代价的。如果演讲者进入一个很长的从句、或者环境噪声让模型持续不敢下判断, 就会连续多块空输出。这时听众已经七八秒没听到任何东西,必须有人介入——这套机制叫 ST 补偿。
| 触发 | 含义 | 参数 |
|---|---|---|
| 连续空输出 | 连着 N 块都没吐字,说明模型一直在忍 | st_wait_chunks = 4 |
| 句末静音 | 连续静音 2.4 秒,说话人停了,该收尾 | trailing_silence_seconds = 2.4 |
| 音频结束 | 用户点了停止或文件读完,剩下的一次性译完 | finish() → flush(force=True) |
这三个口子最终都进同一个函数 flush()。它内部还有几道防重复的闸:
同一段音频不重复尝试(用起止采样点做 key)、超过 st_fallback_max_seconds 就裁掉头部并记一笔"漏译"、
结果为空则丢弃并可选重置。这些细节不影响理解主链路,但决定了长时间运行的稳定性。
messages[-1]['content'] = ...),而不是追加。因为这次重译覆盖了之前几块的时间范围,
追加会造成重复。这一步做错,后面每一轮都会读到一份重复的译文。
第 3 节说过每轮重发全部历史。这个设计的成本是线性的:说得越久,每次请求越长。 按默认参数估算,音频本身每分钟贡献约 412 个 token,译文再贡献约 190 个。
上下文逼近上限时的处理不是"报错",而是主动断开对话、从零开始。
切断点有三处,都汇总到 cut():
finish_reason == 'length')。这一轮已经写满 64 个 token 还没说完,说明模型在"补一大段",
而 prompt 也快满了。默认先抛错(reset_on_max_tokens = false),可配置成保留部分输出后重置。prompt + completion + 256 ≥ max_model_len 时切断。256 是留给下一轮的余量。window_seconds 默认 0(关闭),开启后按固定时长重置,用于超长会议。
切断前一定先 flush():把未成句的音频译完再清空,避免"切断瞬间丢掉半句话"。
清空只清对话(messages、等待计数),不清 VAD 状态,也不回退源时间轴——
这两套时钟是分开的,第 2 节提过。
# simulinfer/translation.py:549-566 —— 切断的顺序很重要 async def cut(self, event, reason): if any(not b[3] for b in self.pending): await self.flush(event, force=True, trigger=reason) # 先译完 ... if self.run == run: self.reset(event, reason) # 再清空
前面几节拆的是零件。这里把第 5 节的例子摊到时间轴上,特别注意第三行的积压—— 它解释了为什么"块 3 没说出话,块 4 却能把它一起译出来"。
从这张图能直接读出这条关系:
用户听到第一个字的时间 ≈ 首块长度 + 一次推理往返
≈ 1.6 s + (prefill + decode)
句子被完整译出的时间 ≈ 句子结束时刻 + 一次推理往返 + 可能的补偿
(如果模型中途 WAIT 过,就要等见证后续音频的那一轮)
第一行是硬下限,1.6 秒无法绕过——要译一句话,至少得先听完这么大一块。 第二行才是质量与延迟的真实权衡点:模型越敢"提前说",第二行越短、但改口概率越高; 越保守,质量越稳、但听众等得越久。README 里 StreamLAAL ≈ 3 秒,本质上就是这个权衡的产物。
想把某个环节落到具体文件时查这张表。行号对应当前仓库版本。
| 环节 | 文件 | 关键内容 |
|---|---|---|
| 切块与单步驱动 | simulinfer/translation.py:371 | push() 攒够一块就调 step() |
| 单步主逻辑 | simulinfer/translation.py:386 | step():VAD → 组装 → 调模型 → 决策 → 记账 |
| 请求构造 | simulinfer/translation.py:61 | chat_request():前缀续写 + 消息拼装 |
| 模型调用与解析 | simulinfer/translation.py:224 | generate():采样参数、停止符、token 计数校验 |
| 源文/译文拆分 | simulinfer/translation.py:39 | split_reply():按 790 / 791 两个分隔符切分 |
| 补偿重译 | simulinfer/translation.py:610 | flush():裁剪、拼接、短语音守卫、写回历史 |
| 切窗与重置 | simulinfer/translation.py:549 | cut():先 flush 再 reset |
| chat 模板 | simulinfer/templates/simulst.jinja | 音频占位符、角色头、生成提示的位置 |
| 语音词元计数 | simulinfer/budget.py:27 | speech_tokens():mel → 编码器帧 → 词元 |
| 上下文预算 | simulinfer/budget.py:43 | render():渲染模板并修正音频占位偏移 |
| VAD | simulinfer/vad.py | Silero / FireRed 流式与非流式检测 |
| 人声裁剪 | simulinfer/noise.py:21 | padded_st_audio():按人声区间裁剪并补边 |
| 输出约束(模型侧) | simulinfer/repetition.py | 请求级 n-gram 去重处理器 |
| 音频编码 | simulinfer/audio.py:121 | AudioPart:波形 → base64 WAV + 采样数元数据 |
| 模型结构(训练侧) | SimulTrain/s2s_train/common/ | modeling_speech_llm.py 与 modeling_speech_adapter.py |
binary 传输选项——WebSocket 上直接发裸 PCM,省掉 base64 的 1.33 倍膨胀。
三个不需要 GPU 的复算,直接对着仓库跑就能对上:
# 1. 语音词元数(纯算术,不依赖 torch) python3 -c "from simulinfer.budget import speech_tokens; \ print([speech_tokens(n) for n in (25600, 12800, 480000)])" # → [11, 5, 195] # 2. 模板渲染后长什么样(看占位符与角色头的位置) python3 -c "from simulinfer.budget import render; \ print(render([{'role':'user','content':[{'type':'text','text':'probe'}]}], 'target_only')[0])" # 3. 真实跑一遍,观察每个 chunk 的事件字段 curl -N http://127.0.0.1:8080/translate/stream -F direction=en-zh -F file=@audio.wav
第 3 条会流式吐出 JSON 事件,每块一个,看 action(WAIT/EMIT)、
prompt_tokens(是否随轮次线性增长)、stop_reason(是否正常收尾)、
st_attempted(有没有触发补偿)。这四个字段基本能还原每一次决策的来龙去脉。
self.messages 就知道上下文在哪、什么时候被清空。reply.text.strip() 就知道分支走向。pending 就知道哪些音频还没变成文字——它是补偿机制的唯一输入。本文基于 Hy-SimulMT 仓库当前代码静态分析,所有结构结论均可回查上表所列文件。 文中译文示例仅用于说明节奏,不作为质量评测依据;时序图中的推理耗时宽度为示意值。
分析对象:SimulInfer/simulinfer · SimulTrain/s2s_train · 2026-11-01