包如何掌控?cái)?shù)據(jù)到服務(wù)全鏈路)
1. 從零手搓AI工程為什么我不建議你直接調(diào)包很多人一上來(lái)就想搞個(gè)大模型應(yīng)用第一反應(yīng)是找API、裝框架、跑通一個(gè)Demo然后覺(jué)得自己“入門AI工程”了。我剛開(kāi)始也這么干過(guò)結(jié)果踩了一堆坑接口一改就崩、成本失控、延遲高得離譜、出了問(wèn)題完全不知道從哪查。后來(lái)我才意識(shí)到AI工程的核心能力不是“會(huì)調(diào)包”而是理解從數(shù)據(jù)到模型再到服務(wù)的整條鏈路。這就是我決定從零開(kāi)始手搓一遍的原因?!癮i-engineering-from-scratch”這個(gè)方向說(shuō)白了就是不依賴高級(jí)封裝用最基礎(chǔ)的工具把AI系統(tǒng)的關(guān)鍵環(huán)節(jié)自己實(shí)現(xiàn)一遍。它解決的不是“能不能跑通”的問(wèn)題而是“跑通之后你能不能掌控它”的問(wèn)題。適合誰(shuí)看如果你已經(jīng)會(huì)寫(xiě)Python、懂一點(diǎn)線性代數(shù)和概率但每次遇到模型效果不好、推理太慢、顯存爆了就只能上網(wǎng)搜答案那這套東西就是給你準(zhǔn)備的。我下面會(huì)按實(shí)際動(dòng)手的順序把數(shù)據(jù)管線、模型訓(xùn)練、推理優(yōu)化、服務(wù)部署這幾個(gè)環(huán)節(jié)拆開(kāi)講每個(gè)環(huán)節(jié)都給出可復(fù)現(xiàn)的代碼思路和參數(shù)選擇的理由。2. 整體設(shè)計(jì)思路為什么我要把“調(diào)包”拆成“手搓”2.1 先想清楚手搓到底練的是什么很多人對(duì)手搓有誤解覺(jué)得是要自己寫(xiě)一個(gè)PyTorch出來(lái)。不是的。手搓的目的是讓你對(duì)每一層抽象都有“掀開(kāi)蓋子”的能力。比如你知道m(xù)odel.fit()背后發(fā)生了什么嗎梯度是怎么累積的學(xué)習(xí)率在哪個(gè)時(shí)刻衰減數(shù)據(jù)是怎么分批的這些細(xì)節(jié)在調(diào)包時(shí)全是黑盒但一旦你自己用NumPy實(shí)現(xiàn)一遍反向傳播再用PyTorch對(duì)照驗(yàn)證你對(duì)訓(xùn)練過(guò)程的理解會(huì)完全不一樣。我給自己定的原則是能用基礎(chǔ)庫(kù)就不用高級(jí)封裝能自己寫(xiě)的模塊就不調(diào)現(xiàn)成函數(shù)。但也不是什么都從零寫(xiě)像矩陣乘法這種底層算子直接用NumPy或者PyTorch的底層接口就行沒(méi)必要自己寫(xiě)CUDA核。關(guān)鍵是理解每一層的輸入輸出和計(jì)算邏輯。2.2 技術(shù)選型為什么是Python NumPy PyTorch底層API選Python沒(méi)什么好說(shuō)的生態(tài)最全。NumPy用來(lái)做數(shù)據(jù)預(yù)處理和手寫(xiě)算法驗(yàn)證因?yàn)樗銐虻讓幽隳芸吹矫恳粋€(gè)數(shù)組的形狀變化。PyTorch我只用torch.tensor、torch.autograd和torch.nn.functional這些底層接口不用nn.Module的高級(jí)封裝更不用Trainer。這樣做的代價(jià)是代碼量會(huì)多兩三倍但好處是每一個(gè)超參數(shù)、每一次前向傳播、每一次梯度更新都在你眼皮底下。有人會(huì)問(wèn)為什么不直接用JAX或者TensorFlow。我的考慮是PyTorch的動(dòng)態(tài)圖機(jī)制對(duì)調(diào)試最友好而且它的底層API和NumPy的思維模式最接近從NumPy過(guò)渡到PyTorch幾乎沒(méi)有認(rèn)知負(fù)擔(dān)。JAX雖然快但函數(shù)式編程的風(fēng)格對(duì)新手不太友好調(diào)試也麻煩。2.3 整體架構(gòu)從數(shù)據(jù)到服務(wù)的四層拆分我把整個(gè)系統(tǒng)拆成四層每一層都可以獨(dú)立測(cè)試和替換數(shù)據(jù)層負(fù)責(zé)原始數(shù)據(jù)的讀取、清洗、分詞、分批。這一層的關(guān)鍵是可復(fù)現(xiàn)同樣的隨機(jī)種子必須產(chǎn)生同樣的批次順序。模型層定義網(wǎng)絡(luò)結(jié)構(gòu)、損失函數(shù)、優(yōu)化器。這一層的關(guān)鍵是可解釋每一層的參數(shù)量、計(jì)算量、梯度流動(dòng)都要能打印出來(lái)。訓(xùn)練層控制訓(xùn)練循環(huán)、學(xué)習(xí)率調(diào)度、梯度裁剪、模型保存。這一層的關(guān)鍵是可觀測(cè)loss曲線、梯度范數(shù)、學(xué)習(xí)率變化都要實(shí)時(shí)記錄。服務(wù)層把訓(xùn)練好的模型封裝成API處理并發(fā)請(qǐng)求、批處理、超時(shí)。這一層的關(guān)鍵是可伸縮單機(jī)能跑多機(jī)也能擴(kuò)。這四層之間的接口我全部用最樸素的Python字典和NumPy數(shù)組來(lái)傳遞不用任何框架特有的數(shù)據(jù)結(jié)構(gòu)。這樣做的目的是讓每一層都可以單獨(dú)拿出來(lái)測(cè)試比如我可以不啟動(dòng)訓(xùn)練直接用假數(shù)據(jù)測(cè)試服務(wù)層的吞吐量。3. 核心細(xì)節(jié)解析數(shù)據(jù)管線與模型訓(xùn)練的實(shí)操要點(diǎn)3.1 數(shù)據(jù)管線為什么你的模型效果不好八成是數(shù)據(jù)沒(méi)弄對(duì)我見(jiàn)過(guò)太多人把精力全花在調(diào)模型結(jié)構(gòu)上結(jié)果數(shù)據(jù)管線里藏著一堆bug。數(shù)據(jù)管線的第一原則是任何一步都要能單獨(dú)驗(yàn)證。比如分詞之后你要能隨機(jī)抽幾條出來(lái)看分批之后你要能打印出每個(gè)批次的形狀和標(biāo)簽分布。具體怎么做我一般會(huì)寫(xiě)一個(gè)DataPipeline類里面每個(gè)方法只做一件事class DataPipeline: def __init__(self, raw_texts, labels, tokenizer, batch_size, seed42): self.raw_texts raw_texts self.labels labels self.tokenizer tokenizer self.batch_size batch_size self.rng np.random.default_rng(seed) def clean(self): # 去重、去空、去異常字符 cleaned [] for text in self.raw_texts: text text.strip() if len(text) 2: continue cleaned.append(text) return cleaned def tokenize(self, texts): # 這里用最簡(jiǎn)單的空格分詞實(shí)際項(xiàng)目可以換成BPE return [self.tokenizer.encode(t) for t in texts] def batch(self, token_ids, labels): # 先打亂再按batch_size切分 indices self.rng.permutation(len(token_ids)) for i in range(0, len(indices), self.batch_size): batch_idx indices[i:iself.batch_size] yield [token_ids[j] for j in batch_idx], [labels[j] for j in batch_idx]注意幾個(gè)細(xì)節(jié)隨機(jī)種子要固定不然每次跑的結(jié)果都不一樣沒(méi)法對(duì)比實(shí)驗(yàn)。清洗規(guī)則要可配置不同數(shù)據(jù)集的最短長(zhǎng)度要求不一樣。分批之前一定要打亂不然模型會(huì)學(xué)到順序信息這在很多任務(wù)里是致命的。還有一個(gè)坑padding的位置。如果你用固定長(zhǎng)度的批次短句子后面補(bǔ)0那計(jì)算loss的時(shí)候一定要mask掉這些0不然模型會(huì)學(xué)著去預(yù)測(cè)padding。我一般會(huì)在batch方法里同時(shí)返回一個(gè)mask數(shù)組訓(xùn)練時(shí)用loss (loss * mask).sum() / mask.sum()來(lái)算真實(shí)loss。3.2 模型層手寫(xiě)一個(gè)Transformer的注意力機(jī)制既然是從零手搓那注意力機(jī)制肯定要自己寫(xiě)一遍。很多人覺(jué)得Transformer很復(fù)雜其實(shí)拆開(kāi)看就是幾個(gè)矩陣乘法和softmax。我用NumPy寫(xiě)一個(gè)最基礎(chǔ)的單頭注意力def attention(Q, K, V, maskNone): # Q, K, V的形狀都是 (batch_size, seq_len, d_model) d_k Q.shape[-1] scores np.matmul(Q, K.transpose(0, 2, 1)) / np.sqrt(d_k) if mask is not None: scores np.where(mask 0, -1e9, scores) weights softmax(scores, axis-1) return np.matmul(weights, V), weights def softmax(x, axis-1): x_max np.max(x, axisaxis, keepdimsTrue) exp_x np.exp(x - x_max) return exp_x / np.sum(exp_x, axisaxis, keepdimsTrue)這里有幾個(gè)關(guān)鍵點(diǎn)除以sqrt(d_k)是為了防止點(diǎn)積過(guò)大導(dǎo)致softmax梯度消失這個(gè)縮放因子不是隨便選的是讓方差保持在1左右。mask要在softmax之前加而且要用一個(gè)很大的負(fù)數(shù)而不是0因?yàn)閟oftmax(0)是有值的會(huì)污染注意力分布。softmax要減去最大值這是數(shù)值穩(wěn)定性的常規(guī)操作不然exp容易溢出。寫(xiě)完之后我會(huì)用PyTorch的torch.nn.functional.scaled_dot_product_attention對(duì)照驗(yàn)證確保輸出一致。這一步很重要手搓的代碼必須和成熟實(shí)現(xiàn)對(duì)齊不然你根本不知道是自己寫(xiě)錯(cuò)了還是模型本身效果不好。3.3 訓(xùn)練層學(xué)習(xí)率調(diào)度和梯度裁剪的實(shí)操參數(shù)訓(xùn)練循環(huán)看起來(lái)簡(jiǎn)單但里面的坑最多。我一般會(huì)記錄四個(gè)東西訓(xùn)練loss、驗(yàn)證loss、梯度范數(shù)、學(xué)習(xí)率。這四個(gè)指標(biāo)能覆蓋90%的訓(xùn)練問(wèn)題。學(xué)習(xí)率調(diào)度我用的是帶warmup的余弦退火參數(shù)是這樣選的warmup步數(shù)總步數(shù)的5%到10%。比如總共訓(xùn)練10000步warmup設(shè)500到1000步。warmup的作用是讓模型在初期不要更新太猛避免梯度爆炸。最大學(xué)習(xí)率1e-4到3e-4之間。我一般從3e-4開(kāi)始試如果loss震蕩就降到1e-4。最小學(xué)習(xí)率最大學(xué)習(xí)率的十分之一。余弦退火到最后會(huì)降到這個(gè)值讓模型在末期微調(diào)。梯度裁剪我設(shè)的是全局范數(shù)裁剪閾值1.0。具體做法是把所有參數(shù)的梯度拼成一個(gè)向量算它的L2范數(shù)如果超過(guò)1.0就按比例縮放。這個(gè)操作能防止個(gè)別批次的異常梯度把模型帶偏。def clip_gradients(parameters, max_norm1.0): total_norm 0.0 for p in parameters: total_norm np.sum(p.grad ** 2) total_norm np.sqrt(total_norm) clip_coef max_norm / (total_norm 1e-6) if clip_coef 1.0: for p in parameters: p.grad * clip_coef return total_norm注意1e-6這個(gè)epsilon不能省不然total_norm為0的時(shí)候會(huì)除零。返回的total_norm要記錄下來(lái)如果它一直很大說(shuō)明學(xué)習(xí)率可能太高了。4. 實(shí)操過(guò)程從零搭建一個(gè)文本分類服務(wù)的完整記錄4.1 環(huán)境準(zhǔn)備與依賴安裝我用的環(huán)境是Python 3.10依賴只有四個(gè)numpy、torch、flask、requests。不用transformers、不用datasets、不用accelerate。安裝命令很簡(jiǎn)單pip install numpy torch flask requests有人會(huì)問(wèn)不用transformers怎么加載預(yù)訓(xùn)練模型我的做法是自己寫(xiě)一個(gè)最小的模型加載器從HuggingFace的bin文件里讀權(quán)重然后映射到我手寫(xiě)的網(wǎng)絡(luò)結(jié)構(gòu)上。這個(gè)過(guò)程很麻煩但能讓你徹底搞清楚預(yù)訓(xùn)練模型的參數(shù)命名規(guī)則和結(jié)構(gòu)。如果只是想快速驗(yàn)證也可以先用隨機(jī)初始化的模型跑通流程再替換成預(yù)訓(xùn)練權(quán)重。4.2 數(shù)據(jù)準(zhǔn)備用一個(gè)小數(shù)據(jù)集跑通全流程我用的是一個(gè)公開(kāi)的中文情感分類數(shù)據(jù)集大概1萬(wàn)條數(shù)據(jù)正負(fù)樣本各半。數(shù)據(jù)格式是每行一個(gè)JSON包含text和label兩個(gè)字段。讀取和清洗的代碼如下import json def load_data(path): texts, labels [], [] with open(path, r, encodingutf-8) as f: for line in f: item json.loads(line) texts.append(item[text]) labels.append(item[label]) return texts, labels texts, labels load_data(sentiment.jsonl) print(f總樣本數(shù): {len(texts)}) print(f正樣本比例: {sum(labels) / len(labels):.2f})打印正樣本比例這一步很重要如果比例嚴(yán)重失衡準(zhǔn)確率這個(gè)指標(biāo)就沒(méi)意義了得換F1或者AUC。我一般會(huì)先看一眼這個(gè)比例再?zèng)Q定用哪些評(píng)估指標(biāo)。4.3 模型定義一個(gè)極簡(jiǎn)的文本分類網(wǎng)絡(luò)我的模型結(jié)構(gòu)很簡(jiǎn)單詞嵌入 平均池化 全連接。沒(méi)有用Transformer因?yàn)樵谶@個(gè)數(shù)據(jù)量下簡(jiǎn)單模型反而更穩(wěn)。結(jié)構(gòu)如下import torch import torch.nn as nn class TextClassifier(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.fc nn.Linear(embed_dim, num_classes) def forward(self, input_ids, mask): # input_ids: (batch, seq_len) embeds self.embedding(input_ids) # (batch, seq_len, embed_dim) # 用mask做加權(quán)平均池化 mask mask.unsqueeze(-1).float() pooled (embeds * mask).sum(dim1) / mask.sum(dim1).clamp(min1e-6) logits self.fc(pooled) return logits注意padding_idx0這個(gè)參數(shù)它讓padding位置的嵌入向量不參與梯度更新。池化的時(shí)候用mask加權(quán)平均而不是直接mean這樣padding不會(huì)影響結(jié)果。clamp(min1e-6)是防止mask全0的時(shí)候除零。4.4 訓(xùn)練循環(huán)每一步都打印關(guān)鍵指標(biāo)訓(xùn)練循環(huán)我寫(xiě)得比較啰嗦但每一步都記錄了關(guān)鍵信息def train(model, dataloader, optimizer, scheduler, num_epochs): for epoch in range(num_epochs): model.train() total_loss 0.0 for step, (input_ids, mask, labels) in enumerate(dataloader): input_ids torch.tensor(input_ids, dtypetorch.long) mask torch.tensor(mask, dtypetorch.float) labels torch.tensor(labels, dtypetorch.long) logits model(input_ids, mask) loss nn.functional.cross_entropy(logits, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪 grad_norm clip_gradients(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() if step % 50 0: lr scheduler.get_last_lr()[0] print(fEpoch {epoch} Step {step} Loss {loss.item():.4f} fGradNorm {grad_norm:.4f} LR {lr:.6f}) avg_loss total_loss / len(dataloader) print(fEpoch {epoch} Avg Loss {avg_loss:.4f})這里有幾個(gè)實(shí)操心得每50步打印一次太頻繁會(huì)刷屏太稀疏會(huì)漏掉異常。梯度范數(shù)和學(xué)習(xí)率一起打印這樣能看出它們之間的關(guān)聯(lián)。驗(yàn)證集評(píng)估放在每個(gè)epoch結(jié)束不要放在訓(xùn)練循環(huán)里面不然會(huì)拖慢訓(xùn)練速度。4.5 服務(wù)層用Flask封裝一個(gè)帶批處理的推理接口訓(xùn)練完之后模型要能對(duì)外提供服務(wù)。我用Flask寫(xiě)了一個(gè)最簡(jiǎn)單的接口支持單條和批量推理from flask import Flask, request, jsonify import torch app Flask(__name__) model load_model(model.pt) model.eval() app.route(/predict, methods[POST]) def predict(): data request.get_json() texts data[texts] # 支持列表 if isinstance(texts, str): texts [texts] input_ids, mask tokenize_and_pad(texts) with torch.no_grad(): logits model(input_ids, mask) probs torch.softmax(logits, dim-1) preds torch.argmax(probs, dim-1) results [] for text, pred, prob in zip(texts, preds.tolist(), probs.tolist()): results.append({ text: text, label: pred, confidence: max(prob) }) return jsonify({results: results}) if __name__ __main__: app.run(host0.0.0.0, port5000)注意torch.no_grad()一定要加不然推理會(huì)構(gòu)建計(jì)算圖顯存會(huì)爆。批處理接口比單條接口吞吐量高很多因?yàn)镚PU的并行能力只有在大batch下才能發(fā)揮出來(lái)。我實(shí)測(cè)下來(lái)batch_size32的時(shí)候QPS是單條的8倍左右。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 訓(xùn)練loss不下降從數(shù)據(jù)到梯度的排查順序loss不降是最常見(jiàn)的問(wèn)題我一般按這個(gè)順序排查先看數(shù)據(jù)隨機(jī)抽幾條樣本打印它們的token ids和label確認(rèn)沒(méi)有錯(cuò)位。我遇到過(guò)label和text反了的情況查了半天才發(fā)現(xiàn)是數(shù)據(jù)加載的時(shí)候字段名寫(xiě)錯(cuò)了。再看梯度打印每一層的梯度范數(shù)如果某一層梯度全是0說(shuō)明那一層沒(méi)參與計(jì)算。常見(jiàn)原因是mask寫(xiě)錯(cuò)了或者某一層的輸入被detach了。然后看學(xué)習(xí)率如果學(xué)習(xí)率太大loss會(huì)震蕩太小則下降很慢。我一般會(huì)跑一個(gè)學(xué)習(xí)率掃描從1e-5到1e-2每個(gè)跑100步看哪個(gè)loss降得最快。最后看模型結(jié)構(gòu)如果以上都沒(méi)問(wèn)題那可能是模型容量不夠或者結(jié)構(gòu)有bug。我會(huì)先用一個(gè)極小的數(shù)據(jù)集比如100條過(guò)擬合一下如果連100條都過(guò)擬合不了那肯定是代碼有問(wèn)題。5.2 顯存不夠用幾個(gè)立竿見(jiàn)影的優(yōu)化手段顯存不夠的時(shí)候按這個(gè)優(yōu)先級(jí)來(lái)優(yōu)化減小batch_size最直接但會(huì)影響訓(xùn)練穩(wěn)定性。我一般會(huì)配合梯度累積比如batch_size8累積4次等效batch_size32。用混合精度torch.cuda.amp能省一半顯存速度還快。但要注意有些操作在fp16下會(huì)溢出需要用GradScaler。檢查有沒(méi)有不必要的張量保留比如在訓(xùn)練循環(huán)里把loss存到一個(gè)列表里如果loss是tensor那整個(gè)計(jì)算圖都會(huì)被保留。正確做法是存loss.item()。用梯度檢查點(diǎn)這個(gè)比較高級(jí)適合大模型。原理是不保存中間激活值反向傳播時(shí)重新計(jì)算。代價(jià)是訓(xùn)練速度慢20%左右。5.3 推理延遲高從模型到服務(wù)的全鏈路優(yōu)化推理延遲高先定位瓶頸在哪排查點(diǎn)可能原因優(yōu)化手段模型前向?qū)訑?shù)太多、注意力計(jì)算量大剪枝、量化、換更小的模型數(shù)據(jù)預(yù)處理分詞慢、padding太多緩存分詞結(jié)果、動(dòng)態(tài)padding服務(wù)框架單條推理、沒(méi)有批處理加批處理、用異步框架硬件CPU推理、顯存帶寬不夠換GPU、用TensorRT我實(shí)測(cè)下來(lái)動(dòng)態(tài)padding對(duì)延遲的改善最明顯。因?yàn)榇蟛糠志渥拥拈L(zhǎng)度都遠(yuǎn)小于最大長(zhǎng)度固定padding會(huì)浪費(fèi)大量計(jì)算。動(dòng)態(tài)padding就是每個(gè)batch按當(dāng)前最長(zhǎng)句子來(lái)padding能省30%到50%的計(jì)算量。5.4 常見(jiàn)問(wèn)題速查表問(wèn)題現(xiàn)象可能原因快速驗(yàn)證方法解決方案loss變成NaN學(xué)習(xí)率太大、梯度爆炸打印梯度范數(shù)降低學(xué)習(xí)率、加梯度裁剪驗(yàn)證loss上升過(guò)擬合對(duì)比訓(xùn)練和驗(yàn)證loss曲線加dropout、早停、數(shù)據(jù)增強(qiáng)預(yù)測(cè)結(jié)果全是同一類數(shù)據(jù)失衡、模型沒(méi)學(xué)到打印預(yù)測(cè)分布重采樣、換損失函數(shù)服務(wù)響應(yīng)超時(shí)批處理太大、模型太慢打印每個(gè)請(qǐng)求的處理時(shí)間減小batch、加超時(shí)限制模型加載失敗參數(shù)名不匹配、形狀不對(duì)打印state_dict的key手動(dòng)映射參數(shù)名6. 我踩過(guò)的坑和最后再分享幾個(gè)小技巧第一個(gè)坑是隨機(jī)種子沒(méi)固定全。Python的random、NumPy的np.random、PyTorch的torch.manual_seed都要設(shè)而且DataLoader的worker_init_fn也要設(shè)不然多進(jìn)程加載數(shù)據(jù)的時(shí)候順序還是會(huì)變。我現(xiàn)在的做法是在訓(xùn)練腳本開(kāi)頭寫(xiě)一個(gè)set_seed(42)函數(shù)把所有能設(shè)的種子都設(shè)一遍。第二個(gè)坑是學(xué)習(xí)率調(diào)度器的step位置。PyTorch的CosineAnnealingLR是按epoch調(diào)的但OneCycleLR是按step調(diào)的。如果搞混了學(xué)習(xí)率曲線會(huì)完全不對(duì)。我現(xiàn)在的習(xí)慣是每個(gè)step都打印學(xué)習(xí)率這樣一眼就能看出調(diào)度器有沒(méi)有正常工作。第三個(gè)坑是模型保存和加載的不一致。訓(xùn)練的時(shí)候用了nn.DataParallel保存的state_dict的key會(huì)多一個(gè)module.前綴加載的時(shí)候如果不用DataParallel就會(huì)報(bào)錯(cuò)。解決辦法是保存的時(shí)候用model.module.state_dict()或者加載的時(shí)候用OrderedDict把前綴去掉。最后分享一個(gè)小技巧在訓(xùn)練循環(huán)里加一個(gè)異常捕獲把出錯(cuò)的batch的數(shù)據(jù)和中間結(jié)果保存下來(lái)。這樣即使訓(xùn)練崩了你也能復(fù)現(xiàn)問(wèn)題。我一般會(huì)在try塊里跑訓(xùn)練except塊里把當(dāng)前batch的input_ids、mask、labels和loss都存成npy文件然后重新拋出異常。這個(gè)習(xí)慣幫我省了很多調(diào)試時(shí)間。還有一個(gè)技巧是用小數(shù)據(jù)集做快速迭代。每次改完代碼先用100條數(shù)據(jù)跑10個(gè)step確認(rèn)沒(méi)有形狀錯(cuò)誤和NaN再上全量數(shù)據(jù)。這樣能把調(diào)試周期從幾小時(shí)縮短到幾分鐘。我現(xiàn)在的流程是改代碼 - 小數(shù)據(jù)跑通 - 全量訓(xùn)練 - 驗(yàn)證集評(píng)估 - 服務(wù)部署每一步都有明確的檢查點(diǎn)不會(huì)等到最后才發(fā)現(xiàn)問(wèn)題。