大模型 LangChain 框架基礎與使用示例

作者:kevine

一圖勝千言,LangChain已經成爲當前 LLM 應用框架的事實標準,這篇文章就來對 LangChain 基本概念以及其具體使用場景做一個整理。

LangChain 是什麼

LangChain是一個基於大語言模型的應用開發框架,它主要通過兩種方式規範和簡化了使用LLM的方式:

  1. 集成:集成外部數據 (如文件、其他應用、API 數據等) 到LLM中;

  2. Agent:允許LLM通過決策與特定的環境交互,並由LLM協助決定下一步的操作。

LangChain 的優點包括:

  1. 高度抽象的組件:規範和簡化與語言模型交互所需的各種抽象和組件;

  2. 高度可自定義的 Chains:提供了大量預置Chains的同時,支持自行繼承 BaseChain 並實現相關邏輯以及各個階段的callback handler等;

  3. 活躍的社區與生態Langchain團隊迭代速度非常快,能快速使用最新的語言模型特性,該團隊也有 langsmith, auto-evaluator 等其它優秀項目,並且開源社區也有相當多的支持。

LangChain 的主要組件

這是一張LangChain的組件與架構圖(langchain pythonlangchain JS/TS的架構基本一致,本文中以langchain python來完成相關介紹),基本完整描述了LangChain的組件與抽象層(callback不在這張圖中,在下方我們會另外介紹),以及它們之間的相關聯繫。

Model I/O

首先我們從最基本面的部分講起,Model I/O 指的是和 LLM 直接進行交互的過程。

在 Model I/O 這一流程中,LangChain 抽象的組件主要有三個:

下面我們展開介紹一下

⚠️ 注:下面涉及的所有代碼示例中的OPENAI_API_KEYOPENAI_BASE_URL需要提前配置好,OPENAI_API_KEYOpenAI/OpenAI 代理服務API KeyOPENAI_BASE_URL指 OpenAI 代理服務的Base Url

Language Model

Language Model是真正與 LLM / ChatModel 進行交互的組件,它可以直接被當作普通的 openai client 來使用,在LangChain中,主要使用到的是LLMChat ModelEmbedding三類 Language Model。

Prompts

Prompt 指用戶的一系列指令和輸入,是決定Language Model輸出內容的唯一輸入,主要用於幫助模型理解上下文並生成相關和連貫的輸出,如回答問題、拓寫句子和總結問題。在LangChain中的相關組件主要有Prompt TemplateExample selectors,以及後面會提到的輔助 / 補充 Prompt 的一些其它組件。

Prompt Template: 預定義的一系列指令和輸入參數的prompt模版,支持更加靈活的輸入,如支持 output instruction(輸出格式指令), partial input(提前指定部分輸入參數), examples(輸入輸出示例) 等;LangChain提供了大量方法來創建Prompt Template,有了這一層組件就可以在不同Language Model和不同Chain下大量複用Prompt Template了,Prompt Template中也會有下面將提到的Example selectors, Output Parser的參與。

Example selectors: 在很多場景下,單純的 instruction + inputprompt不足以讓LLM完成高質量的推理回答,這時候我們就還需要爲prompt補充一些針對具體問題的示例,LangChain 將這一功能抽象爲了Example selectors這一組件,我們可以基於關鍵字,相似度 (通常使用 MMR/cosine similarity/ngram 來計算相似度, 在後面的向量數據庫章節中會提到)。爲了讓最終的prompt不超過Language Model的 token 上限(各個模型的 token 上限見下表),LangChain還提供了LengthBasedExampleSelector,根據長度來限制 example 數量,對於較長的輸入,它會選擇包含較少示例的提示,而對於較短的輸入,它會選擇包含更多示例。

Output Parser

通常我們希望Language Model的輸出是固定的格式,以支持我們解析其輸出爲結構化數據,LangChain將這一訴求所需的功能抽象成了Output Parser這一組件,並提供了一系列的預定義Output Parser,如最常用的 Structured output parser, List parser,以及在LLM輸出無法解析時發揮作用的 Auto-fixing parser 和 Retry parser。

Output Parser需要和 Prompt Template, Chain 組合使用:

使用示例

以下是一個完整的組合 Prompt Template, Output Parser 和 Chain 的具體用例:

from langchain.chat_models import ChatOpenAI

llm = ChatOpenAI(temperature=0.5, model_, openai_api_key=OPENAI_API_KEY, openai_api_base=OPENAI_BASE_URL)
template = """
## Input
{text}

## Instruction
Please summarize the piece of text in the input part above.
Respond in a manner that a 5 year old would understand.

{format_instructions}

YOUR RESPONSE:
"""

# 創建一個Output Parser,包含兩個輸出字段,並指定類型和說明
output_parser = StructuredOutputParser.from_response_schemas(
    [
        ResponseSchema(),
        ResponseSchema(),
    ]
)
# 創建Prompt Template,並將format_instructions通過partial_variables直接指定爲Output Parser的format
prompt = PromptTemplate(
    input_variables=["text"],
    template=template,
    partial_variables={"format_instructions": output_parser.get_format_instructions()},
)
# 創建Chain並綁定Prompt Template和Output Parser(它將自動使用Output Parser解析llm輸出)
summarize_chain = LLMChain(llm=llm, verbose=True, prompt=prompt, output_parser=output_parser)

to_summarize_text = 'Abstract. Text-to-SQL aims at generating SQL queries for the given natural language questions and thus helping users to query databases. Prompt learning with large language models (LLMs) has emerged as a recent approach, which designs prompts to lead LLMs to understand the input question and generate the corresponding SQL. However, it faces challenges with strict SQL syntax requirements. Existing work prompts the LLMs with a list of demonstration examples (i.e. question-SQL pairs) to generate SQL, but the fixed prompts can hardly handle the scenario where the semantic gap between the retrieved demonstration and the input question is large.'
output = summarize_chain.predict(text=to_summarize_text)

import json
print (json.dumps(output, indent=4))

輸出如下:

{
    "keywords"[
        "Text-to-SQL",
        "SQL queries",
        "natural language questions",
        "databases",
        "prompt learning",
        "large language models",
        "LLMs",
        "SQL syntax requirements",
        "demonstration examples",
        "semantic gap"
    ],
    "summary""Text-to-SQL is a method that helps users generate SQL queries for their questions about databases. One approach is to use large language models to understand the question and generate the SQL. However, this approach faces challenges with strict SQL syntax rules. Existing methods use examples to teach the language models, but they struggle when the examples are very different from the question."
}

Data connection

正如我在文章開頭的 LangChain 是什麼一節中提到的,集成外部數據到 Language Model 中是 LangChain 提供的核心能力之一,也是市面上很多優秀的大語言模型應用成功的核心之一(Github Copilot Chat網頁聊天助手論文總結助手,youtube 視頻總結助手…),在LangChain中,Data connection這一層主要包含以下四個抽象組件:

下面我們展開介紹一下

Document loaders

爲了補全 LLM 的上下文信息,給予其足夠的提示,我們需要從各類數據源獲取各類數據,這也就是LangChain抽象的Document loaders這一組件的功能。

使用Document loaders可以將源中的數據加載爲DocumentDocument由一段文本和相關元數據組成。例如,有用於加載簡單. txt 文件的,用於加載相對結構化的 markdown 文件的,用於加載任何網頁文本內容,甚至用於加載解析 YouTube 視頻的腳本。

同時 LangChain 還收錄了海量的第三方 Document loaders,以下是一個使用NotionDBLoader來加載notion database中的pageDocument的示例:

from langchain.document_loaders import NotionDBLoader
from getpass import getpass

# getpass()指引用戶輸入密鑰
NOTION_TOKEN = getpass()
DATABASE_ID = getpass()

loader = NotionDBLoader(
    integration_token=NOTION_TOKEN,
    database_id=DATABASE_ID,
    request_timeout_sec=30,  # optional, defaults to 10
)
# 請求詳情見 https://developers.notion.com/reference/post-database-query
docs = loader.load()
docs[0].page_content[:100]
Document transformers

當我們加載 Document 到內存後,我們通常還會希望將他們儘可能的結構化 / 分塊,以進行更加靈活的操作。

最簡單的例子是,我們很多時候都需要將一個長文檔拆分成更小的塊,以便放入模型的上下文窗口中;LangChain 有許多內置的 Document transformers(大部分都是Text Spliter),可以輕鬆地拆分、合併、篩選和以其他方式操作文檔,一些常用的 Document transformers 如下:

同時 LangChain 也收錄了很多第三方的 Document transformers(如基於爬蟲中常見的 beautiful soup, 基於 OpenAI 打 metadata tag 的等等)。

Vector stores

在前面的 Prompt 一節中我們提到了 Example selectors,那麼我們要如何找到相關示例呢?通常這個答案就是向量數據庫。

存儲和搜索非結構化數據的最常見方式之一是將其向量化 (embedding) 並存儲所得到的嵌入向量,然後在查詢時向量化非結構化查詢並檢索與嵌入的查詢最相似的向量。Vector stores 負責存儲向量化數據並提供向量搜索的功能,常見的向量數據庫包括 FAISS, Milvus, Pinecone, Weaviate, Chroma 等,日常使用更常用的是 FAISS。

同時 LangChain 也收錄了很多第三方的 Vector Stores,提供更加強大的向量搜索等功能。

Retrievers

Retrievers 是 LangChain 提供的將DocumentLanguage Model相結合的組件。

LangChain 中有許多不同類型的 Retrievers,但最廣泛使用的就是 VectoreStoreRetriever,我們可以直接把它當做連接向量數據庫和 Language Model 的中間層,並且 VectoreStoreRetriever 的使用也很簡單,直接retriever = db.as_retriever()即可。

當然我們還有很多其它的 Retrievers 如 Web search Retrievers 等,LangChain 也收錄了很多第三方的 Retrievers

使用示例 5

以下就是一個使用向量數據庫 + VectoreStoreRetriever + QA Chain 的 QA 應用示例:

from langchain.chat_models import ChatOpenAI
from langchain.vectorstores import FAISS
from langchain.chains import RetrievalQA
from langchain.document_loaders import TextLoader
from langchain.embeddings.openai import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter

# 初始化LLM
llm = ChatOpenAI(temperature=0.5, model_, openai_api_key=OPENAI_API_KEY, openai_api_base=OPENAI_PROXY_URL)

# 加載文檔
loader = TextLoader('path/to/related/document')
doc = loader.load()
print (f"You have {len(doc)} document")

print (f"You have {len(doc[0].page_content)} characters in that document")

# 分割字符串
text_splitter = RecursiveCharacterTextSplitter(chunk_size=3000, chunk_overlap=400)
docs = text_splitter.split_documents(doc)
num_total_characters = sum([len(x.page_content) for x in docs])
print (f"Now you have {len(docs)} documents that have an average of {num_total_characters / len(docs):,.0f} characters (smaller pieces)")

# 初始化向量化模型
embeddings = OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY, openai_api_base=OPENAI_PROXY_URL)

# 向量化Document並存入向量數據庫(綁定向量和對應Document元數據),這裏我們選擇本地最常用的FAISS數據庫
# 注意: 這會向OpenAI產生請求併產生費用
doc_search = FAISS.from_documents(docs, embeddings)

qa = RetrievalQA.from_chain_type(llm=llm, chain_type="stuff"retriever=doc_search.as_retriever()verbose=True)
query = "Specific questions to be asked"
qa.run(query)

執行後 verbose 輸出日誌如下:

You have 1 document
You have 74663 characters in that document
Now you have 29 documents that have an average of 2,930 characters (smaller pieces)

**

Entering new chain...
**

Prompt after formatting:

**_
System: Use the following pieces of context to answer the users question. If you don't know the answer, just say that you don't know, don't try to make up an answer.
---------------_**

**_
Human: What does the author describe as good work?
_**

**_
The author describes working on things that aren't prestigious as a sign of good work. They believe that working on unprestigious types of work can lead to the discovery of something real and that it indicates having the right kind of motives. The author also mentions that working on things that last, such as paintings, is considered good work._**

Chains

接下來就是 LangChain 中的主角——Chains 了,Chains 是 LangChain 中爲連接多次與 Language Model 的交互過程而抽象的重要組件,它可以將多個組件組合在一起以創建一個單一的、連貫的任務,也可以嵌套多個 Chain 組合在一起,或者將 Chain 與其他組件組合來構建更復雜的 Chain。

除了單一 Chain 外,常用的幾個 Chains 如下:

Router Chain

在一些場景下,我們需要根據輸入 / 上下文決定使用哪一條 Chain,甚至哪一個 Prompt,Router Chain 就提供了諸如 MultiPromptChain, LLMRouterChain 等一系列用於決策的 Chain(這與後面我們會提到的 Agent 有類似的地方)。

Router Chain 由兩部分組成:

Sequential Chain

顧名思義,順序執行的串行 Chain,其中最簡單的 SimpleSequentialChain 非常簡單粗暴,SimpleSequentialChain 的每個子 Chain 都有一個單一的輸入 / 輸出,並且一個步驟的輸出是下一步的輸入。

而高階一些的 SequentialChain 則允許多輸入輸出,並且我們可以通過添加後面會提到的 Memory 等來提高其推理表現。

Map-reduce Chain

Map-reduce Chain 主要用於 summary 的場景,針對那些超長的文檔,首先我們通過前面提到過的 TextSpliter 按一定規則分割文檔爲更小的 Chunks(通常使用 RecursiveCharacterTextSplitter,如果 Document 是結構化的可以考慮使用指定的 TextSpliter), 然後對每個分割的部分執行”map-chain”,收集全部”map-chain” 的輸出後,再執行”reduce-chain”,獲得最終的 summary 輸出。

使用示例

下面就是一個 Router Chain 中的 MultiPromptChain 的具體示例:

from langchain.chains.router import MultiPromptChain
from langchain.llms import OpenAI
from langchain.chains import ConversationChain
from langchain.chains.llm import LLMChain
from langchain.prompts import PromptTemplate
from langchain.chains.router.llm_router import LLMRouterChain, RouterOutputParser
from langchain.chains.router.multi_prompt_prompt import MULTI_PROMPT_ROUTER_TEMPLATE

# 定義要路由的prompts
physics_template = """You are a very smart physics professor. \
You are great at answering questions about physics in a concise and easy to understand manner. \
When you don't know the answer to a question you admit that you don't know.

Here is a question:
{input}"""

math_template = """You are a very good mathematician. You are great at answering math questions. \
You are so good because you are able to break down hard problems into their component parts, \
answer the component parts, and then put them together to answer the broader question.

Here is a question:
{input}"""

# 整理prompt和相關信息
prompt_infos = [
    {
        "name""physics",
        "description""Good for answering questions about physics",
        "prompt_template": physics_template,
    },
    {
        "name""math",
        "description""Good for answering math questions",
        "prompt_template": math_template,
    },
]

llm = OpenAI(temperature=0.5, openai_api_key=OPENAI_API_KEY, openai_api_base=OPENAI_PROXY_URL+"/v1")
destination_chains = {}
for p_info in prompt_infos:
    # 以每個prompt爲基礎創建一個destination_chain(開啓verbose)
    name = p_info["name"]
    prompt_template = p_info["prompt_template"]
    prompt = PromptTemplate(template=prompt_template, input_variables=["input"])
    chain = LLMChain(llm=llm, prompt=prompt)
    destination_chains[name] = chain
# 創建一個缺省chain,如果沒有其他chain滿足路由條件,則使用該chain
default_chain = ConversationChain(llm=llm, output_key="text")

destinations = [f"{p['name']}: {p['description']}" for p in prompt_infos]
destinations_str = "\n".join(destinations)
# 根據prompt_infos中的映射關係創建router_prompt
router_template = MULTI_PROMPT_ROUTER_TEMPLATE.format(destinations=destinations_str)
router_prompt = PromptTemplate(
    template=router_template,
    input_variables=["input"],
    output_parser=RouterOutputParser(),
)
# 創建router_chain(開啓verbose)
router_chain = LLMRouterChain(llm_chain=LLMChain(llm=llm, prompt=router_prompt, verbose=True)verbose=True)

# 將router_chain和destination_chains以及default_chain組合成MultiPromptChain(開啓verbose)
chain = MultiPromptChain(
    router_chain=router_chain,
    destination_chains=destination_chains,
    default_chain=default_chain,
    verbose=True,
)
# run
chain.run("What is black body radiation?")

執行後 verbose 輸出日誌如下:

**Entering new chain...**Prompt after formatting: Given a raw text input to a language model select the model prompt best suited for the input. You will be given the names of the available prompts and a description of what the prompt is best suited for. You may also revise the original input if you think that revising it will ultimately lead to a better response from the language model.

<> Return a markdown code snippet with a JSON object formatted to look like:

{
    "destination": string name of the prompt to use or "DEFAULT"
    "next_inputs": string a potentially modified version of the original input
}

REMEMBER: "destination" MUST be one of the candidate prompt names specified below OR it can be "DEFAULT" if the input is not well suited for any of the candidate prompts. REMEMBER: "next_inputs" can just be the original input if you don't think any modifications are needed.

<> physics: Good for answering questions about physics math: Good for answering math questions

<>
What is black body radiation?

<>

** Finished chain.
**

physics: {'input': 'What is black body radiation?'}

**

Entering new chain...
**

Prompt after formatting:

You are a very smart physics professor. You are great at answering questions about physics in a concise and easy to understand manner. When you don't know the answer to a question you admit that you don't know.

Here is a question:
 What is black body radiation?

**

Finished chain.**

Memory

Memory可以幫助Language Model補充歷史信息的上下文,LangChain中的Memory是一個有點模糊的術語,它可以像記住你過去聊天過的信息一樣簡單,也可以結合向量數據庫做更加複雜的歷史信息檢索,甚至維護相關實體及其關係的具體信息,這取決於具體的應用。

通常 Memory 用於較長的 Chain,能一定程度上提高模型的推理表現。

常用的 Memory 類型如下:

使用示例

下面是一個Conversation Summary Buffer MemoryConversationChain中的使用示例,包含了切換會話時恢復現場 memory 的方法以及自定義 summary prompt 的方法:

from langchain.chains import ConversationChain
from langchain.memory import ConversationSummaryBufferMemory
from langchain.llms import OpenAI
from langchain.schema import SystemMessage, AIMessage, HumanMessage
from langchain.memory.prompt import SUMMARY_PROMPT
from langchain.prompts import PromptTemplate

llm = OpenAI(temperature=0.7, openai_api_key=OPENAI_API_KEY, openai_api_base=OPENAI_PROXY_URL+"/v1")
# ConversationSummaryBufferMemory默認使用langchain.memory.prompt.SUMMARY_PROMPT作爲summary的PromptTemplate
# 如果對它summary的格式/內容有特殊要求,可以自定義PromptTemplate(實測默認的summary有些流水賬)
prompt_template_str = """
## Instruction
Progressively summarize the lines of conversation provided, adding onto the previous summary returning a new concise and detailed summary.
Don't repeat the conversation directly in the summary, extract key information instead.

## EXAMPLE
Current summary:
The human asks what the AI thinks of artificial intelligence. The AI thinks artificial intelligence is a force for good.

New lines of conversation:
Human: Why do you think artificial intelligence is a force for good?
AI: Because artificial intelligence will help humans reach their full potential.

New summary:
The human inquires about the AI's opinion on artificial intelligence. The AI believes that it is a force for good as it can help humans reach their full potential.

## Current summary
{summary}

## New lines of conversation
{new_lines}

## New summary

"""
prompt = PromptTemplate(
    input_variables=SUMMARY_PROMPT.input_variables, # input_variables爲SUMMARY_PROMPT中的input_variables不變
    template=prompt_template_str, # template替換爲上面重新編寫的prompt_template_str
)
memory = ConversationSummaryBufferMemory(llm=llm, prompt=prompt, max_token_limit=60)
# 添加歷史memory,其中第一條SystemMessage爲歷史對話中Summary的內容,第二條HumanMessage和第三條AIMessage爲歷史對話中最後的對話內容
memory.chat_memory.add_message(SystemMessage(content="The human asks what the AI thinks of artificial intelligence. The AI thinks artificial intelligence is a force for good because it will help humans reach their full potential. The human then asks the difference between python and golang in short. The AI responds that python is a high-level interpreted language with an emphasis on readability and code readability, while golang is a statically typed compiled language with a focus on concurrency and performance. Python is typically used for general-purpose programming, while golang is often used for building distributed systems."))
memory.chat_memory.add_user_message("Then if I want to build a distributed system, which language should I choose?")
memory.chat_memory.add_ai_message("If you want to build a distributed system, I would recommend golang as it is a statically typed compiled language that is designed to facilitate concurrency and performance.")
# 調用memory.prune()確保chat_memory中的對話內容不超過max_token_limit
memory.prune()
conversation_with_summary = ConversationChain(
    llm=llm,
    # We set a very low max_token_limit for the purposes of testing.
    memory=memory,
    verbose=True,
)
# memory.prune()會在每次調用predict()後自動執行
conversation_with_summary.predict(input="Is there any well-known distributed system built with golang?")
conversation_with_summary.predict(input="Is there a substitutes for Kubernetes in python?")

執行後 verbose 輸出日誌如下:

> Entering new chain...

Prompt after formatting:

The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.

Current conversation: System: The human asks the AI about its opinion on artificial intelligence and is told that it is a force for good that can help humans reach their full potential. The human then inquires about the differences between python and golang, with the AI explaining that python is a high-level interpreted language for general-purpose programming, while golang is a statically typed compiled language often used for building distributed systems. Human: Then if I want to build a distributed system, which language should I choose? AI: If you want to build a distributed system, I would recommend golang as it is a statically typed compiled language that is designed to facilitate concurrency and performance. Human: Is there any well-known distributed system built with golang?
AI:

> Finished chain.

> Entering new chain...

Prompt after formatting:

The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.

Current conversation: System: The human asks the AI about its opinion on artificial intelligence and is told that it is a force for good that can help humans reach their full potential. The human then inquires about the differences between python and golang, with the AI explaining that python is a high-level interpreted language for general-purpose programming, while golang is a statically typed compiled language designed to facilitate concurrency and performance, thus better suited for distributed systems. The AI recommends golang for building distributed systems. Human: Is there any well-known distributed system built with golang? AI: Yes, there are several well-known distributed systems built with golang. These include Kubernetes, Docker, and Consul. Human: Is there a substitutes for Kubernetes in python?
AI:

**> Finished chain.
**

'Yes, there are several substitutes for Kubernetes in python. These include Dask, Apache Mesos and Marathon, and Apache Aurora.’

Agent

在一些場景下,我們需要根據用戶輸入靈活地調用LLM和其它工具(LangChain將工具抽象爲 Tools 這一組件),Agent 爲這樣的應用程序提供了相關的支持。

Agent可以訪問一套工具,並根據用戶輸入確定要使用Chain或是Function,我們可以簡單的理解爲他可以動態的幫我們選擇和調用 Chain 或者已有的工具。

常用的Agent類型如下:

Conversational Agent

這類 Agent 可以根據 Language Model 的輸出決定是否使用指定的 Tool,以及使用什麼 Tool(這裏的 Tool 也可以是一個 Chain),以及時的爲 Model I/O 的過程補充信息。

OpenAI functions Agent

類似 Conversational Agent,但它能夠讓 Agent 更進一步地幫忙提取指定 Tool 的參數等,甚至使用多個 Tools。

Plan and execute Agent

抽象 Agent“決定做什麼”的過程爲 “planning what to do” 和“executing the sub tasks”(這種方法來自 "Plan-and-Solve" 這一篇論文),其中 “planning what to do” 這一步通常完全由 LLM 完成,而 “executing the sub tasks” 這一任務則通常由更多的 Tools 來完成。

ReAct Agent

結合對 LLM 輸出的歸因和執行,類似 OpenAI functions Agent,提供了一個更加明確的框架以及由論文支撐的方法。

這類 Agent 會基於 LLM 的輸出,自行調用 Tools 以及 LLM 來進行額外的搜索和自查,以達到拓展和優化輸出的目的。

總結

LangChain 中的 Data Connection 將 LLM 與萬物互聯,給 LangChain 構建的應用帶來了無限可能,而 Agent 又爲應用開發中非常常見的” 事件驅動 “這一開發框架提供了偷懶的途徑,將分支決策的工作交給 LLM,這又進一步簡化了應用開發的工作;結合基於高度抽象的 Model I/O 及 Memory 等組件,LangChain 讓開發者能夠更快,更好更靈活地實現 LLM 助手、對話機器人等應用,極大地降低了 LLM 的使用門檻。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KrWM3cMywMvYUiawRZ94Gg