聊天机器人开发实战--(微信小程序+SpringCloud+Pytorch+Flask)【后端部分】

news2024/11/13 9:33:52

文章目录

  • 前言
  • 架构
  • SpringCloud服务构建
    • 后台搭建
    • Python服务调用
  • Python算法服务
    • app
  • 总结

前言

趁着五一有时间,先把大三下个学期的期末作业做了,把微信小程序和Java开发的一起做了。顺便把机器学习的也一起做了。所以的话,我们完整项目的技术体系主要有 微信小程序开发,Java Web开发(因为我喜欢把admin后台管理和用户端服务分开,所有我选择SpringCloud做一个切分,实际上就是两个服务+网关),然后是基于Pytorch的NLP对话机器人,那么关于对话机器人的话,这个没办法,只能继续用先前GPT2的那个,没办法,有几个效果不错的,但是嘛,跑不动,当然也可以直接那啥,但是吧有一定的风险,能跑就行了,架子搭起来,上面都好说。

同样的,文章分为上下两篇,后端与前端部分,其实也没办法,一天没写完,中间准备会议录屏去了。中间还遇到了Python的一个bug,查了小半天的issue。

整个项目的设计非常简单,也没有做什么复杂的东西,dome而已,没必要那么复杂,也不见得那些老师可以看懂,没必要把自己搞得那么累,能花500搞定绝不花1000精力搞定。

所以,整个项目是很简单的,不过涉及到的东西不少,所以问题在你对于上面提到的技术熟不熟悉。

架构

ok,这里我们可以先看到我们整个项目的基本架构。由于这里没有涉及到部署,所以这里的话,我就不画那些花里胡哨的东西了。

在这里插入图片描述

那么这里的网关的话,其实就是这个SpringCloud当中的GateWay,然后我们的flask算法服务都是注册到nacos,进行服务发现注册调用的。通过网关我们开放了对外的访问接口,但是直接通过网关不能直接访问到flask程序,这个程序是通过SpringBoot进行远程调用,远程调用的地址是通过Nacos获取的,换一句话来说,我们的算法服务是属于内网服务,不暴露。

那么在nacos的视角是这样的:
在这里插入图片描述

SpringCloud服务构建

ok,废话不多说,我们先来看看这个SpringCloud服务是如何构建的。

其实这里的话,我们先是偷了个懒,没错直接把先前开发WhiteHole准备好的模板工程拿了过来。这个模板工程是基于人人开源做的。当然我们在这个基础上做了改动,使得可以更加符合我的需求。
在这里插入图片描述

后台搭建

ok,对SpringCloud的服务的话,我们其实就两个,一个是后台管理,还有一个是正经微信小程序的服务端。Python不提供直接的服务,都是通过Java程序调用的。

那么关于这个后台的搭建的话,可以看到我以前的这两篇博文:

https://huterox.blog.csdn.net/article/details/126847925

https://huterox.blog.csdn.net/article/details/126977137

这边的话,我就不复述了,完成之后是这样的:
在这里插入图片描述

Python服务调用

之后是,调用我们的Python服务,其实也就是我们的算法,我们要的效果是这样的:

我们访问的是SpringBoot程序
在这里插入图片描述
然后它调用到Flask程序,然后给到我们的前端
在这里插入图片描述

那么这里的实现的话,很简单,就是拿到nacos然后就好了。

package com.huterox.ikun.chat.service.impl;

import com.alibaba.nacos.api.naming.pojo.Instance;
import com.huterox.common.utils.R;
import com.huterox.ikun.chat.entity.Q.ChatQ;
import com.huterox.ikun.chat.entity.R.ChatR;
import com.huterox.ikun.chat.service.WChatService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;


import java.util.List;
import java.util.Map;

@Service
public class WChatServiceImpl implements WChatService {


    private final RestTemplate restTemplate;
    private final DiscoveryClient discoveryClient;

    @Autowired
    public WChatServiceImpl(RestTemplate restTemplate, DiscoveryClient discoveryClient) {
        this.restTemplate = restTemplate;
        this.discoveryClient = discoveryClient;
    }
    @Override
    public R wChat(ChatQ chatQ) {
        String serviceName = "flaskService";
        ServiceInstance instance = discoveryClient.getInstances(serviceName).stream()
                .findFirst()
                .orElseThrow(() -> new RuntimeException("no available instances"));

        String url = String.format("http://%s:%d/message", instance.getHost(), instance.getPort());
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        HttpEntity<ChatQ> entity = new HttpEntity<>(chatQ, headers);
        ResponseEntity<Map<String, Object>> response = restTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReference<Map<String, Object>>() {});

        Map<String, Object> body = response.getBody();

        ChatR chatR = new ChatR ();
        chatR.setRes((String) body.get("res"));
        chatR.setSpendTime((Double) body.get("spend_time"));
        return  R.ok().put("chatR",chatR);
    }
}

Python算法服务

之后,是我们的算法服务构建。
首先我们的算法还是先前的这个项目的基础上改动的:
https://gitee.com/Huterox/gpt-play

在这里插入图片描述
那么改动的地方的话,就两个地方:app.py,和controller.py
在这里插入图片描述

首先是controller:


import torch
import os
import argparse
from datetime import datetime
import logging
from transformers.models.gpt2.modeling_gpt2 import GPT2LMHeadModel
from transformers import BertTokenizer
import torch.nn.functional as F
from flask_caching import Cache

PAD = '[PAD]'
pad_id = 0



def set_interact_args():
    """
    Sets up the training arguments.
    """
    parser = argparse.ArgumentParser()
    parser.add_argument('--device', default='0', type=str, required=False, help='生成设备')
    # parser.add_argument('--temperature', default=1, type=float, required=False, help='生成的temperature')
    # parser.add_argument('--topk', default=8, type=int, required=False, help='洛水K个我只饮一瓢')
    # parser.add_argument('--topp', default=0.9, type=float, required=False, help='最高积累概率')  #0
    parser.add_argument('--model_config', default='../GPT2/config/model_config_dialogue_small.json', type=str, required=False,
                        help='模型参数')
    parser.add_argument('--log_path', default='../GPT2/generatorlog/generator.log', type=str, required=False, help='interact日志存放位置')
    parser.add_argument('--voca_path', default='../GPT2/vocabulary/vocab_small.txt', type=str, required=False, help='选择词库')
    # parser.add_argument('--dialogue_model_path', default=r'../GPT2/model/norm_model/poertymodel', type=str, required=False, help='模型路径')  #dialogue_model_path/
    parser.add_argument('--save_samples_path', default="../GPT2/sample/", type=str, required=False, help="保存聊天记录的文件路径")
    parser.add_argument('--repetition_penalty', default=1.0, type=float, required=False,
                        help="重复惩罚参数,若生成的对话重复性较高,可适当提高该参数")
    parser.add_argument('--seed', type=int, default=None, help='设置种子用于生成随机数,以使得训练的结果是确定的')
    # parser.add_argument('--max_len', type=int, default=128, help='每个utterance的最大长度,超过指定长度则进行截断')
    # parser.add_argument('--max_history_len', type=int, default=5, help="聊天的history的最大长度")
    parser.add_argument('--no_cuda', action='store_true', help='不使用GPU进行预测')
    return parser.parse_args()


def create_logger(args):
    """
    将日志输出到日志文件和控制台
    """
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.INFO)

    formatter = logging.Formatter(
        '%(asctime)s - %(levelname)s - %(message)s')

    # 创建一个handler,用于写入日志文件
    file_handler = logging.FileHandler(
        filename=args.log_path)
    file_handler.setFormatter(formatter)
    file_handler.setLevel(logging.INFO)
    logger.addHandler(file_handler)

    # 创建一个handler,用于将日志输出到控制台
    console = logging.StreamHandler()
    console.setLevel(logging.DEBUG)
    console.setFormatter(formatter)
    logger.addHandler(console)

    return logger


def top_k_top_p_filtering(logits, top_k=0, top_p=0.0, filter_value=-float('Inf')):
    """ Filter a distribution of logits using top-k and/or nucleus (top-p) filtering
        Args:
            logits: logits distribution shape (vocabulary size)
            top_k > 0: keep only top k tokens with highest probability (top-k filtering).
            top_p > 0.0: keep the top tokens with cumulative probability >= top_p (nucleus filtering).
                Nucleus filtering is described in Holtzman et al. (http://arxiv.org/abs/1904.09751)
        From: https://gist.github.com/thomwolf/1a5a29f6962089e871b94cbd09daf317
    """
    assert logits.dim() == 1  # batch size 1 for now - could be updated for more but the code would be less clear
    top_k = min(top_k, logits.size(-1))  # Safety check
    if top_k > 0:
        # Remove all tokens with a probability less than the last token of the top-k
        # torch.topk()返回最后一维最大的top_k个元素,返回值为二维(values,indices)
        # ...表示其他维度由计算机自行推断
        indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]  #增加了一个维度。newaxis效果和None是一样的,None是别名
        logits[indices_to_remove] = filter_value  # 对于topk之外的其他元素的logits值设为负无穷

    if top_p > 0.0:
        sorted_logits, sorted_indices = torch.sort(logits, descending=True)  # 对logits进行递减排序
        cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)

        # Remove tokens with cumulative probability above the threshold
        sorted_indices_to_remove = cumulative_probs > top_p
        # Shift the indices to the right to keep also the first token above the threshold
        sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
        sorted_indices_to_remove[..., 0] = 0

        indices_to_remove = sorted_indices[sorted_indices_to_remove]
        logits[indices_to_remove] = filter_value
    return logits



def load_model(model_path):
    args = set_interact_args()
    args.cuda = torch.cuda.is_available() and not args.no_cuda
    device = 'cuda' if args.cuda else 'cpu'
    logger = create_logger(args)
    # 当用户使用GPU,并且GPU可用时
    logger.info('using device:{}'.format(device))
    os.environ["CUDA_VISIBLE_DEVICES"] = args.device
    model = GPT2LMHeadModel.from_pretrained(model_path)
    model.to(device)
    model.eval()
    return model,device,args


def GPTgetSentence(input_target,
                    temperature=1,
                    topK = 10,
                    topP = 0.9,
                    max_len = 128,
                    history = None,
                    max_history_len=5,
                    max_history = 100,
                    chat=False,
                    model_path = None,
                   ):
    """
    :param input_target:
    :param temperature:
    :param topK:
    :param topP:
    :param max_len:
    :param history:
    :param max_history_len: 参考历史聊天记录
    :param max_history: 历史记录长度
    :param chat: 是否为聊天模式
    :return:
    """

    assert history!=None and max_history>max_history_len,"history不为空,max_history必须大于max_history_len"
    from .app import chat_model
    if(chat_model.get("model")):
        model, device, args = chat_model.get("model")
    else:
        model, device, args = load_model(model_path)
        chat_model['model'] = (model, device, args)
    tokenizer = BertTokenizer(vocab_file=args.voca_path)
    if args.save_samples_path:
        if not os.path.exists(args.save_samples_path):
            os.makedirs(args.save_samples_path)
        samples_file = open(args.save_samples_path + '/samples.txt', 'a', encoding='utf8')
        samples_file.write("聊天记录{}:\n".format(datetime.now()))
        # 存储聊天记录,每个utterance以token的id的形式进行存储

    text = input_target
    if args.save_samples_path:
        samples_file.write("user:{}\n".format(text))
    if(chat):
        if(max_history<len(history)):
            history = []
        history.append(tokenizer.encode(text))
    input_ids = [tokenizer.cls_token_id]  # 每个input以[CLS]为开头
    if(chat):
        for history_id, history_utr in enumerate(history[-max_history_len:]):
            input_ids.extend(history_utr)
            input_ids.append(tokenizer.sep_token_id)

    curr_input_tensor = torch.tensor(input_ids).long().to(device)
    generated = []
    # 最多生成max_len个token
    for _ in range(max_len):
        outputs = model(input_ids=curr_input_tensor)
        next_token_logits = outputs[0][-1, :]
        # 对于已生成的结果generated中的每个token添加一个重复惩罚项,降低其生成概率
        for id in set(generated):
            next_token_logits[id] /= args.repetition_penalty
        next_token_logits = next_token_logits / temperature
        # 对于[UNK]的概率设为无穷小,也就是说模型的预测结果不可能是[UNK]这个token
        next_token_logits[tokenizer.convert_tokens_to_ids('[UNK]')] = -float('Inf')
        filtered_logits = top_k_top_p_filtering(next_token_logits, top_k=topK, top_p=topP)
        # torch.multinomial表示从候选集合中无放回地进行抽取num_samples个元素,权重越高,抽到的几率越高,返回元素的下标
        next_token = torch.multinomial(F.softmax(filtered_logits, dim=-1), num_samples=1)
        if next_token == tokenizer.sep_token_id:  # 遇到[SEP]则表明response生成结束
            break
        generated.append(next_token.item())
        curr_input_tensor = torch.cat((curr_input_tensor, next_token), dim=0)
    history.append(generated)
    text = tokenizer.convert_ids_to_tokens(generated)
    if args.save_samples_path:
        samples_file.write("GPT:{}\n".format("".join(text)))
    return "".join(text)

if __name__ == '__main__':
    histroy = []
    model_path = "../GPT2/model/norm_model/poertymodel"
    print(GPTgetSentence("你好",1,10,0.9,128,histroy,5,100,False,model_path))

然后是我们的app服务。

app

这里的话,我先说一下有些坑在这个python当中,我们连接nacos使用的是这个:nacos-sdk-python。但是在我实际的使用当中,发现这个玩意会莫名其妙下线,但是服务是正常的。可能是版本还是什么问题,这个咱也不知道,也不敢说,最后查了查iusse,最后的话,写了个临时的方案,具体说明原因的话,我这里就不展开了。因为也不确定分析的对不对,猜测应该是这样的。

import queue
import sys
import os
import threading

curPath = os.path.abspath(os.path.dirname(__file__))
rootPath = os.path.split(curPath)[0]
sys.path.append(rootPath)
import requests
from Server.controller import GPTgetSentence

import time
from flask import Flask, request, jsonify
from nacos import NacosClient


"""
这里对原先的GPT2-play进行修改
"""
history_cache = {}
chat_model = {}



app = Flask(__name__)

# NacosClient.set_debugging()
client = NacosClient("127.0.0.1:8848", namespace="public")
app.config['JSON_AS_ASCII'] = False

q = queue.Queue()
lock = threading.Lock()

def init():
    """
    懒得改了,激活一下就好了
    :return:
    """
    start = time.time()
    chat_model_path = "../GPT2/model/norm_model/chatmodel"
    result = GPTgetSentence("你好", 1.2, 10, 0.95, 256,
                            [], 24, 100, True, chat_model_path
                            )
    end = time.time()
    print("激活完成~,耗时:",end-start)

# 定义处理心跳消息的函数
def handle_heartbeat():
    while True:
        try:
            requests.get('http://127.0.0.1:8100/heartbeat')
            instance_info = q.get()
            client.add_naming_instance(instance_info['serviceName'], instance_info['ip'], instance_info['port'])
        except:
            if(not q.empty()):
                return


# 启动处理心跳消息的专用线程
# t = threading.Thread(target=handle_heartbeat)
# t.start()

@app.route('/heartbeat')
def heartbeat():

    instance_info = {'serviceName': 'flaskService', 'ip': '127.0.0.1', 'port': 8100}
    lock.acquire()
    try:
        q.put(instance_info)
        time.sleep(2)
    finally:
        lock.release()
    return 'OK'

@app.route('/message',methods=['POST'])
def ikun_chat():

    user_input = request.json['msg']
    user_id = request.json['uid']

    history = history_cache.get(user_id,[])
    chat_model_path = "../GPT2/model/norm_model/chatmodel",
    start = time.time()
    result = GPTgetSentence(user_input, 1.2, 10, 0.95, 256,
                            history, 24, 100, True, chat_model_path
                            )
    end = time.time()
    response = {
        'res': result ,
        'spend_time': end - start
    }

    return jsonify(response)


if __name__ == '__main__':
    init()
    # 注册 Flask 应用到 Nacos 中

    client.add_naming_instance('flaskService', '127.0.0.1', 8100)
    is_start = True
    t = threading.Thread(target=handle_heartbeat)
    t.start()
    app.run(debug=False,port=8100)




总结

这个玩意,一个作孽可以当三个交,还是能玩玩的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/481201.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

如何用ChatGPT做书籍、报告、文件的读取与互动式问答?故事人物活起来

该场景对应的关键词库&#xff08;15个&#xff09;&#xff1a; 书籍、报告、文件、详细信息、查询、主题、作者、出版日期、出版社、问题、方面、原则、方法、概括、主要观点、解释。 注意&#xff1a; ChatGPT的知识库截止于2021年9月&#xff0c;对于更新的数据或最新出版…

系统化思维:大数中心原理与限制性选择原理。

系统化思维&#xff1a;大数中心原理与限制性选择原理TOC 许多人的思考特点都是混乱而复杂的&#xff0c;只有受过严格训练的人才能做到系统化思维。这里将讨论系统化思维的基础考量。 大数中心原理&#xff1a;大数中心原理是客观而真实的普遍存在&#xff0c;应用在思维上就…

ImageJ实践——拟合矩形选区探究(bounding rectangle),左侧优先法则

在上一篇ImageJ实践中ImageJ实践——测量大小/长短&#xff08;以细胞为例&#xff09;&#xff0c;我勾选了Set Measurements中的Bounding rectangle以测量细胞的长和宽&#xff08;实际上是拟合矩形的长短边&#xff09;&#xff0c;文末我也提出了自己的疑惑&#xff1a;拟合…

【GORM框架】模型定义超详解,确定不来看看?

博主简介&#xff1a;努力学习的大一在校计算机专业学生&#xff0c;热爱学习和创作。目前在学习和分享&#xff1a;数据结构、Go&#xff0c;Java等相关知识。博主主页&#xff1a; 是瑶瑶子啦所属专栏: GORM框架学习 近期目标&#xff1a;写好专栏的每一篇文章 目录 一、GORM…

Ansible自动化运维工具---Playbook

Ansible自动化运维工具--playbook 一、playbook1、playbook简介2、playbook应用场景3、yaml基本语法规则4、yaml支持数据结构 二、Inventory中的变量1、inventor变量参数 三、playbook实例1、编写httpd的playbook2、tasks列表和action3、条件测试4、迭代5、with_items模块6、te…

5.4.1树的存储结构 5.4.2树和森林的遍历

回忆一下树的逻辑结构&#xff1a; 双亲表示法&#xff08;顺序存储&#xff09; 如果增加一个结点M&#xff0c;L。毋须按照逻辑上的次序存储。 如果是删除元素&#xff1a; 方案一&#xff1a;比如说删除元素为G,设置其双亲结点为-1。 方案二&#xff1a; 把尾部的结点提上…

真题详解(对象)-软件设计(六十四)

真题详解(DNS)-软件设计&#xff08;六十三)https://blog.csdn.net/ke1ying/article/details/130448106 TCP和UCP都提供了_____能力。 端口寻址 解析&#xff1a; UDP是不可靠无连接协议&#xff0c;没有连接管理&#xff0c;没有流量控制&#xff0c;没有重试。 面向对象…

MySQL 常用命令

#--------------------------- #----cmd命令行连接MySql--------- cd C:\Program Files\MySQL\MySQL Server 5.5\bin # 启动mysql服务器 net start mysql # 关闭mysql服务器 net stop mysql # 进入mysql命令行 mysql -h localhost -u root -p 或mysql -u root -p #---------…

推荐算法实战项目:AutoRec模型原理以及案例实战(附完整 Python 代码)

本文要介绍的AutoRec模型是由澳大利亚国立大学在2015年提出的&#xff0c;它将自编码器(AutoEncoder)的思想与协同过滤(Collaborative Filter)的思想结合起来&#xff0c;提出了一种单隐层的简单神经网络推荐模型。 可以说这个模型的提出&#xff0c;拉开了使用深度学习解决推…

LVS - DR 模式集群搭建

VIPRIPweb1192.168.88.136httpdweb2192.168.88.139httpdLVS192.168.88.110192.168.88.144(DIP) 省略最后的共享存储&#xff0c;webserver内容不一致&#xff08;方便查看负载的效果&#xff09;&#xff0c;关闭防火墙&#xff0c;关闭se 1、配置web服务 web1和web2相同 y…

【文件描述符|重定向|缓冲区】

1 C语言文件操作的回顾 这块博主在讲解C语言时就已经做了很详细的讲解&#xff0c;这里就不详细讲了&#xff0c;直接给出代码。 写操作&#xff1a; #include<stdio.h> #include<stdlib.h> #include<errno.h> #define LOG "log.txt" …

【STM32CubeMX】外部中断

前言 本文记录下我学习STM32CubeMX时的流程&#xff0c;方便以后回忆。本章记录外部中断。 步骤 该实验步骤以&#xff0c;配置PA1为外部中断下降沿触发事件&#xff0c;在触发事件后点亮板载PC13LED灯 时钟配置和生成文件配置之类的&#xff0c;其它文章讲过了&#xff0c;这…

MySQL高级篇——性能分析工具

导航&#xff1a; 【黑马Java笔记踩坑汇总】JavaSEJavaWebSSMSpringBoot瑞吉外卖SpringCloud黑马旅游谷粒商城学成在线设计模式牛客面试题 目录 1. 数据库服务器的优化步骤 2. 查看系统性能参数 2.1 SHOW STATUS LIKE 参数 2.2 查看SQL的查询成本 3. 定位执行慢的 SQL&am…

【Java虚拟机】JVM垃圾回收机制和常见回收算法原理

1.垃圾回收机制 &#xff08;1&#xff09;什么是垃圾回收机制&#xff08;Garbage Collection&#xff0c; 简称GC) 指自动管理动态分配的内存空间的机制&#xff0c;自动回收不再使用的内存&#xff0c;以避免内存泄漏和内存溢出的问题最早是在1960年代提出的&#xff0c;程…

20230501-win10-制作U盘启动盘-firpe

20230501-win10-制作U盘启动盘-firpe 一、软件环境 zh-cn_windows_10_consumer_editions_version_22h2_updated_march_2023_x64_dvd_1e27e10b.isofirpe 1.8.2标签&#xff1a;firpe win10分栏&#xff1a;WINDOWS 二、硬件环境 8G或以上的U盘一个FX86笔记本一台 三、官方下…

2。硬件基础知识

介绍嵌入式软件开发所需要了解的硬件基础知识&#xff0c;与软件相结合学习 一 电阻 阻值&#xff1a;直标法&#xff0c;或色标法&#xff08;碳膜电阻上的横线&#xff09; 类型&#xff1a;线性&#xff0c;非线性&#xff08;压敏电阻、热敏电阻&#xff09; 基本参数&a…

HQL - 查询首次下单后第二天连续下单的用户比率

水善利万物而不争&#xff0c;处众人之所恶&#xff0c;故几于道&#x1f4a6; 题目&#xff1a; 从订单信息表(order_info)中查询首次下单后第二天仍然下单的用户占所有下单用户的比例&#xff0c;结果保留一位小数&#xff0c;使用百分数显示&#xff0c; 解题&#xff1a; …

STL常用梳理——STRING

容器——string篇 STL简介String常见接口函数深度了解String构造函数拷贝构造赋值重载析构函数运算符重载查找 STL简介 STL(Standard Template Library,标准模板库)&#xff0c;是惠普实验室开发的一系列软件的统 称。现在主要出现在 c中&#xff0c;但是在引入 c之前该技术已…

Day959.架构现代化模式 -遗留系统现代化实战

架构现代化的新城区模式 Hi&#xff0c;我是阿昌&#xff0c;今天学习记录的是关于架构现代化的新城区模式的内容。 前面的四个现代化并不是层层递进的关系&#xff0c;而是既可以同时进行&#xff0c;也可以颠倒顺序。 比如&#xff0c;既可以先重构代码&#xff0c;再拆分…