包搭建可運(yùn)行系統(tǒng)的完整指南)
1. 從零手搓AI工程為什么我不建議你直接調(diào)包第一次看到ai-engineering-from-scratch這個項目名的時候我腦子里蹦出來的畫面是一個人坐在黑漆漆的終端前拒絕所有現(xiàn)成的框架從矩陣乘法開始一行一行地把一個能跑起來的AI系統(tǒng)給搭出來。這個直覺基本是對的但它比“手寫一個神經(jīng)網(wǎng)絡(luò)”要寬得多。AI工程不等于模型訓(xùn)練它是一整條鏈路——數(shù)據(jù)怎么進(jìn)來、特征怎么處理、模型怎么選、推理怎么部署、服務(wù)怎么監(jiān)控、成本怎么壓下來。這個項目標(biāo)題里的 “from scratch”我理解成兩層意思一層是不依賴重型框架去理解底層原理另一層是從零搭建一套可運(yùn)行的工程骨架而不是停留在 notebook 里跑個 demo。我做了十多年一線見過太多團(tuán)隊在“調(diào)包”這件事上翻車。不是調(diào)包不好而是當(dāng)你不知道包里面發(fā)生了什么出了問題你連日志都看不懂。模型輸出突然變差你懷疑是數(shù)據(jù)漂移結(jié)果發(fā)現(xiàn)是預(yù)處理里某個歸一化參數(shù)寫反了線上延遲飆高你以為是模型太大結(jié)果發(fā)現(xiàn)是 batch 拼裝邏輯在某個邊界條件下退化成了一條一條推理。這些坑只有你親手從零搭過一遍才會有肌肉記憶。這篇東西適合誰看如果你是剛?cè)胄?、只會model.fit()的算法同學(xué)它能幫你把工程這條腿補(bǔ)上如果你是后端轉(zhuǎn)AI、被各種框架繞暈的工程師它能給你一條清晰的、不依賴魔法的主線如果你是帶團(tuán)隊的技術(shù)負(fù)責(zé)人它可以當(dāng)作一份“最小可用AI系統(tǒng)”的搭建清單用來對齊團(tuán)隊認(rèn)知。我不打算寫成教科書就按我自己搭這類系統(tǒng)的真實(shí)順序來講中間會穿插大量我踩過的坑和實(shí)測參數(shù)。2. 整體架構(gòu)設(shè)計先想清楚邊界再動手寫代碼2.1 從零不等于從原始社會開始很多人對 “from scratch” 有個誤解覺得必須連 NumPy 都不用純 Python 列表推導(dǎo)式算矩陣。我明確說沒必要也不推薦。從零的核心是“你清楚每一層的職責(zé)和數(shù)據(jù)形態(tài)”而不是“拒絕一切工具”。NumPy 是數(shù)值計算的基礎(chǔ)設(shè)施用它不丟人真正要警惕的是那種一行pipeline.fit()把數(shù)據(jù)清洗、特征工程、模型訓(xùn)練全包了、你完全不知道中間發(fā)生了什么的黑盒。我的選型原則很簡單底層數(shù)值用 NumPy數(shù)據(jù)處理用原生 Python Pandas 做輕量清洗模型部分手寫核心算子服務(wù)層用 FastAPI。為什么不用 PyTorch 或 TensorFlow因為一旦用了你就很難忍住不去調(diào)nn.Linear而手寫一遍前向和反向傳播你對梯度、維度、初始化的理解會上一個臺階。等你手寫完再回去用框架你會發(fā)現(xiàn)你讀源碼的速度完全不一樣了。這里有個關(guān)鍵取舍手寫模型只適合中小規(guī)模、結(jié)構(gòu)清晰的場景。如果你要做的是十億參數(shù)的大模型那必須用分布式框架手寫不現(xiàn)實(shí)。所以這個項目的定位是“教學(xué) 小規(guī)模生產(chǎn)驗證”不是“替代工業(yè)級訓(xùn)練框架”。想清楚這一點(diǎn)后面的技術(shù)選型就不會擰巴。2.2 分層架構(gòu)與數(shù)據(jù)流設(shè)計我習(xí)慣把整個系統(tǒng)切成四層每層只跟相鄰層打交道這樣出問題的時候能快速定位是哪一層的鍋。層級職責(zé)典型技術(shù)出問題時的表現(xiàn)數(shù)據(jù)層采集、清洗、切分、版本管理Pandas、Parquet、DVC訓(xùn)練指標(biāo)抖動、特征分布異常特征層歸一化、編碼、特征交叉NumPy、sklearn預(yù)處理線上線下不一致、推理結(jié)果偏移模型層前向、損失、反向、優(yōu)化手寫NumPy損失不下降、梯度爆炸/消失服務(wù)層接口、批處理、監(jiān)控、日志FastAPI、Prometheus延遲高、超時、內(nèi)存泄漏數(shù)據(jù)流是這樣的原始數(shù)據(jù)先落盤成 Parquet比 CSV 快得多列式存儲對特征讀取友好然后經(jīng)過清洗腳本產(chǎn)出訓(xùn)練集和驗證集特征層把原始字段轉(zhuǎn)成模型能吃的數(shù)值矩陣模型層訓(xùn)練并保存權(quán)重服務(wù)層加載權(quán)重對外提供推理。每一層的輸出都要落盤或打日志沒有落盤的中間產(chǎn)物等于沒有這是我在生產(chǎn)環(huán)境用血換來的教訓(xùn)。2.3 為什么堅持“可復(fù)現(xiàn)”優(yōu)先于“高性能”新手搭系統(tǒng)最容易犯的錯是一上來就追求 QPS 和低延遲結(jié)果代碼寫得極其復(fù)雜換個數(shù)據(jù)集就跑不起來。我的建議是第一階段只追求可復(fù)現(xiàn)。什么叫可復(fù)現(xiàn)同樣的數(shù)據(jù)、同樣的隨機(jī)種子、同樣的代碼跑出來的結(jié)果必須一模一樣。為此你要做三件事固定所有隨機(jī)源NumPy、Python random、甚至哈希種子把數(shù)據(jù)切分邏輯寫成確定性的把超參數(shù)全部外置成配置文件。我實(shí)測下來一個可復(fù)現(xiàn)的樸素實(shí)現(xiàn)哪怕推理延遲是優(yōu)化后的三倍它的價值也遠(yuǎn)高于一個跑得快但結(jié)果飄忽的版本。因為只有可復(fù)現(xiàn)你才能做 A/B 對比才能定位是哪個改動帶來了提升。等你把基線跑穩(wěn)了再去做向量化、批處理、緩存這些優(yōu)化順序不能反。3. 核心模塊拆解手寫模型到底要寫哪些東西3.1 數(shù)據(jù)預(yù)處理最臟最累但最不能省數(shù)據(jù)預(yù)處理這塊我見過太多人草草了事。真實(shí)數(shù)據(jù)里一定有缺失值、異常值、類型不一致、時間格式混亂。我的處理順序是先做數(shù)據(jù)探查統(tǒng)計每個字段的缺失率、唯一值數(shù)量、數(shù)值分布然后做清洗缺失率超過 60% 的字段直接丟棄數(shù)值字段用中位數(shù)填充比均值抗異常值類別字段用眾數(shù)或單獨(dú)標(biāo)記為 “unknown”最后做切分按時間切分而不是隨機(jī)切分如果你的場景有時序性。這里有個大坑歸一化參數(shù)必須從訓(xùn)練集計算然后應(yīng)用到驗證集和測試集。我見過有人對整個數(shù)據(jù)集做歸一化再切分這會導(dǎo)致數(shù)據(jù)泄漏驗證集指標(biāo)虛高上線后直接崩。正確做法是訓(xùn)練集算均值和標(biāo)準(zhǔn)差存下來推理時用同一套參數(shù)。這個參數(shù)文件要跟模型權(quán)重一起版本管理缺一不可。# 訓(xùn)練集計算歸一化參數(shù) mean X_train.mean(axis0) std X_train.std(axis0) 1e-8 # 防止除零 X_train_norm (X_train - mean) / std # 驗證集和測試集用同一套參數(shù) X_val_norm (X_val - mean) / std # 保存參數(shù) np.savez(norm_params.npz, meanmean, stdstd)那個1e-8是防止某個特征方差為零導(dǎo)致除零這種細(xì)節(jié)不寫出來線上就會給你報一堆 nan。3.2 手寫前向傳播維度對齊是永恒的主題手寫前向傳播核心就三件事矩陣乘法、激活函數(shù)、維度對齊。我建議從最簡單的全連接網(wǎng)絡(luò)開始兩層隱藏層足夠驗證整條鏈路。權(quán)重初始化用 He 初始化針對 ReLU公式是std sqrt(2 / fan_in)其中fan_in是輸入維度。為什么不用全零初始化因為全零會讓所有神經(jīng)元對稱反向傳播時梯度一樣等于白搭。為什么不用過大的隨機(jī)值因為會導(dǎo)致激活值飽和梯度消失。def init_weights(in_dim, out_dim): std np.sqrt(2.0 / in_dim) return np.random.randn(in_dim, out_dim) * std def forward(X, W1, b1, W2, b2, W3, b3): z1 X W1 b1 a1 np.maximum(0, z1) # ReLU z2 a1 W2 b2 a2 np.maximum(0, z2) z3 a2 W3 b3 return z3, (z1, a1, z2, a2)維度對齊這塊我的經(jīng)驗是每寫一行就打印一次 shape。別嫌麻煩等你遇到(32, 10) (64, 10)這種報錯的時候回頭查維度能查到你懷疑人生。我習(xí)慣在開發(fā)階段加一個assert檢查比如assert X.shape[1] W1.shape[0]上線前再把這些斷言去掉或改成日志。3.3 反向傳播鏈?zhǔn)椒▌t的工程化落地反向傳播是手寫模型里最容易寫錯的部分。我的方法是先推導(dǎo)再編碼推導(dǎo)過程寫在注釋里。以交叉熵?fù)p失 Softmax 為例輸出層的梯度有一個非常優(yōu)雅的簡化形式dz y_pred - y_true。這個結(jié)論能省掉一大堆求導(dǎo)但前提是你用的是 Softmax 交叉熵的組合。如果你換成別的損失函數(shù)這個簡化就不成立了必須老老實(shí)實(shí)按鏈?zhǔn)椒▌t推。def backward(X, y_true, cache, weights): z1, a1, z2, a2 cache W1, W2, W3 weights m X.shape[0] # 輸出層梯度Softmax 交叉熵的簡化形式 dz3 (softmax(z3) - y_true) / m dW3 a2.T dz3 db3 dz3.sum(axis0) # 隱藏層2 da2 dz3 W3.T dz2 da2 * (z2 0) # ReLU導(dǎo)數(shù) dW2 a1.T dz2 db2 dz2.sum(axis0) # 隱藏層1 da1 dz2 W2.T dz1 da1 * (z1 0) dW1 X.T dz1 db1 dz1.sum(axis0) return dW1, db1, dW2, db2, dW3, db3注意那個/ m是對 batch 求平均這樣學(xué)習(xí)率不會隨 batch size 變化而需要重新調(diào)。ReLU 的導(dǎo)數(shù)(z 0)在 z0 處不可導(dǎo)工程上直接取 0 或 1 都行實(shí)測影響可以忽略。3.4 優(yōu)化器與訓(xùn)練循環(huán)學(xué)習(xí)率是最重要的超參數(shù)優(yōu)化器我建議從最樸素的 SGD 開始然后加 Momentum最后再上 Adam。為什么因為 SGD 能讓你直觀感受到學(xué)習(xí)率的影響而 Adam 的自適應(yīng)學(xué)習(xí)率會掩蓋很多問題。學(xué)習(xí)率的選擇有個經(jīng)驗公式先試1e-1、1e-2、1e-3、1e-4四個量級看損失下降曲線選那個下降最快又不震蕩的。我實(shí)測下來對于中小型全連接網(wǎng)絡(luò)1e-3配合 Adam 通常是個不錯的起點(diǎn)。訓(xùn)練循環(huán)里必須有的東西訓(xùn)練損失、驗證損失、驗證指標(biāo)、早停機(jī)制。早停的 patience 我一般設(shè) 5 到 10 個 epoch具體看數(shù)據(jù)量。數(shù)據(jù)量大就設(shè)小一點(diǎn)因為每個 epoch 成本高數(shù)據(jù)量小就設(shè)大一點(diǎn)給它更多機(jī)會。還有一個細(xì)節(jié)每個 epoch 結(jié)束后打亂訓(xùn)練數(shù)據(jù)但驗證集不要打亂這樣驗證指標(biāo)才可比。4. 工程化落地從能跑到能用還差十萬八千里4.1 配置管理別把超參數(shù)寫死在代碼里我見過太多項目學(xué)習(xí)率、batch size、層數(shù)全寫在代碼里想改一個參數(shù)得改代碼、重新提交、重新部署。正確做法是用配置文件YAML 或 JSON代碼只讀配置。配置里至少包含數(shù)據(jù)路徑、模型結(jié)構(gòu)參數(shù)、訓(xùn)練超參數(shù)、服務(wù)端口、日志級別。這樣你換數(shù)據(jù)集、調(diào)參、部署到不同環(huán)境都只改配置不改代碼。# config.yaml data: train_path: data/train.parquet val_path: data/val.parquet batch_size: 64 model: hidden_dims: [128, 64] dropout: 0.2 training: lr: 0.001 epochs: 100 patience: 7 serving: host: 0.0.0.0 port: 8000配置管理還有個好處實(shí)驗可追溯。每次訓(xùn)練把配置和對應(yīng)的指標(biāo)存到一張表里回頭分析“哪個配置效果好”的時候直接查表就行不用翻聊天記錄。4.2 模型持久化權(quán)重、參數(shù)、版本一個都不能少模型保存不是只存權(quán)重就完事了。我要求保存的東西包括模型權(quán)重、歸一化參數(shù)、特征字段列表、模型版本號、訓(xùn)練時的配置、訓(xùn)練日期。為什么因為推理的時候你需要知道輸入字段的順序、需要做同樣的歸一化、需要知道這個模型是什么時候訓(xùn)練的。少任何一樣線上都可能出問題。我習(xí)慣用目錄來組織models/v1/weights.npz、models/v1/norm_params.npz、models/v1/feature_list.json、models/v1/config.yaml。服務(wù)啟動時加載整個目錄版本號從目錄名讀。這樣回滾的時候直接切目錄就行干凈利落。4.3 服務(wù)層設(shè)計批處理與單條推理要兼顧服務(wù)層用 FastAPI 是個務(wù)實(shí)的選擇輕量、異步、自帶文檔。接口設(shè)計上我建議同時提供兩個端點(diǎn)/predict處理單條請求/predict_batch處理批量請求。為什么因為線上流量往往是混合的實(shí)時請求走單條離線補(bǔ)數(shù)據(jù)走批量。如果只做單條批量場景下網(wǎng)絡(luò)開銷會拖垮性能如果只做批量實(shí)時請求又沒法滿足。from fastapi import FastAPI import numpy as np app FastAPI() model load_model(models/v1) app.post(/predict) def predict(item: dict): x preprocess(item) # 用保存的歸一化參數(shù) logits model.forward(x) prob softmax(logits) return {label: int(np.argmax(prob)), confidence: float(np.max(prob))} app.post(/predict_batch) def predict_batch(items: list): X np.stack([preprocess(item) for item in items]) logits model.forward(X) probs softmax(logits) return {labels: np.argmax(probs, axis1).tolist()}批處理的時候注意內(nèi)存別一次塞太多。我一般限制 batch 上限為 256超過就分片處理。還有推理的時候記得關(guān)掉梯度計算雖然手寫 NumPy 沒有自動求導(dǎo)但如果你用了框架torch.no_grad()是必須的。4.4 監(jiān)控與日志上線才是真正的開始服務(wù)上線只是開始監(jiān)控才是保證它活著的東西。我至少要監(jiān)控四個指標(biāo)請求量、延遲分布P50/P95/P99、錯誤率、輸入特征分布。前三個是常規(guī)的第四個是AI系統(tǒng)特有的。為什么監(jiān)控輸入分布因為模型對訓(xùn)練時沒見過的數(shù)據(jù)分布表現(xiàn)很差如果線上輸入分布突然偏移模型輸出會悄悄變差但你從延遲和錯誤率上看不出來。日志方面我要求每次推理都記錄請求ID、輸入特征摘要脫敏后、輸出結(jié)果、耗時。這樣出問題的時候能快速定位是哪個請求、什么輸入導(dǎo)致的。日志量大的話采樣記錄但錯誤請求必須全量記錄。5. 常見問題與排查技巧實(shí)錄5.1 損失不下降的排查順序損失不下降是新手最常遇到的問題。我的排查順序是先看數(shù)據(jù)再看初始化再看學(xué)習(xí)率最后看梯度。數(shù)據(jù)方面檢查標(biāo)簽有沒有對齊、有沒有全零或全一的特征、類別是否極度不平衡。初始化方面檢查權(quán)重標(biāo)準(zhǔn)差是不是太大或太小。學(xué)習(xí)率方面從1e-4到1e-1掃一遍。梯度方面打印每一層的梯度范數(shù)如果某層梯度全是零說明那層死了ReLU 的經(jīng)典問題可以換 LeakyReLU 或調(diào)小學(xué)習(xí)率?,F(xiàn)象可能原因排查方法解決損失不變學(xué)習(xí)率過小掃學(xué)習(xí)率調(diào)大損失震蕩學(xué)習(xí)率過大看損失曲線調(diào)小或加動量損失變 nan梯度爆炸打印梯度范數(shù)梯度裁剪驗證損失上升過擬合對比訓(xùn)練/驗證曲線加正則、早停某層梯度全零神經(jīng)元死亡打印激活值換激活函數(shù)5.2 線上線下不一致的經(jīng)典原因線上線下不一致我總結(jié)下來就三個原因特征處理不一致、歸一化參數(shù)不一致、字段順序不一致。特征處理不一致比如訓(xùn)練時對缺失值填中位數(shù)線上填了零歸一化參數(shù)不一致比如訓(xùn)練用訓(xùn)練集均值線上用了全量均值字段順序不一致比如訓(xùn)練時字段是 [A, B, C]線上傳進(jìn)來是 [C, A, B]。這三個問題只要你在服務(wù)層嚴(yán)格復(fù)用訓(xùn)練時的預(yù)處理代碼和參數(shù)文件就能避免。我的做法是把預(yù)處理邏輯封裝成一個類訓(xùn)練和推理共用同一個類。訓(xùn)練時fit計算參數(shù)并保存推理時load參數(shù)并transform。這樣代碼只有一份不存在兩邊邏輯漂移的可能。5.3 性能優(yōu)化的幾個實(shí)用手段性能優(yōu)化別一上來就搞復(fù)雜的。我實(shí)測有效的順序是先向量化再批處理再緩存最后才考慮模型壓縮。向量化就是把 Python 循環(huán)換成 NumPy 操作這一步通常能帶來十倍以上的提升。批處理是把多條請求合并成一次矩陣運(yùn)算提升吞吐。緩存是針對重復(fù)輸入如果線上有大量重復(fù)查詢加一層 LRU 緩存能顯著降低延遲。模型壓縮量化、剪枝是最后的手段因為它會損失精度需要仔細(xì)評估。提示優(yōu)化之前一定要有基線數(shù)據(jù)不然你無法判斷優(yōu)化是否有效。我習(xí)慣用time.perf_counter()在關(guān)鍵路徑打點(diǎn)記錄每個階段的耗時這樣瓶頸在哪一目了然。5.4 我踩過的三個真實(shí)坑第一個坑隨機(jī)種子沒固定全。我只固定了 NumPy 的種子忘了 Python 內(nèi)置 random 的種子結(jié)果數(shù)據(jù)打亂順序每次不一樣實(shí)驗無法復(fù)現(xiàn)。后來我寫了個set_seed函數(shù)把所有隨機(jī)源都固定一遍。第二個坑歸一化參數(shù)保存成了 Python list加載后變成了字符串。因為用了 JSON 保存浮點(diǎn)數(shù)精度丟失加上類型轉(zhuǎn)換導(dǎo)致推理結(jié)果和訓(xùn)練差了一點(diǎn)點(diǎn)。后來改用np.savez保存二進(jìn)制問題消失。第三個坑服務(wù)層沒有限制請求體大小有人傳了一個超大 batch直接把內(nèi)存打滿服務(wù)掛了。后來加了請求體大小限制和 batch 上限并且在入口做了參數(shù)校驗。6. 從零搭建的擴(kuò)展方向與個人體會這套從零搭起來的骨架跑通之后你會發(fā)現(xiàn)它像個樂高底座往上加?xùn)|西很自然。想加文本特征就在特征層加一個分詞和 embedding 模塊想加時序特征就在數(shù)據(jù)層加滑窗邏輯想加多模型融合就在服務(wù)層加一個路由層。因為每一層職責(zé)清晰擴(kuò)展的時候不會牽一發(fā)動全身。我個人在實(shí)際操作中的體會是從零搭一遍最大的收獲不是那個模型本身而是你對“數(shù)據(jù)怎么流動”的直覺。以前調(diào)包的時候數(shù)據(jù)在框架里怎么走我是不關(guān)心的手寫一遍之后我知道每一步的輸入輸出是什么形狀、什么范圍、什么含義。這種直覺在你排查線上問題、做架構(gòu)設(shè)計、跟別人討論方案的時候價值巨大。最后分享一個小技巧如果你覺得從零寫整個系統(tǒng)太重可以先從只手寫模型層開始數(shù)據(jù)層和服務(wù)層用現(xiàn)成工具。等你對模型層有感覺了再逐步往下替換。這樣學(xué)習(xí)曲線平緩也不容易半途而廢。這個項目后續(xù)還可以往分布式訓(xùn)練、模型版本灰度發(fā)布、特征存儲這些方向擴(kuò)展但那是另一個階段的事了先把單機(jī)版跑穩(wěn)再說。