Hy-SimulMT · Inference Walkthrough

边听边译,究竟是怎么算出来的
一次 1.6 秒音频的完整推理旅程

这份文档把仓库里的推理代码路径拆成八张图:音频怎么切、请求怎么长、模型内部怎么压缩、 什么时候该说、说漏了怎么补、上下文什么时候撑爆。每一步都给出可回查的代码位置与可复算的数字。

SimulInfer / simulinfer vLLM + HYV3SpeechForCausalLM 代码依据:translation.py · budget.py · simulst.jinja 分析日期 2026-11-01

00先给结论

这套推理的本质不是"流式 ASR + 流式 MT"的级联,而是把同声传译当成一场多轮对话: 每 1.6 秒音频就是用户发来的一条新消息,模型回答"这一块我现在能确定的那部分译文",说完就停。

1.6s
首块长度,后续块可降到 0.8 s
11token
1.6 s 音频压缩成的语音词元数
145ms
每个语音词元代表的真实时长
64token
单轮生成上限(贪心,遇 EOT 即停)
4块
连续空输出后触发整段重译补偿
2.4s
连续静音达到此值判定句末
  1. 切块。音频按 1.6 s 一段滑进缓冲,首块严格 1.6 s;VAD 判定为静音的块直接跳过,根本不发请求。
  2. 长上下文。每一轮请求都把之前所有音频块 + 之前所有译文重新发一遍,新的一块音频追加在最后。
  3. 模型续写。模板让模型永远停在"接着我上一句话往下说"的位置,于是术语、语气、句法自然连贯。
  4. 自己决定说不说。有把握就吐字(EMIT),没把握就输出空串(WAIT)——"什么时候开口"由模型判断,不是外部 wait-k 策略。
  5. 兜底追平。连续 4 块都没出字、或 2.4 s 静音、或音频结束,就把积压音频整段重译一次,一口气补上。
  6. 防爆。上下文随对话线性增长,所以有三条切断线:输出被截断、token 预算逼近、定时切窗。
一句话记住区别 传统同传系统问的是"我该等几个词再翻";Hy-SimulMT 问的是"这段音频我现在能译出多少,剩下的先不说"。 前者是外部调度问题,后者是模型自己的能力,推理层只负责把音频一块块喂进去、把空回答翻译成"再等等"。

01整体路径:从麦克风到耳朵

一次请求要穿过五个阶段。左侧三段的职责是把连续音频整理成模型能吃的离散输入, 右侧两段负责把模型的稀疏输出还原成连续的说话节奏。

1 采集与分块 16 kHz 单声道 float32 裸流 首块 1.6 s 后续 1.6 / 0.8 s 不落盘,只在 内存里攒够一块 2 语音活动检测 FireRed / Silero 在 CPU 上跑 静音块不进模型 给出人声区间 时间轴只随真实 音频前进 3 构造请求 chat 模板渲染 历史全部重发 音频占位符展开 预检上下文预算 1 个音频标记 展开成 11 个向量 4 模型推理 mel → 声学编码器 → FSK 适配器 → LLM 自回归 贪心,遇 EOT 停 temperature = 0 max_tokens = 64 5 输出与回流 空 → WAIT 有字 → EMIT 增量立刻下发 可选 TTS 克隆 事件里带 token 计数与停止原因 事件回流:每个块返回一个 chunk 事件 action(WAIT / EMIT)· text 增量 · prompt_tokens / completion_tokens · stop_reason · generated_token_ids 回传
图 1 · 端到端五阶段。注意第 2 段与第 4 段是两个不同的模型:VAD 是几 MB 的 CPU 小模型,只做二元判断; 翻译模型是 30B-A3B 的多模态 LLM。前者的输出决定后者是否被调用。

为什么第 1 段必须固定 1600 ms

首块长度在代码里被硬校验: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)

02切块与静音:什么时候根本不叫模型

切块之后并非每块都会触发推理。VAD 会给每一块打上"是否静音"的标签, 静音块直接被丢弃而不产生任何模型调用(skip_initial_silence 默认开启)。 这次省下的不只是一次前向,而是一整个往返。

源时间轴(8 秒示例,每格 1.6 s) 块 1 块 2 块 3 块 4 块 5 0.0 – 1.6 s 1.6 – 3.2 s 3.2 – 4.8 s 4.8 – 6.4 s 6.4 – 8.0 s 无人声 EMIT WAIT EMIT 静音 · 跳过 EMIT 块 4 无人声 → 不发请求,静音检测与裁剪状态照常推进;下一次模型调用看到的上下文里也不会有这一块。 EMIT / WAIT 是上层会话的动作,由模型"这一步是否吐字"直接决定,见第 5 节。
图 2 · 分块与动作序列。灰色块是被 VAD 吃掉的静音块。注意 WAIT 与"静音跳过"是两件不同的事: 前者调用了模型但模型不说话,后者压根没调模型。
易混点:WAIT 不是失败 模型返回空字符串是一次成功的推理,只是结论为"信息还不够"。它消耗的算力极小—— 解码到第一个 token 就是 EOT(结束符),实际只走了 1 步。真正的问题在连续多次都这样时才出现,见第 6 节的补偿机制。

VAD 只做三件事,但每件都影响正确性

职责具体行为配置项
门控判静音的块不送模型,省一次往返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 秒一个、带静音标记的音频块"。接下来的问题是: 这些块怎么变成模型能读的上下文?

03请求是怎么"长"出来的

这是整套机制最反直觉的地方:没有增量状态,每一轮都把全部历史重发一次。 会话里维护一个 messages 列表,每处理一块音频就往后追加两条——一条 user(音频块)、 一条 assistant(这一块的译文)。下一轮把这个列表原样再发一遍,末尾再加上新的一块音频。

请求 #1 t = 1.6 s 请求 #2 t = 3.2 s 请求 #3 t = 4.8 s 同声传译指令 音频₁ · 11 tok 模型回答 "所以关键的想法是" 同声传译指令 音频₁ · 11 tok 译文₁ · 原样入历史 音频₂ · 11 tok 模型回答 "我们要翻译" 同声传译指令 音频₁ · 11 tok 译文₁ 音频₂ · 11 tok 译文₂ 音频₃ · 11 tok 模型回答 (空)→ WAIT 音频块(user 角色,粗边框为本轮新增) 已出译文(assistant 角色) 指令,只在第一轮出现 已出的译文会以 assistant 身份留在历史里,所以模型下一轮看得到自己刚说过什么,术语与语气因此连贯。 代价:第 n 轮的请求里要带上前 n−1 轮的全部 token。收益:不需要任何增量状态,天然支持拒绝、回退与重启。
图 3 · 三轮请求的 diff 关系。指令只在第一轮出现(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}]})
为什么这个设计值得学 模型并没有被要求"输出 JSON 增量"或"输出对齐时间戳"。它只是在做最擅长的事——接着上文续写。 于是"同传"这个任务被降维成"给定这部分音频和已有译文,下一句该写什么"。 编译期不需要流式解码器,运行时也不需要维护任何增量状态机。

两种输出模式

target_only只出译文

标准 add_generation_prompt 路径,模型直接写译文。前端看不到源文转写。

用于 Hy-SimulMT-30B-A3B 系列。

source_target源文 + 译文

强制以 place▁holder▁no▁790 起头,先写源文,遇到 place▁holder▁no▁791 再写译文,上层用 split_reply() 拆开。

用于 Interleave 系列,可以同时显示双语字幕。

04模型内部:1600 ms 如何变成 11 个词元

音频不是以"文本转写"的形式进入 LLM 的。它先被压成一条很短的离散序列, 再从词嵌入表旁边"借道"插进语言模型里。整条链上有两次压缩,合起来把 160 个 mel 帧压成 11 个词元, 时间轴上每个词元代表约 145 ms。

波形 1600 ms 25 600 采样 · 16 kHz pcm16 或 float32 Mel 频谱 160 帧 128 bin · hop 160 每帧 10 ms 声学编码器 21 帧 × 2048 维 约 7.6 倍压缩 FSK 适配器 11 词元 相邻 2 帧拼接 → MLP 2 倍压缩 · 长度可变 LLM 自回归 → 文本 30B 总参 / 3B 激活 与 11 个向量同处一条序列 同一个 1600 ms,在时间轴上就是下面这 11 段,每段约 145 ms: 1 2 3 4 5 6 7 8 9 10 11 复算:25 600 采样 ÷ 160 = 160 mel 帧 → 每 100 帧出 13 帧、尾部每 8 帧出 1 帧 = 21 帧 → 相邻 2 帧拼接向上取整 = 11 词元。 speech_tokens() 在请求前算占位符数量、在模型侧生成同等个数的 embedding,两边必须严格一致,否则请求直接被拒。 块长 800 ms 时是另一组数:80 mel 帧 → 10 帧 → 5 词元(每词元 160 ms)。
图 4 · 两次压缩的分解。声学编码器理解"声音里有什么",适配器负责把维度对齐到 LLM 的词嵌入空间, 长度由音频时长决定(FSK 是变长适配器,不是固定的 150 个 query)。

把数字验一遍

这几个数字不是估的,是 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 ms25 6001602111145 ms
短分块 800 ms12 80080105160 ms
单条音频上限 30 s480 0003000390195154 ms
为什么这个压缩比决定了延迟 如果走"先转写再翻译"的级联路线,1.6 秒语音要先变成 20–30 个汉字 token, 再让第二个模型读这些 token 去翻——两次前向、两份上下文。这里 1.6 秒只占 11 个位置, 而且和文本 token 混在同一条序列里一次算完。序列短,prefill 就便宜,首字延迟才压得住。

占位符是怎么被"撑开"的

请求里音频只体现为一个占位符,但服务端算出的 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模板不一致')

05说不说:一步决策的全部逻辑

模型返回之后,会话层只做一件事——看这段文本是不是空的。这个判断看着太简单, 但它是整条链路里唯一决定"用户什么时候听到声音"的地方。

① 收到一块音频(1600 ms) 已过 VAD 门控:静音块不会走到这里 ② 组装 messages 并预检 token 预算 历史音频 + 历史译文全部重发;不够就先切窗再发 ③ vLLM 贪心解码 temperature = 0 · max_tokens = 64 · 遇 EOT / EOS 即停 ④ 这一步有可输出的译文吗? 判断依据只有一条:reply.text 去空白后是否为空 非空 空串 EMIT · 提交本块译文 追加进 full_text 与 messages,等待计数归零 WAIT · 等待计数 +1 模型认为信息还不够,本轮不产生输出 达到补偿触发条件? 连续 4 块 WAIT · 静音 2.4 s · 音频结束 ST · 积压音频整段重译 见第 6 节;通常一次吐出,把进度追平 这一步音频随即出队,不再参与后续补偿
图 5 · 单步决策。整条链路只有一个真正影响用户感知的分支点(④), 而这个分支的判据是一行 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 五块音频 + 四段译文
块 3 是整套设计的灵魂 如果第 3 块强行输出"在说话人",中文会变成一个悬空的状语,听众要等两秒才知道它在修饰什么。 模型选择沉默,等到第 4 块听全了"while the speaker is still talking"再一次性说出来。 这个"忍一下"的判断没有写在任何策略代码里,它来自模型本身。

四道输出约束

"让模型自己决定"不等于放任。采样端挂了四个约束,防止它在长上下文里跑偏:

禁止 3-gram 重复

no_repeat_ngram_size = 3。同一轮里重复过的三词组合被置为 −∞。同传模式下只看当前块,历史译文不参与统计——否则模型会因为"上一轮说过"而不敢复用常用搭配。

重复惩罚

repetition_penalty 同传 1.0、整段重译 1.05。整段重译更容易陷入"这句我译过"的死循环,所以略微加压。

硬停止符

stop = [EOT, EOS],且 skip_special_tokens = false——保留结束符字面,方便上层核对到底是"正常收尾"还是"被截断"。

目标语言遮罩(可选)

中译英时打开 no_chinese_target,把中文 token 全部屏蔽。用于压制"听着英文说着中文"的串语。

06说漏了怎么办:ST 整段重译

模型"忍一下"是有代价的。如果演讲者进入一个很长的从句、或者环境噪声让模型持续不敢下判断, 就会连续多块空输出。这时听众已经七八秒没听到任何东西,必须有人介入——这套机制叫 ST 补偿。

前两块正常出字之后,连续四块空输出,第四块返回时触发补偿 WAIT 1 WAIT 2 WAIT 3 WAIT 4 一次 EMIT 大面积文本 覆盖前四块积压的全部音频 0 s1.63.2 4.86.4 触发 用户在这里才重新听到声音 补偿做了什么 取出 pending 队列里所有未成句的块,它们可能夹着静音块 按人声区间重新裁剪,前后各留 240 ms——直接拼原始波形会带上大段空白 换成整段翻译指令("要全部输出,不要分块"),并把已出译文作为前缀喂进去 短语音守卫:若人声不足 1.5 s,限制输出词数,防止模型对着半句话脑补出一整段 按配置决定是否清空对话重来;不清空时把这次结果写回历史,下一块接着用 为什么是"重译"而不是"补齐" 积压音频之所以没被译出,通常是因为前半段听不全。 一旦后文到了,前半段的语法角色才明确。所以必须 把它当作一整段重新看一遍,而不是在旧译文末尾补字。 代价:这次调用的输入比普通轮长得多,也更慢。
图 6 · 补偿的时间结构。注意它是滞后的:第 4 块返回后才知道要触发, 所以用户会先经历一段沉默,然后收到一大段文本。这种"憋一口气再说完"的节奏,正是真人同传也会有的。

三个触发口

触发含义参数
连续空输出连着 N 块都没吐字,说明模型一直在忍st_wait_chunks = 4
句末静音连续静音 2.4 秒,说话人停了,该收尾trailing_silence_seconds = 2.4
音频结束用户点了停止或文件读完,剩下的一次性译完finish() → flush(force=True)

这三个口子最终都进同一个函数 flush()。它内部还有几道防重复的闸: 同一段音频不重复尝试(用起止采样点做 key)、超过 st_fallback_max_seconds 就裁掉头部并记一笔"漏译"、 结果为空则丢弃并可选重置。这些细节不影响理解主链路,但决定了长时间运行的稳定性。

一个容易忽略的工程细节 补偿结果写回历史时,代码会把最后一条 assistant 消息整段替换成本次重译的结果 (messages[-1]['content'] = ...),而不是追加。因为这次重译覆盖了之前几块的时间范围, 追加会造成重复。这一步做错,后面每一轮都会读到一份重复的译文。

07上下文什么时候撑爆

第 3 节说过每轮重发全部历史。这个设计的成本是线性的:说得越久,每次请求越长。 按默认参数估算,音频本身每分钟贡献约 412 个 token,译文再贡献约 190 个。

0 2 000 4 000 6 000 8 000 10 000 0 4 8 12 16 分钟 token max_model_len = 8 192 音频累计 ≈ 6 600 含译文总计 ≈ 9 600 ≈ 13 分钟触顶 音频 token 音频 + 译文
图 7 · 按默认参数(1.6 s 块、每块 11 个音频词元、译语速约 3 字/秒)估算的增长曲线。 实际斜率取决于语速与译文长度,但量级不会差太多:十分钟是一道坎。

三条切断线

上下文逼近上限时的处理不是"报错",而是主动断开对话、从零开始。 切断点有三处,都汇总到 cut():

  1. 输出被截断(finish_reason == 'length')。这一轮已经写满 64 个 token 还没说完,说明模型在"补一大段", 而 prompt 也快满了。默认先抛错(reset_on_max_tokens = false),可配置成保留部分输出后重置。
  2. token 预算逼近。prompt + completion + 256 ≥ max_model_len 时切断。256 是留给下一轮的余量。
  3. 定时切窗。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)                                    # 再清空

08把节奏摊平:一段 8 秒的完整时序

前面几节拆的是零件。这里把第 5 节的例子摊到时间轴上,特别注意第三行的积压—— 它解释了为什么"块 3 没说出话,块 4 却能把它一起译出来"。

源音频 模型调用 待译积压 块 1 · 1.6 s 块 2 · 1.6 s 块 3 · 1.6 s 块 4 · 1.6 s 块 5 · 1.6 s 「所以关键的想法是」 EMIT 「我们要翻译」 EMIT (空) WAIT · 只出 EOT 「说话人还在讲话时」 EMIT · 合并两块 「这就是为什么」 EMIT 块 3 的音频一直留在队列里,块 4 到达后两块合并送进模型 0 s1.63.2 4.86.48.0 源时间 深色小方块 = 一次模型调用(宽度仅为示意,真实耗时取决于 GPU 与批内并发)。橙色音频块 = 这一步模型选择等待;浅色条越高表示积压的音频块越多。
图 8 · 8 秒内的四次调用与一次空转。关键在读第三行:积压不是浪费—— 块 3 的音频在块 4 的请求里被原样重发,所以模型在 5.25 秒那一刻同时看到两块音频, 才有条件把"While the speaker is still talking"这个完整从句译出来。

延迟到底由什么决定

从这张图能直接读出这条关系:

用户听到第一个字的时间 ≈ 首块长度 + 一次推理往返
                       ≈ 1.6 s + (prefill + decode)

句子被完整译出的时间 ≈ 句子结束时刻 + 一次推理往返 + 可能的补偿
                       (如果模型中途 WAIT 过,就要等见证后续音频的那一轮)

第一行是硬下限,1.6 秒无法绕过——要译一句话,至少得先听完这么大一块。 第二行才是质量与延迟的真实权衡点:模型越敢"提前说",第二行越短、但改口概率越高; 越保守,质量越稳、但听众等得越久。README 里 StreamLAAL ≈ 3 秒,本质上就是这个权衡的产物。

09代码地图

想把某个环节落到具体文件时查这张表。行号对应当前仓库版本。

环节文件关键内容
切块与单步驱动simulinfer/translation.py:371push() 攒够一块就调 step()
单步主逻辑simulinfer/translation.py:386step():VAD → 组装 → 调模型 → 决策 → 记账
请求构造simulinfer/translation.py:61chat_request():前缀续写 + 消息拼装
模型调用与解析simulinfer/translation.py:224generate():采样参数、停止符、token 计数校验
源文/译文拆分simulinfer/translation.py:39split_reply():按 790 / 791 两个分隔符切分
补偿重译simulinfer/translation.py:610flush():裁剪、拼接、短语音守卫、写回历史
切窗与重置simulinfer/translation.py:549cut():先 flush 再 reset
chat 模板simulinfer/templates/simulst.jinja音频占位符、角色头、生成提示的位置
语音词元计数simulinfer/budget.py:27speech_tokens():mel → 编码器帧 → 词元
上下文预算simulinfer/budget.py:43render():渲染模板并修正音频占位偏移
VADsimulinfer/vad.pySilero / FireRed 流式与非流式检测
人声裁剪simulinfer/noise.py:21padded_st_audio():按人声区间裁剪并补边
输出约束(模型侧)simulinfer/repetition.py请求级 n-gram 去重处理器
音频编码simulinfer/audio.py:121AudioPart:波形 → base64 WAV + 采样数元数据
模型结构(训练侧)SimulTrain/s2s_train/common/modeling_speech_llm.py 与 modeling_speech_adapter.py
一个设计上的取舍 推理层完全不碰模型权重,全部通过 HTTP 调用 vLLM。好处是模型可以换、可以扩、可以多实例; 代价是每一轮都要序列化一次完整历史(音频要重新编码成 base64)。这也是为什么仓库提供了 binary 传输选项——WebSocket 上直接发裸 PCM,省掉 base64 的 1.33 倍膨胀。

10自己验证这些数字

三个不需要 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(有没有触发补偿)。这四个字段基本能还原每一次决策的来龙去脉。

读代码时的三个锚点
  1. 看到 self.messages 就知道上下文在哪、什么时候被清空。
  2. 看到 reply.text.strip() 就知道分支走向。
  3. 看到 pending 就知道哪些音频还没变成文字——它是补偿机制的唯一输入。

本文基于 Hy-SimulMT 仓库当前代码静态分析,所有结构结论均可回查上表所列文件。 文中译文示例仅用于说明节奏,不作为质量评测依据;时序图中的推理耗时宽度为示意值。

分析对象:SimulInfer/simulinfer · SimulTrain/s2s_train · 2026-11-01