从 Tokenizer 到 Next Token,穿透数据流,完整理解 Transformer 底层逻辑

很多人啃大模型技术的时候都会陷入同一种困境,单独看每个模块都能看懂,背得出缩放点积注意力公式,能说出多头注意力、残差连接、层归一化这些名词,但只要把整个链路串起来,就会出现断层。如果我们抛出一个简单的问题,把一句提示词送入大模型,从文字输入,到模型输出下一个汉字,中间张量到底经历了怎样完整的变换过程,很多人依然无法完整复现整条计算链路。

市面上绝大多数教程都习惯先抛出架构大图,把所有组件一次性摊开,然后逐个拆解名词。这种学习方式最大的弊端,就是把一套连续运行的计算系统切割成一堆孤立知识点。我们记住了名词,却不知道每个模块在数据流里承担的职责,不清楚张量维度在每一步如何变化,更无法理解为什么这些组件要按照当前的顺序组合在一起。

这篇文章将换一种思路,我们全程跟随一句文本,顺着真实数据流往前走,从原始自然语言文本切入,走完分词、向量映射、位置编码、多层Transformer Block迭代,直到最后输出概率分布采样出下一个token。我们不仅会覆盖基础原理,还会进一步深挖很多入门教程刻意回避的工程细节,比如BPE训练过程的边界问题、RoPE相对位置编码替代正弦位置编码的底层原因、GQA分组注意力的设计动机、RMSNorm与LayerNorm的差异,以及预训练阶段并行计算与自回归推理之间的矛盾。所有原理都会绑定张量形状变化来讲解,搭配伪代码片段,方便后续阅读开源模型源码。

模型接收不到文字,它只能处理离散数字与张量

人类阅读"中国的首都是",大脑直接理解句子语义、主谓关系与背后的事实知识。但神经网络本质只是大量矩阵乘法与非线性函数堆叠而成的计算图,它没有办法直接读取汉字、字母、标点符号。所有文本输入,第一步必须完成符号到数字的转换,这个转换单元就是Tokenizer。

很多初学者会下意识把token等同于单词或者单个汉字,这是最常见的认知误区。token是模型词表里面最小的离散处理单元,它可以是单个汉字,一个完整词语,单词的词根片段,标点符号,空格,数字片段,甚至是代码里的括号、运算符。

举个中文分词示例:

text 复制代码
原始文本:中国的首都是
BPE分词结果:["中国", "的", "首都", "是"]
Token ID序列:[3421, 125, 8745, 296]

看到这个例子,我们自然会产生疑问,为什么不直接按单个汉字切分,变成"中","国","的","首","都","是"?如果拆分成单字,词表构建简单,逻辑直观,但是会带来严重的计算开销问题。假设一句话拆得更碎,token序列长度L就会变长。自注意力的时间复杂度是O(L2)O(L^2)O(L2),序列长度翻倍,注意力计算量会变成原来四倍。长序列下显存占用、矩阵乘法耗时都会急剧膨胀。

反过来,我们也不能无限扩大token的粒度,直接把所有词语、短语全部放进词表。现实世界的文本组合几乎是无限的,不断涌现新词汇、专业术语、网络名词。如果词表收录每一个独立短语,词表规模会膨胀到千万甚至上亿级别,Embedding矩阵的参数量会大到无法存储,训练成本完全不可接受。

现代大模型普遍采用BPE字节对编码,也就是Subword子词分词方案。这套方案的核心思路很朴素,统计训练语料里面相邻字符的共现频率,不断合并高频相邻单元,低频词汇保留拆分能力。

BPE的训练过程并不是学习语义,它只做字符层面的统计。初始状态下,训练语料全部拆分为单个字节或者单个字符,之后反复执行下面流程:

python 复制代码
# BPE训练简化伪代码
def train_bpe(corpus, vocab_size_target):
    vocab = init_vocab_by_chars(corpus)
    while len(vocab) < vocab_size_target:
        # 统计所有相邻字符对的出现频次
        pair_counts = count_adjacent_pairs(corpus)
        best_pair = max(pair_counts, key=pair_counts.get)
        # 合并频次最高字符对,加入词表
        corpus = merge_all_occur(corpus, best_pair)
        vocab.add(best_pair)
    return vocab

在中文语料里面,大量句子反复出现"人工"这个相邻字符组合,BPE训练时就会把"人"和"工"合并为一个token。后续训练又发现"智能"高频共现,继续合并。最终词表里面会同时保留"人""工""智""能""人工""智能""人工智能"多个条目。很多人看到这个结果会误以为BPE读懂了词语含义。这里必须澄清,BPE没有任何语义理解能力,它仅仅捕捉字符串共现统计规律。只是人类语言本身存在规律,稳定概念通常对应稳定的文字表达,高频短语才会被合并为子词token。遇到生僻新词时,BPE会自动拆分为更小的基础单元完成编码,保证任何输入文本都可以被词表覆盖,不会出现未知token。

Tokenizer输出的Token ID只是一串没有大小关系的编号。ID等于3421的"中国",并不代表它的权重高于ID等于125的"的"。单纯依靠整数编号无法送入神经网络做向量运算,我们需要Embedding层完成第二步转换,把离散ID映射为连续高维向量。

Embedding层:可训练的巨型查表矩阵,语义在训练过程中逐步生长

Embedding本质就是一个巨大的二维可训练矩阵,矩阵维度为[vocab_size, hidden_dim]。vocab_size代表词表总token数量,hidden_dim是模型主干隐藏层维度。比如一个词表10万,隐藏层维度4096的模型,Embedding矩阵形状就是[100000, 4096]。

前向过程的逻辑就是查表,输入token ID,取出矩阵对应行向量,作为这个token的初始向量表示。

python 复制代码
# Embedding前向简化伪代码
embedding_matrix = torch.nn.Embedding(vocab_size, hidden_dim)
token_ids = torch.tensor([3421, 125, 8745, 296])
token_embeds = embedding_matrix(token_ids)
print(token_embeds.shape) # [seq_len, hidden_dim]

模型初始化阶段,Embedding矩阵全部用随机值填充。此时向量没有任何语义信息,"猫"和"狗"对应的向量没有任何关联。经过海量文本预训练之后,模型在学习预测下一个token的任务中不断反向传播更新Embedding矩阵参数。当"猫""狗"大量出现在相似上下文,比如"养了一只X""X是宠物",两个token对应的向量会在高维空间慢慢靠近。

这里有一个非常关键,却经常被忽略的知识点。Embedding向量只是token最基础的初始表征,丰富复杂的语义并不是全部存储在Embedding矩阵之中。随着文本向量逐层经过Transformer Block,每一层都会根据上下文动态调整hidden state,深层隐藏向量承载的上下文语义,远比原始Embedding丰富。同一个token,放在完全不同的句子里,经过多层变换之后,最终的hidden state会差异巨大,这就是上下文嵌入的核心思想。

当我们一次性处理多条样本,就会引入Batch维度,也就是大模型源码中随处可见的张量形状[B, L, D]。

  • B:batch size,单次并行处理样本数量
  • L:sequence length,单条样本token序列长度
  • D:hidden dimension,隐藏层向量维度

举个实例,batch size等于8,每条序列512个token,隐藏维度4096,张量形状就是[8, 512, 4096]。从Tokenizer输出的[B, L] token id矩阵经过Embedding查表之后,直接转换为[B, L, D]张量,后续Transformer全部计算都围绕这个三维张量展开。

位置编码:自注意力本身没有时序概念,必须手动注入位置信息

自注意力机制在计算token之间关联的时候,只依靠向量之间的点积,它本身无法感知token的先后顺序。两组完全相同的token集合,调换顺序之后,原始token向量完全一致。比如"我喜欢你"和"你喜欢我",分词之后token集合完全一样,如果不增加位置信息,模型会判定两个句子等价,语义完全丢失。

原始Transformer论文使用正弦余弦位置编码,也就是Sinusoidal Positional Encoding。它的设计思路是使用多组不同频率的正弦余弦函数,为序列每一个位置生成专属向量。可以类比钟表,不同频率波形组合,就可以唯一标记序列位置。低频分量捕捉长距离位置差异,高频分量区分相邻token。

正弦位置编码有一个优势,支持外推,训练时没有见过的更长位置,依然可以通过三角函数公式计算位置向量。但现在主流开源模型LLaMA、Qwen都放弃了原始正弦位置编码,转而采用RoPE旋转位置编码。这是工程演进里面很重要的一步,很多入门教程不会深入解释。

RoPE不再直接把位置向量加到token embedding上,而是在计算Q、K向量的时候,对向量做复数空间旋转操作。位置信息直接编码进QK点积的计算过程。两个token的相对距离,直接体现在点积结果里面。RoPE天然建模相对位置,长文本外推能力更好,在长上下文场景下衰减更加平缓,这也是现代大模型普遍选用RoPE的根本原因。

位置信息和token embedding相加之后,我们得到融合位置信息的输入张量X,形状依旧保持[B, L, D]。接下来就要进入自注意力模块,生成Q、K、V三组向量。

QKV投影与缩放点积注意力,模型学习的是关系匹配,不是单纯语义相似

很多初学者死记硬背QKV定义,却不理解为什么要把同一个输入张量投影成三组向量。我们可以用一句话概括三者职责:

Q是当前token提出的查询,代表我想要寻找什么信息;

K是所有token对外暴露的检索索引,代表别人凭借什么条件匹配到我;

V是匹配成功之后,最终要传递出去的内容信息。

Q、K、V全部来自输入张量X,通过三组独立可训练线性投影矩阵得到。并不是三组不同的输入,只是同一个token表征映射到三个独立特征空间。

python 复制代码
# QKV线性投影伪代码
Wq = nn.Linear(D, D)
Wk = nn.Linear(D, D)
Wv = nn.Linear(D, D)
Q = Wq(X)
K = Wk(X)
V = Wv(X)

注意力打分核心是Q和K做点积,衡量Q向量与每个K向量的匹配程度。向量方向越接近,点积数值越大。但要明白,这种匹配不是向量天然自带的属性,是模型在海量预训练数据中训练出来的。模型不断调整Wq、Wk参数,让存在强关联关系的token对应的QK点积更高。

这里还有一个极易踩坑的认知误区,注意力学习的匹配关系不等于语义相似。举个例子,句子"小明吃苹果",token"吃"对应的Q向量,会和"苹果"的K向量产生很高点积。"吃"和"苹果"并不是近义词,二者是谓语和宾语的句法关系。注意力头学到的是token之间的关联关系,可以是主谓、动宾、时间修饰、指代等等,词语同义只是众多关系类型里面的其中一种。

得到QK点积分数之后,需要除以dk\sqrt{d_k}dk ,也就是缩放操作,对应缩放点积注意力里面的Scaled。dkd_kdk是单个Q/K向量维度。高维向量点积求和之后,数值方差会随着维度变大而膨胀。数值过大送入softmax函数,输出分布会极度尖锐,权重集中在极少数位置,梯度消失,训练难以收敛。缩放操作用来稳定数值分布,保证训练稳定。

Softmax把打分转换成0到1之间的权重,所有权重总和等于1。权重代表当前token分配给上下文每个token的信息关注度。权重再和V向量加权求和,得到自注意力输出。整个过程分为两步,QK负责检索匹配,确定关注哪些token,V负责传递信息,检索完成之后提取对应内容。可以类比数据库检索,Q是检索语句,K是数据表索引,V是数据库存储的真实数据。

Causal Mask因果掩码,在并行训练下实现自回归约束

GPT这类Decoder-only架构模型,核心任务是自回归文本生成,预测当前位置之后的下一个token。训练阶段,我们送入完整句子,不能让预测位置看到未来token,不然答案直接泄露,模型无法学到有效预测能力。Causal Mask就是用来屏蔽未来位置的机制。

掩码矩阵是上三角矩阵,未来位置填充负无穷。负无穷送入softmax之后,对应的注意力权重归零。

python 复制代码
# Causal Mask构建伪代码,下三角保留,上三角屏蔽
seq_len = 5
mask = torch.triu(torch.full((seq_len, seq_len), float("-inf")), diagonal=1)

以序列中国 的 首都 是 北京举例,位置4的token"是",只能看见位置0到位置4,不能看到位置5的"北京"。每一个位置只能访问自身以及前文token。

Causal Mask最精妙的工程价值,是实现并行训练。传统RNN类序列模型,必须按时间顺序逐个计算,无法并行。而Transformer训练时一次性送入完整长序列,依靠掩码屏蔽未来信息,一次性计算全部位置的预测损失。单轮前向传播,同时完成多个预测任务,训练效率得到数量级提升。这也是Transformer能够支撑超大模型预训练的关键设计。

多头注意力与GQA演进:多组独立特征空间建模多样化关系

单一注意力头只能学习一套注意力权重分布,但自然语言一句话里面存在多种类型关系。比如一句话"小明昨天在学校吃了一个苹果",动词"吃"需要同时关联主语小明,宾语苹果,时间状语昨天,地点学校。单头注意力无法同时建模多种独立关系。

多头注意力将QKV切分成多个头,每个头拥有独立的投影子空间,各自独立计算注意力。每个头会自发学习不同类型token关联模式,有的头偏向句法主谓关系,有的头捕捉指代,有的头擅长长距离依赖。模型不会人为指定每个头负责哪一类任务,所有模式都是在预训练过程中自发涌现。

多头计算完成之后,把每个头输出向量拼接在一起,再通过线性层融合,把多个独立空间提取的信息合并回主干维度。

python 复制代码
# 多头注意力维度变化示例
# Q shape [B, L, D] -> split to heads [B, H, L, Dh]
# H=32头,D=4096,Dh=128
Q = Q.reshape(B, L, H, Dh).transpose(1,2)
# 每个头独立计算注意力
attn_out = scaled_dot_product_attention(Q,K,V,mask)
# 合并多头结果
attn_out = attn_out.transpose(1,2).reshape(B, L, D)

当模型规模持续扩大,头数量增加,K、V矩阵存储开销会快速上涨。GQA分组查询注意力就是在多头基础上做的工程优化,用来平衡推理显存开销与模型能力。GQA把多个Q头共享同一组K、V,相比标准MHA多头注意力,大幅降低KV缓存占用,现在LLaMA2、Qwen等主流大模型都采用GQA。很多学习资料只介绍MHA,忽略GQA,而阅读现代模型源码一定会遇到这个模块。GQA不是推翻自注意力底层原理,只是一种兼顾性能与显存的工程变体。

残差连接、归一化,让几十层深度网络稳定训练

自注意力输出之后,并不会直接替换原始输入向量,而是残差连接,输入X加上注意力模块输出。残差的核心思想,不是每一层彻底替换原有表征,而是在原有信息基础上增量修正。每一个Transformer Block,都是在前一层的hidden state之上补充新提取的上下文信息。

残差连接的梯度特性是它能够支撑深层网络堆叠的根本。梯度回传时存在一条恒等映射通路,即使中间注意力与FFN分支梯度衰减,梯度依然可以沿着残差主干顺利回传,解决深度网络梯度消失问题。

经过矩阵乘法、非线性变换之后,张量数值分布会持续漂移。层数堆叠越多,数值偏移越严重,模型训练难以收敛。归一化模块负责稳定每一个token内部特征分布。原始Transformer使用LayerNorm,对单个token对应的D维向量做归一化,减去均值除以标准差,再引入可学习缩放参数γ和平移参数β。归一化不是强制把特征固定为固定分布,先做标准化稳定尺度,再允许模型学习调整分布。

现在很多大模型改用RMSNorm,RMSNorm省去均值计算,只做均方根归一化,计算量更少,数值稳定性同样优秀。这也是工程迭代的重要变化,RMSNorm在LLaMA系列模型中广泛使用。

现代Decoder-only模型普遍采用Pre-Norm结构,也就是先归一化,再送入注意力和FFN模块,和原始论文Post-Norm顺序不一样。Pre-Norm训练稳定性更好,深层模型更容易收敛,这也是阅读源码必须留意的架构差异。

FFN前馈网络:token内部非线性特征加工,和注意力形成分工

注意力模块负责横向信息交换,token之间互相读取上下文信息。FFN也就是MLP前馈网络,负责单个token向量内部的特征变换,不跨token交互。二者分工明确,注意力做上下文信息收集,FFN做特征空间非线性加工。

标准FFN结构先升维,再激活函数非线性变换,最后降维回到主干hidden维度。常见配置是升维到4倍hidden维度。

python 复制代码
# FFN简化伪代码
class FFN(nn.Module):
    def __init__(self, hidden_dim, expand_factor=4):
        super().__init__()
        up_dim = hidden_dim * expand_factor
        self.w1 = nn.Linear(hidden_dim, up_dim)
        self.w2 = nn.Linear(up_dim, hidden_dim)
        self.act = nn.SiLU()
    def forward(self, x):
        return self.w2(self.act(self.w1(x)))

升维操作,可以理解为把向量映射到更高维特征空间,在高维空间里面组合生成更复杂特征,之后再压缩回原始维度。很多人会疑惑,已经有注意力提取上下文,为什么还需要FFN。如果去掉FFN,模型只能做token之间线性加权,无法构建复杂非线性表征,模型表达能力会严重不足。

注意力模块加上FFN,搭配残差与归一化,共同组成一个完整Transformer Block。Pre-Norm结构的Block计算顺序:

  1. 对输入hidden state执行LayerNorm/RMSNorm
  2. 送入多头自注意力,计算上下文信息
  3. 残差相加,保留原有表征
  4. 再次归一化
  5. 送入FFN,做单token非线性特征加工
  6. 再次残差相加,输出Block结果

Block输出张量形状依旧保持[B, L, D],输入输出维度不变,所以我们可以把Block反复堆叠几十层。每一层都会重新检索上下文关联关系,更新hidden state。浅层更多学习基础语法、局部词语关联,深层构建长距离依赖、事实知识、逻辑关系。经过多层迭代之后,最开始简单的Embedding向量,转变为高度上下文相关的hidden state。

LM Head与Softmax,从隐藏向量映射回词表概率分布

多层Transformer Block计算完成,最后一层输出hidden state,依旧是[B, L, D]张量。这个向量只是高维空间表征,不是可读的文字。我们需要LM Head,把hidden state映射到词表维度,得到每个token对应的Logit分数。

LM Head本质就是线性层,输入维度等于hidden_dim,输出维度等于词表大小vocab_size。

python 复制代码
# LM Head 伪代码
lm_head = nn.Linear(hidden_dim, vocab_size, bias=False)
logits = lm_head(last_hidden_state)
print(logits.shape) # [B, L, vocab_size]

Logit只是原始分数,不是概率,数值范围不受限制。Softmax函数将Logit转换为概率分布,所有概率相加等于1。在"中国的首都是"这个例子中,最后位置对应的概率分布里面,token"北京"概率最高。

推理阶段,我们根据概率分布采样下一个token。最简单策略是贪心采样,直接选取概率最高的token。还有top-k、top-p核采样等策略,用来增加生成文本多样性。拿到token ID之后,送入Tokenizer解码器,把ID还原成人类可读文字"北京"。

自回归生成循环与KV缓存,推理阶段的核心工程优化

得到新生成token之后,模型并不会一次性重新编码全部文本。新token追加到上下文序列末尾,进入下一轮预测,继续预测下一个token,这个过程就是自回归生成Autoregressive。

原始 naive 推理方式,每新增一个token,重新完整计算全部历史token的注意力。序列越长,重复计算开销越大。KV缓存就是为了解决这个问题,推理时把历史token计算好的K、V向量保存下来,新token只需要计算自身Q向量,和缓存好的历史K、V做注意力计算,不再重复计算过往token的K和V。KV缓存是大模型推理加速最核心手段,所有线上部署模型都会启用KV缓存。

训练阶段和推理阶段的计算模式存在巨大差异。训练时一次性输入完整序列,并行计算全部位置损失,不需要KV缓存。推理时只能逐个token生成,依靠KV缓存降低计算成本。很多初学者混淆训练和推理两套计算模式,会产生很多理解障碍。

预训练损失函数,整个模型的学习目标

预训练任务目标非常简单,最大化文本序列的联合概率。把完整文本拆解为条件概率乘积,给定前面所有token,预测下一个token。训练的时候构造输入序列与目标序列,输入是中国 的 首都 是,对应的目标序列是的 首都 是 北京。依靠Causal Mask并行算出所有位置预测logit,和真实目标token做交叉熵损失Cross Entropy Loss。

损失反向传播,更新模型全部参数,Embedding、QKV投影矩阵、多头融合矩阵、FFN权重、归一化参数、LM Head全部参与更新。海量文本反复迭代优化参数,模型慢慢学会语言规律、事实、逻辑等能力。

很多人会产生疑问,模型仅仅学习预测下一个token,为什么能涌现推理、写作、代码能力。本质上自然语言文本本身就编码大量世界知识、逻辑规则。当模型拟合海量文本的下一个token分布,就必须学习语法、事实、因果推理,才能精准预测后续文字。这也是大模型能力涌现的底层来源。

完整张量形状链路复盘,阅读源码前必须吃透这条主线

当我们打开LLaMA、Qwen的PyTorch源码,全程跟着张量形状流动阅读,理解难度会大幅下降。下面给出完整的张量变换链路,示例参数B=8,L=512,hidden_dim=4096,头数H=32,单头维度Dh=128,词表大小100000。

text 复制代码
Input IDs张量                [8, 512]
Embedding查表+位置编码融合    [8, 512, 4096]
QKV线性投影                  [8, 512, 4096]
拆分多头                     Q/K/V -> [8, 32, 512, 128]
注意力分数矩阵               [8, 32, 512, 512]
注意力加权求和输出           [8, 32, 512, 128]
多头合并还原主干维度         [8, 512, 4096]
残差+归一化+FFN升维          [8, 512, 16384]
FFN降维输出                  [8, 512, 4096]
多层Block循环之后得到最终隐藏态 [8, 512, 4096]
LM Head映射到词表维度        [8, 512, 100000]
Softmax得到token概率分布     [8, 512, 100000]

这条链路贯穿整个模型,所有模块操作都只是对张量形状与数值做变换。读懂张量流动,就能跳出名词背诵式学习。

Transformer的持续演进,所有变体都建立在主干框架之上

市面上各种大模型变体,并不是推翻Transformer基础架构,而是在主干基础上做针对性优化。RoPE替代正弦位置编码、GQA优化KV开销、RMSNorm简化归一化、SwiGLU替换传统激活函数,这些都是组件层面改进。还有像Mamba这类结构化状态空间模型,属于全新架构,但当前主流商用大模型依旧基于Decoder-only Transformer。

当你打通从Tokenizer到Next Token完整数据流之后,再去看这些优化方案,就不会把它们当成零散知识点。每一项优化,都可以找到它在整条计算链路里面对应的位置,理解优化要解决的痛点。比如长上下文场景下,会遇到注意力O(L2)O(L^2)O(L2)复杂度瓶颈,于是出现滑动窗口注意力、稀疏注意力等方案,所有优化目标都是降低长序列计算开销。

写在最后

学习Transformer最忌讳的路径,就是一开始死磕公式,孤立记忆每个模块定义,之后再拼凑整个系统。这种学习方式很容易造成"看得懂名词,跑不通数据流"的困境。

更好的学习思路,始终盯住一条主线,一句原始文本送入模型之后,张量一步一步发生了哪些变换,每一步计算的目的是什么,为什么需要这个模块,如果删掉这个组件,模型会出现什么问题。持续带着数据流视角思考,Transformer就不再是一堆孤立数学公式,而是一套连续运行,端到端优化的计算系统。

很多人高估了Transformer底层的复杂度,也低估了数据流串联理解的价值。公式只是工具,真正的核心是理解整套系统如何把人类语言转换为向量,在多层网络里面交换上下文信息,加工特征,最后还原成文字预测。只要这条链路在脑海里面完整跑通,后续阅读开源模型代码,研究各类模型变体,都会顺畅很多。

相关推荐
道尔柯南9 小时前
C++ string 类详解:从常用接口到深拷贝,彻底掌握字符串处理
android·java·c++
唐诺10 小时前
5、Android JNI 动态注册中的“签名“详解
android·jni
zhaoshuzhaoshu13 小时前
Android 系统启动与调试中的日志抓取方式全解析
android
在所不辞兄13 小时前
为什么学习率预热(warmup)能显著改善Transformer等大模型的训练稳定性?
人工智能·深度学习·神经网络·机器学习·智能仿真
雨声不在14 小时前
android 动画掉帧
android
在多学一点15 小时前
MYSQL第二次作业
android·数据库·mysql
武子康15 小时前
语音聊了十分钟,为什么不能拿转写分钟算费用?
人工智能·深度学习·agent
骑着蜗牛撵大象32715 小时前
MySQL 8.0 迁移后 SQL 变慢:执行计划里 3 类被忽略的成本差异
android·数据库·sql·mysql·优化器·执行计划
武天旭16 小时前
AI安全--AI大模型的隐私保护解析
人工智能·深度学习·安全·机器学习
我叫张土豆16 小时前
Android 14+ 模拟器抓包实录:把 Burp CA 装成系统 CA,让 targetSdk≥34 的 App 乖乖解密
android