在自定義數(shù)據(jù)集上使用QLoRA微調(diào)大型語(yǔ)言模型(LLM)
微调大语言模型
自然语言处理领域通过大型语言模型(LLMs)发生了革命性的变化,这些模型展示了先进的能力和复杂的解决方案。这些模型在文本生成、翻译、摘要和问答等任务中表现出色,它们是在大规模文本数据集上训练的。尽管这些模型非常强大,但它们并不总是与特定任务或领域完全契合。
在本教程中,我们将探讨如何通过微调大语言模型(LLM)显著提升模型性能、降低训练成本并实现更准确和特定于上下文的结果。
什么是LLM微调?微调大语言模型(LLM)涉及对一个已经从大规模数据集中学习到模式和特征的现有模型进行额外训练,使用一个较小的、特定领域的数据集。在“LLM微调”的背景下,LLM指的是像OpenAI的GPT系列这样的“大语言模型”。这种方法很重要,因为从头开始训练一个大语言模型在计算资源和时间方面都非常耗费。利用预训练模型中嵌入的知识,可以在特定任务上实现高性能,同时大幅减少数据和计算需求。
以下是一些涉及大规模语言模型微调的关键步骤:
- 选择一个预训练模型:对于大规模语言模型的微调,第一步是仔细选择一个与我们所需架构和功能相匹配的基础预训练模型。预训练模型是在大量未标记数据上训练的通用模型。
- 收集相关数据集:然后我们需要收集一个与我们的任务相关的数据集。数据集应该被标记或以一种模型可以从中学习的方式来结构化。
- 预处理数据集:一旦数据集准备好,我们需要对其进行一些预处理,以便进行微调,包括清理数据集、将其拆分为训练集、验证集和测试集,并确保它与我们想要微调的模型兼容。
- 微调:在选择了一个预训练模型后,我们需要在我们预处理的相关数据集上对其进行微调,该数据集更具体地针对手头的任务。我们选择的数据集可能与特定领域或应用相关,使模型能够适应并专门化于该上下文。
- 任务特定的适应:在微调过程中,模型的参数会根据新数据集进行调整,帮助模型更好地理解和生成与特定任务相关的文本。这一过程保留了预训练期间获得的一般语言知识,同时使模型适应目标领域的细微差别。
微调大语言模型常用于自然语言处理任务,如情感分析、命名实体识别、摘要生成、翻译或其他任何需要理解上下文并生成连贯语言的应用。它有助于利用预训练模型中编码的知识,以完成更专业和特定领域的任务。
微调方法微调大型语言模型(LLM)涉及一种监督学习过程。在此方法中,使用包含标注样本的数据集来调整模型的权重,从而增强其在特定任务上的表现。现在,让我们来探讨一些在微调过程中使用的重要技术。
- 全量微调(指令微调):指令微调是一种通过在引导模型响应查询的示例上训练来提高模型在各种任务中表现的策略。数据集的选择至关重要,并且要根据具体任务(如摘要或翻译)进行定制。这种方法被称为全量微调,它会更新所有模型权重,创建一个功能更强大的新版本。然而,它需要足够的内存和计算资源,类似于预训练,以处理训练期间的梯度、优化器和其他组件的存储和处理。
- 参数高效微调(PEFT) 是一种比全量微调更高效的指令微调形式。训练语言模型,特别是进行全量大语言模型微调,需要大量的计算资源。内存不仅需要用于存储模型,还需要用于训练期间的必要参数,这对简单硬件来说是一个挑战。PEFT通过仅更新部分参数来解决这个问题,有效地“冻结”其余参数。这减少了可训练参数的数量,使内存需求更加可控,并防止灾难性遗忘。与全量微调不同,PEFT保留了原始大语言模型的权重,避免了先前学习信息的丢失。这种方法在处理多任务微调的存储问题时非常有益。实现参数高效微调有多种方式,低秩适应 LoRA 和 QLoRA 是最广泛使用且有效的。
LoRA 是一种改进的微调方法,与微调构成预训练大型语言模型的所有权重不同,LoRA 只微调两个较小的矩阵,这两个矩阵近似于较大的矩阵。这两个矩阵构成了 LoRA 适配器。然后将这个微调后的适配器加载到预训练模型中,并用于推理。
在对特定任务或用例进行LoRA微调后,结果是原始大语言模型(LLM)保持不变,而出现了一个相对较小的“LoRA适配器”,通常只占原始LLM大小的个位数百分比(以MB而不是GB为单位)。
在推理过程中,LoRA适配器必须与其原始的LLM结合使用。其优势在于许多LoRA适配器可以重用原始的LLM,从而在处理多个任务和用例时减少总体内存需求。
什么是量化LoRA(QLoRA)?QLoRA 代表一种更节省内存的 LoRA 迭代。QLoRA 通过将 LoRA 的适配器权重(较小的矩阵)量化到更低的精度(例如,从 8 位量化到 4 位)进一步改进了 LoRA。这进一步减少了内存占用和存储需求。在 QLoRA 中,预训练模型使用量化为 4 位的权重加载到 GPU 内存中,而 LoRA 使用的是 8 位。尽管位精度有所降低,但 QLoRA 仍保持了与 LoRA 相当的有效性。
在本教程中,我们将使用 QLoRA 参数高效微调。
现在让我们来探索如何在单个GPU上使用QLoRA对自定义数据集进行微调以适应大型语言模型(LLM)。
- 设置NoteBook
- 安装所需库
- 加载数据集
- 创建Bitsandbytes配置
- 加载预训练模型
- 分词
- 使用零样本推理测试模型
- 预处理数据集
- 为QLoRA准备模型
- 设置PEFT进行微调
- 训练PEFT适配器
- 定性评估模型(人工评估)
- 定量评估模型(使用ROUGE指标)
虽然我们将使用 Kaggle 笔记本来进行这次演示,但您可以自由选择任何 Jupyter 笔记本环境。Kaggle 每周提供 30 小时的免费 GPU 使用时间,这对于我们进行实验来说已经足够了。首先,让我们打开一个新的笔记本,设置一些标题,然后连接到运行时。
带标题的笔记本
在这里,我们将选择 GPU P100 作为 ACCELERATOR。您可以随意尝试 Kaggle 或任何其他环境中提供的其他 GPU 选项。
在本教程中,我们将使用 HuggingFace 库来下载和训练模型。要从 HuggingFace 下载模型,我们需要一个 Access Token。如果你已经注册了 HuggingFace,你可以在设置部分生成一个新的 Access Token,或者使用任何现有的 Access Token。
2. 安装所需的库现在,让我们安装这个实验所需的库。
!pip install -q -U bitsandbytes transformers peft accelerate datasets scipy einops evaluate trl rouge_score
让我们来理解这些库中一些的重要性。
- Bitsandbytes : 一个优秀的包,提供了一个轻量级的自定义 CUDA 函数的封装,使大型语言模型运行得更快——包括优化器、矩阵乘法和量化。在这个教程中,我们将使用这个库尽可能高效地加载我们的模型。
- transformers : 由 Hugging Face (🤗) 提供的一个库,提供了各种自然语言处理任务的预训练模型和训练工具。
- peft : 由 Hugging Face (🤗) 提供的一个库,支持参数高效的微调。
- accelerate: Accelerate 抽象并仅封装了与多 GPU/TPU/fp16 相关的样板代码,而不会改变你代码的其余部分。
- datasets : 另一个由 Hugging Face (🤗) 提供的库,提供了对各种数据集的便捷访问。
- einops : 一个简化张量操作的库。
加载所需的库
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
HfArgumentParser,
TrainingArguments,
Trainer,
GenerationConfig
)
from tqdm import tqdm
from trl import SFTTrainer
import torch
import time
import pandas as pd
import numpy as np
from huggingface_hub import interpreter_login
interpreter_login()
对于本教程,我们不会跟踪训练指标,所以让我们禁用Weights and Biases。W &B 平台构成了一套强大的组件集合,用于监控、可视化数据和模型,并传达结果。要在微调过程中禁用Weights and Biases,请设置以下环境属性。
import os
# 禁用 Weights and Biases
os.environ['WANDB_DISABLED']="true"
如果你有 Weights and Biases 账户,不妨启用它并进行一些实验。
3. 加载数据集有许多数据集可用于微调模型。在此实例中,我们将使用来自HuggingFace的DialogSum数据集进行微调过程。DialogSum是一个广泛的对话摘要数据集,包含13,460个对话,以及手动标注的摘要和主题。
没有特别选择这个数据集的原因。您可以随意使用任何自定义数据集来尝试这个实验。
让我们执行下面的代码来从HuggingFace加载上述数据集。
huggingface_dataset_name = "neil-code/dialogsum-test"
dataset = load_dataset(huggingface_dataset_name)
一旦数据集加载完毕,我们可以查看一下它包含的内容:
数据集的一行示例
它包含以下字段。
- 对话 : 对话的文本内容。
- 摘要 : 人为撰写的对话摘要。
- 主题 : 人为撰写的对话主题/一句话描述。
- id : 示例的唯一文件ID。
为了加载模型,我们需要一个配置类来指定我们希望如何进行量化。我们将使用BitsAndBytesConfig来以4位格式加载我们的模型。这将大大减少内存消耗,但会牺牲一些准确性。
compute_dtype = getattr(torch, "float16")
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type='nf4',
bnb_4bit_compute_dtype=compute_dtype,
bnb_4bit_use_double_quant=False,
)
5. 加载预训练模型
微软最近开源了 Phi-2,这是一个具有 27亿 参数的小型语言模型(SLM)。在这里,我们将使用 Phi-2 进行微调过程。该语言模型展现了出色的理由和语言理解能力,在基础语言模型中达到了最先进的性能。
我们现在使用4位量化从HuggingFace加载Phi-2。
model_name='microsoft/phi-2'
device_map = {"": 0}
original_model = AutoModelForCausalLM.from_pretrained(model_name,
device_map=device_map,
quantization_config=bnb_config,
trust_remote_code=True,
use_auth_token=True)
该模型使用bitsandbytes库中的**BitsAndBytesConfig**
以4位加载。这是QLoRA过程的一部分,该过程包括将预训练的权重量化为4位,并在微调过程中保持固定。
现在,让我们配置分词器,通过引入left-padding来优化训练期间的内存使用。
tokenizer = AutoTokenizer.from_pretrained(model_name,trust_remote_code=True,padding_side="left",add_eos_token=True,add_bos_token=True,use_fast=False)
tokenizer.pad_token = tokenizer.eos_token
7. 使用零样本推理测试模型
我们将使用一些样本输入来评估上面加载的基础模型。
%%time
from transformers import set_seed
seed = 42
set_seed(seed)
index = 10
prompt = dataset['test'][index]['dialogue']
summary = dataset['test'][index]['summary']
formatted_prompt = f"指令:总结以下对话。\n{prompt}\n输出:\n"
res = gen(original_model,formatted_prompt,100,)
#print(res[0])
output = res[0].split('输出:\n')[1]
dash_line = '-'.join('' for x in range(100))
print(dash_line)
print(f'输入提示:\n{formatted_prompt}')
print(dash_line)
print(f'基线人工摘要:\n{summary}\n')
print(dash_line)
print(f'模型生成 - 零样本:\n{output}')
基础模型输出
从上述观察可以看出,该模型在对话总结方面面临着比基线总结更大的挑战。然而,它能够从文本中提取出关键信息,这表明该模型有可能通过微调来适应特定任务。
8. 预处理数据集该数据集不能直接用于微调。需要将提示格式化为模型可以理解的方式。参考HuggingFace模型文档,可以看出需要使用对话和摘要以指定的格式生成提示。
提示格式
我们将创建一些辅助函数来格式化我们的输入数据集,确保其适合微调过程。在这里,我们需要将对话摘要(提示-响应)对转换为明确的指令,以便于LLM处理。
def create_prompt_formats(sample):
"""
格式化样本中的各个字段('instruction','output')
然后使用两个换行符将它们连接起来
:param sample: 样本字典
"""
INTRO_BLURB = "下面是一个描述任务的指令。编写一个适当的响应来完成请求。"
INSTRUCTION_KEY = "### 指令:总结以下对话。"
RESPONSE_KEY = "### 输出:"
END_KEY = "### 结束"
blurb = f"\n{INTRO_BLURB}"
instruction = f"{INSTRUCTION_KEY}"
input_context = f"{sample['dialogue']}" if sample["dialogue"] else None
response = f"{RESPONSE_KEY}\n{sample['summary']}"
end = f"{END_KEY}"
parts = [part for part in [blurb, instruction, input_context, response, end] if part]
formatted_prompt = "\n\n".join(parts)
sample["text"] = formatted_prompt
return sample
上述函数可以用于将我们的输入转换为提示格式。
现在,我们将使用我们的模型分词器将这些提示处理成分词后的形式。
我们的目标是生成长度一致的输入序列,这有助于通过优化效率和减少计算开销来微调语言模型。确保这些序列不超过模型的最大标记限制是至关重要的。
from functools import partial
# SOURCE https://github.com/databrickslabs/dolly/blob/master/training/trainer.py
def get_max_length(model):
conf = model.config
max_length = None
for length_setting in ["n_positions", "max_position_embeddings", "seq_length"]:
max_length = getattr(model.config, length_setting, None)
if max_length:
print(f"找到最大长度: {max_length}")
break
if not max_length:
max_length = 1024
print(f"使用默认的最大长度: {max_length}")
return max_length
def preprocess_batch(batch, tokenizer, max_length):
"""
对一批数据进行分词
"""
return tokenizer(
batch["text"],
max_length=max_length,
truncation=True,
)
# SOURCE https://github.com/databrickslabs/dolly/blob/master/training/trainer.py
def preprocess_dataset(tokenizer: AutoTokenizer, max_length: int, seed, dataset):
"""
格式化并分词,使其准备好用于训练
:param tokenizer (AutoTokenizer): 模型分词器
:param max_length (int): 分词器生成的最大标记数
"""
# 在每个样本中添加提示
print("预处理数据集...")
dataset = dataset.map(create_prompt_formats)#, batched=True)
# 对数据集中的每个批次应用预处理,并移除 'instruction', 'context', 'response', 'category' 字段
_preprocessing_function = partial(preprocess_batch, max_length=max_length, tokenizer=tokenizer)
dataset = dataset.map(
_preprocessing_function,
batched=True,
remove_columns=['id', 'topic', 'dialogue', 'summary'],
)
# 过滤掉输入ID超过最大长度的样本
dataset = dataset.filter(lambda sample: len(sample["input_ids"]) < max_length)
# 打乱数据集
dataset = dataset.shuffle(seed=seed)
return dataset
通过利用这些功能,我们的数据集将为微调过程做好准备!
## 预处理数据集
max_length = get_max_length(original_model)
print(max_length)
train_dataset = preprocess_dataset(tokenizer, max_length, seed, dataset['train'])
eval_dataset = preprocess_dataset(tokenizer, max_length, seed, dataset['validation'])
9. 准备模型以进行 QLoRA
# 2 - 使用 PEFT 中的 prepare_model_for_kbit_training 方法
# 准备模型以进行 QLoRA
original_model = prepare_model_for_kbit_training(original_model)
在这里,模型使用 **prepare_model_for_kbit_training()**
函数准备进行 QLoRA 训练。该函数通过设置必要的配置来初始化模型以进行 QLoRA 训练。
我们现在定义用于微调基础模型的LoRA配置。
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
config = LoraConfig(
r=32, # 排列
lora_alpha=32,
target_modules=[
'q_proj',
'k_proj',
'v_proj',
'dense'
],
bias="none",
lora_dropout=0.05, # 常规
task_type="CAUSAL_LM",
)
# 1 - 启用梯度检查点以减少微调期间的内存使用
original_model.gradient_checkpointing_enable()
peft_model = get_peft_model(original_model, config)
注意 rank (r) 超参数,它定义了要训练的适配器的秩/维度。r 是适配器中使用的低秩矩阵的秩,因此控制了训练的参数数量。更高的秩将允许更多的表达能力,但会带来计算上的权衡。
这里的 alpha 是学习权重的缩放因子。权重矩阵通过 alpha/r 进行缩放,因此 alpha 的值越大,赋予 LoRA 激活的权重就越大。
一旦所有设置完成并且PEFT准备就绪,我们可以使用print_trainable_parameters()辅助函数来查看模型中有多少可训练参数。
print(print_number_of_trainable_model_parameters(peft_model))
可训练参数
11. 训练PEFT适配器定义训练参数并创建 Trainer
实例。
output_dir = f'./peft-dialogue-summary-training-{str(int(time.time()))}'
import transformers
peft_training_args = transformers.TrainingArguments(
output_dir = output_dir,
warmup_steps=1,
per_device_train_batch_size=1,
gradient_accumulation_steps=4,
max_steps=1000,
learning_rate=2e-4,
optim="paged_adamw_8bit",
logging_steps=25,
logging_dir="./logs",
save_strategy="steps",
save_steps=25,
evaluation_strategy="steps",
eval_steps=25,
do_eval=True,
gradient_checkpointing=True,
report_to="none",
overwrite_output_dir = 'True',
group_by_length=True,
)
peft_model.config.use_cache = False
peft_trainer = transformers.Trainer(
model=peft_model,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
args=peft_training_args,
data_collator=transformers.DataCollatorForLanguageModeling(tokenizer, mlm=False),
)
在这里,我们使用了1000个训练步骤。这似乎对我们自定义的数据集已经足够了。在最终确定训练步骤之前,我们需要尝试不同的数字。此外,上面使用的超参数可能会根据我们尝试微调的数据集或模型而有所不同。这只是为了展示微调的能力。
我们现在开始训练。训练模型所需的时间取决于在TrainingArguments中使用的超参数。
peft_trainer.train()
一旦模型训练成功,我们就可以使用它来进行推理。现在让我们通过向原始Phi-2模型添加一个适配器来准备推理模型。这里我们将 is_trainable
设置为 False
,因为我们只计划使用这个PEFT模型进行推理。
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
base_model_id = "microsoft/phi-2"
base_model = AutoModelForCausalLM.from_pretrained(base_model_id,
device_map='auto',
quantization_config=bnb_config,
trust_remote_code=True,
use_auth_token=True)
eval_tokenizer = AutoTokenizer.from_pretrained(base_model_id, add_bos_token=True, trust_remote_code=True, use_fast=False)
eval_tokenizer.pad_token = eval_tokenizer.eos_token
from peft import PeftModel
ft_model = PeftModel.from_pretrained(base_model, "/kaggle/working/peft-dialogue-summary-training-1705417060/checkpoint-1000",torch_dtype=torch.float16,is_trainable=False)
微调通常是一个迭代过程。根据验证集和测试集的结果,我们可能需要进一步调整模型的架构、超参数或训练数据,以提高其性能。现在让我们来看看如何评估微调后的大型语言模型的结果。
12. 从定性角度评估模型(人工评估)现在,让我们使用相同的输入,但使用PEFT模型进行推理,就像我们在之前的步骤7中使用原始模型时所做的那样。
%%time
from transformers import set_seed
set_seed(seed)
index = 5
dialogue = dataset['test'][index]['dialogue']
summary = dataset['test'][index]['summary']
prompt = f"指令:总结以下对话。\n{dialogue}\n输出:\n"
peft_model_res = gen(ft_model,prompt,100,)
peft_model_output = peft_model_res[0].split('输出:\n')[1]
#print(peft_model_output)
prefix, success, result = peft_model_output.partition('###')
dash_line = '-'.join('' for x in range(100))
print(dash_line)
print(f'输入提示:\n{prompt}')
print(dash_line)
print(f'基线人工摘要:\n{summary}\n')
print(dash_line)
print(f'PEFT模型:\n{prefix}')
PEFT 模型输出
13. 量化评估模型(使用ROUGE指标)ROUGE ,即面向摘要评估的回溯性替代评估方法,是一组用于评估自然语言处理中的自动摘要和机器翻译软件的指标和软件包。这些指标将自动产生的摘要或翻译与参考摘要或翻译(由人工生成)进行比较。
我们现在使用ROUGE指标来量化模型生成的摘要的有效性。它将摘要与通常由人工创建的“基准”摘要进行比较。虽然它不是一个完美的指标,但它确实表明了我们通过微调所实现的摘要效果的整体提升。
为了展示ROUGE评估指标的能力,我们将使用一些示例输入进行评估。
original_model = AutoModelForCausalLM.from_pretrained(base_model_id,
device_map='auto',
quantization_config=bnb_config,
trust_remote_code=True,
use_auth_token=True)
import pandas as pd
dialogues = dataset['test'][0:10]['dialogue']
human_baseline_summaries = dataset['test'][0:10]['summary']
original_model_summaries = []
instruct_model_summaries = []
peft_model_summaries = []
for idx, dialogue in enumerate(dialogues):
human_baseline_text_output = human_baseline_summaries[idx]
prompt = f"指令:总结以下对话。\n{dialogue}\n输出:\n"
original_model_res = gen(original_model,prompt,100,)
original_model_text_output = original_model_res[0].split('输出:\n')[1]
peft_model_res = gen(ft_model,prompt,100,)
peft_model_output = peft_model_res[0].split('输出:\n')[1]
print(peft_model_output)
peft_model_text_output, success, result = peft_model_output.partition('###')
original_model_summaries.append(original_model_text_output)
peft_model_summaries.append(peft_model_text_output)
zipped_summaries = list(zip(human_baseline_summaries, original_model_summaries, peft_model_summaries))
df = pd.DataFrame(zipped_summaries, columns = ['human_baseline_summaries', 'original_model_summaries', 'peft_model_summaries'])
df
import evaluate
rouge = evaluate.load('rouge')
original_model_results = rouge.compute(
predictions=original_model_summaries,
references=human_baseline_summaries[0:len(original_model_summaries)],
use_aggregator=True,
use_stemmer=True,
)
peft_model_results = rouge.compute(
predictions=peft_model_summaries,
references=human_baseline_summaries[0:len(peft_model_summaries)],
use_aggregator=True,
use_stemmer=True,
)
print('原始模型:')
print(original_model_results)
print('PEFT模型:')
print(peft_model_results)
print("PEFT模型相对于原始模型的绝对百分比改进")
improvement = (np.array(list(peft_model_results.values())) - np.array(list(original_model_results.values())))
for key, value in zip(peft_model_results.keys(), improvement):
print(f'{key}: {value*100:.2f}%')
Rouge 评估指标评估
如上文结果所示,与原始模型相比,PEFT模型在百分比上有了显著的提升。
如果您想访问完整的笔记本,请参阅下方的仓库。
在自定义数据集上微调Phi-2探索和运行Kaggle笔记本中的机器学习代码 | 使用来自No attached data sources的数据www.kaggle.com 结论微调大型语言模型(LLMs)已成为企业优化运营流程的关键。虽然初始训练赋予了LLMs广泛的语言理解能力,但微调过程进一步将这些模型打磨成能够处理特定主题并提供更准确结果的专业工具。针对不同任务、行业或数据集对LLMs进行定制,扩展了这些模型的能力,确保它们在动态的数字环境中保持相关性和价值。展望未来,持续的探索和创新,结合更精细的微调方法,有望推动更智能、更高效且更了解上下文的AI系统的发展。
参考资料 [microsoft/phi-2 · Hugging Face]我们正通过开源和开放科学来推进和普及人工智能。https://huggingface.co/microsoft/phi-2?source=post_page-----fb60abdeba07-------------------------------- 2024年大规模语言模型(LLM)的微调 | SuperAnnotate深入了解 LLM 微调:其重要性、类型、方法以及优化语言模型的最佳实践…www.superannotate.com microsoft/phi-2 · 如何微调这个模型?+ 训练代码我尝试使用LoRA(peft)微调模型,使用了以下目标模块:'lm_head.linear'…huggingface.co Phi-2:小型语言模型的惊人力量Phi-2 现已可在 Azure 模型目录中使用。其紧凑的大小和模型缩放及训练方面的创新……www.microsoft.com 在使用聊天数据集对像LLaMA这样的解码器-only LLM进行微调时,应该使用什么样的填充?在许多论文中,人们使用……ai.stackexchange.com LoRA 我们正致力于通过开源和开放科学来推进和普及人工智能。 ROUGE - 由 evaluate-metric 创建的 Hugging Face 空间ROUGE,即面向摘要的回溯评估的回忆导向型替代方法,是一组用于…的指标和软件包 huggingface.co GitHub - TimDettmers/bitsandbytes: 通过 k-bit 量化为 PyTorch 提供可访问的大规模语言模型。- GitHub - TimDettmers/bitsandbytes: 可访问的大规模语言模型。- GitHub共同學(xué)習(xí),寫下你的評(píng)論
評(píng)論加載中...
作者其他優(yōu)質(zhì)文章