写在前面
在人工智能技术飞速发展的今天,大语言模型(LLM)展现出了惊人的能力。然而,如何让 LLM 真正落地到垂直领域,解决实际问题,仍然是一个值得深入探讨的课题。RAG(Retrieval-Augmented Generation,检索增强生成)作为目前最成熟的解决方案之一,通过将外部知识库与 LLM 结合,有效解决了模型知识更新滞后、幻觉等问题。
本文将带你完整走一遍我最近实现的一个企业级模块化 RAG 问答系统。这不是一个简单的 Demo,而是一个集成了多种检索策略、支持多轮对话、具有流式输出能力的生产级系统。我会详细拆解系统的每一个模块,分享设计思路,并附上完整的架构图和流程图。
无论你是正准备上手 RAG 项目的开发者,还是希望深入理解 RAG 内部机制的学习者,希望这篇文章能给你带来一些启发。
一、项目概述:我们要解决什么问题?
1.1 业务背景
假设你正在为一个在线教育平台构建智能问答助手。平台有海量的课程资料(PDF、PPT、Word、图片),学生经常询问课程内容、知识点细节、学习路径等问题。传统的做法是:
-
纯 LLM 方案:模型可能不了解平台特有的课程内容,容易产生幻觉
-
关键词检索方案:只能匹配精确词汇,无法理解语义相似的问题
RAG 方案的优势在于:既能利用 LLM 的强大生成能力,又能基于实际文档内容进行回答,保证答案的准确性和可追溯性。
1.2 核心需求
| 需求 | 描述 |
|---|---|
| 多格式文档支持 | PDF、Word、PPT、图片、Markdown |
| 智能检索 | 语义理解 + 关键词匹配 |
| 多轮对话 | 记住上下文,连续问答 |
| 高性能 | 毫秒级响应,支持流式输出 |
| 可扩展 | 模块化设计,易于新增功能 |
二、系统架构:一张图看懂全貌
python
"""
┌─────────────────────────────────────────────────────────────────────────────┐
│ 用户交互层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web 前端 │ │ API 接口 │ │ WebSocket │ │ 命令行 │ │
│ │ (HTML/JS) │ │ (RESTful) │ │ (流式) │ │ (CLI) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ API 网关层 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ FastAPI 服务 │ │
│ │ • CORS 跨域支持 • 会话管理 • 历史记录 • 健康检查 • 静态文件 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 集成问答系统层 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ IntegratedQASystem │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 会话管理 │ │ 历史存储 │ │ 流程编排 │ │ 流式输出 │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ 一级检索 │ │ 二级检索 │ │ 三级检索 │
│ (BM25 + MySQL) │ │ (Milvus + BGE) │ │ (Reranker) │
│ │ │ │ │ │
│ • 关键词匹配 │ │ • 稠密向量检索 │ │ • Cross-Encoder │
│ • 精确问答 │ │ • 稀疏向量检索 │ │ • 精排优化 │
│ • 毫秒级响应 │ │ • 语义理解 │ │ • 相关性打分 │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│ │ │
└────────────────────────┼────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 数据存储层 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ MySQL │ │ Redis │ │ Milvus │ │ 本地模型 │ │
│ │ • 知识库 │ │ • 缓存 │ │ • 向量库 │ │ • BERT │ │
│ │ • 会话历史 │ │ • BM25索引 │ │ • 父子文档 │ │ • BGE-M3 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
"""
三、核心模块详解
3.1 文档加载器:多源异构文档的统一处理
这是系统的"输入"环节。用户的资料可能是 PDF、Word、PPT,甚至是一张截图。我们需要一个统一的加载框架来应对这种多样性。
设计思路
python
# 工厂模式 + 策略模式
document_loaders = {
".pdf": OCRPDFLoader, # PDF 加载器(支持 OCR)
".docx": OCRDOCLoader, # Word 加载器(支持图片 OCR)
".pptx": OCRPPTLoader, # PPT 加载器(支持组合图形)
".jpg": OCRIMGLoader, # 图片加载器(纯 OCR)
".png": OCRIMGLoader,
".md": UnstructuredMarkdownLoader, # Markdown 加载器
}
关键实现
1. PDF 加载器(OCRPDFLoader)
python
class OCRPDFLoader(BaseLoader):
def pdf2text(self):
ocr = get_ocr() # 获取 OCR 实例
doc = fitz.open(self.file_path)
for page in doc:
# 提取文本层
text = page.get_text("text")
resp += text + "\n"
# 提取图片层(智能过滤)
for img in page.get_image_info():
# 只 OCR 占据页面 60% 以上的大图
if self._is_large_image(img, page):
pix = fitz.Pixmap(doc, img["xref"])
result, _ = ocr(np.array(pix))
resp += "\n".join([line[1] for line in result])
设计亮点:
-
双通道提取:同时提取文本层和图片层
-
智能过滤:只对占据页面 60% 以上的大图进行 OCR,避免对 logo、图标等小图的无效处理
-
旋转校正:处理扫描件时自动校正页面旋转
2. Word 加载器(OCRDOCLoader)
Word 文档的结构比 PDF 更复杂,需要处理段落、表格、内嵌图片。
python
def doc2text(self, filepath):
doc = Document(filepath)
for block in iter_block_items(doc):
if isinstance(block, Paragraph):
# 提取段落文本
resp += block.text.strip() + "\n"
# 提取段落中的图片
for image in self._extract_images(block):
resp += self._ocr_image(image)
elif isinstance(block, Table):
# 提取表格内容
for row in block.rows:
for cell in row.cells:
resp += cell.text.strip() + "\n"
3. PPT 加载器(OCRPPTLoader)
PPT 有特殊的"组合形状"概念,需要递归处理。
python
def extract_text(self, shape):
if shape.has_text_frame:
resp += shape.text.strip() + "\n"
elif shape.has_table:
# 提取表格
for row in shape.table.rows:
for cell in row.cells:
resp += cell.text.strip() + "\n"
elif shape.shape_type == 13: # 图片类型
result, _ = ocr(np.array(Image.open(BytesIO(shape.image.blob))))
resp += "\n".join([line[1] for line in result])
elif shape.shape_type == 6: # 组合类型(递归处理)
for child in shape.shapes:
self.extract_text(child)
4. 图片加载器(OCRIMGLoader)
最简洁的加载器,直接进行 OCR 识别。
python
def img2text(self):
ocr = get_ocr()
result, _ = ocr(self.img_path)
return "\n".join([line[1] for line in result])
3.2 文本切分器:中文语义的精准分割
文档加载后得到的是原始文本,但直接存入向量库会有问题:
-
文本太长,检索精度下降
-
文本太短,上下文不完整
解决方案是父子文档切分策略。
父子文档策略
python
"""
原始文档 (3000字)
│
▼ parent_splitter (chunk_size=1200)
┌─────────────────────────────────────────────────────────────┐
│ Parent Chunk 0 (1200字) parent_id: doc_0_parent_0 │
│ │ │
│ ▼ child_splitter (chunk_size=300) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │
│ │ Child 0_0 │ │ Child 0_1 │ │ Child 0_2 │ │ Child 0_3 │ │
│ │ parent_id │ │ parent_id │ │ parent_id │ │ parent_id │ │
│ │ parent_ │ │ parent_ │ │ parent_ │ │ parent_ │ │
│ │ content │ │ content │ │ content │ │ content │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
"""
设计思想:
-
子块入库:用于检索,粒度小,匹配精准
-
父块输出:作为 LLM 上下文,内容完整,语义连贯
这样,当用户问"什么是神经网络"时,系统可以在子块中精准匹配到相关段落,然后返回完整的父块给 LLM,保证答案的完整性。
中文优化切分器
python
class ChineseRecursiveTextSplitter(RecursiveCharacterTextSplitter):
def __init__(self):
self._separators = [
"\n\n", # 段落分隔
"\n", # 换行
"。|!|?", # 中文句尾标点(正则支持多匹配)
"\.\s|\!\s|\?\s", # 英文句尾+空格
";|;\s", # 分号
",|,\s" # 逗号
]
相比 LangChain 原生切分器,这个版本对中文更友好,能够按语义边界进行切分。
3.3 向量存储:Milvus 的高效检索
Schema 设计
python
schema.add_field("id", VARCHAR, is_primary=True) # MD5 哈希作为主键
schema.add_field("text", VARCHAR) # 子块文本内容
schema.add_field("dense_vector", FLOAT_VECTOR, dim=1024) # 稠密向量
schema.add_field("sparse_vector", SPARSE_FLOAT_VECTOR) # 稀疏向量
schema.add_field("parent_id", VARCHAR) # 父块 ID(溯源)
schema.add_field("parent_content", VARCHAR) # 父块内容(避免二次查询)
schema.add_field("source", VARCHAR) # 学科过滤字段
混合检索原理
稠密向量:由 BGE-M3 模型生成,捕获语义相似性。例如:"汽车"和"轿车"在语义空间中是接近的。
稀疏向量:也是由 BGE-M3 生成,但本质上是学习出来的关键词权重分布。例如:每个 token 对应一个权重分数,表示该词在文档中的重要性。
python
# 稀疏向量示例(概念示意)
sparse_vector = {
token_id_1: 0.85, # "人工智能" 的权重
token_id_2: 0.62, # "课程" 的权重
token_id_3: 0.31, # "介绍" 的权重
}
两阶段排序
python
"""
用户 Query
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 第一阶段:混合检索 + 加权合并 │
│ │
│ 稠密向量检索 (权重 1.0) ─────┐ │
│ ├── WeightedRanker ──► 粗排 │
│ 稀疏向量检索 (权重 0.7) ─────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 第二阶段:Cross-Encoder 重排序 │
│ │
│ BGE-Reranker-Large 对候选文档逐一打分 │
│ 按相关性得分重新排序 ──► 最终结果 │
└─────────────────────────────────────────────────────────────┘
"""
什么需要两阶段?
| 方法 | 速度 | 精度 | 适用 |
|---|---|---|---|
| 加权合并 | 快(毫秒级) | 一般 | 初筛,从百万级降到百级 |
| Reranker | 慢(秒级) | 高 | 精排,从百级降到最终 K 个 |
3.4 查询分类器:BERT 微调实现意图识别
并不是所有问题都需要走 RAG 流程。像"今天天气怎么样"这类通用问题,直接让 LLM 回答即可,既能节省成本,又能提升响应速度。
python
class QueryClassifier:
def __init__(self):
self.model = BertForSequenceClassification.from_pretrained(
"bert-base-chinese",
num_labels=2 # 二分类:通用知识 vs 专业咨询
)
def predict_category(self, query):
encoding = self.tokenizer(query, return_tensors="pt")
outputs = self.model(**encoding)
prediction = torch.argmax(outputs.logits, dim=1).item()
return "专业咨询" if prediction == 1 else "通用知识"
训练数据示例:
python
{"query": "AI 课程学什么?", "label": "专业咨询"}
{"query": "今天天气怎么样?", "label": "通用知识"}
{"query": "JAVA 的学费是多少?", "label": "专业咨询"}
通过 5000 条标注数据微调,分类准确率可达 90% 以上。
3.5 策略选择器:LLM 即路由器
这是项目中一个创新的设计------用 LLM 来决定使用哪种检索策略。
python
class StrategySelector:
def select_strategy(self, query):
prompt = f"""
从以下策略中选择最适合的:
1. 直接检索:适用于意图明确的问题
2. 子查询检索:适用于涉及多个实体的问题
3. 假设问题检索(HyDE):适用于抽象问题
4. 回溯问题检索:适用于复杂问题
用户查询:{query}
策略:
"""
return self.llm(prompt).strip()
| 策略 | 适用场景 | 示例 |
|---|---|---|
| 直接检索 | 意图明确 | "AI 学科学费是多少?" |
| 子查询检索 | 涉及多个实体 | "比较 Milvus 和 Zilliz Cloud 的优缺点" |
| HyDE | 抽象问题 | "人工智能在教育领域的应用" |
| 回溯问题 | 复杂问题 | "100亿条数据存 Milvus 可以吗?" |
3.6 BM25 检索:快速降级方案
RAG 虽然强大,但每次都要调用 LLM,成本和延迟都较高。因此,我们设计了一级快速检索:BM25 + MySQL。
python
class BM25Search:
def search(self, query, threshold=0.85):
# 1. 查 Redis 缓存
cached = self.redis_client.get_answer(query)
if cached:
return cached, False
# 2. BM25 打分
scores = self.bm25.get_scores(query_tokens)
softmax_scores = self._softmax(scores)
best_score = softmax_scores.max()
# 3. 阈值判断
if best_score >= threshold:
answer = self.mysql_client.fetch_answer(best_question)
self.redis_client.set_data(f"answer:{query}", answer)
return answer, False
# 4. 降级到 RAG
return None, True
设计亮点:
-
两级缓存:答案缓存 + BM25 索引缓存
-
Softmax 归一化:将 BM25 原始分数映射到 [0,1] 区间
-
阈值降级:低于 0.85 的查询自动降级到 RAG
3.7 多轮对话管理:会话隔离 + 历史记忆
python
# 会话表结构
CREATE TABLE conversations (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(36) NOT NULL, -- 会话隔离
question TEXT NOT NULL,
answer TEXT NOT NULL,
timestamp DATETIME NOT NULL,
INDEX idx_session_id (session_id) -- 加速查询
);
# 获取最近5轮对话(最新在前)
SELECT question, answer FROM conversations
WHERE session_id = %s
ORDER BY timestamp DESC LIMIT 5
历史格式转换:
python
# SQL 返回(最新 → 最旧)
history = [Q5, Q4, Q3, Q2, Q1]
# 反转后(最旧 → 最新)
history = history[::-1] # [Q1, Q2, Q3, Q4, Q5]
# 格式化为 LLM 可理解的字符串
history_text = "\n".join([f"Q:{h['question']}\nA:{h['answer']}" for h in history])
3.8 流式输出:提升用户体验
python
def call_llm_stream(self, prompt):
response = ollama.chat(model='qwen2.5:7b', stream=True)
for chunk in response:
yield chunk['message']['content']
# 使用
for token in self.llm_stream(prompt):
yield token, False # False 表示未完成
yield "", True # True 表示完成
效果:用户不用等待完整回答,可以逐字看到生成过程,体验大幅提升。
四、完整问答流程
python
"""
用户: "AI 课程学什么?" + session_id="001" + source_filter="ai"
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 1: 获取历史对话 │
│ SELECT * FROM conversations WHERE session_id='001' │
│ ORDER BY timestamp DESC LIMIT 5 │
│ → 反转后得到 [Q1,A1, Q2,A2, Q3,A3, Q4,A4, Q5,A5] │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 2: BM25 快速检索 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2.1 查 Redis 缓存 │ │
│ │ key = "answer:AI 课程学什么?" │ │
│ │ → 未命中 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 2.2 BM25 打分 │ │
│ │ query_tokens = ["AI", "课程", "学什么"] │ │
│ │ scores = bm25.get_scores() │ │
│ │ softmax_scores = softmax(scores) │ │
│ │ best_score = 0.32 (< 0.85) │ │
│ │ → 需要降级到 RAG │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 3: RAG 深度检索 │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3.1 BERT 分类 │ │
│ │ "AI 课程学什么?" → "专业咨询" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3.2 策略选择 (LLM 路由) │ │
│ │ → "直接检索" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3.3 混合检索 (Milvus) │ │
│ │ • 稠密向量检索 (语义) │ │
│ │ • 稀疏向量检索 (关键词) │ │
│ │ • WeightedRanker 加权合并 │ │
│ │ • 提取父块去重 │ │
│ │ • BGE-Reranker 精排 │ │
│ │ → 返回 2 个父块 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3.4 构建 Prompt │ │
│ │ context = 父块1 + 父块2 │ │
│ │ history = 最近5轮对话 │ │
│ │ prompt = format(context, history, query) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 3.5 流式生成答案 │ │
│ │ for token in llm_stream(prompt): │ │
│ │ yield token, False │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Step 4: 更新历史 │
│ │
│ • INSERT INTO conversations (session_id, question, answer) │
│ • DELETE 超出5轮的旧记录 │
│ • 缓存答案到 Redis │
└─────────────────────────────────────────────────────────────┘
│
▼
用户收到流式答案
"""
五、关键技术选型
| 组件 | 技术选型 | 选型理由 |
|---|---|---|
| 向量数据库 | Milvus | 支持混合检索、高性能、云原生 |
| 嵌入模型 | BGE-M3 | 同时支持稠密+稀疏向量,中文效果好 |
| 重排序模型 | BGE-Reranker-Large | Cross-Encoder 架构,精排精度高 |
| LLM | DeepSeek-V3 (via SiliconFlow) | 中文能力强,性价比高 |
| 分类模型 | BERT-base-chinese | 轻量级,微调后效果好 |
| 文档加载 | PyMuPDF + python-docx + python-pptx | 成熟稳定,支持 OCR |
| 文本切分 | 自定义中文递归切分器 | 对中文语义边界更友好 |
| 数据库 | MySQL | 结构化数据存储 |
| 缓存 | Redis | 高性能缓存 |
| API 框架 | FastAPI | 高性能、异步支持、自动文档 |
| OCR | RapidOCR (Paddle/ONNX) | 自动降级,支持 GPU/CPU |
六、项目亮点总结
6.1 架构设计亮点
-
模块化设计:每个组件职责单一,可独立替换和升级
-
三级检索降级:Redis 缓存 → BM25 关键词 → Milvus 向量,层层递进
-
父子文档策略:子块精准检索 + 父块完整上下文,兼顾精度和完整性
-
会话隔离:支持多用户、多会话独立对话历史
6.2 检索策略亮点
-
混合检索:稠密向量(语义)+ 稀疏向量(关键词),互补优势
-
两阶段排序:加权合并快速初筛 + Cross-Encoder 精排
-
LLM 路由:用 LLM 智能选择检索策略,而非硬编码规则
-
BERT 分类器:快速识别通用问题,避免不必要的 RAG 调用
6.3 工程实现亮点
-
多格式文档加载:PDF/Word/PPT/图片,统一接口
-
智能 OCR:只对大图进行 OCR,避免性能浪费
-
流式输出:逐字返回,用户体验好
-
自动历史清理:只保留最近 5 轮,控制上下文长度
-
两级缓存:Redis 缓存答案和 BM25 索引
6.4 用户体验亮点
-
WebSocket 流式:实时反馈,无等待感
-
多会话支持:用户可创建多个独立对话
-
学科过滤:可按学科限定检索范围
-
历史追溯:所有问答都有记录
七、运行效果
命令行模式
python
$ python new_main.py
欢迎使用集成问答系统!
支持的来源: ['ai', 'java', 'test', 'ops', 'bigdata']
请您输入对话ID: 001
输入查询: AI 课程学什么?
输入来源过滤 (ai/java/test/ops/bigdata) (按 Enter 跳过): ai
根据提供的文档,AI 课程主要包括以下几个模块:
1. 人工智能导论
2. 机器学习基础
3. 深度学习框架
4. 自然语言处理
5. 计算机视觉
...
API 模式
python
# 启动服务
$ python app.py
# 调用接口
$ curl -X POST http://localhost:8001/api/query \
-H "Content-Type: application/json" \
-d '{"query": "AI 课程学什么?", "source_filter": "ai"}'
# 响应
{
"answer": "根据提供的文档,AI 课程主要包括...",
"is_streaming": false,
"session_id": "xxx",
"processing_time": 1.23
}
WebSocket 流式
python
const ws = new WebSocket("ws://localhost:8001/api/stream");
ws.send(JSON.stringify({
query: "AI 课程学什么?",
source_filter: "ai",
session_id: "001"
}));
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "token") {
console.print(data.token); // 逐字打印
} else if (data.type === "end") {
console.log("\n生成完成,耗时:", data.processing_time);
}
};
八、踩坑与优化
8.1 遇到的问题
| 问题 | 解决方案 |
|---|---|
| ModelScope 依赖冲突 | 降级 setuptools 到 65.5.0 |
| BERT 路径错误 | 使用绝对路径替代相对路径 |
| 流式输出换行符过多 | 过滤 \n token 或使用 .strip() |
| WebSocket 循环提前退出 | 移除 break,使用 continue |
| 混合检索精度不足 | 增加 Reranker 精排阶段 |
8.2 性能优化建议
-
批量插入:Milvus 插入时使用批量操作
-
连接池:数据库和 Redis 使用连接池
-
异步处理:API 层使用异步 IO
-
模型预热:启动时预加载模型到内存
九、扩展方向
-
多模态支持:增加图像、音频理解能力
-
Agent 模式:让 LLM 自主选择工具和策略
-
知识图谱:引入结构化知识增强推理
-
评估体系:建立 RAG 评估指标(忠实度、答案相关性等)
-
A/B 测试:支持多版本模型对比
写在最后
这个 RAG 项目从文档加载到向量检索,从 BM25 降级到多轮对话,从流式输出到 API 封装,覆盖了一个完整问答系统的所有环节。
回顾整个开发过程,最大的体会是:RAG 不是一个单一的技术,而是一套系统工程。它需要你综合考虑文档处理、向量检索、模型选型、系统架构、用户体验等多个维度。
希望这篇文章能帮助你理解 RAG 系统的内部机制,也希望能给你在自己的项目中落地 RAG 提供一些参考。
如果你有任何问题或建议,欢迎留言交流!
参考资源:
可以的话加个好友,互相学习,感谢阅读。