「在我電腦上明明跑得起來啊。」
這句熟悉的對話日常,在 ML 系統會是場災難,因為 ML 多了兩個變數:資料與模型權重。第一部曲我們將 AI 職缺目前在市場的需求進行說明,第二部曲聚焦在地基,今天要做的,就是降低只能回答出「在我電腦上明明可以」的風險。
以下三個問題都很常見:環境、版控與金鑰,不處理會卡住:
問題一:環境不可重現。 去年執行 pip install pandas 裝到的是 2.x,今天重新建立環境可能已經裝到 3.x。同一份程式碼、一樣呼叫 groupby(),光是套件預設行為改變,輸出就可能不同。程式碼能重跑,不代表結果能重現。 [註1]
問題二:Git 用得太感人。 把 500MB 的模型檔 commit 進去、把 data/ 整包推上 GitHub、或更糟——把 .env 推上去了。
問題三:金鑰外洩。 這件事沒有「小心一點就好」,因為 GitHub 是被機器人 24 小時掃描的。推上去的那一刻就要當作已經外洩,改密碼是唯一解,刪 commit 沒有用。
📊 職缺訊號
這篇對應的不是單一任務訊號,而是所有任務的前提。來自 4,343 職缺之中梳理了實際的數字:MLOps 職缺中 58.5% 提到平臺與基礎設施、42.6% 提到訓練管線與 CI/CD,這兩件事都建立在「環境可重現」之上。
坑一:requirements.txt 沒有鎖版本。
pandas
scikit-learn
正確做法是鎖住版本,並區分「直接依賴」與「完整鎖定」,專案實際主動使用的套件、間接相依套件版本鎖住,確保不同時間、不同機器都能建立出一致的執行環境。
坑二:Dockerfile 分層順序寫反。 這個坑不會讓你的程式出錯,只會默默偷走你的時間:

圖 5-1:Dockerfile 分層順序對重建時間的影響(依 Docker 快取失效規則推算的示範值)。左圖是單次重建,右圖是一天 30 次建置的累積差距,約 48 分鐘。
原理是 Docker 的逐層快取:任何一層變動,其後所有層的快取全部失效。如果你寫 COPY . /app 再 RUN pip install,那麼改一行程式碼就會讓依賴安裝層失效,每次都重裝一次。
坑三:金鑰進了版本控制。 反覆提醒自己:程式碼裡永遠不出現金鑰字串。
「環境」不只是套件。一個 ML 工作可重現,需要五件事同時被固定:
| 要素 | 用什麼固定 | 沒固定會怎樣 |
|---|---|---|
| 程式碼 | Git commit | 不知道跑的是哪一版 |
| 依賴套件 | uv.lock / requirements.lock |
套件改版導致行為改變 |
| 系統層(CUDA、glibc) | Docker 映像 | 「我這台有 GPU 你那台沒有」 |
| 資料 | DVC 或資料版本標記(Day 09) | 同一份程式碼跑出不同結果 |
| 隨機性 | 固定 seed | 每次訓練結果都不同,無法比較 |
前三項是今天說明的範圍,後兩項在 Day 09 與 Day 10。 這五項缺一就難以達到「可重現」,而可重現是 Day 04 「不能省的三件事」之首。
# 安裝 uv(Rust 寫的 Python 套件管理器)
curl -LsSf https://astral.sh/uv/install.sh | sh
uv init my-ai-service && cd my-ai-service
uv add fastapi uvicorn scikit-learn pandas # 會自動產生並更新 uv.lock
uv add --dev pytest ruff # 開發依賴分開管理
uv run pytest # 在鎖定環境中執行
uv.lock 必須進版控——它記錄了完整依賴樹的精確版本,是「可重現」的第二根柱子。
.gitignore一般範本不夠用,ML 專案要多擋三類東西:
# ── 一般 Python ──
__pycache__/
.venv/
*.egg-info/
# ── ML 專案特有:大檔絕不進 Git ──
data/ # 資料改用 DVC 管理(Day 09)
*.pkl # 模型檔改用 MLflow / 物件儲存(Day 10)
*.pt
*.safetensors
mlruns/ # MLflow 本地紀錄
.ipynb_checkpoints/
# ── 金鑰:這一段最重要 ──
.env
.env.*
!.env.example # 只有「範本」可以進版控
*.pem
*credentials*.json
.env.example 模式# .env.example —— 進版控,只有鍵沒有值,讓別人知道要準備什麼
OPENAI_API_KEY=
DATABASE_URL=
MODEL_REGISTRY_URI=
# app/config.py —— 程式碼裡只讀環境變數,且缺少時「大聲失敗」
import os
def require(key: str) -> str:
"""取得必要的環境變數,缺少時立刻失敗——不要讓服務帶著空金鑰啟動。"""
val = os.environ.get(key)
if not val:
raise RuntimeError(f"缺少必要環境變數 {key};請參考 .env.example 設定")
return val
OPENAI_API_KEY = require("OPENAI_API_KEY")
⚠️ 如果你已經把金鑰推上去了
步驟只建議一個順序:1. 立刻到服務商後台撤銷該金鑰 → 2. 產生新金鑰 → 3. 才處理 Git 歷史。 很多人順序做反先清 Git 歷史,但就算只晚 10 分鐘撤銷,那把金鑰也早就被掃走了。
# ── 建置階段:裝依賴,這一層要能被快取 ──
FROM python:3.11-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./ # ← 只複製依賴描述,程式碼還沒進來
RUN pip install --no-cache-dir uv && uv sync --frozen --no-dev
# ── 執行階段:只帶走執行期需要的東西 ──
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH"
COPY app/ ./app/ # ← 程式碼最後才複製,改它不影響上面的快取
EXPOSE 8000
# 健康檢查:讓編排器知道服務活著(Day 12 會用到)
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request;urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
三個重點:依賴描述先複製(快取)、多階段建置(最終映像不含建置工具,體積與攻擊面都小)、永遠不用 ENV 塞金鑰(執行時用 -e 或 K8s Secret 注入)。
docker build -t my-ai-service:0.1.0 .
docker run --rm -p 8000:8000 --env-file .env my-ai-service:0.1.0
把這四步串起來,就是你之後 25 天每天都會用到的開發迴圈:

圖 5-2:本機開發迴圈的示意架構——鎖定依賴、程式碼進版控、金鑰走環境變數、服務跑在容器裡並掛載 volume 供熱重載。這個迴圈在 Day 12 會延伸到 Kubernetes,在 Day 17 會延伸到 LLM 應用。
工程紀律有成本,不是每個情境都值得:
| 情境 | 建議 | 理由 |
|---|---|---|
| 探索性分析、一次性報表 | Notebook + venv 就好 | 不會有人重跑,投資報酬率低 |
| 要交給別人跑的分析 | 加上鎖定的依賴清單 | 「別人跑不起來」的成本開始出現 |
| 會上線的服務 | 完整容器化 + 健康檢查 | 沒有例外 |
| 需要 GPU 的訓練 | 容器化,但基底映像換成 CUDA 版 | 系統層依賴(驅動、CUDA)是最難重現的部分 |
| 團隊共用的 Notebook | 考慮改寫成腳本+管線(Day 11) | Notebook 無法 code review、無法自動測試 |
💡 一個實用的判準
問自己:「這份程式碼,三個月後有沒有可能被我或別人再跑一次?」如果答案是可能,現在多花的 30 分鐘會回本。如果不會,就別過度工程。
我知道有人看到「別用 Notebook」會不服氣,所以講清楚:Notebook 沒有錯,錯的是把 Notebook 當成交付物。
Notebook 在探索階段是無可取代的:即時看到中間結果、圖表與資料並排、想到什麼就試什麼。這種互動性在寫死的腳本裡做不到。問題出在它同時具備三個特性——執行順序可以亂跳、狀態藏在記憶體裡、diff 出來是一團 JSON——這三件事讓它無法被 code review、無法自動測試、也無法保證重跑結果一致。
實務上的分界線很清楚:
train.py 就是範例)。jupytext 之類的工具把 Notebook 與 .py 雙向同步,探索與版控兩者兼得。一個判斷 Notebook 該不該畢業的訊號:當你開始在 Notebook 裡複製貼上同一段程式碼到第三次時,它就該變成模組了。
今天的內容看起來離 AI 很遠,但它決定了你後面 25 天的體驗。一個具體的對照:同樣是 Day 12 部署到 Kubernetes 時遇到 Pod 一直重啟,環境有紀律的人 10 分鐘查出是探針逾時設太短;環境沒紀律的人會花兩小時,因為他無法確定「本機能跑、叢集不能跑」的差異究竟來自程式碼、依賴、還是系統層。
uv 管理環境並把 uv.lock 進版控;ML 專案的 .gitignore 要額外擋資料、模型檔與金鑰。.env.example 進版控、.env 不進;外洩處理的第一步永遠是撤銷金鑰,不是清 Git 歷史。pandas 2.1 已經宣布 DataFrame.groupby() / Series.groupby() 的 observed=False 將在未來改成 True,但到了 pandas 2.3,預設仍然是 False。到了pandas 3.0 改成 True 為預設 。
import pandas as pd
df = pd.DataFrame({
"type": pd.Categorical(
["A", "A"],
categories=["A", "B"]
),
"value": [10, 20]
})
df.groupby("type")["value"].sum()
在舊版預設 observed=False 時,未出現過的 B 也可能出現在結果中:
type
A 30
B 0
而 pandas 3.0 預設改成 observed=True 後,只會留下實際觀察到的群組:
type
A 30
環境有了,明天談 ML 專案本身的形狀:為什麼一個 ML 專案裡,模型程式碼只佔 5%?以及那個吃掉最多專案的斷崖——從「跑出模型」到「上線」。我會用一張漏斗圖說明為什麼多數 AI 專案死在最後一哩。