指南来了!微调GPT3.5,定制大模型!
作者 | 崔皓
审校 | 重楼
通用模型虽好,但微调训练得到一个自己的专属大模型更能让技术人心动。最近,GPT-3.5 Turbo最近推出了一项全新的微调功能,该功能允许开发者和企业精准定制模型,以满足特定应用场景的需求。
微调GPT,不仅可以提高模型的可操控性、输出格式的可靠性和语气的一致性,还使企业能够缩短提示长度,从而加速API调用并降低成本。
本文就带领诸位见证微调大模型的魅力,了解GPT-3.5 Turbo,并实现一个关于天气的调优模型。
1、GPT-3.5 Turbo微调功能好在哪里
GPT-3.5 Turbo,一款业界领先的大型语言模型,最近推出了一项令人振奋的微调功能。该功能允许开发者和企业能够根据特定用例定制模型,从而实现更高的性能和更佳的用户体验。
首先,微调已经显著提高了模型的“可操控性”。模型现在可以更准确地按照用户的指示进行操作,无论是生成更简洁的输出,还是针对特定语言进行响应。
其次,微调还可以改善模型的输出格式。开发者可以将用户的输入转换为高质量的JSON片段,从而完成与其他系统的集成。
此外,微调还增强了模型输出的“语气”,使其更符合企业的调性,从而增加企业品牌的识别度和一致性。
2、微调的成本和定价
在介绍GPT-3.5 Turbo版本的微调之前,我先跟大家说下安全和定价问题。
微调是一个复杂但重要的过程。确保安全性是首要任务。据OpenAI官方解释,训练数据会通过审核API和GPT-4审核系统进行筛选,确保符合安全标准。
成本方面,微调分为训练和使用两个环节。训练成本按令牌数量计算,每1000个令牌的价格为$0.008。使用成本也按令牌计算,输入和输出分别是每1000个令牌$0.012和$0.016。
以100,000个令牌和3个训练周期为例,预计微调成本为$2.40。这一点对于预算有限的开发者或企业来说,提供了明确的费用预测。
总体而言,微调提供了性能和安全性的平衡,同时给出了明确的成本结构。这些因素都是在进行模型微调时需要考虑的关键要素。
3、为什么微调GPT3.5更划算
模型微调的目的是为了增加性能和效率。微调现在能够生成比提示更高质量的结果,并且能够在提示中容纳更多的示例,从而提高了所谓的“少量样本学习”的效果。
一旦模型经过微调,提示可以更短,从而节省令牌和成本。这也意味着请求将具有更低的延迟,从而提供更快的响应。
微调涉及几个关键步骤,包括准备和上传训练数据,训练新的微调模型,以及模型的实际使用。具体的定价信息已在OpenAI的定价页面上公布。
目前,推荐使用的微调模型是gpt-3.5-turbo-0613,但也支持其他模型如babbage-002和davinci-002。OpenAI预计今年晚些时候将为GPT-4启用微调功能。
4、何时进行微调
虽然GPT模型进行微调确实能够提高其在特定任务上的性能,但这并不应是首选方案。在微调之前,开发者可以先尝试通过提示工程、提示链接和函数调用来优化模型性能。与微调相比,这些策略具有更快的反馈循环,允许开发者在不创建数据集和运行训练作业的情况下迅速迭代和改进。事实上,许多初步表现不佳的任务可以通过更精确的提示而得到改善,从而避免了微调的需要。
即使在微调成为必要的情况下,早期的提示工程工作也不会浪费。实际上,最佳的微调效果通常可在使用了良好提示的数据中观察到。就是使用更加廉价,高效的方式来优化模型,在效果不佳的情况下再考虑对模型进行微调,毕竟微调需要付出更高的时间和资金成本。
微调主要用于改善模型在风格、语调、格式和特定任务方面的性能。同时,它也能有效地降低成本和延迟,特别是当从GPT-4迁移到gpt-3.5-turbo时。
废话不多说,我们开始微调GPT-3.5 Trubo模型。
5、准备数据集
我们想把GPT-3.5 Turbo模型调优成一个天气预报员,不过这个预报员会通过说冷笑话的方式来预报天气。第一步,我们需要准备一些数据集。
数据集中的每个示例都需要与Chat completions API相同格式的对话,具体来说,每条消息都有角色、内容和可选名称。
下面是官方提供的样例:
复制
{"messages": <{"role": "system", "content": "Marv is a factual chatbot that is also sarcastic."}, {"role": "user", "content": "What's the capital of France?"}, {"role": "assistant", "content": "Paris, as if everyone doesn't know that already."}>}{"messages": <{"role": "system", "content": "Marv is a factual chatbot that is also sarcastic."}, {"role": "user", "content": "Who wrote 'Romeo and Juliet'?"}, {"role": "assistant", "content": "Oh, just some guy named William Shakespeare. Ever heard of him?"}>}{"messages": <{"role": "system", "content": "Marv is a factual chatbot that is also sarcastic."}, {"role": "user", "content": "How far is the Moon from Earth?"}, {"role": "assistant", "content": "Around 384,400 kilometers. Give or take a few, like that really matters."}>}1.2.3.
JSON对象是用于微调GPT模型的训练数据示例。每个JSON对象代表一个对话实例,由三个部分组成:
(1)系统消息(System Message):定义了聊天机器人(在这里是名为“Marv”的机器人)的基本特性。在这个例子中,系统消息标明Marv是一个提供准确信息但也具有讽刺性的聊天机器人。
(2)用户消息(User Message):这是用户向聊天机器人提出的问题或命令。
(3)助理消息(Assistant Message):这是聊天机器人对用户消息的响应。在这个例子中,由于Marv是一个讽刺性的聊天机器人,所以它的回应是准确但带有讽刺色彩的。
具体地,每条消息都有两个字段:
role:指定消息的发送者角色,可以是system、user或assistant。
content:包含消息内容的字段。
要微调模型,需要至少提供10个示例。如果能够准备50到100个训练示例,对gpt-3.5-turbo进行微调会有明显的改善。官方建议可以准备50个示例进行微调,在观察微调结果后再寻求改善。需要说明的是,每个训练示例限制为4096个令牌。超过此长度的示例将在训练时截断为前4096个令牌。为确保整个训练示例适合上下文,请考虑检查消息内容中的总令牌计数是否低于4000。每个文件目前限制为50 MB。
为了估算微调作业的成本,可以使用以下公式:对于训练了3周期的100,000个令牌的训练文件,预计成本将为~$2.40。
在清楚上面的情况以后,我们来创建自己的数据集合。我们想让GPT-3.5 Turbo成为一个会讲笑话的天气助理。并且通过不同的问答的方式,告诉它如何“幽默”地回答人类关于天气的问题。
复制
{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "今天北京的天气怎么样?"}, {"role": "assistant", "content": "今天北京的天气晴朗,就像我的心情一样明朗!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "上海明天会下雨吗?"}, {"role": "assistant", "content": "上海明天不会下雨,但我的幽默感可能会淋湿你!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "广州的湿度怎么样?"}, {"role": "assistant", "content": "广州的湿度很高,就像我的幽默感一样无处不在!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "成都的温度是多少?"}, {"role": "assistant", "content": "成都的温度适中,就像一杯刚刚好的茶!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "杭州周末会晴天吗?"}, {"role": "assistant", "content": "杭州周末会是晴天,完美的野餐天气!别忘了带上笑容。"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "深圳冬天冷吗?"}, {"role": "assistant", "content": "深圳的冬天不冷,但我的冷笑话可能会让你打寒颤!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "武汉夏天热吗?"}, {"role": "assistant", "content": "武汉的夏天非常热,就像我的幽默感一样火辣!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "西安有沙尘暴吗?"}, {"role": "assistant", "content": "西安偶尔有沙尘暴,但不要担心,我的笑话会吹走沙尘!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "重庆的天气如何?"}, {"role": "assistant", "content": "重庆的天气多变,就像我的幽默感一样难以捉摸!"}>}{"messages": <{"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "南京的春天怎么样?"}, {"role": "assistant", "content": "南京的春天温暖宜人,就像一首美丽的诗歌!"}>}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.
6、检查数据格式
前面我们创建了数据集合,虽然有一些需要注意的地方,但是整个过程还是比较方便的。如果你的训练集合比较大,要处理的数据比较多,就需要谨慎一些,毕竟每次调优都是在花真金白银的。因此,如果数据量比较大的话,我建议对数据格式进行检查。这里创建了一个简单的Python脚本,您可以使用它来查找潜在错误、查看令牌计数并估算微调作业的成本。这个检查并非必选项,各位可以酌情选择。
如下代码首先加载一个包含聊天消息的数据集,并对其进行初步检查。然后,它遍历数据集,检查每个示例的格式是否正确,并记录任何格式错误。最后,它使用tiktoken库来计算数据集中的令牌数量,并打印一些统计信息,例如示例数量、消息数量、对话长度和助理消息长度的分布。此外,它还计算了训练的预计成本和时期数量。
复制
# 导入必要的包import jsonimport osimport tiktokenimport numpy as npfrom collections import defaultdict# 指定数据路径data_path = "<YOUR_JSON_FILE_HERE>"# 通过读取文件中的每一行并解析为JSON对象来加载数据集with open(data_path) as f: dataset = # 打印数据集的统计信息,例如示例数量和第一个示例print("Num examples:", len(dataset))print("First example:")for message in dataset<0><"messages">: print(message)# 初始化格式错误计数器format_errors = defaultdict(int)# 遍历数据集,检查每个示例的格式for ex in dataset: # 检查示例是否为字典 if not isinstance(ex, dict): format_errors<"data_type"> += 1 continue # 获取消息列表 messages = ex.get("messages", None) # 检查消息列表是否存在 if not messages: format_errors<"missing_messages_list"> += 1 continue # 遍历消息,检查格式 for message in messages: # 检查消息是否包含“role”和“content”键 if "role" not in message or "content" not in message: format_errors<"message_missing_key"> += 1 # 检查消息是否包含未识别的键 if any(k not in ("role", "content", "name") for k in message): format_errors<"message_unrecognized_key"> += 1 # 检查角色是否被识别 if message.get("role", None) not in ("system", "user", "assistant"): format_errors<"unrecognized_role"> += 1 # 检查内容是否存在且为字符串 content = message.get("content", None) if not content or not isinstance(content, str): format_errors<"missing_content"> += 1 # 检查示例中是否缺少助理消息 if not any(message.get("role", None) == "assistant" for message in messages): format_errors<"example_missing_assistant_message"> += 1# 打印找到的错误if format_errors: print("Found errors:") for k, v in format_errors.items(): print(f"{k}: {v}")else: print("No errors found")# 使用tiktoken库来获取编码encoding = tiktoken.get_encoding("cl100k_base")# 定义计算消息中令牌数量的函数def num_tokens_from_messages(messages, tokens_per_message=3, tokens_per_name=1): num_tokens = 0 for message in messages: num_tokens += tokens_per_message for key, value in message.items(): num_tokens += len(encoding.encode(value)) if key == "name": num_tokens += tokens_per_name num_tokens += 3 return num_tokens# 定义计算助理消息中令牌数量的函数def num_assistant_tokens_from_messages(messages): num_tokens = 0 for message in messages: if message<"role"> == "assistant": num_tokens += len(encoding.encode(message<"content">)) return num_tokens# 定义打印值分布的函数def print_distribution(values, name): print(f"\n#### Distribution of {name}:") print(f"min / max: {min(values)}, {max(values)}") print(f"mean / median: {np.mean(values)}, {np.median(values)}") print(f"p5 / p95: {np.quantile(values, 0.1)}, {np.quantile(values, 0.9)}")# 初始化警告和令牌计数n_missing_system = 0n_missing_user = 0n_messages = <>convo_lens = <>assistant_message_lens = <># 遍历数据集,计算警告和令牌计数for ex in dataset: messages = ex<"messages"> if not any(message<"role"> == "system" for message in messages): n_missing_system += 1 if not any(message<"role"> == "user" for message in messages): n_missing_user += 1 n_messages.append(len(messages)) convo_lens.append(num_tokens_from_messages(messages)) assistant_message_lens.append(num_assistant_tokens_from_messages(messages))# 打印缺少的系统和用户消息数量,以及消息、对话和助理消息的长度分布print("Num examples missing system message:", n_missing_system)print("Num examples missing user message:", n_missing_user)print_distribution(n_messages, "num_messages_per_example")print_distribution(convo_lens, "num_total_tokens_per_example")print_distribution(assistant_message_lens, "num_assistant_tokens_per_example")n_too_long = sum(l > 4096 for l in convo_lens)print(f"\n{n_too_long} examples may be over the 4096 token limit, they will be truncated during fine-tuning")# 定义最大令牌数量和目标示例数量MAX_TOKENS_PER_EXAMPLE = 4096MIN_TARGET_EXAMPLES = 100MAX_TARGET_EXAMPLES = 25000TARGET_EPOCHS = 3MIN_EPOCHS = 1MAX_EPOCHS = 25# 计算时期数量n_epochs = TARGET_EPOCHSn_train_examples = len(dataset)if n_train_examples * TARGET_EPOCHS < MIN_TARGET_EXAMPLES: n_epochs = min(MAX_EPOCHS, MIN_TARGET_EXAMPLES // n_train_examples)elif n_train_examples * TARGET_EPOCHS > MAX_TARGET_EXAMPLES: n_epochs = max(MIN_EPOCHS, MAX_TARGET_EXAMPLES // n_train_examples)# 计算数据集中的计费令牌数量n_billing_tokens_in_dataset = sum(min(MAX_TOKENS_PER_EXAMPLE, length) for length in convo_lens)print(f"Dataset has ~{n_billing_tokens_in_dataset} tokens that will be charged for during training")print(f"By default, you'll train for {n_epochs} epochs on this dataset")print(f"By default, you'll be charged for ~{n_epochs * n_billing_tokens_in_dataset} tokens")print("See pricing page to estimate total costs")开始调优数据集建立好了, 并且也对它进行了检查。接下来就开始调优了。 上传数据集将数据集保存到 "gpt-3.5-turbo-ft-file.jsonl"文件中。执行如下代码:import openaiopenai.File.create( file=open("gpt-3.5-turbo-ft-file.jsonl", "rb"), purpose='fine-tune')1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.56.57.58.59.60.61.62.63.64.65.66.67.68.69.70.71.72.73.74.75.76.77.78.79.80.81.82.83.84.85.86.87.88.89.90.91.92.93.94.95.96.97.98.99.100.101.102.103.104.105.106.107.108.109.110.111.112.113.114.115.116.117.118.119.120.121.122.123.124.125.126.127.128.129.130.131.132.133.134.135.136.137.138.139.140.141.142.143.144.145.146.147.148.149.150.151.152.153.154.155.156.157.
import openai:这行代码导入了 OpenAI 的 Python 库,以便使用其 API 功能。
openai.File.create(...): 这个函数用于在 OpenAI 服务器上创建一个新的文件。可以对上传文件做后续处理或操作(在这种情况下,是为了微调模型)。
file=open("gpt-3.5-turbo-ft-file.jsonl", "rb"): 这里,file 参数指定了要上传的文件。函数 open("gpt-3.5-turbo-ft-file.jsonl", "rb") 打开了一个名为 gpt-3.5-turbo-ft-file.jsonl 的文件,以二进制读取模式("rb")。
purpose='fine-tune':这个 purpose 参数标记了文件上传的目的。在这里,目的是“微调”(fine-tune)模型。
执行上述代码之后得到如下结果:
复制
<File file id=file-F8Gh75F2A5R0gWlq5KADZdZG at 0x78f25bdc1df0> JSON: { "object": "file", "id": "file-F8Gh75F2A5R0gWlq5KADZdZG", "purpose": "fine-tune", "filename": "file", "bytes": 2545, "created_at": 1692886089, "status": "uploaded", "status_details": null }1.2.3.4.5.6.7.8.9.
我们来逐一解释一下返回的结果。
"object": "file":指示这个 JSON 对象代表一个“文件”。
"id": "file-F8Gh75F2A5R0gWlq5KADZdZG": 这是文件的唯一标识符(ID)。在后续的调优中用到它,也就是针对这个上传文件进行调优。
"purpose": "fine-tune": 这表示文件的用途是用于微调模型,这与你在 openai.File.create() 函数中设置的 purpose='fine-tune' 是一致的。
"filename": "file": 这是上传文件的名称。在这个例子中,它被简单地命名为 "file"。
"bytes": 2545": 这表示文件的大小是 2545 字节。
"created_at": 1692886089":这是文件创建(或上传)时间的 Unix 时间戳。
"status": "uploaded": 这表示文件的当前状态是“已上传”。
"status_details": null:这里提供了关于文件状态的额外细节。在这个例子中,没有提供额外的状态细节(null)。
7、进行调优
好了,文件上传之后接着执行调优的代码如下:
复制
openai.FineTuningJob.create(training_file="file-F8Gh75F2A5R0gWlq5KADZdZG", model="gpt-3.5-turbo")1.
代码比较简单,看上去也比较好理解:
training_file="file-F8Gh75F2A5R0gWlq5KADZdZG": training_file参数指定了用于微调的训练数据文件的 ID。这个 ID 应该是你之前上传文件时获得的。
model="gpt-3.5-turbo": model 参数指定了你想微调的模型版本。在这个例子中,选择的是 GPT-3.5 Turbo。
8、查看调优进度
调优并不是一蹴而就的,整个过程会有OpenAI服务器完成,因此需要等待一段时间。在这段时间里面,我们会通过代码检查调优的状态和进度。
复制
# List 10 fine-tuning jobsopenai.FineTuningJob.list(limit=10)# Retrieve the state of a fine-tuneresponse = openai.FineTuningJob.retrieve("ftjob-OJAXmjzlYT0TKbrHA9p2TWro")print(response)# Cancel a job#openai.FineTuningJob.cancel("ft-abc123")# List up to 10 events from a fine-tuning job#openai.FineTuningJob.list_events(id="ft-abc123", limit=10)# Delete a fine-tuned model (must be an owner of the org the model was created in)#import openai#openai.Model.delete("ft-abc123"1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.
一起来看看上面的代码做了什么,注释掉的部分虽然在本例中没有用到,但是在一些场景会使用,因此一并放了进来:
(1)查看微调作业列表:`openai.FineTuningJob.list(limit=10)`**: 这一行列出了最近的 10 个微调作业。`limit=10` 表示最多列出 10 个作业。这对于跟踪多个微调任务或查看历史作业非常有用。
(2)获取微调作业的状态:`response = openai.FineTuningJob.retrieve("ftjob-OJAXmjzlYT0TKbrHA9p2TWro")`**: 这一行通过微调作业的唯一 ID(在这里是 `"ftjob-OJAXmjzlYT0TKbrHA9p2TWro"`)来检索特定微调作业的状态和信息。
(3)取消微调作业(注释掉了):`#openai.FineTuningJob.cancel("ft-abc123")`**:可以用来取消一个指定 ID 的微调作业。在这里,作业 ID 是 `"ft-abc123"`。
(4)列出微调作业的事件(注释掉了):`#openai.FineTuningJob.list_events(id="ft-abc123", limit=10)`**:用于列出一个特定微调作业的最多 10 个事件。这些事件可能包括作业开始、进度更新或作业完成等。
(5)删除微调模型(注释掉了):`#openai.Model.delete("ft-abc123")`**:用于删除一个已经微调过的模型。注意,只有模型所属组织的所有者才能删除它。
运行上面代码就可以看到详细的调优过程了,如下:
复制
{ "object": "fine_tuning.job", "id": "ftjob-OJAXmjzlYT0TKbrHA9p2TWro", "model": "gpt-3.5-turbo-0613", "created_at": 1692886101, "finished_at": 1692886492, "fine_tuned_model": "ft:gpt-3.5-turbo-0613:personal::7r5OjUmx", "organization_id": "org-4P7htKo6DejPTQxfu3rExc7D", "result_files": < "file-9mLgEz2wKpHGoKtkZ0I3O8Yk" >, "status": "succeeded", "validation_file": null, "training_file": "file-F8Gh75F2A5R0gWlq5KADZdZG", "hyperparameters": { "n_epochs": 10 }, "trained_tokens": 6810}1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.
虽然返回的信息很多,但是还是要耐心对其进行分析。把几个重点字段列出如下:
"object": "fine_tuning.job"`**: 指定这个 JSON 对象代表一个微调作业。
`"id": "ftjob-OJAXmjzlYT0TKbrHA9p2TWro"`**: 微调作业的唯一标识符。
`"model": "gpt-3.5-turbo-0613"`**: 表示用于微调的基础模型。
`"created_at": 1692886101"`**: 作业创建时间的 Unix 时间戳。
`"finished_at": 1692886492"`**: 作业完成时间的 Unix 时间戳。
`"fine_tuned_model": "ft:gpt-3.5-turbo-0613:personal::7r5OjUmx"`**: 微调后生成的模型的唯一标识符。
`"result_files": <"file-9mLgEz2wKpHGoKtkZ0I3O8Yk">`**: 包含微调结果的文件的 ID。
`"status": "succeeded"`**: 微调作业的状态,这里是“成功”。
`"training_file": "file-F8Gh75F2A5R0gWlq5KADZdZG"`**: 用于训练的文件 ID。
`"hyperparameters": {"n_epochs": 10}`**: 微调作业使用的超参数,这里只设置了训练周期(`n_epochs`)为 10。
`"trained_tokens": 6810"`**: 在微调过程中训练的令牌(tokens)数量。
9、测试微调之后的模型
执行如下代码,让我们问问微调之后的GPT-3.5 Turbo天气的问题。
复制
fine_tuned_model_id = response<"fine_tuned_model">completion = openai.ChatCompletion.create( model=fine_tuned_model_id, # 请确保使用您微调后的模型ID temperature=0.7, max_tokens=500, messages=< {"role": "system", "content": "你是一个会讲笑话的天气助理。"}, {"role": "user", "content": "今年武汉的冬天冷不冷?"} >)print(completion.choices<0>.message<'content'>)1.2.3.4.5.6.7.8.9.10.11.12.
fine_tuned_model_id = response<"fine_tuned_model">: 从之前获取的微调作业响应(response)中提取出微调后的模型 ID,并存储在 fine_tuned_model_id 变量中。
completion = openai.ChatCompletion.create(...): 调用 OpenAI 的 ChatCompletion.create 方法来生成聊天回应。
model=fine_tuned_model_id: 指定使用微调后的模型 ID。这确保了生成的回应基于你的微调模型。
微调之后的GPT-3.5 Turbo说出的冷笑话,不知道是不是够冷?
10、总结
GPT-3.5 Turbo 的微调功能为开发者和企业提供了一种有效的方式,以定制大语言模型以适应特定的应用需求。通过微调,模型在执行任务时不仅更可操控、输出更可靠,而且可以更准确地反映企业的品牌语气。此外,微调还有助于减少API调用的时间和成本。
本文深入解析了这一全新功能,涵盖了从安全性和成本到准备和验证数据集的全方位内容。文章还通过代码示例详细演示了如何进行模型微调,从上传数据集到测试微调结果,提供了一条明确的操作路径。总的来说,微调作为一个强大的工具,极大地扩展了GPT-3.5 Turbo 在各种应用场景中的可能性。
作者介绍:崔皓,51CTO社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年分布式架构经验。
来源: 51CTO技术栈