1. 从零开始:理解BERT为什么能改变游戏规则

如果你在2018年之后接触过自然语言处理,那你一定绕不开BERT这个名字。它就像是一颗投入平静湖面的巨石,彻底改变了整个NLP领域的玩法。我记得当时论文刚出来的时候,我和团队里的几个同事连夜读完,那种感觉就像是发现了一个新大陆——原来语言模型还能这么玩。

在BERT出现之前,我们处理文本任务,比如情感分析、命名实体识别,基本都是一个套路:先拿预训练好的词向量(比如Word2Vec或GloVe)把单词变成一串数字,然后后面接一个LSTM或者CNN之类的编码器,最后加个分类层。这个流程听起来没问题,但实际用起来痛点一大堆。最大的问题就是,那个词向量是“死”的。举个例子,“苹果”这个词,在“我想吃苹果”和“苹果手机发布了”这两个句子里,意思完全不同,但传统的词向量给它的表示是固定不变的,它根本不知道上下文是啥。这就好比给你一张静态地图,但你实际需要的是一个能实时导航的GPS。

另一个更头疼的问题是模型训练。那时候的编码器(比如LSTM)参数都是随机初始化的,你要想让模型学会理解语言,就必须喂给它海量的、标注好的数据。标注数据有多贵、多费劲,做过项目的人都懂。这就导致很多中小团队或者个人开发者,面对复杂的NLP任务时根本玩不转,数据门槛太高了。

BERT的聪明之处在于,它换了一种思路:与其用昂贵的标注数据从头教模型理解语言,不如先让它通过“阅读”海量无标注文本来“自学成才”。这个过程就叫“预训练”。它设计了两个巧妙的“自监督”任务,让模型在“完形填空”和“判断上下句”的游戏中,自己摸索出语言的规律。等这个模型具备了强大的语言理解能力之后,我们再用自己手头那些有限的、有标注的数据,去稍微调整一下它(也就是“微调”),它就能出色地完成我们特定的任务,比如判断评论是好评还是差评。

这种“预训练+微调”的范式,一下子把门槛拉低了很多。你不再需要为每个新任务收集成千上万的标注数据,也不再需要从零开始设计复杂的网络结构。你只需要找到一个预训练好的BERT模型,然后用你相对少量的业务数据去“点拨”它一下,效果往往就比之前的老方法好上一大截。这也就是为什么我说,BERT不仅仅是一个模型,更是一种新的、更高效的NLP研发范式。接下来,我就带你亲手走一遍这个完整的流程,从理解它的核心原理开始,一直到用代码把它用起来。

2. 深入核心:BERT预训练到底在学什么?

很多人把BERT当作一个黑盒,直接拿来微调,效果不错就完事了。但如果你想真正用好它,尤其是在遇到问题想调优的时候,理解它的预训练机制至关重要。这部分咱们不堆公式,就用大白话和实际例子把它讲明白。

2.1 两大自监督任务:模型的“语言自学课”

想象一下,你要教一个外星人学中文,但没有现成的教材。你会怎么做?你可能会找一大堆中文文章给它看,然后设计一些练习题。BERT的研发者也是这么想的,他们设计了两个经典的练习题。

第一个任务叫“掩码语言模型”,说白了就是“完形填空”。比如我们有句话:“今天天气真[MASK],我们一起去公园吧。” 模型的任务就是根据上下文,猜出被[MASK]盖住的词很可能是“好”或者“不错”。在技术实现上,BERT会随机遮盖输入句子中15%的单词(注意,是单词级别的子词,这个后面会细说)。在这15%里,又有三种情况:80%真的替换成 [MASK] 这个特殊符号;10%随机换成另一个词(比如把“好”换成“苹果”);还有10%保持原词不变。你可能会问,为什么要搞得这么复杂?直接全用 [MASK] 不行吗?

这里有个非常关键的考量:我们微调的时候,输入里可没有 [MASK] 符号。如果模型只在看到 [MASK] 时才努力思考,那在实际应用中就会懵。所以,加入随机替换和保留原词,是为了“逼”模型去真正理解每个词在上下文中的合理性。它必须学会判断:“苹果天气”这种搭配是不对的,从而更深入地学习语法和语义知识。这个任务主要让模型学会了“词级”的理解。

光会填词还不够,理解语言还需要把握句子之间的逻辑。这就引出了第二个任务:“下一句预测”。给模型两个句子A和B,让它判断B是不是A的下一句。比如:

  • 正例:A=“猫在沙发上睡觉。”, B=“它睡得很香。”
  • 反例:A=“猫在沙发上睡觉。”, B=“明天我要去爬山。”

这个任务迫使模型去理解句子间的连贯性和逻辑关系,从而学习“句级”的表示。在实现上,输入格式是固定的:[CLS] 句子A [SEP] 句子B [SEP]。模型会取出开头的 [CLS] 这个特殊符号对应的向量(经过一个叫 BertPooler 的小网络加工后),来判断这两个句子的关系。

正是通过同时完成这两个任务,BERT在预训练阶段就变成了一个“语言通”,既懂单词的微妙含义,也懂句子间的起承转合。我们拿到手的预训练模型,里面已经蕴含了从海量文本(比如维基百科、书籍)中学到的丰富知识。

2.2 文本的“化妆术”:Subword Tokenizer详解

现在我们来解决一个实际问题:模型怎么“读”文本?你输入的是“Hello World”,但模型处理的是数字。这个转换的第一步就是分词。BERT没有采用传统的按空格分单词的方法,而是用了更聪明的 Subword Tokenization(子词分词)

为什么不用老方法?假设你的词表里有“old”,“older”,“oldest”,但没有“smart”,“smarter”,“smartest”。传统分词遇到“smarter”就傻了,会把它当作一个没见过的词(OOV问题),随便给个默认向量,信息就丢失了。Subword的思路是把长词、难词拆成更小的、常见的部件。

以BERT常用的WordPiece算法为例,它会把“playing”拆成 [“play”, “##ing”]。这里的 ## 表示这个子词不是一个新词的开始。对于中文,它通常简单粗暴地把每个字都分开,比如“我喜欢机器学习”会变成 [“我”,“喜”,“欢”,“机”,“器”,“学”,“习”]

这里有一个至关重要的实践细节:你必须使用与预训练模型完全相同的分词器! 因为模型在预训练时,就是学习这些特定子词(如 ##ing)的向量表示。如果你微调时换了一个分词方式,就相当于给模型输入了它从来没“见过”的符号,效果肯定会大打折扣。在Hugging Face的 transformers 库中,这一点被封装得非常好,你只需要指定模型名称,它会自动加载对应的分词器。

from transformers import BertTokenizer

# 加载BERT-base模型对应的分词器
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')

# 对文本进行分词和编码
text = "Hello, how are you doing today?"
encoded_input = tokenizer(text, return_tensors='pt') # 返回PyTorch张量

print(encoded_input)
# 输出会包含 `input_ids` (词ID), `token_type_ids` (句子标识), `attention_mask` (注意力掩码)

运行上面几行代码,你就完成了从原始文本到模型可读数字的转换。input_ids 就是句子中每个子词在词表中的编号。attention_mask 告诉模型哪些位置是真实的词(1),哪些是填充的(0),这对于处理批量中不同长度的句子非常关键。

3. 实战准备:搭建你的BERT微调环境

理论懂了,手开始痒了是吧?别急,工欲善其事,必先利其器。咱们先把环境搭好,确保你能无障碍地跑通后面的所有例子。我推荐使用Python和PyTorch的组合,因为生态最丰富,社区支持最好。

3.1 一站式环境配置

首先,我强烈建议使用 Anaconda 来管理你的Python环境,它能避免各种包版本冲突的“玄学”问题。打开你的终端(或Anaconda Prompt),跟着我一步步来:

# 1. 创建一个新的虚拟环境,Python版本建议3.8或3.9,兼容性最好
conda create -n bert_tutorial python=3.8 -y

# 2. 激活这个环境
conda activate bert_tutorial

# 3. 安装PyTorch。请务必去PyTorch官网(https://pytorch.org/get-started/locally/)
# 根据你的CUDA版本(如果有GPU)选择安装命令。例如,对于CUDA 11.3:
pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu113

# 如果你没有GPU,就安装CPU版本:
# pip install torch torchvision torchaudio

# 4. 安装NLP神器:Hugging Face Transformers 和 Datasets 库
pip install transformers datasets

# 5. 安装数据处理和评估常用库
pip install pandas scikit-learn tqdm

安装完成后,你可以写个简单的测试脚本验证一下:

import torch
print(f"PyTorch版本: {torch.__version__}")
print(f"GPU是否可用: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"GPU设备: {torch.cuda.get_device_name(0)}")

from transformers import BertModel, BertTokenizer
print("BERT模型和分词器导入成功!")

3.2 理解BERT的输入与Embedding

环境好了,我们来看看BERT到底吃进去什么,吐出来什么。前面提到分词器会把句子变成三个关键的张量:input_ids, token_type_ids, attention_mask。它们进入BERT的嵌入层后,会融合成一种综合的表示。

BERT的嵌入层其实是一个“三合一”的产物:

  1. Token Embeddings:这就是每个子词(如“play”,“##ing”)自己的向量。是模型从海量文本中学到的核心语义。
  2. Segment Embeddings:用来区分句子A和句子B。在单句任务中,所有位置都是0;在句子对任务(如下句预测、问答)中,第一个句子的位置是0,第二个句子的位置是1。
  3. Position Embeddings:告诉模型每个词在序列中的位置。原始的BERT使用绝对位置编码,就是给每个位置(1,2,3...)学一个固定的向量。现在也有很多研究在用相对位置编码,效果更好。

这三者相加,就得到了每个输入词最终的初始向量表示。你可以把这个过程想象成给每个词发了一张“综合身份证”,上面同时记录了“你是谁”(Token)、“你属于哪句话”(Segment)、“你排第几位”(Position)。

import torch
from transformers import BertModel, BertTokenizer

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')

# 准备一个句子对
text_a = "How old are you?"
text_b = "I am 25 years old."
inputs = tokenizer(text_a, text_b, return_tensors='pt', padding=True, truncation=True)

print("分词器输出的字典键:", inputs.keys())
print("input_ids 形状:", inputs['input_ids'].shape)
print("token_type_ids 形状:", inputs['token_type_ids'].shape)
print("attention_mask 形状:", inputs['attention_mask'].shape)

# 将输入送入模型
with torch.no_grad():
    outputs = model(**inputs)

# outputs 包含多个东西,最重要的是 last_hidden_state 和 pooler_output
print("\n模型输出:")
print("last_hidden_state 形状:", outputs.last_hidden_state.shape)
# 形状为 (batch_size, sequence_length, hidden_size),这是每个token的上下文向量
print("pooler_output 形状:", outputs.pooler_output.shape)
# 形状为 (batch_size, hidden_size),通常代表整个句子的语义,由[CLS]向量经过一个全连接层得到

运行这段代码,你能直观地看到输入的形状和模型输出的形状。last_hidden_state 是宝藏,它包含了句子中每个词经过BERT深度理解后的上下文相关向量。后续的所有微调任务,几乎都是在这个基础上做文章。

4. 手把手微调:三大经典NLP任务实战

好了,热身结束,真正的实战开始。我们挑选NLP中最常见、也最有代表性的三个任务:文本分类、序列标注和关系抽取,来看看如何把预训练好的BERT,变成解决我们特定问题的“专家”。

4.1 文本分类:以情感分析为例

文本分类大概是应用最广的任务了,比如情感分析、新闻分类、意图识别。它的目标就是给一段文本打上一个或多个标签。我们用IMDb电影评论情感分析(二分类:正面/负面)这个经典数据集来演示。

核心思路:利用 [CLS] 令牌。BERT在设计时,就倾向于将整个句子的语义信息汇聚到这个特殊的起始令牌的最终输出向量里。我们微调时,就把这个 [CLS] 对应的向量(通常是 pooler_output 或者 last_hidden_state[:, 0, :])拿出来,接上一个全连接分类层。

import torch
import torch.nn as nn
from transformers import BertModel, BertTokenizer, AdamW
from torch.utils.data import DataLoader, Dataset
from datasets import load_dataset
import pandas as pd

# 1. 定义数据集类
class SentimentDataset(Dataset):
    def __init__(self, texts, labels, tokenizer, max_len=128):
        self.texts = texts
        self.labels = labels
        self.tokenizer = tokenizer
        self.max_len = max_len

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, idx):
        text = str(self.texts[idx])
        label = self.labels[idx]
        encoding = self.tokenizer.encode_plus(
            text,
            add_special_tokens=True,
            max_length=self.max_len,
            padding='max_length',
            truncation=True,
            return_tensors='pt',
            return_attention_mask=True
        )
        return {
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten(),
            'labels': torch.tensor(label, dtype=torch.long)
        }

# 2. 定义模型
class BertForSentiment(nn.Module):
    def __init__(self, num_labels=2):
        super().__init__()
        self.bert = BertModel.from_pretrained('bert-base-uncased')
        # 冻结BERT底层参数(可选,可加快训练并防止灾难性遗忘)
        # for param in self.bert.parameters():
        #     param.requires_grad = False
        self.dropout = nn.Dropout(0.1) # 防止过拟合
        self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels)

    def forward(self, input_ids, attention_mask):
        outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
        pooled_output = outputs.pooler_output # 取[CLS]的汇聚输出
        pooled_output = self.dropout(pooled_output)
        logits = self.classifier(pooled_output)
        return logits

# 3. 加载数据(这里用datasets库加载IMDb,实际中替换为自己的数据)
dataset = load_dataset('imdb')
train_texts = dataset['train']['text'][:1000] # 取1000条做演示
train_labels = dataset['train']['label'][:1000]

# 4. 初始化
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForSentiment()
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(device)

train_dataset = SentimentDataset(train_texts, train_labels, tokenizer)
train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)

# 5. 训练准备
optimizer = AdamW(model.parameters(), lr=2e-5)
criterion = nn.CrossEntropyLoss()

# 6. 训练循环(简化版)
model.train()
for epoch in range(3): # 跑3个epoch
    total_loss = 0
    for batch in train_loader:
        input_ids = batch['input_ids'].to(device)
        attention_mask = batch['attention_mask'].to(device)
        labels = batch['labels'].to(device)

        optimizer.zero_grad()
        logits = model(input_ids, attention_mask)
        loss = criterion(logits, labels)
        loss.backward()
        optimizer.step()

        total_loss += loss.item()
    print(f"Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}")

这段代码提供了一个完整的训练骨架。关键点在于 BertForSentiment 这个类,我们在预训练BERT模型后面“嫁接”了一个简单的分类头。在实际项目中,你还需要加入验证集、学习率调度、模型保存等逻辑。

4.2 序列标注:以命名实体识别为例

序列标注任务要求给句子中的每一个词(或子词)都打上标签。命名实体识别是典型代表,它要找出文本中的人名、地名、组织名等。比如“马云在杭州创立了阿里巴巴集团”,模型需要输出 [B-PER, I-PER, O, B-LOC, O, O, B-ORG, I-ORG, I-ORG](B-表示开始,I-表示内部,O-表示非实体)。

核心挑战:BERT的分词是子词级别的,但我们的标注通常是词级别的。比如“阿里巴巴”可能被分成 [“阿”, “##里”, “##巴”, “##巴”],但它们的标签应该都是 I-ORG。我们需要处理这个对齐问题。

微调方法:通常采用“每个Token一个分类”的方式。我们取BERT输出的 last_hidden_state,它的形状是 [batch, seq_len, hidden_size],其中 seq_len 就是每个子词的位置。我们为序列中的每一个位置(忽略 [CLS], [SEP] 和填充符号)都预测一个标签。

import torch.nn as nn

class BertForNER(nn.Module):
    def __init__(self, num_labels):
        super().__init__()
        self.bert = BertModel.from_pretrained('bert-base-chinese') # 中文NER用中文模型
        self.dropout = nn.Dropout(0.1)
        # 分类层,对每个token位置做分类
        self.classifier = nn.Linear(self.bert.config.hidden_size, num_labels)

    def forward(self, input_ids, attention_mask, labels=None):
        outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
        sequence_output = outputs.last_hidden_state # [batch, seq_len, hidden]
        sequence_output = self.dropout(sequence_output)
        logits = self.classifier(sequence_output) # [batch, seq_len, num_labels]

        loss = None
        if labels is not None:
            loss_fct = nn.CrossEntropyLoss(ignore_index=-100) # -100通常用于忽略填充位置的loss
            # 只计算非填充位置的loss,attention_mask可以帮我们定位
            active_loss = attention_mask.view(-1) == 1
            active_logits = logits.view(-1, self.num_labels)[active_loss]
            active_labels = labels.view(-1)[active_loss]
            loss = loss_fct(active_logits, active_labels)
        return (loss, logits) if loss is not None else logits

这里的关键是损失函数的计算。我们使用 attention_mask 来区分真实token和填充token,只对真实token计算损失。标签对齐的细节通常在数据预处理阶段完成,需要将词级别的标签分配到对应的子词上(通常第一个子词继承原词标签,后续的子词标为 XI-XXX)。

进阶技巧:BERT+CRF。上面的方法假设每个位置的标签是独立的,但实际上标签之间有很强的依赖关系,比如 I-ORG 前面不可能接 B-PER。CRF层可以学习标签之间的转移规则,强制输出合法的标签序列。虽然增加了复杂度,但在严谨的NER任务上通常能提升1-2个点的F1分数。Hugging Face的 transformers 库也提供了 BertForTokenClassification 模型,它已经封装好了基础版本,你可以直接使用。

4.3 关系抽取:从非结构化文本中抽取结构化知识

关系抽取是信息抽取的核心,旨在判断句子中两个实体之间的关系。例如,在句子“马云在杭州创立了阿里巴巴”中,我们要判断实体“马云”和“阿里巴巴”之间的关系是“创始人”,实体“阿里巴巴”和“杭州”之间的关系是“所在地”。

微调方法:这里介绍一种简单有效的方法——实体标记法。我们在输入句子中,用特殊的标记明确标出实体的位置,从而“引导”BERT关注它们。

具体操作:在分词前,我们在实体前后加上特殊的标记,比如 [E1][/E1] 包围第一个实体,[E2][/E2] 包围第二个实体。这些特殊标记需要被添加到分词器的词表中。然后,我们可以取这两个特殊标记 [E1][E2] 对应的输出向量,将它们拼接起来,送入分类层来判断关系。

# 假设我们有两个实体:head_entity="马云", tail_entity="阿里巴巴",关系是"创始人"
sentence = "马云在杭州创立了阿里巴巴。"
# 插入特殊标记
marked_sentence = f"[E1]{head_entity}[/E1]在杭州创立了[E2]{tail_entity}[/E2]。"

tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
# 将新标记加入词表(如果词表里没有)
new_tokens = ['[E1]', '[/E1]', '[E2]', '[/E2]']
tokenizer.add_tokens(new_tokens)

inputs = tokenizer(marked_sentence, return_tensors='pt', padding=True, truncation=True)
# 找到 [E1] 和 [E2] 在 input_ids 中的位置
e1_start_id = tokenizer.convert_tokens_to_ids('[E1]')
e2_start_id = tokenizer.convert_tokens_to_ids('[E2]')

# 在模型定义中,需要扩展BERT词嵌入层的大小,以容纳新加入的标记
model = BertModel.from_pretrained('bert-base-chinese')
model.resize_token_embeddings(len(tokenizer)) # 重要!调整嵌入层大小

# 前向传播后,根据 token ID 找到对应位置的输出向量
outputs = model(**inputs)
last_hidden_state = outputs.last_hidden_state # [1, seq_len, hidden]

# 假设我们找到了索引位置
e1_start_pos = (inputs['input_ids'][0] == e1_start_id).nonzero(as_tuple=True)[0].item()
e2_start_pos = (inputs['input_ids'][0] == e2_start_id).nonzero(as_tuple=True)[0].item()

e1_vector = last_hidden_state[0, e1_start_pos, :] # 取[E1]标记的向量
e2_vector = last_hidden_state[0, e2_start_pos, :] # 取[E2]标记的向量

# 拼接并分类
combined_vector = torch.cat([e1_vector, e2_vector], dim=-1)
# 将 combined_vector 送入一个分类层...

这种方法通过显式的标记,让模型能更直接地捕捉到与实体相关的信息,比单纯依赖上下文向量效果更好。当然,关系抽取还有更复杂的模型设计,比如考虑实体类型、使用图神经网络等,但实体标记法是一个强大且直观的基线方法。

5. 避坑指南:微调中的常见问题与调优技巧

走通了基本流程,并不意味着就能高枕无忧。在实际项目中,你会遇到各种各样的问题。下面我分享几个最常见的“坑”以及我的解决经验。

问题一:样本不均衡怎么办? 在真实场景中,数据很少是均匀分布的。比如在情感分析中,好评可能占90%,差评只有10%。直接用原始数据训练,模型会倾向于把所有样本都预测为好评,因为这样损失函数最小。

解决方案

  1. 重采样:对少数类进行过采样(复制样本),或对多数类进行欠采样(丢弃部分样本)。简单的实现可以用 imbalanced-learn 库。但要注意,过采样可能导致过拟合,欠采样可能丢失重要信息。
  2. 损失函数加权:这是更优雅的方法。在PyTorch的 CrossEntropyLoss 中,你可以直接传入 weight 参数,给少数类更高的权重。
    # 假设类别0有100个样本,类别1有10个样本
    class_weights = torch.tensor([1.0, 10.0]) # 给类别1十倍权重
    criterion = nn.CrossEntropyLoss(weight=class_weights)
    
  3. Focal Loss:这是目标检测领域提出来的,专门解决难易样本不均衡。它通过降低容易分类样本的权重,让模型更关注难分类的样本。公式是 FL(p_t) = -α_t (1 - p_t)^γ log(p_t)γ 参数调节难易样本的权重,α_t 平衡正负样本。对于NLP任务,有时也能带来惊喜。

问题二:训练时损失震荡或不下降?

  • 学习率太大:BERT微调通常用很小的学习率(2e-5到5e-5)。尝试调小。
  • 批次大小不合适:GPU显存允许的情况下,适当增大批次大小可能使训练更稳定。
  • 梯度爆炸:可以加入梯度裁剪 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
  • 验证集上过拟合:加入Dropout(BERT模型本身有,分类头也可以加),或者使用更早的停止策略。

问题三:如何选择预训练模型?

  • Base vs Largebert-base 有1.1亿参数,bert-large 有3.4亿。Large效果通常更好,但更慢、更吃资源。对于大多数任务,Base已经足够优秀。
  • 多语言 vs 单语言:如果你的任务是中文,就用 bert-base-chinese,它在中文语料上预训练过,对中文理解更好。
  • 领域适配:如果你的数据是医学、法律等专业领域,可以尝试寻找在该领域语料上继续预训练过的模型(如BioBERT、LegalBERT),或者用自己的领域语料对通用BERT进行领域自适应预训练,这通常比直接微调效果更好。

问题四:需要微调所有层吗? 不一定。一种常见的策略是分层解冻学习率。BERT的底层(靠近输入)学习的是通用语法、词法特征,高层学习的是更抽象的任务相关特征。你可以:

  • 先只训练最后添加的分类头,冻结BERT所有参数。
  • 然后解冻BERT的最后1-2层,用更小的学习率微调。
  • 如果数据量足够大,再考虑解冻更多层。 这样做既能利用预训练知识,又能让模型更好地适应新任务,还能有效防止过拟合。Hugging Face的 Trainer API 可以很方便地设置不同参数组的学习率。

最后,记住实验记录是关键。每次调整超参数(学习率、批次大小、训练轮数)、尝试不同的技巧(Focal Loss、CRF),都要记录下在验证集上的表现。只有通过系统性的对比,你才能找到最适合你数据和任务的“配方”。NLP模型调优,三分靠技术,七分靠耐心和细致的实验。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐