Skip to content

Latest commit

 

History

History
797 lines (539 loc) · 27.4 KB

File metadata and controls

797 lines (539 loc) · 27.4 KB

SelfPlay

可以考虑引入自对抗版本(自己打自己),这就是selfplay吧,关于这个我一直有个问题,就是自己和自己当前最新的版本打可以吗?会不会导致一直赢不了而导致退化?

ChatGPT 说:

非常好的问题!!!你问到了self-play最核心的问题之一! 而且这个问题,OpenAI、DeepMind 这些顶级团队都非常重视, 他们确实发现了:直接用当前最新自己对打,容易出问题!

来,我详细给你讲清楚:


🎯 直接用「自己最新版本 vs 自己」会遇到的问题

如果你总是用当前最新的策略(π_t)和自己对打,问题有两个:

问题 解释 后果
1. 非稳定性 策略更新是连续的,但对抗是离散的,很容易出现「版本突然变了」导致全输或全赢。 训练变得极度不稳定,甚至崩掉
2. 灾难性遗忘 策略只记得打赢当前对手,但忘记了怎么打以前遇到过的对手。 学会一招鲜,对某些旧打法反而不会应对了

举个极端例子: 你训练了一个战斗策略 π_t,突然 π_t+1 全部变了风格,π_t+1 打不过 π_t,就陷入自我怀疑、退化了……


🧠 顶级团队是怎么解决的?

比如 OpenAI FiveAlphaZero,一般采用两种非常实用的办法:

方法 1:对抗「历史版本池」

  • 保存过去一段时间的老策略(比如保存最近 100 个版本)。
  • 每次自对弈时,从历史池里随机选一个旧版本当对手。
  • 这样训练的策略,既要能打赢现在的对手,也要不怕老对手。

📈 效果: 更稳定、不会灾难性遗忘,也更抗干扰。


方法 2:对抗「慢速更新版本」

  • 在训练时,维护一个「影子版本」(叫做 target policy)。
  • target policy 每隔一段时间慢慢同步一次(比如软更新,τ=0.005那种)。
  • 训练时是「当前版本 vs 影子版本」对打。

📈 效果: 变化缓慢,避免突然大跳跃导致崩盘。


🚀 总结一句话:

不能只跟自己最新版本对打! 要么引入历史版本,要么引入慢速影子版本,让对抗更加平稳、多样化。


✨ 给你一个简单例子(伪代码)

python


复制编辑
# 历史版本池
history_pool = []

def update_history(policy):
    history_pool.append(copy.deepcopy(policy))
    if len(history_pool) > 100:
        history_pool.pop(0)  # 保持池子大小

def sample_opponent():
    if random.random() < 0.5:
        return current_policy  # 50%概率和当前打
    else:
        return random.choice(history_pool)  # 50%概率和历史版本打

然后训练时:

python


复制编辑
opponent = sample_opponent()
battle_result = fight(current_policy, opponent)
update(current_policy, battle_result)
update_history(current_policy)

就是这么简单,但非常有效!


🧠 小建议(特别适合你):

如果你的空战博弈也想做 self-play,强烈建议加历史对手池,不然一旦出问题,训练就直接废了(特别是到了课程式后期,策略复杂了之后,风险更大)。


要不要我顺便也给你写一个适合你空战项目用的 self-play管理器小模块?(可以直接用的版本) 如果要的话告诉我「要selfplay模块!」我给你配套好!👀

您说:

ChatGPT 说:

明白了! 我来给你写一个专业版的,适合你以后强化学习+自对抗训练用的 【Self-Play管理器】模块。

目标是做到:

  • 支持历史版本池(防止灾难性遗忘)
  • 支持慢速影子版本(防止突然跳跃)
  • 适配1v1对抗空战环境
  • 可以轻松接入你的PPO训练循环

✨ SelfPlayManager 模块(完整版)

python


复制编辑
import random
import copy

class SelfPlayManager:
    def __init__(self, 
                 max_history=100, 
                 use_shadow=True, 
                 shadow_tau=0.005):
        """
        Self-Play 管理器
        Args:
            max_history: 历史对手池的最大容量
            use_shadow: 是否启用影子(慢速)对手
            shadow_tau: 影子更新速率 (比如 0.005)
        """
        self.history_pool = []
        self.max_history = max_history
        self.use_shadow = use_shadow
        self.shadow_tau = shadow_tau
        self.shadow_policy = None  # 影子策略
        
    def update_history(self, current_policy):
        """把当前策略加入历史池"""
        self.history_pool.append(copy.deepcopy(current_policy))
        if len(self.history_pool) > self.max_history:
            self.history_pool.pop(0)

    def update_shadow(self, current_policy):
        """慢速更新影子策略"""
        if self.shadow_policy is None:
            self.shadow_policy = copy.deepcopy(current_policy)
        else:
            for shadow_param, current_param in zip(self.shadow_policy.parameters(), current_policy.parameters()):
                shadow_param.data.copy_(
                    (1.0 - self.shadow_tau) * shadow_param.data + self.shadow_tau * current_param.data
                )

    def sample_opponent(self, current_policy):
        """
        采样一个对手
        可能是:
        - 当前自己
        - 历史对手
        - 影子对手
        """
        candidates = [current_policy]
        if self.history_pool:
            candidates.append(random.choice(self.history_pool))
        if self.use_shadow and self.shadow_policy is not None:
            candidates.append(self.shadow_policy)
        
        opponent = random.choice(candidates)
        return opponent

    def step(self, current_policy):
        """
        每个训练回合要调用一次:
        - 更新影子策略
        - 更新历史池
        """
        self.update_shadow(current_policy)
        self.update_history(current_policy)

🔥 如何使用它?

在你的PPO训练循环里,每次 rollout 或对战时, 把对手不设定为自己,而是这么采样:

python


复制编辑
# 初始化
selfplay_manager = SelfPlayManager(max_history=100, use_shadow=True, shadow_tau=0.005)

# 在每个step或者每个epoch末尾
selfplay_manager.step(current_policy)

# 在需要对战时
opponent_policy = selfplay_manager.sample_opponent(current_policy)

# 然后 current_policy 和 opponent_policy 进行空战
result = simulate_fight(current_policy, opponent_policy)

⚡ 特别提示

  • simulate_fight 是你自己的空战仿真对抗函数,输入两个策略,输出胜负。
  • current_policy 应该是你在训练中的主策略,比如PPO Actor。
  • 你可以调 max_historyshadow_tau,看你想要更快变化还是更稳定。

🎯 总结一句话

Self-Play绝不能只跟自己最新版本对打!必须合理引入历史和慢速对手,训练才又稳定又进步。


要不要我顺便也给你出一个更「豪华版」(带动态调节采样概率,比如前期多打历史版,后期多打影子版)的? 如果想要告诉我:「要更豪华版!」😆 (适合后面打更高强度空战的阶段)

您说:

ChatGPT 说:

好的!这里是一个豪华版SelfPlayManager,包含了动态调节采样概率,可以根据训练阶段或策略的状态来灵活调整每次对战时选择对手的策略:


✨ 豪华版 SelfPlayManager(动态采样)

python


复制编辑
import random
import copy
import numpy as np

class SelfPlayManager:
    def __init__(self, 
                 max_history=100, 
                 use_shadow=True, 
                 shadow_tau=0.005,
                 initial_history_weight=0.5,  # 初始历史池权重
                 shadow_weight=0.5):  # 初始影子池权重
        """
        豪华版 Self-Play 管理器,支持动态采样概率
        Args:
            max_history: 历史对手池的最大容量
            use_shadow: 是否启用影子(慢速)对手
            shadow_tau: 影子更新速率 (比如 0.005)
            initial_history_weight: 历史对手采样权重(初始值)
            shadow_weight: 影子对手采样权重(初始值)
        """
        self.history_pool = []
        self.max_history = max_history
        self.use_shadow = use_shadow
        self.shadow_tau = shadow_tau
        self.shadow_policy = None  # 影子策略
        self.initial_history_weight = initial_history_weight
        self.shadow_weight = shadow_weight

        self.history_weight = initial_history_weight  # 动态调整历史池权重
        self.shadow_weight_dynamic = shadow_weight  # 动态调整影子池权重

    def update_history(self, current_policy):
        """把当前策略加入历史池"""
        self.history_pool.append(copy.deepcopy(current_policy))
        if len(self.history_pool) > self.max_history:
            self.history_pool.pop(0)

    def update_shadow(self, current_policy):
        """慢速更新影子策略"""
        if self.shadow_policy is None:
            self.shadow_policy = copy.deepcopy(current_policy)
        else:
            for shadow_param, current_param in zip(self.shadow_policy.parameters(), current_policy.parameters()):
                shadow_param.data.copy_(
                    (1.0 - self.shadow_tau) * shadow_param.data + self.shadow_tau * current_param.data
                )

    def sample_opponent(self, current_policy, step):
        """
        采样一个对手,动态调整历史池和影子策略的采样概率
        Args:
            current_policy: 当前训练策略
            step: 当前训练步数,用来调整采样概率
        """
        # 动态调整权重(逐步减小历史权重、逐步增加影子权重)
        decay_factor = 1 - np.clip(step / 1000000, 0, 1)  # 阶段性衰减
        self.history_weight = self.initial_history_weight * decay_factor
        self.shadow_weight_dynamic = 1.0 - self.history_weight

        # 根据权重采样对手
        candidates = []
        if self.history_pool:
            candidates.append((random.choice(self.history_pool), self.history_weight))
        if self.use_shadow and self.shadow_policy is not None:
            candidates.append((self.shadow_policy, self.shadow_weight_dynamic))

        # 归一化采样概率
        total_weight = sum(weight for _, weight in candidates)
        prob = [weight / total_weight for _, weight in candidates]

        # 依据概率进行采样
        opponent = np.random.choice([opponent for opponent, _ in candidates], p=prob)
        return opponent

    def step(self, current_policy, step):
        """
        每个训练回合要调用一次:
        - 更新影子策略
        - 更新历史池
        - 更新采样权重
        """
        self.update_shadow(current_policy)
        self.update_history(current_policy)
        self.sample_opponent(current_policy, step)  # 更新采样对手时的动态权重

🎯 如何使用豪华版?

与之前一样,你只需要在训练循环中调用 step() 来更新历史池、影子策略以及动态调整采样概率,然后在训练时通过 sample_opponent() 采样对手。

python


复制编辑
# 初始化
selfplay_manager = SelfPlayManager(max_history=100, use_shadow=True, shadow_tau=0.005)

# 每次训练回合调用
for step in range(training_steps):
    selfplay_manager.step(current_policy, step)
    
    # 采样对手
    opponent_policy = selfplay_manager.sample_opponent(current_policy, step)

    # 对抗训练
    result = simulate_fight(current_policy, opponent_policy)

🎯 关键优势

  • 动态调整采样概率:随着训练的进行,历史池和影子策略的采样概率逐渐变化。初期更多依赖历史对手,而后期逐步增加影子策略的作用。
  • 自适应对抗:通过逐步增加影子的权重,你可以避免过早“退化”在某个较弱的版本上,确保模型在对抗中有更平衡的训练。
  • 手动或自动化调整:你可以通过控制 step 来管理训练的不同阶段,进一步细化训练策略。

🎯 总结

动态调整的Self-Play管理器非常适合你这种多阶段任务和复杂对抗的环境。它能让你避免自对抗时的“退化”问题,并且随着训练过程中的对抗需求变化,逐步优化对手的选择策略。

您说:

还有个问题,如果模型一开始就和一个比自己强很多的对手打,几乎每次都是失败,那么这个模型还能有所提升吗?

ChatGPT 说:

这是一个非常重要且实际的问题。确实,如果模型从一开始就与一个远远强于自己的对手对抗,它可能会陷入一个恶性循环,尤其是在 self-play 训练中,如果对手远强于自己的话,模型可能会不断失败,从而导致学习到的策略难以改善。

这种情况的关键问题在于 训练难度过高,使得智能体无法有效从对战中学习。以下是一些可能的解决方法,来帮助避免这种情况:

1. 逐步增加对手的难度(渐进式挑战)

通过逐渐增加对手的强度,可以让模型更容易适应,从而避免训练初期对手过强导致的“负面反馈”:

  • 初期阶段,可以让智能体对战较弱的版本或“预设”的训练对手。
  • 随着智能体的逐步进步,逐渐增加对手的强度,直到接近当前版本的对手能力。

例如

  • 在训练的早期阶段,可以设置一个较低难度的对手模型。
  • 在模型逐渐学习并能战胜这些较弱对手后,再切换到一个较强的版本。

2. 引入对战策略的多样性(随机对战策略)

即使是一个强对手,也不一定每次都采用相同的策略。可以考虑 混合策略 进行对抗:

  • 随机化对手的策略:通过给强对手设置一些随机化的动作策略,智能体能够学习如何应对不同的情况,避免只学会应对一种固定的“最强”策略。
  • 设置一些“虚拟”较弱的对手:为了让智能体能够快速学习,你可以模拟一些虚拟的弱策略,这样即使强对手胜率高,也能保证一定的学习机会。

3. 使用强化学习中的奖励塑形(Reward Shaping)

在一开始与强对手对战时,直接的失败可能会让智能体丧失信心,从而陷入困境。奖励塑形可以帮助智能体获得更多的正向反馈,鼓励它做出更好的动作:

  • 正向奖励:即便是模型没有赢得对局,也可以给它奖励,比如通过特定的行为策略(如生存时间、得分、击中等)来给予部分奖励。
  • 负向奖励:逐步惩罚失败的行为,帮助智能体学会哪些行为是不可取的。

4. Shadow策略(影子策略)加速学习

你可以让模型和自己强于自己的“影子策略”进行对抗,避免一开始就直接与强对手对抗。影子策略通常采用较慢的更新策略,可以起到让模型避免过快地适应过强对手的作用。

  • 影子策略可以作为一种慢速对抗训练的方式,在对抗过程中逐步增加学习难度,让模型逐步适应。

5. 适应性探索与利用

你可以调整模型的探索利用的比例:

  • 在训练初期,保持较高的探索率,以鼓励智能体尝试各种策略。
  • 在训练后期,当模型的表现有所进步时,逐渐减少探索,增加利用率,让模型更多地依赖已学到的知识应对强对手。

6. 从较强对手中提取有价值的信息(信息回放)

有时强对手的策略可以为智能体提供极好的学习机会。你可以引入“信息回放”机制,保存与强对手对战中的一些关键状态和动作:

  • 记录失败的局面,并通过逆向推理或分离策略来让模型从失败中学到更多信息,而不仅仅是从胜利中学习。

小结

如果模型一开始就与一个非常强的对手对抗,的确可能会导致它陷入负反馈并失去训练动力。通过逐步提升对手的难度、随机化对战策略、奖励塑形、引入影子策略等方法,可以有效避免这种情况,并帮助模型逐步进步。最重要的是让智能体能够在适当的挑战下逐渐成长,从而提升其对抗能力。

综合版

那你结合最近的这三个代码块,整合出来一个完整的代码,实现子博弈兑对象分别是:

1、当前模型(fast learner)

2、慢速影子模型(EMA shadow)

3、历史版本池(opponent pool)

并且,对于慢速影子模型和历史版本池,都有模型的保存和加载功能。

明白,我帮你整合出一个完整的 OpponentManager 版本,支持:

  1. 当前模型(fast learner,直接用训练中的策略)
  2. 慢速影子模型(EMA 更新)
  3. 历史版本池(对手池)

并且影子模型和对手池都可以保存/加载。下面是完整代码示例:

import os
import torch
import random

class OpponentManager:
    def __init__(self, policy_class, save_dir="opponent_data", 
                 max_pool_size=10, device="cpu"):
        """
        管理训练过程中的对手

        Args:
            policy_class: 策略网络类,用于实例化
            save_dir: 保存 checkpoint 的目录
            max_pool_size: 对手池最大容量
            device: 模型加载到的设备
        """
        self.policy_class = policy_class
        self.save_dir = save_dir
        self.pool_dir = os.path.join(save_dir, "opponents")
        self.shadow_path = os.path.join(save_dir, "shadow.pth")
        self.max_pool_size = max_pool_size
        self.device = device

        os.makedirs(self.pool_dir, exist_ok=True)

        self.pool = []       # [(step, model)]
        self.shadow_model = None

    # -------------------
    # 对手池
    # -------------------
    def add_to_pool(self, policy, step):
        """保存并加入新的历史对手"""
        filename = os.path.join(self.pool_dir, f"opponent_{step}.pth")
        torch.save(policy.state_dict(), filename)

        # 内存加载副本
        opponent_copy = self.policy_class()
        opponent_copy.load_state_dict(torch.load(filename, map_location=self.device))
        opponent_copy.to(self.device)
        opponent_copy.eval()
        self.pool.append((step, opponent_copy))

        # 控制大小
        if len(self.pool) > self.max_pool_size:
            old_step, _ = self.pool.pop(0)
            old_file = os.path.join(self.pool_dir, f"opponent_{old_step}.pth")
            if os.path.exists(old_file):
                os.remove(old_file)

    def sample_opponent(self):
        """随机选择一个历史对手"""
        if not self.pool:
            raise ValueError("Opponent pool is empty!")
        return random.choice(self.pool)[1]

    def load_pool(self):
        """从磁盘加载对手池"""
        files = sorted(os.listdir(self.pool_dir))
        self.pool = []
        for f in files:
            if f.endswith(".pth"):
                step = int(f.split("_")[1].split(".")[0])
                path = os.path.join(self.pool_dir, f)
                model = self.policy_class()
                model.load_state_dict(torch.load(path, map_location=self.device))
                model.to(self.device)
                model.eval()
                self.pool.append((step, model))
        print(f"[OpponentManager] Loaded {len(self.pool)} opponents from {self.pool_dir}")

    # -------------------
    # 慢速影子模型(EMA)
    # -------------------
    def update_shadow(self, policy, tau=0.01):
        """软更新影子模型:shadow = (1 - tau) * shadow + tau * policy"""
        if self.shadow_model is None:
            self.shadow_model = self.policy_class()
            self.shadow_model.load_state_dict(policy.state_dict())
            self.shadow_model.to(self.device)
            self.shadow_model.eval()
        else:
            new_state = {}
            for k, v in policy.state_dict().items():
                new_state[k] = (1 - tau) * self.shadow_model.state_dict()[k] + tau * v
            self.shadow_model.load_state_dict(new_state)

        # 保存影子模型
        torch.save(self.shadow_model.state_dict(), self.shadow_path)

    def load_shadow(self):
        """从磁盘恢复影子模型"""
        if os.path.exists(self.shadow_path):
            self.shadow_model = self.policy_class()
            self.shadow_model.load_state_dict(torch.load(self.shadow_path, map_location=self.device))
            self.shadow_model.to(self.device)
            self.shadow_model.eval()
            print("[OpponentManager] Shadow model loaded from disk")
        else:
            print("[OpponentManager] No shadow model found, need to initialize later")

    # -------------------
    # 获取博弈对象
    # -------------------
    def get_opponents(self, use_shadow=True, use_pool=True):
        """
        返回当前训练中的三个对手对象:
            1. 当前策略(fast learner)
            2. 慢速影子模型(EMA shadow)
            3. 随机历史对手(opponent pool)
        """
        opponents = {"current": None, "shadow": None, "pool": None}

        # 当前模型必须传入
        opponents["current"] = "fast learner (train-time policy)"  # 训练时直接用当前模型

        if use_shadow:
            if self.shadow_model is None:
                raise ValueError("Shadow model is not initialized!")
            opponents["shadow"] = self.shadow_model

        if use_pool:
            if not self.pool:
                raise ValueError("Opponent pool is empty!")
            opponents["pool"] = self.sample_opponent()

        return opponents

使用示例

# 初始化管理器
manager = OpponentManager(PolicyNet, save_dir="opponent_data", max_pool_size=5, device="cuda")

# 恢复历史对手和影子模型
manager.load_pool()
manager.load_shadow()

# 训练循环示例
for step in range(1, total_steps + 1):
    # 训练 PPO 模型...
    
    # 每隔 1w 步,把当前策略加入对手池
    if step % 10000 == 0:
        manager.add_to_pool(current_policy, step)

    # 每隔一定步,更新影子模型(EMA)
    if step % 500 == 0:
        manager.update_shadow(current_policy, tau=0.01)

    # 获取博弈对象
    opponents = manager.get_opponents()
    fast_policy = current_policy
    shadow_policy = opponents["shadow"]
    historical_opponent = opponents["pool"]

    # 用这三个对象进行对抗训练
    # env.step(action_dict={"self": fast_policy, "shadow": shadow_policy, "opponent": historical_opponent})

✅ 特性总结:

  1. fast learner:训练中的策略
  2. shadow model:慢速 EMA 更新,保证训练稳定
  3. opponent pool:历史策略池,增加对抗多样性
  4. 保存/加载:对手池和影子模型都可以持久化
  5. 易集成 PPO 或 SAC 训练循环

大厂的做法

OpenAI Five 的设计和 AlphaStar、AlphaZero 确实不一样:


🔹 OpenAI Five 的做法

  • 没有维护显式的历史版本池(不像 AlphaStar 有一整个 league 体系)。
  • 它采用的是 持续更新的影子模型(slow-moving target network)
    • 训练时,红方和蓝方不是对等更新的,而是一个用 最新策略,一个用 滞后的影子策略(类似 PPO 的 old policy / target network)。
    • 这样可以避免两个智能体都在快速变化,导致训练发散。
  • 它还会通过 多策略并行训练(Population Based Training, PBT),在大规模集群里跑出不同超参数/策略,然后定期复制、进化。
    • 但这不是显式的 “历史版本池”,而是一个持续演化的 population

🔹 AlphaStar 的区别

  • AlphaStar 使用了 对手池(opponent pool),里面有不同风格的历史 agent(主力、探险、作弊 agent 等),保证对抗多样性。

🔹 总结

  • OpenAI Five
    • 依赖 影子模型(slow policy)+ PBT 人口并行
    • 没有显式存历史快照来回采样
  • AlphaStar
    • 强调 历史池 + 联赛结构(league training)
    • 确保不会被某一类对手完全针对

所以如果你在做 空战 PPO 自博弈,可以借鉴:

  • 如果算力有限:用 影子模型(像 OpenAI Five)
  • 如果能跑多副本:用 历史池 + 联赛结构(像 AlphaStar)

多策略并行训练(Population Based Training, PBT) 这是啥?

ChatGPT 说:

这个点你问得非常好 👍,Population Based Training (PBT) 是 OpenAI Five、DeepMind AlphaStar 等大规模对抗 AI 的核心技巧之一。

我来帮你拆开讲:


🔹 普通训练 vs PBT

  • 普通训练: 你只维护一个 agent,固定超参数(比如学习率、熵系数、奖励权重),然后让它慢慢优化。
    • 问题:超参数如果选得不好,整个训练就会停滞或者过拟合。
    • 而且只有一个 agent,很可能陷入单一策略,被对手轻易针对。
  • PBT: 不是训练一个,而是同时训练 一群 agent(population)。 每个 agent 可以有不同的超参数、探索系数、甚至奖励 shaping。 这些 agent 在训练过程中 相互对战,并且会定期进行“进化”:
    1. 评估表现:定期计算 winrate / Elo 等指标。
    2. 选择复制:表现差的 agent 会被替换成强者的副本(参数 + 超参数)。
    3. 小幅变异:复制时对超参数加一点扰动(比如学习率 × 1.2,熵系数 ÷ 0.8)。 这样 population 会逐步“进化”,既保留强者,又探索更多可能。

🔹 在 OpenAI Five 里的应用

  • OpenAI Five 训练时跑了成千上万个并行副本(分布式 GPU 集群)。
  • 它们不是都用相同超参数,而是让 PBT 来自动搜索:
    • 哪些熵系数 α 比较好?
    • 哪些 reward shaping 更利于稳定训练?
    • 哪些学习率能更快收敛?
  • 表现差的副本会被淘汰掉,用强者+突变后的版本继续探索。

这种方法 避免了人工调超参数,同时保证了多样性(不会全员学到一样的打法)。


🔹 直观类比

你可以把 PBT 想成:

  • 在一个“公司”里,不是只培养一个员工(单一 agent),而是培养一群。
  • 每隔一段时间,业绩最差的会被开除,然后老板让他抄作业(复制)业绩最好的,再改一点习惯(超参数变异),重新尝试。
  • 最后留下的团队整体会越来越强,同时还能探索不同路线。

🔹 你在 PPO 里怎么用?

  • 维护多个 PPO agent 副本(比如 8–16 个)。
  • 每个 agent:
    • 用不同学习率、熵系数、价值损失权重
    • 定期对战(比如循环赛)
  • 每隔 N 步:
    • 淘汰最弱的几个
    • 复制强者参数,稍微改一下超参数
  • 这样整个系统会自动搜索 合适的超参数和策略组合,而不是手工调参。

要不要我帮你写一个 简化版的 PBT 管理器代码(Python 伪代码,能集成到 PPO),方便你上手试试?

参考资料

对话记录:

https://chatgpt.com/c/680f2455-0c60-800f-a49b-53eecb1c27f7