Transformers PEFT库支持的高效微调方法介绍

PEFT简介

自BERT问世后,NLP任务的主流范式变为:预训练语言模型(PLMs)+微调。但是现在PLMs的趋势就是模型参数量越来越大,BERT-base只有0.1B参数,而现在的大模型起步就要6、7B。因此,对大规模PLMs进行微调的成本十分高昂。

参数高效微调(Parameter-Efficient Fine-Tuning,PEFT)是一种微调策略,旨在仅训练少量参数使模型适应到下游任务,在一些场景下甚至不输于全量微调,极大降低了计算和存储成本。

PEFT通过冻结预训练模型的某些层,仅微调特定于下游任务的最后几层来实现这种效率,只对模型的一小部分参数(这部分可能来源于模型自身,也可能是外部引入的)进行训练,在计算资源有限的情况下十分有用。

《Scaling Down to Scale Up: A Guide to Parameter-Efficient Fine-Tuning》:常见的 PEFT 方法分类

如图,论文将PEFT技术大致分为三类:

  1. 引入额外参数(additive):这部分又包括适配器(adapters)和软提示(soft prompts),当然hard prompts也属于这类。要注意的是关于adapter,在PEFT里并没有现成的实现。
  2. 选择部分参数(selective):选择参数进行更新。
  3. 引入重参数(reparametrization-based):最著名的当属LoRa。

微调 & 参数高效微调:

  • 微调是在预训练好的模型上,用新的数据在新的任务上进一步训练,所有参数都会被训练。
  • 参数高效微调是只训练预训练语言模型参数的子集,更新这些关键参数。

下面以一个生成式对话机器人(Bloom模型)为例,比较几个经典PEFT方法下需要被更新的参数量。数据集:https://huggingface.co/datasets/shibing624/alpaca-zh

1
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh")

先估算一下如果要对这个模型进行全量微调所需要额显存,可以看到这个模型参数可达到1.3B(B是billion,十亿),粗略算一下跑整个模型要占用的显存至少需要20个G。

1. BitFit

《BitFit: Simple Parameter-efficient Fine-tuning or Transformer-based Masked Language-models》

BitFit是一种稀疏的微调方法,它选择模型里的 bias 参数进行更新(属于selective类)。实现方式就是将所有非 bias 部分的是否可求导param.requires_grad设置为 False。

1
2
3
4
5
for name, param in model.named_parameters():
if "bias" not in name:
param.requires_grad = False
else:
num_param += param.numel()

可以看到,使用BitFit方法微调涉及到的参数只有54万,大概占总参数的0.04%。

未经过BitFit微调前,模型的推理效果如下,可以看到模型的回答会出现反复的情况。

经过BitFit微调后训练一段时间,每 step 打印 log,可以看到 loss 还是能收敛的。

模型效果也会得到提升。

PEFT库里没有实现BitFit,实现的大致思路是把可训练的部分参数训练完取出来save,在下次加载模型的时候除了加载原模型,还要用save的参数替换原模型的参数。

2. Prompt-Tuning

《The Power of Scale for Parameter-Efficient Prompt Tuning》

Prompt-Tuning会冻结主模型的全部参数,在训练数据前加入一小段 prompt,只训练prompt对应的embedding,然后将 Prompt Embedding 与 Input Embedding 拼接起来一起送入 Transformer Blocks(属于additive类)。prompt 又分为 hard prompt 和 soft prompt。

  • hard prompt:prompt内容是人为定义的自然语言,比如“对输入内容进行文本摘要”。
  • soft prompt:prompt内容是一组可学习的参数,通常是一个小的嵌入向量,在训练过程中不断被优化。
图来自:https://github.com/zyds/transformers-code/

在加载原始模型之后创建 peft model ,主要分为两步:首先构造配置信息,然后根据配置信息创建模型。先导入必要的包:

  • PromptTuningConfigget_peft_model结合使用来加载 peft model;
  • TaskType用于指定任务类型;
  • CAUSAL_LM也就是本次例子使用的模型类型,即Causal Language Model(因果语言模型);
  • PromptTuningInit可以用于控制是 hard prompt 还是 soft prompt 。
1
from peft import PromptTuningConfig, get_peft_model, TaskType, PromptTuningInit

TaskType里还支持很多类型的任务,不过CAUSAL_LM基本支持各类 peft 方法,其他任务不一定。

2.1 soft prompt

先来说 soft prompt 的配置信息,它的 prompt 内容不需要人为指定,是让模型自己去学的,所以用num_virtual_tokens指定prompt长度就可以创建简单的 soft prompt。

1
2
3
4
5
6
# 加载原始 model
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh")
# 构造配置信息
config = PromptTuningConfig(task_type=TaskType.CAUSAL_LM, num_virtual_tokens=10)
# 创建 peft model
model = get_peft_model(model, config)

此时PromptTuningInit未指定,默认是RANDOM随机初始化。

可以看到,在get_peft_model后,模型外套了一个 PeftModel,并且在末尾可以看到一个embedding层维度是10(num_virtual_tokens)×2048,对应的就是 soft prompt。

此时,模型要训练的参数量就是 soft prompt 的 embedding 涉及的参数量,即20480,占总参数的0.001%。

但是因为这种方式涉及的参数太少,loss 可能会降得很慢,可以看到刚开始一直徘徊在3左右,所以需要训练更多的轮数来达到比较好的效果。

训练后加载 peft model,这里需要重新执行一下之前加载原始 model 的单元格,model_id指向我们用Prompt-Tuning方法冻结参数训练后保存的模型路径。

1
2
3
from peft import PeftModel

peft_model = PeftModel.from_pretrained(model=model, model_id="./chatbot/checkpoint-20/")

模型推理效果(如果这一步报错 NoneType,要检查模型和数据是否都在 cpu/gpu 上)。

2.2 hard prompt

再说hard prompt 的配置信息,它的 prompt 内容是人为指定的自然语言,所以这里就需要通过prompt_tuning_init指定初始化方式为TEXT,并prompt_tuning_init_text指定 prompt 内容。此时,num_virtual_tokens的大小就是 prompt 分词后的长度,用tokenizer_name_or_path指定 prompt 使用的分词器。

1
2
3
4
5
6
7
8
9
10
# 加载原始模型
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh")
# 构造配置信息
config = PromptTuningConfig(task_type=TaskType.CAUSAL_LM,
prompt_tuning_init=PromptTuningInit.TEXT,
prompt_tuning_init_text="下面是一段人与机器人的对话。",
num_virtual_tokens=len(tokenizer("下面是一段人与机器人的对话。")["input_ids"]),
tokenizer_name_or_path="Langboat/bloom-1b4-zh")
# 创建 peft model
model = get_peft_model(model, config)

此时,模型的num_virtual_tokens=8,模型要训练的参数量是 8×2048 =16384,占总参数量的0.001%。

可以看到 hard prompt 方式的 loss 下降得更快。

此外,Prompt Tuning 还提出了 Prompt Ensembling,也就是在一个 Batch 里同时训练同一个任务的不同 prompt(即采用多种不同方式询问同一个问题),这样相当于训练了不同模型,比模型集成的成本小很多。

3. P-Tuning

《GPT Understands, Too》

P-Tuning 只支持 soft prompt,它把 Prompt Embedding 变为一个 Prompt Encoder,在 Prompt-Tuning 的基础上对 Prompt 部分进行编码计算,来解决前面 soft prompt 优化太慢的问题,加速收敛。PEFT 中支持两种编码方式,LSTM 或者是 MLP。

图来自:https://github.com/zyds/transformers-code/

相比于之前,这里要用到的包是PromptEncoderConfigPromptEncoderReparameterizationType,如果不指定后者的话,默认是使用 MLP 编码,会有三个全连接层;如果指定是 LSTM 就是 LSTM 加两个全连接层。

1
2
3
4
5
6
7
8
from peft import PromptEncoderConfig, TaskType, get_peft_model, PromptEncoderReparameterizationType

# 加载原始模型
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh")
# 构造配置信息
config = PromptEncoderConfig(task_type=TaskType.CAUSAL_LM, num_virtual_tokens=10)
# 创建 peft model
model = get_peft_model(model, config)

LSTM

LSTM 层可以控制的参数:encoder_dropoutencoder_num_layersencoder_hidden_size

1
2
3
config = PromptEncoderConfig(task_type=TaskType.CAUSAL_LM, num_virtual_tokens=10,
encoder_reparameterization_type=PromptEncoderReparameterizationType.LSTM,
encoder_dropout=0.1, encoder_num_layers=5, encoder_hidden_size=1024)

使用 LSTM 编码涉及到的参数量更大,比重达到9%,很难把显存控制在8G以内。

MLP

MLP 层可以控制的参数:encoder_dropoutencoder_hidden_sizeencoder_num_layers一直为2。

1
2
3
config = PromptEncoderConfig(task_type=TaskType.CAUSAL_LM, num_virtual_tokens=10,
encoder_reparameterization_type=PromptEncoderReparameterizationType.MLP,
encoder_dropout=0.1, encoder_hidden_size=1024)

使用 MLP 编码所涉及到的参数量占总参数量的0.4%。

4. P-Tuning v2

《P-Tuning v2: Prompt Tuning Can Be Comparable to Fine-tuning Universally Across Scales and Tasks》

P-Tuning v2具体做法基本同Prefix-Tuning,可以看作是将文本生成的Prefix-Tuning技术适配到NLU(自然语言理解)任务中,在每一层都加入了Prompts tokens作为输入,而不是仅仅加在输入层,且位置也不局限于前缀,也可以是中间或尾部,可以是离散的token或连续的向量。这带来两个方面的好处:

  • 更多可学习的参数(从P-tuning和Prompt Tuning的0.01%增加到0.1%-3%)。
  • 加入到更深层结构中的Prompt能给模型预测带来更直接的影响。

P-Tuning v2还做了一些改进:

  • 移除了重参数化的编码器(如 Prefix-Tuning 中的 MLP、P-Tuning 中的 LSTM);
  • 针对不同任务采用不同的提示长度;
  • 引入多任务学习;
  • 回归传统的分类标签范式,而不是映射器(verbalizer)。

5. Prefix-Tuning

《Prefix-Tuning: Optimizing Continuous Prompts for Generation》

P-Tuning是训练一个额外的 prompt 编码器来调整模型的输入表示,仅限于输入层;而Prefix-Tuning是将其作为可学习的前缀放在 transformer blocks 的每一层输入中,从而引导模型的计算(属于additive类)。

具体来说,prefix 会影响当前时间步 key 和 value 的计算,计算结果随后会变成后续时间步的 past_key 和 past_values 继续影响后面注意力的计算,引导模型生成更符合特定任务的输出。

past_key_value是生成式模型在处理长序列时提高效率的一个 trick。在解码时模型是根据历史输入预测下一个 token 的,在这个过程中会产生大量重复的计算,因为历史输入是不受后面的词影响的,所以可以将过去计算的 key 和 value 缓存下来,作为 past_key_value 输入到下一次计算中,这一技术又被称为kv_cache

增加 prefix 并不会影响输入序列 \(X\)\({ Q, K, V }\) 计算后的维度。假设 \(X\) 的原始维度为 \(m \times n\)\(m\) 时 token 的数量,\(n\) 是 token 嵌入后向量的长度;增加的 prefix 维度是 \(p\times n\),则最后新的输入序列 \(X'\) 维度变为 \((m+p)\times n\),经过线性变换后得到的 \({ Q, K, V }\) 维度也变为 \((m+p)\times n\),注意力计算的结果维度依旧是 \((m+p)\times n\),与 \(X\) 对应。

关于注意力计算过程详见之前的文章Transform学习笔记

Prefix-Tuning要使用到的包是PrefixTuningConfig

  • num_virtual_tokens:指定可学习前缀的长度;
  • prefix_projection:用于控制全连接层(两层MLP)要不要插入可学习前缀,默认是 False。
1
2
3
4
5
6
7
8
from peft import PrefixTuningConfig, get_peft_model, TaskType

# 加载原始模型
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh")
# 构造配置信息
config = PrefixTuningConfig(task_type=TaskType.CAUSAL_LM, num_virtual_tokens=10, prefix_projection=False)
# 创建 peft model
model = get_peft_model(model, config)

此时模型新增的部分如下图。

涉及的参数量占总参数的 0.07%。

如果默认prefix_projection=False,可以发现训练的时候 loss 收敛的会比较慢,类似 Prompt-Tuning 里的 soft prompt。因此可以设置prefix_projection=True,将重参数层打开,加快 loss 收敛,当然涉及到的参数量也会增加很多,显存占用也会增多。out_features大小可以用参数encoder_hidden_size控制。

要注意的是,针对不同的模型结构,需要构造不同的Prefix。

  • 针对自回归架构模型在句子前面添加前缀,得到 z = [PREFIX; x; y],合适的上文能够在固定 LM 的情况下去引导生成下文(比如:GPT3的上下文学习)。
  • 针对编码器-解码器架构模型Encoder和Decoder都增加了前缀,得到 z = [PREFIX; x; PREFIX0; y]。Encoder端增加前缀是为了引导输入部分的编码,Decoder 端增加前缀是为了引导后续token的生成。

6. LoRA

《LoRA: LOW-RANK ADAPTATION OF LARGE LANGUAGE MODELS》

LoRA的核心思想就是通过低秩分解来模拟参数的改变量,从而以极小的参数量来实现大模型的间接训练(属于重参数类)

在涉及到矩阵相乘的模块,在原始的PLM旁边增加一个新的通路,通过前后两个矩阵 \(A,B\) 相乘,第一个矩阵 \(A\) 负责降维,第二个矩阵 \(B\) 负责升维,中间层维度为 \(r\),即 \(h=W_0x+ΔWx=W_0x+{BA}x\)

可训练层维度和预训练模型层维度一致为 \(d\),先将维度\(d\)通过全连接层降维至 \(r\),再从 \(r\) 通过全连接层映射回 \(d\) 维度,其中 \(r<<d\)\(r\) 是矩阵的秩。这样矩阵计算就从 \(d \times d\) 变为 \(d \times r + r \times d\),减少参数量。

LoRA使用到的包是LoraConfig

  • target_module:指定新增通路的部分,比如默认的模块是['query_key_value']
  • r:指定中间层的维度,默认是8;
  • modules_to_save:指定除了lora微调的部分还想要训练的部分;
  • lora_alpha:缩放因子,控制lora微调参数的权重。

具体参数说明可以看LoraConfig里的说明。

通过正则指定对 module name 中 1 开头的 module 进行微调,同时训练 word_embeddings 部分的参数。

1
2
3
4
5
6
7
8
from peft import LoraConfig, get_peft_model, TaskType

# 加载原始模型
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh")
# 构造配置信息
config = LoraConfig(task_type=TaskType.CAUSAL_LM, target_modules=".*\.1.*query_key_value", modules_to_save=["word_embeddings"])
# 创建 peft model
model = get_peft_model(model, config)

执行成功后的模型信息如图,可以看到 query_key_value 部分包含 loraA 和 loraB两块,word_embedding层也发生了改变。

训练20%左右的推理效果如下。

1
2
3
model = model.cuda()
ipt = tokenizer("Human: {}\n{}".format("考试有哪些技巧?", "").strip() + "\n\nAssistant: ", return_tensors="pt").to(model.device)
tokenizer.decode(model.generate(**ipt, max_length=128, do_sample=True)[0], skip_special_tokens=True)

保存 lora 微调后的模型,并与原始模型合并保存到本地。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

# 加载基础模型
model = AutoModelForCausalLM.from_pretrained("Langboat/bloom-1b4-zh", low_cpu_mem_usage=True)
tokenizer = AutoTokenizer.from_pretrained("Langboat/bloom-1b4-zh")

# 加载 lora 模型
p_model = PeftModel.from_pretrained(model, model_id="./chatbot/checkpoint-500/")
# 模型合并并保存到本地
merge_model = p_model.merge_and_unload()
merge_model.save_pretrained("./chatbot/merge_model")

# 使用模型进行推理
ipt = tokenizer("Human: {}\n{}".format("考试有哪些技巧?", "").strip() + "\n\nAssistant: ", return_tensors="pt")
tokenizer.decode(merge_model.generate(**ipt, max_length=128, do_sample=False)[0], skip_special_tokens=True)

LoRA支持的模型可以再 peft.utils文件中找到,其中TRANSFORMERS_MODELS_TO_LORA_TARGET_MODULES_MAPPING包含了LoRA支持的模型中默认支持LoRA的 module。

如果微调的模型不在默认支持列表里,需要在这里定义一下模型和默认 LoRA 微调的部分。

AdaLoRA

《ADAPTIVE BUDGET ALLOCATION FOR PARAMETEREFFICIENT FINE-TUNING》

AdaLoRA是对LoRA的一种改进,它根据重要性评分动态分配参数预算给权重矩阵。具体做法如下:

  • 调整增量矩分配AdaLoRA将关键的增量矩阵分配高秩以捕捉更精细和任务特定的信息,而将较不重要的矩阵的秩降低,以防止过拟合并节省计算预算。
  • 以奇异值分解的形式对增量更新进行参数化,并根据重要性指标裁剪掉不重要的奇异值,同时保留奇异向量。由于对一个大矩阵进行精确SVD分解的计算消耗非常大,这种方法通过减少它们的参数预算来加速计算,同时,保留未来恢复的可能性并稳定训练。

\[W=W(0)+Δ=W(0)+PΛQ.\]

  • 在训练损失中添加了额外的惩罚项,规范奇异矩阵 \(P\)\(Q\) 的正交性,避免SVD的大量计算并稳定训练。

QLoRA

《QLORA: Efficient Finetuning of Quantized LLMs》

QLoRA使用一种新颖的高精度技术将预训练模型量化为 4 bit,然后添加一小组可学习的低秩适配器权重,这些权重通过量化权重的反向传播梯度进行微调

QLoRA有一种低精度存储数据类型(4 bit),还有一种计算数据类型(BFloat16)。实际上,这意味着无论何时使用 QLoRA 权重张量,我们都会将张量反量化为 BFloat16,然后执行 16 位矩阵乘法。QLoRA提出了两种技术实现高保真 4 bit微调——4 bit NormalFloat(NF4) 量化和双量化。此外,还引入了分页优化器,以防止梯度检查点期间的内存峰值,从而导致内存不足的错误,这些错误在过去使得大型模型难以在单台机器上进行微调。

  • 4bit NormalFloat(NF4):对于正态分布权重而言,一种信息理论上最优的新数据类型,该数据类型对正态分布数据产生比 4 bit整数和 4bit 浮点数更好的实证结果。
  • 双量化:对第一次量化后的那些常量再进行一次量化,减少存储空间。
  • 分页优化器:使用NVIDIA统一内存特性,该特性可以在在GPU偶尔OOM的情况下,进行CPU和GPU之间自动分页到分页的传输,以实现无错误的 GPU 处理。该功能的工作方式类似于 CPU 内存和磁盘之间的常规内存分页。使用此功能为优化器状态(Optimizer)分配分页内存,然后在 GPU 内存不足时将其自动卸载到 CPU 内存,并在优化器更新步骤需要时将其加载回 GPU 内存。

7. IA3

《Few-Shot Parameter-Efficient Fine-Tuning is Better and Cheaper than In-Context Learning》

IA3是对模型的一些激活层进行抑制或放大,也就是通过点乘一个可学习向量的形式对模型的一部分参数进行加权。可以看到,IA3的参数分为两部分你,一部分是 \(l_V\)\(l_K\),一部分是 FFN 部分的 \(l_{ff}\)

这里要用到的包是IA3Config,其中target_modulesmodules_to_save的作用与 LoRA 一样。

  • target_modules:IA3的这部分包含两块,{'query_key_value', 'mlp.dense_4h_to_h'};
  • feedforward_modules:用于指定target_modules中哪块是 feedforward 层。

如果是feedforward层,可学习向量与feedforward的输入相乘,再进入 linear;

如果不是,可学习向量与注意力块的输出result相乘

涉及的参数量占总参数的 0.02%。

原论文中提到IA3的最佳学习率是3e-3

Adapter-Tuning

《Parameter-Efficient Transfer Learning for NLP》

预训练模型参数量越来越多,在训练下游任务时进行全量微调变得昂贵且耗时。基于此,作者提出了Adapter在预训练模型每层中插入用于下游任务的参数(针对每个下游任务,仅增加3.6%的参数),在微调时将模型主体冻结,仅训练特定于任务的参数,从而减少了训练时的算力开销。

该方法设计了Adapter结构,并将其嵌入Transformer的结构里面,针对每一个Transformer层,增加了两个Adapter结构(分别是多头注意力的投影之后和第二个feed-forward层之后)在训练时,固定住原来预训练模型的参数不变,只对新增的 Adapter 结构和 Layer Norm 层进行微调,从而保证了训练的高效性

每当出现新的下游任务,通过添加 Adapter 模块来产生一个易于扩展的下游模型,从而避免全量微调与灾难性遗忘的问题。

Adapter Fusion

《AdapterFusion:Non-Destructive Task Composition for Transfer Learning》

Adapter Fusion一种融合多任务信息的Adapter的变体,在Adapter的基础上进行优化,通过将学习过程分为两阶段来提升下游任务表现。

  • 知识提取阶段:在不同任务下引入各自的 Adapter 模块,用于学习特定任务的信息。
  • 知识组合阶段:将预训练模型参数与特定于任务的 Adapter 参数固定,引入新参数(AdapterFusion)来学习组合多个Adapter中的知识,以提高模型在目标任务中的表现

通过AdapterFusion,模型可以为不同的任务对应的adapter分配不同的权重,聚合N个任务的信息,从而为特定任务输出更合适的结果。通过将适配器的训练分为知识提取和知识组合两部分,解决了灾难性遗忘、任务间干扰和训练不稳定的问题。但是,Adapter模块的添加也导致模型整体参数量的增加,降低了模型推理时的性能。

AdapterDrop

《AdapterDrop: On the Efficiency of Adapters in Transformers》

作者通过对Adapter的计算效率进行分析,发现与全量微调相比,Adapter在训练时快60%,但是在推理时慢4%-6%。基于此,作者提出了AdapterDrop方法缓解该问题。

AdapterDrop在不影响任务性能的情况下,对Adapter动态高效的移除,尽可能的减少模型的参数量,提高模型在反向传播(训练)和正向传播(推理)时的效率

AdapterDrop通过从较低的 Transformer 层删除可变数量的Adaper来提升推理速度。 当对多个任务执行推理时,动态地减少了运行时的计算开销,并在很大程度上保持了任务性能。

UniPELT

《UNIPELT: A Unified Framework for Parameter-Efficient Language Model Tuning》

近年来,涌现出了许多针对语言模型的参数高效微调方法,在模型训练参数极大的减少的情况下,模型效果与全量微调相当。但是不同的PELT方法在同一个任务上表现差异可能都非常大,这让针对特定任务选择合适的方法非常繁琐。

基于此,作者提出了UniPELT方法,将不同的PELT方法作为子模块,并通过门控机制学习激活最适合当前数据或任务的方法。UniPELT是 LoRA、Prefix Tuning和Adapter的门控组合。

更具体地说,LoRA 重参数化用于 \(W_Q\)\(W_V\) 注意力矩阵,Prefix Tuning应用于每一 Transformer 层的 key 和 value,并在 Transformer 块的 feed-forward 子层之后添加Adapter

  • 对于每个模块,门控被实现为线性层,通过 \(G_P\) 参数控制Prefix-tuning方法的开关,\(G_L\) 控制LoRA方法的开关,\(G_A\) 控制Adapter方法的开关。
  • 可训练参数包括 LoRA 矩阵 \(W_{A/Down}\)\(W_{B/up}\),提示调优参数 \(P_K\)\(P_V\)、Adapter参数和门函数权重。即图中蓝颜色的参数为可学习的参数。

本方法始终优于常规的全量微调以及它在不同设置下包含的子模块,通常超过在每个任务中单独使用每个子模块的最佳性能的上限;并且,通过研究结果表明,多种 PELT 方法的混合涉及到PLM 的不同部分可能对模型有效性和鲁棒性都有好处。

总结

PEFT方法总结

Reference:

【HuggingFace Transformers-实战篇】参数高效微调PPT

A Guide to Parameter-Efficient Fine-Tuning

llm面试:有监督微调


Transformers PEFT库支持的高效微调方法介绍
https://jiangcara.github.io/posts/9cdded66/
作者
Jiang Cara
发布于
2024年10月21日
许可协议