Justme0 的博客

撷英采华,以备不需

大模型 ASR 语音识别原理分析

Qwen3 在2026年初开源了ASR语音识别模型,据官方称是state-of-the-art效果,引起Redis作者antirez的注意,他用C语言重写了语音识别推理的代码[1],这个项目第三方依赖很少,通过它可以方便地理解大模型语音识别的过程。

下面是在CPU上测试一段12.76s的音频文件离线识别的耗时分布,可以大致了解各阶段的消耗占比。

Phase Time %
Tokenizer load (vocab.json) 76ms 1.4%
Mel spectrogram (1276 frames) 30ms 0.5%
Encoder (166 tokens) 2370ms 42.6%
Prefill (181 tokens into KV cache) 2352ms 42.2%
Decode (21 tokens, 34.3ms/tok) 720ms 12.9%
Overhead ~20ms 0.4%

Real-time factor: 5.57s / 12.76s = 0.44

涉及代码中的主要函数

qwen_transcribe_audio
  transcribe_segment
    qwen_mel_spectrogram
    qwen_encoder_forward
    qwen_decoder_prefill
    qwen_decoder_forward

source: https://sourcegraph.com/r/github.com/antirez/qwen-asr@main/-/blob/qwen_asr.c?L590

下面将介绍以上运行过程,标题后面的百分比是耗时占比,有的细节没有展开说,只记录了要点,主要是我的学习笔记,重点讲解大模型 Transformer 架构。

1. 音频预处理(PCM变换为频谱) 1%

过程:预加重、分帧、加窗;短时FFT;Mel滤波。

分帧:每帧25ms,为防止割裂保持平滑性,取帧时与前后帧边界重叠, 窗口每次移动10ms,所以 100 frames/s。

短时FFT:重点, 将窗口内时域转为频域

Mel刻度:音高与频率是对数关系,符合人耳听觉。来自心理学中的Weber-Fechner定律(也称对数定律),除了听觉音高,还适用于听觉响度、味觉、触觉等。

\[mel(f) = 2595 \space log_{10}{(1 + f/700)}\]

1000以下近似线性,以上对数关系。

mel

mel刻度

spectrom_zh.png

语谱图

FBank

取能量谱,经mel滤波器组滤波,将频率范围按对数方式划分为n段,人耳感觉到每段的音高差异相同,得到FBank。

\[E_k=\sum_{f}|X(f)|^2 H_k(f)\]

其中 $X(f)$是频率点的幅度,$ H_k(f) $是三角滤波器权重,k是mel bin序号,从0到n-1。

Bin 0:    20 -   45 Hz   (low bass)
Bin 1:    45 -   71 Hz
Bin 2:    71 -   98 Hz
...
Bin 60:  1000 - 1100 Hz  (mid, speech fundamentals)
...
Bin 127: 7200 - 8000 Hz  (high, sibilants like "s", "t")

2. Encode 43%

    int enc_seq_len = 0;
    float *enc_output = qwen_encoder_forward(ctx, mel, mel_frames, &enc_seq_len);

source: https://sourcegraph.com/r/github.com/antirez/qwen-asr@b00b789b17051aea61e9717458171100662318a4/-/blob/qwen_asr.c?L612

2.1 Conv2D 22%

将FBank下采样(压缩),减少数据量。

使用卷积kernel(也称channel/filter/mask)提取特征,一种kernel负责提取一种特征。

Why Compress:

The transformer’s attention is $O(n^2)$. Processing all 1,276 mel frames directly:

mel frame: 1,276 × 1,276 = 1,628,176 attention pairs ← expensive! token: 166 × 166 = 27,556 attention pairs ← 59× cheaper!

The Conv2D stem compresses 8× (1,276 → 166) while preserving the important speech information. The 3 Conv2D layers learn what to keep and what to discard — they keep phoneme boundaries, formant transitions, and pitch changes, while discarding redundant information between adjacent frames. [5]

\[\text{Edge detector} = \begin{bmatrix} -1 & -1 & -1 \\ -1 & 8 & -1 \\ -1 & -1 & -1 \end{bmatrix}\] \[\text{Blur} = \begin{bmatrix} \frac{1}{9} & \frac{1}{9} & \frac{1}{9} \\ \frac{1}{9} & \frac{1}{9} & \frac{1}{9} \\ \frac{1}{9} & \frac{1}{9} & \frac{1}{9} \end{bmatrix}\] \[\text{Sharpen} = \begin{bmatrix} 0 & -1 & 0 \\ -1 & 5 & -1 \\ 0 & -1 & 0 \end{bmatrix}\]

考察1s音频:

enc->conv_out_weight is the [7680, 896] matrix, loaded from thinker.audio_tower.conv_out.weight in the safetensors file. These 6.8 million weights were learned during training.

考察单个token的变换如下(注意代码和论文中的向量是横向量,线性代数教科书中的向量通常是列向量)

\[\alpha_{1 \times 7680} A_{7680\times 896} = \beta_{ 1 \times 896}\]

参数汇总:

以上是音频相关的内容,token化得到 input embedding,接下来是LLM通用的步骤,也适用于图片、视频、文本token化之后的处理。

2.2 Transformer 21%

transformer.png

Transformer架构

Positional Encoding

transformer如何利用输入序列中token的前后位置信息?引入Sinusoidal正弦曲线编码位置信息。[6]

/* Add per-chunk sinusoidal position embeddings (starting from pos 0) */
float *pe = (float *)malloc(w3 * d_model * sizeof(float));
qwen_sinusoidal_pe(pe, w3, d_model);
qwen_add_inplace(projected, pe, w3 * d_model);

...

void qwen_sinusoidal_pe(float *pe, int n_pos, int d_model) {
    int half = d_model / 2;
    float log_timescale = logf(10000.0f) / (float)(half - 1);

    for (int p = 0; p < n_pos; p++) {
        float *row = pe + p * d_model;
        for (int d = 0; d < half; d++) {
            float inv_timescale = expf(-(float)d * log_timescale);
            float angle = (float)p * inv_timescale;
            row[d] = sinf(angle);          /* first half: sin */
            row[half + d] = cosf(angle);   /* second half: cos */
        }
    }
}

考察1s音频:

\[\begin{aligned} PE_{(p, d)} &= \sin(p*0.0001^{d/(d_{model}/2-1)}) \approx \sin(p * 0.9796^d) \\ PE_{(p, d_{model}/2+d)} &= \cos(p*0.0001^{d/(d_{model}/2-1)}) \approx \cos(p * 0.9796^d) \end{aligned}\]

前一半维度用sin,后一半用cos,称为 NeoX split-half 风格,使两个向量两两不同。

注:Attention论文中是偶数维用sin,奇数维用cos,类似于PCM的两种排列方式 interleaved 和 planar。与AI沟通,为什么使用split-half风格而不是奇偶交错呢?后面decode时有类似计算需要重用,对于SIMD友好,而encode阶段则无影响,这里可能是为了保持风格一致。

函数图像:

sin.png

sin PE

cos.png

cos PE

注意到d越大,三角函数的频率越低,最后将PE向量与token向量相加,得到最终的embedding。

Attention 7%

Pre-attention LayerNorm

数据处理技巧,将一个token向量标准化为均值0、方差1,避免数值间相差太大,w、b向量训练时得到,突出和抑制某些维度。

\[\text{LayerNorm}(x) = \frac{x-\mu}{\sigma} \odot w + b\]

注:规范名称应是 Standardization(标准化) 或者 z-score,不是 Normalization (归一化,有 L1, L2, min-max, RMS 等) [2] ; $\odot$ 表示逐元素相乘,即 Hadamard 积

token预处理成Query/Key/Value向量

为降低理解门槛,考察单个token向量 x ,通过三个训练时得到的weight矩阵 $W_q$, $W_k$, $W_v$和三个bias向量(有的模型没有),分别得到 q, k, v 三个向量。

\[\begin{aligned} q &= x W_q + b_q \\ k &= x W_k + b_k \\ v &= x W_v + b_v \end{aligned}\]

打个比方,新生入学班上的同学们作自我介绍,大家都很好奇对方,比如你可能对某位女同学的长相感兴趣,你会多关注到她一些,介绍完后你就想了解她的详细情况,比如她的家庭住址、生日、爱好等等。

在这个例子中,我们看到几个重要过程:

你的需求和她的特点匹配上了,然后了解她的详情,就是这个过程。

再看下假如没有这个自我介绍会怎么样?大家坐一起干巴巴地,就在尬聊,互相认识的效果不太好。而这个自我介绍过程相当于对token作预处理,提取出token的query、key、value。

Without these projections, attention would just compare raw embeddings directly. The learned projections allow the model to learn what aspects of the input to compare (keys vs. queries) and what information to pass forward (values).

再举一个例子,以句子 “猫在睡觉,它很可爱” 为例:

前面例子中把需求和标签配对,获取详情的过程,怎么用代码或者公式精确表达?下面就是transformer架构最核心的部分。

Attention

多个token预处理后写成矩阵形式 Q, K, V,每一行表示一个token预处理后的结果。

\[\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d}}\right)V\]

$QK^T$的含义是两两token(包括自己和自己)的q向量与k向量求内积$\langle q, k \rangle=qk^T$,表示相似度。比如有3个token:

\[QK^T =\begin{pmatrix} q_1 \\ q_2 \\ q_3 \end{pmatrix} \begin{pmatrix} k_1 \\ k_2 \\ k_3 \end{pmatrix}^T =\begin{pmatrix} q_1 \\ q_2 \\ q_3 \end{pmatrix} \begin{pmatrix} k_1^T & k_2^T &k_3^T \end{pmatrix} = \begin{pmatrix} \langle q_1, k_1 \rangle & \langle q_1, k_2 \rangle & \langle q_1, k_3 \rangle \\ \langle q_2, k_1 \rangle & \langle q_2, k_2 \rangle & \langle q_2, k_3 \rangle \\ \langle q_3, k_1 \rangle & \langle q_3, k_2 \rangle & \langle q_3, k_3 \rangle \end{pmatrix}\]

除以$\sqrt{d}$ 和 softmax 可认为是一种数据处理技巧,避免数值间相差太大。假设q、k向量的元素是独立随机变量,由前面的LayerNorm得知$\langle q, k \rangle$标准差是$\sqrt{d}$,除以它使标准化;softmax分别对矩阵的每一行处理,得到概率分布列,下面用$\langle q, k \rangle’$表示,与V加权求和得到数学期望。实现中使用更高效的online softmax。

\[\begin{pmatrix} \langle q_1, k_1 \rangle' & \langle q_1, k_2 \rangle' & \langle q_1, k_3 \rangle' \\ \langle q_2, k_1 \rangle' & \langle q_2, k_2 \rangle' & \langle q_2, k_3 \rangle' \\ \langle q_3, k_1 \rangle' & \langle q_3, k_2 \rangle' & \langle q_3, k_3 \rangle' \end{pmatrix} \begin{pmatrix} v_1 \\ v_2 \\ v_3 \end{pmatrix} =\begin{pmatrix} \sum \langle q_1, k_i \rangle' v_i \\ \sum \langle q_2, k_i \rangle' v_i \\ \sum \langle q_3, k_i \rangle' v_i \end{pmatrix}\]

例如考查token 1与所有token的attention:

原始相似度 $ \langle q_1, k_1 \rangle $ $ \langle q_1, k_2 \rangle $ $ \langle q_1, k_3 \rangle $
经缩放和softmax $\langle q_1, k_1 \rangle’ $ $ \langle q_1, k_2 \rangle’ $ $ \langle q_1, k_3 \rangle’ $
示例值 11% 2% 87%

概率分布列:

value $v_1$ $v_2$ $v_3$
概率 11% 2% 87%

结果为数学期望 a=11%$v_1$ + 2%$v_2$ + 87%$v_3$

Multi-Head Attention

将$d_{model}$平均分成 h 份,每份维度$d_{k}$(Qwen3ASR 0.6B将896维划分为14份,每份64维)。每份就是head的意思。每份独立计算Attention,向量维度$d_{k}$,所有份完成后拼接回向量a,维度 $d_{model}$,最后线性变换a整合各份结果:

x += $a W_o + b_o$

attention

分head,分window处理,虚线表示token

Feed-Forward Network (FFN) 14%

逐token独立处理,不涉及跨token处理。 首先 Pre-FFN LayerNorm,类似 Pre-Attention LayerNorm,只是w、b向量不同。

\[\text{FFN}(x)=\text{GeLU}(xW_1+b_1)W_2+b_2\]

After attention gathers context, FFN processes it through a wider hidden layer:
x [166, 896] → fc1 [896, 3584] → GeLU → fc2 [3584, 896] → x [166, 896]

最后 x += FFN(x)

N层循环

How 18 Layers Build Understanding

Each layer adds more abstraction:

Example for “今天天气不错” (nice weather today):

Adapt dimension for decoder

Final projection目的是为了适配decoder的维度,从896维到1024维:

\[\text{GeLU}(xW_1+b_1)W_2+b_2\]

注:W, b与FFN的不同

  FFN (×18) Final Projection (×1)
Expands? Yes: 896 → 3584 → 896 (4× wider) No: 896 → 896 → 1024
Returns to same dim? Yes: 896 → 896 No: 896 → 1024 (new size!)
Has residual? Yes: x = x + ffn_out No residual
Purpose Learn nonlinear transformations Change dimension for decoder
Repeated? 18 times Once, at the very end

3. 准备 Prompt

Building the decoder’s input by combining text token embeddings with encoder audio embeddings into one flat array. The decoder expects input in a chat format. This code builds it as an embedding array input_embeds [total_seq, 1024]:

Token IDs:

Token IDs Section Token Count
[<im_start>] [system] [\n] prefix head 3 tokens
[optional system prompt tokens...] user prompt 0 tokens default
[<im_end>] [\n] [<im_start>] [user] [\n] [<audio_start>] prefix tail 6 tokens
[ENCODER_OUTPUT_0] [ENCODER_OUTPUT_1] ... [ENCODER_165] audio 166 tokens
[<audio_end>] [<im_end>] [\n] [<im_start>] [assistant] [\n] suffix 6 tokens

Total: 3 + 0 + 6 + 166 + 6 = 181 tokens

4. Prefill 42%

// 输入180个token,保留最后一个token用于decode阶段解码产生结果
int prefill_len = total_seq - 1; /* prefill all but last */
qwen_decoder_prefill(ctx, input_embeds, prefill_len);

source: https://sourcegraph.com/r/github.com/antirez/qwen-asr@b00b789b17051aea61e9717458171100662318a4/-/blob/qwen_asr.c?L706

RoPE

旋转位置编码 Rotary Position Embedding [7], 按原论文习惯q, k用列向量,经位置编码后计算两者的相似度:

\[\langle R(x)q, R(y)k\rangle = (R(x)q)^TR(y)k = q^TR(x)^TR(y)k=q^TR(x)^{-1}R(y)k = q^TR(y-x)k\]

其中旋转矩阵R是正交矩阵,转置等于逆,逆表示向相反方向旋转,可以看到结果中只与两个token的相对位置y-x有关。向量维数是2时旋转矩阵如下(可以推广到更多维向量,见原论文)

\[R(x)= \begin{pmatrix} \cos x & -\sin x \\ \sin x & \cos x \end{pmatrix}\] \[x=p*0.000001^{d/(head\_dim/2)} \approx p * 0.8058^d\]

注:RoPE论文中使用的底数是$10^{-4}$,Qwen3ASR代码中使用的是$10^{-6}$

计算好R的元素存储,便于后续计算复用。

另外一种推导相似度只与相对位置有关的方法是使用复数,与旋转矩阵的方法等价,向量维数是2时,它与一个复数一一对应,第一个分量对应复数的实部,第二个分量对应复数的虚部,把q, k看成复数,那么相似度计算变成

\[\langle e^{ix}q, e^{iy}k\rangle = \text{Re}[\overline{e^{ix}q} e^{iy}k] = \text{Re}[e^{-ix}\overline{q} e^{iy}k] = \text{Re}[\overline{q}e^{i(y-x)}k]\]

可以看到结果也只与y-x有关,实际计算还是用矩阵的方式方便。

对比矩阵与复数两种方法:

\[R(x)q= \begin{pmatrix} \cos x & -\sin x \\ \sin x & \cos x \end{pmatrix} \begin{pmatrix} x_1 \\ x_2 \end{pmatrix} = \begin{pmatrix} x_1 \cos x - x_2 \sin x \\ x_1 \sin x +x_2 \cos x \end{pmatrix}\] \[e^{ix}q=(\cos x +i \sin x) (x_1 + i x_2)=x_1\cos x - x_2 \sin x +(x_1 \sin x + x_2 \cos x)i\]

Attention

Pre-attention RMSNorm

\[\text{RMSNorm}(x) = \frac{x}{\text{RMS}(x)} \odot w\]

$\frac{x}{\text{RMS}(x)}$ 的模固定是$\sqrt{n}=\sqrt{1024}=32$,向量的方向保持,大小归一化

token预处理成Query/Key/Value向量

特性 MHA (Multi-head attention) GQA (Grouped-query attention)[9] MQA (Multi-query attention)
Q H 个独立 H 个独立 H 个独立
K, V H 个独立 G 个独立 (G<H) 1 个共享
KV 缓存大小 O(H·L·d) O(G·L·d) O(1·L·d)
性能 最高 接近 MHA 最低
推理速度 最慢 中等 最快
通俗理解 每个研究员都带一套完整参考书 → 知识全面但成本高 几位研究员组成小组,每组共用一套参考书 → 成本适中且知识覆盖足够 所有研究员共用一套参考书 → 成本低但可能不够用

使用折中方案 GQA

参数汇总:

Per-head Q, K RMSNorm

对每个token计算 $R(m\theta)q$, $R(n\theta)k$

此时将复用之前计算好的sin, cos值,可使用SIMD加速。

存储$RK$和$V$到缓存

        /* Apply NeoX RoPE */
        qwen_apply_rope_neox(q, rope_cos, rope_sin, seq_len, n_heads, head_dim);
        qwen_apply_rope_neox(k, rope_cos, rope_sin, seq_len, n_kv_heads, head_dim);

        /* Store K, V in cache */
        for (int s = 0; s < seq_len; s++) {
            memcpy(kv_cache_k_at(ctx, layer, start_pos + s),
                   k + s * kv_dim, kv_dim * sizeof(float));
            memcpy(kv_cache_v_at(ctx, layer, start_pos + s),
                   v + s * kv_dim, kv_dim * sizeof(float));
        }

        /* Causal attention */
        int total_seq = start_pos + seq_len;
        float *full_k = kv_cache_k_at(ctx, layer, 0);
        float *full_v = kv_cache_v_at(ctx, layer, 0);
        qwen_causal_attention(attn_out, q, full_k, full_v,
                               seq_len, total_seq, n_heads, n_kv_heads,
                               head_dim, scale, start_pos);

source: https://sourcegraph.com/r/github.com/antirez/qwen-asr@main/-/blob/qwen_asr_decoder.c?L319

计算注意力分数

Causal mask — token i only attends to positions ≤ i:
Q at pos 0:   attends to K at [0]
Q at pos 5:   attends to K at [0,1,2,3,4,5]
Q at pos 179: attends to K at [0,1,…,179]

GQA — every 2 Q heads share 1 KV head:
Q heads 0,1   use K/V head 0
Q heads 2,3   use K/V head 1

Q heads 14,15 use K/V head 7

Output: attn_out [180, 2048] (16 heads × 128 dims)

输出attention

x += $a W_o$
a 维度1$\times$2048, $W_o$ 维度 2048$\times$1024

Post-attention RMSNorm

Feed-Forward Network (FFN)

\(\text{FFN}(x)=\text{SwiGLU}(x_{1\times 1024}W_{1024\times 6144})W_{3072\times 1024}\)

SwiGLU:

gate_val = gate_up[2*i]       // even positions = gate
up_val   = gate_up[2*i + 1]   // odd positions = up (content)
gate[i] = SiLU(gate_val) × up_val

最后 x += FFN(x)

activation.png

激活函数比较

N层循环

28 layers, 每层参数不同

5. Decode 13%

首先输入prefill时遗留的最后一个token,decode返回一个token。

/* First token from last prefill position */
float *last_embed = input_embeds + (size_t)prefill_len * dim;
int token = qwen_decoder_forward(ctx, last_embed);
free(input_embeds);

source: https://sourcegraph.com/r/github.com/antirez/qwen-asr@b00b789b17051aea61e9717458171100662318a4/-/blob/qwen_asr.c?L710

Attention

处理单个token,流程与之前类似。
存储本token的kv到kv cache中。计算与之前token的attention,即 causal attention。

FFN

处理单个token,流程与之前类似。

N层循环

处理单个token,流程与之前类似。

输出一个token

最后做一次RMSNorm,与词表(共151936条)中的每个向量(1024维)做点积比较,最大值的词即结果。

/* Final norm + streaming argmax (no logits buffer needed) */
qwen_rms_norm(x, x, dec->norm, 1, dim, eps);
return qwen_argmax_matvec_bf16(x, dec->tok_embeddings_bf16, dim, cfg->vocab_size);

识别到结束,即解码出 QWEN_TOKEN_ENDOFTEXT 或 QWEN_TOKEN_IM_END token,或者达到输出token数目上限结束。

后记

以上就是 LLM ASR 的识别过程,这里只讲了CPU上运行的基本流程,运行在GPU上的优化手段也值得讨论。回顾 2017 年 Transformer 架构推出后,RoPE、在线 Softmax、SwiGLU、GQA 等优化技术相继涌现,有的为了降成本,有的为了提升效果,很多是围绕着大矩阵乘法$QK^T$这个公式的优化,行业研究者不断迭代、丰富架构细节,共同搭建出当下主流的大模型框架。

参考资料:

[1] qwen-asr C语言版 https://github.com/antirez/qwen-asr

[2] Normalization v.s. Standardization https://stats.stackexchange.com/a/10298

[3] Attention Is All You Need https://arxiv.org/abs/1706.03762

[4] vLLM 推理加速 https://km.woa.com/articles/show/659449

[5] CNN kernel 通俗解释 https://www.bilibili.com/video/BV1dbokBXEiv

[6] 苏剑林 Sinusoidal位置编码追根溯源 https://kexue.fm/archives/8231

[7] 苏剑林等 RoPE位置编码 https://arxiv.org/abs/2104.09864

[8] 苏剑林 Attention论文解读 https://kexue.fm/archives/4765

[9] 苏剑林 Transformer位置编码 https://kexue.fm/archives/8130/comment-page-7#comment-29329

[10] GQA (Grouped-query attention) https://arxiv.org/abs/2305.13245