前幾天在桌機(Ryzen 5600X + RTX 3070 8GB)跑過一輪 Gemma 4 + Claude Code 的實戰,把 本地跑 Claude Code 實戰|Ollama + Gemma 4 + RTX 3070 使用心得Gemma 4 E2B vs E4B 完整實測|Thinking 模式的祕密 兩篇文章寫出來後,心裡留下一個問題:那一整套觀察能不能在筆電上重現?特別是 MacBook Pro 這種 Apple Silicon 架構——GPU 跟 CPU 共用統一記憶體、沒有獨立 VRAM、用 Metal 而不是 CUDA——很多人直覺會以為「筆電跑本地 LLM 會很慘」。這篇就把同一套實驗搬到 MacBook Pro M2 Pro 16GB 重跑一次,順便跟 RTX 3070 的數字放在一起看,幫筆電族的開發者找出本地 LLM 的實用上限。

為什麼這個對比值得做

兩個平台代表兩種完全不同的本地 LLM 思路:

  • RTX 3070:NVIDIA 獨立顯卡 + CUDA 生態,8GB 專用 VRAM,優勢是計算速度與軟體成熟度,但 VRAM 是固定切出來的硬上限。
  • M2 Pro 16GB:Apple Silicon SoC,統一記憶體讓 GPU 可以共用整台機器的 RAM,用 Metal backend 加速,優勢是記憶體彈性與功耗,劣勢是原始計算能力通常被認為比獨顯弱。

這兩種架構對「跑本地 LLM 要花多少錢」的決策影響很大。獨立顯卡的路線需要額外買硬體、處理散熱、受限 VRAM;Apple Silicon 的路線是許多開發者本來就有的筆電,不需要額外投資。但效能究竟差多少、哪些場景誰比較適合,很少有同一組測試方法、同一個模型、同一套 prompt 跑出來的直接對比。這篇就是要補上這個空缺。

測試環境

MacBook Pro M2 Pro 16GB

  • MacBook Pro 14″ (2023)
  • Apple M2 Pro:10 核 CPU(6P + 4E)、16 核 GPU、統一記憶體
  • 16GB 統一記憶體(CPU / GPU 共享)
  • 1TB SSD
  • macOS 26.4

Ollama 用官方 macOS 版本安裝,直接在本機 http://localhost:11434 提供服務,不經過網路。Gemma 4 的 E2B 和 E4B 兩個變體都是 Q4_K_M 量化版本,直接從 Ollama library 拉下來,檔案大小分別是 6.67 GiB 和 8.95 GiB。如果對 Ollama 或 Gemma 4 家族不熟悉,可以先看 Ollama 入門教學Google Gemma 4 多模態開源模型介紹

對照組 RTX 3070

Windows 11 Pro + Ryzen 5 5600X + RTX 3070 8GB VRAM + 32GB DDR4-3200,原始數據在前兩篇文章,這裡只做對比。

測試方法

所有測試都透過 Ollama 的 /api/generate endpoint 直打,用 Python 腳本解析回應裡的 eval_counteval_durationprompt_eval_countprompt_eval_duration 等欄位計算指標。每組測試前會先 warmup 確保模型已經載入,避免把冷啟動時間算進去。

import requests

# 對 Ollama 發送一次 generate 請求
resp = requests.post('http://localhost:11434/api/generate', json={
    'model': 'gemma4:e4b',
    'prompt': 'Explain what Docker is in 100 words.',
    'stream': False,
    'options': {'num_ctx': 8192},  # 預分配 8K context
})
data = resp.json()

# 生成速度:eval_count 個 token 花了 eval_duration 奈秒
gen_tps = data['eval_count'] / (data['eval_duration'] / 1e9)
# Prompt 處理速度:prompt 被模型吃完的速度
prompt_tps = data['prompt_eval_count'] / (data['prompt_eval_duration'] / 1e9)
print(f'生成 {gen_tps:.1f} t/s, prompt {prompt_tps:.1f} t/s')

TPS 基準:M2 Pro 意外地比 RTX 3070 快

先跑最基本的生成速度測試:送三種長度的 prompt,看 gemma4:e4b 每秒能吐出幾個 token。

測試生成 tokensM2 Pro 生成 (t/s)RTX 3070 生成 (t/s)
短句1540.134.0
中等37235.031.1
長文2,55732.129.7

這個結果跟一開始的直覺不一樣:M2 Pro 跑 Gemma 4 E4B 的生成速度全面贏過 RTX 3070,大約快 8–18%。雖然差距沒有很大,但至少打破「筆電跑不動本地 LLM」的刻板印象。幾個可能的原因:

  • M2 Pro 的統一記憶體頻寬很高(官方 200 GB/s),對 Q4_K_M 量化這種「記憶體頻寬限制」型 workload 反而有利。
  • Ollama 在 Apple Silicon 上使用 Metal backend,llama.cpp 在 Metal 的最佳化這兩年做得很積極。
  • RTX 3070 的 CUDA 計算能力理論上更強,但 8GB VRAM 不夠塞整個模型加大 KV cache,瓶頸提早出現。

另一個讓人眼睛一亮的地方是冷啟動時間:M2 Pro 的模型載入只要 ~0.2 秒,RTX 3070 則要 4–5 秒。這是因為 Apple Silicon 的統一記憶體架構,模型檔從 SSD 讀進來就能被 GPU 直接使用,不需要額外把資料搬到 VRAM。對「偶爾才跑一次 LLM」的工作流(例如 shell alias、git hook、VS Code 快捷鍵)影響很大,等於每次呼叫都不用先付那幾秒的等待成本。

num_ctx 的陷阱:預分配 ≠ 真的塞了東西

這次測試最重要的一個觀念澄清。前一篇文章裡我們做過「不同 context length 對速度的影響」測試,結論是從 4K 到 64K,生成速度都差不多。很多網路教學也有類似結論,但這件事其實講了一半——

那個測試用的 prompt 永遠只有 5–6 個 token(”Explain what Docker is in 100 words.”),真正影響的只是 Ollama 為 KV cache 預分配多少記憶體空間,跟「模型每次 decode 時要掃多長的 attention」是兩件完全不同的事。只有真的把 prompt 塞長才會看到 context 對速度的真實影響。

所以這次多做了一組實驗:把 num_ctx 固定在 131072(Gemma 4 原生上限),然後送長度從 1K 到 122K tokens 的 filler prompt,看 prompt eval 跟生成速度的變化。

gemma4:e4b 長 prompt 實測

實際 prompt tokensPrompt eval 耗時Prompt TPS生成 TPS總耗時
7673.1s24431.112.8s
2,7804.7s59727.69.8s
10,89320.5s53123.926.2s
21,75130.8s70622.231.6s
43,46774.4s58514.983.6s
67,867113.5s59810.8126.3s
81,40982.1s9929.996.2s
122,096620.5s1977.6647.1s

這組數據有三個值得注意的觀察:

  1. 生成速度隨 context 變大顯著衰減,從 31 t/s 掉到 7.6 t/s,約 4 倍差距。這才是 context length 對速度的真實影響——每 decode 一個新 token 都要對整個 KV cache 做一次 attention,context 越長單 token 計算量越大。
  2. Prompt eval 速度在中長度 prompt 時反而最快。短 prompt 只有 244 t/s,塞到 32K 時衝到 706 t/s。原因是長 prompt 可以 batch 起來一次算,batch size 夠大時 GPU 利用率反而更高。這也推翻了「prompt 越長越慢」的直覺印象——在中段區間是越長越快。
  3. 128K 上限前的懸崖。從 81K 塞到 122K tokens 時,prompt eval 從 82 秒暴增到 10 分 20 秒(慢 7.5 倍),prompt TPS 從 992 掉到 197。M2 Pro 16GB 帳面能撐 128K,但實際逼近上限時會撞到記憶體頻寬或壓縮瓶頸,慢到完全不能用。

gemma4:e2b 在長 context 全面反轉

同樣的測試再跑一次 e2b,結果是這次實驗最大的反轉:

實際 prompt tokense4b Prompt TPSe2b Prompt TPSe4b 生成 TPSe2b 生成 TPS
76724485931.152.4
10,89353197723.937.7
43,46758588614.919.6
81,4099921,4899.911.9
122,0961975047.68.6

E2B 在長 context 場景全面贏過 E4B。Prompt eval 速度快 1.5–2.5 倍,生成速度也快 10–70%,逼近 128K 上限時還能勉強維持 500 t/s 的 prompt 處理速度。128K context 下 E2B 總耗時 4 分 18 秒,E4B 要 10 分 47 秒,E2B 快了 2.5 倍。

這個結論跟前一篇「E2B 被 Ollama 偷偷開 thinking 模式拖慢 10 倍」的結論乍看矛盾,但兩者其實不衝突:

  • 短輸入明確任務:E2B 被 thinking 拖慢,應該用 E4B(或 E2B + think=False)。
  • 長 context 場景:E2B 的有效參數較少,每個 decode step 的 attention 運算便宜,反而更適合。但還是要記得加 think=False 避免被預設的 thinking 干擾。

實用上限整理

把數據翻成「幾 K tokens 前開發者會覺得可用」的實用建議:

模型舒適區(<30s)可接受(<2min)硬上限
gemma4:e4b~30K tokens~64K tokens~100K tokens
gemma4:e2b~50K tokens~100K tokens~122K tokens

Thinking 模式的 Ollama 行為跨平台重現

前一篇文章裡追到一個有趣的結論:Ollama 的 gemma4 renderer 會給 E2B 偷偷注入 <|think|> token,但 E4B 不會。這個行為跟官方文件「thinking 預設關閉」的說法矛盾,看起來是 Ollama 對 E2B 的特殊處理。當時的疑問是:這到底是 Windows / CUDA 特有的 bug,還是 Ollama 所有平台都這樣?

這次在 macOS + Metal 上把整組實驗重跑了一次——用同一個短句翻譯 prompt 對 E2B 測試 5 種組合:預設、think=Falsethink=Trueraw=true 手動塞 no-think template、raw=true 手動塞 with-think template:

設定耗時tokensthinking 欄位
E2B 預設10.50s5730(被過濾)
E2B think=False0.58s180
E2B think=True9.98s5361,623 字元
E2B raw no-think template0.60s190
E2B raw with-think template10.96s5810

結論跟 RTX 3070 版本完全一致

  • E2B 預設耗 10.5 秒 / 573 tokens,加 think=False 後變 0.58 秒 / 18 tokens,快 18 倍
  • raw=true 手動構造無 think token 的 chat template,結果跟 think=False 幾乎一樣(19 tokens)——證明是 Ollama 在 prompt 前面偷偷加了 <|think|>
  • raw=true 手動塞 with-think template,E2B 直接把 <|channel>thought Thinking Process: 原始 thinking 內容漏在 response 欄位裡——因為 raw 模式跳過了 Ollama 的 gemma4 parser,平常會被過濾的段落全部現形。這是一個意外收穫的直接證據。
  • E4B 不論用什麼設定都只產 19–23 個 token、0.85–1 秒,確認 E4B 訓練時根本不響應 <|think|> token。

重點在於:這個 Ollama 對 E2B 的特殊處理不是 Windows 或 CUDA 特有,macOS + Metal 完全重現。這強化了前一篇的結論——問題出在 Ollama 的 gemma4 renderer 本身,跟硬體或作業系統無關。完整的追查過程跟原理解說在 Gemma 4 E2B vs E4B 完整實測 ,有興趣的開發者可以參考。

跨平台對比總表

把所有關鍵指標放在一起對照:

指標M2 Pro 16GBRTX 3070 8GB勝者
E4B 生成速度(短句)40.1 t/s34.0 t/sM2 Pro
E4B 生成速度(長文)32.1 t/s29.7 t/sM2 Pro
E4B Prompt eval(熱快取短 prompt)279 t/s662 t/sRTX 3070
E4B Prompt eval(32K 長 prompt)706 t/sn/a*
E4B 冷啟動 load~0.2s~4.4sM2 Pro
E4B 短句 TTFT264ms~390msM2 Pro
num_ctx=64K 可行性還有餘裕接近 VRAM 上限M2 Pro
長 prompt 128K 可行性能跑但 10 分鐘VRAM 不夠M2 Pro
E2B thinking 被 Ollama 偷開重現重現跨平台一致

* RTX 3070 沒跑長 prompt 大量塞滿的實驗,因為 8GB VRAM 根本放不下 32K+ prompt 的 KV cache。

該選哪個?

因為我手上只有這兩台機器可以測試,只能給出這兩種比較的參考,綜合所有數據,給幾個實用的決策建議:

MacBook Pro M2 Pro 16GB 適合

  • 筆電族開發者想在出門、咖啡廳、高鐵上跑離線 LLM 輔助編碼。
  • 需要比較長的 context(32K–100K)的 RAG、長文件摘要、Code review bot。RTX 3070 的 8GB VRAM 在這類工作根本塞不下。
  • 需要短延遲多次呼叫的自動化工作流(git hook、shell script、桌面快捷鍵)——冷啟動只要 0.2 秒,每次呼叫都不用等。
  • 在意電力與噪音:M2 Pro 跑 Gemma 4 E4B 時風扇幾乎沒轉起來,也可以電池供電。

RTX 3070 桌機適合

  • 需要極高 prompt 處理吞吐量的場景——RTX 3070 熱快取下 prompt eval 能跑到 662 t/s,比 M2 Pro 快得多。批次處理大量短 prompt(分類、抽取)有優勢。
  • 已經有遊戲主機想順便用,不想額外花錢買筆電升級。

模型選擇

M2 Pro 16GB 上兩個 Gemma 4 變體的使用情境其實有明顯分化:

  • 短輸入 / 明確任務(commit 訊息、關鍵字抽取、情感分類):用 gemma4:e4b,或是 gemma4:e2b + think=False。預設的 E2B 會被 thinking 拖慢 10 倍以上。
  • 長 context 應用(RAG、長文件摘要、程式碼 review):用 gemma4:e2b + think=False,prompt eval 跟生成速度都明顯勝過 E4B。實用上限 ~100K tokens。
  • 需要推理的複雜任務:用 gemma4:e2b + think=True,讓 E2B 啟動 thinking 模式,雖然慢但品質更好。

Python 呼叫範例

把這次的結論整理成一個可以直接複製的 Python 封裝:

import requests

OLLAMA = 'http://localhost:11434/api/generate'

def quick_task(prompt: str) -> str:
    """短輸入明確任務:速度最大化。"""
    r = requests.post(OLLAMA, json={
        'model': 'gemma4:e4b',
        'prompt': prompt,
        'stream': False,
        'options': {'temperature': 0.2, 'num_ctx': 8192},
    })
    return r.json()['response'].strip()

def long_context_task(prompt: str, num_ctx: int = 65536) -> str:
    """長 context 場景(RAG、長文件):用 e2b + think=False。"""
    r = requests.post(OLLAMA, json={
        'model': 'gemma4:e2b',
        'prompt': prompt,
        'stream': False,
        'think': False,                  # 關鍵:避免 Ollama 偷開 thinking
        'options': {
            'temperature': 0.2,
            'num_ctx': num_ctx,          # 依實際 prompt 長度選
        },
    })
    return r.json()['response'].strip()

def reasoning_task(prompt: str) -> str:
    """複雜推理任務:讓 e2b 啟動 thinking。"""
    r = requests.post(OLLAMA, json={
        'model': 'gemma4:e2b',
        'prompt': prompt,
        'stream': False,
        'think': True,
        'options': {'temperature': 1.0, 'num_ctx': 16384},
    })
    data = r.json()
    # thinking 內容在 data['thinking'],最終答案在 data['response']
    return data['response'].strip()

# 範例:用 quick_task 寫 commit 訊息
msg = quick_task('Write a conventional commit message for: added retry logic')
print(msg)

模型問答紀錄

在 MacBook Pro (M2 Pro 16GB RAM) 直接使用 ollama run gemma4:e4b 沒有做其他微調。

> 你是一位專業的程式碼審查者,請審查以下 TypeScript 函式並指出潛在的效能問題與可讀性改進建議。
> 
> function processItems(items: any[]) {
>   const result = [];
>   for (let i = 0; i < items.length; i++) {
>     if (items[i].active) {
>       result.push({ id: items[i].id, name: items[i].name.toUpperCase() });
>     }
>   }
>   return result;
> }
> 
> 請以條列式回覆,並提供改寫後的範例。

Thinking...
Here's a plan to structure the code review:
1.  **Analyze the Goal:** The function `processItems` iterates over an array of objects (`items: any[]`), filters 
for items where `active` is true, and transforms the required fields (`id`, `name`) while capitalizing the name.
2.  **Identify Potential Issues (Performance & Readability):**
    *   **Type Safety (Crucial for TypeScript):** The use of `any[]` is a major issue.
    *   **Performance:** The `for` loop is generally fine, but modern JavaScript/TypeScript prefers functional 
methods like `filter` and `map` for readability and sometimes optimization by the engine.
    *   **Readability:** Using functional chaining (`.filter().map()`) makes the intent clearer (Step 1: Filter, 
Step 2: Transform).
3.  **Formulate Recommendations:** Structure the review in bullet points, addressing types, performance, and style.
4.  **Provide Refactored Code:** Offer a clean, type-safe, and idiomatic TypeScript version. (Self-Correction: 
Ensure the refactored code addresses the `any[]` warning by defining an interface.)
...done thinking.

這是一份專業的程式碼審查報告。總體而言,您提供的函式邏輯是正確的,但在 TypeScript 的應用、效能的最佳化,以及現代 
JavaScript/TypeScript 的寫作慣例(Idiomatic Code)方面,還有許多可以改進的地方。

以下是針對潛在的效能問題與可讀性改進建議:

---

### 🛠️ 程式碼審查報告

#### 🔴 類型安全性 (Type Safety) - **最重要問題**
1.  **問題點:** 函式簽章使用了 `items: any[]`。在 TypeScript 中使用 `any` 會完全繞過型別檢查系統,導致程式碼失去了大
部分的類型安全保障。如果輸入陣列的元素結構(例如:缺少 `id` 或 `active` 屬性)發生變化,編譯器將無法偵測到錯誤。
2.  **改進建議:** 應為輸入的元素結構定義一個明確的介面 (Interface) 或類型 (Type)。這不僅能提高代碼的可維護性,更是使
用 TypeScript 的核心價值。

#### 🟡 可讀性與寫作慣例 (Readability & Idiomatic Code)
1.  **問題點:** 使用傳統的 `for` 迴圈進行資料處理,雖然效能上沒有顯著的差異,但與其宣告的是「篩選 (Filter)」和「轉換
 (Map)」這兩個概念。在現代 JavaScript/TypeScript 中,使用陣列的高階函式(`filter` 和 `map`)能讓程式碼的「意圖」(
Intent)更加明確,符合慣例,並且代碼更簡潔。
2.  **改進建議:** 應改用 `Array.prototype.filter()` 結合 `Array.prototype.map()` 的方式來重寫邏輯,這能極大提高程式
碼的可讀性。

#### 🟢 效能考量 (Performance)
1.  **問題點:** 在邏輯層面,`for` 迴圈與 `filter`/`map` 的時間複雜度(Time Complexity)都是 $O(N)$,因此從理論上來看
,性能差異可以忽略不計。
2.  **最佳實踐建議:** 當需要**同時執行篩選和轉換**的場景,最佳實踐是考慮使用一個單一的迴圈結構(例如 `reduce` 或手動
寫一個迴圈),來避免重複遍歷陣列的開銷。不過,在本例中,由於代碼簡潔度和可讀性提升的巨大優勢,使用 `filter` + `map` 
的組合,在現代 JS 引擎優化下,性能損失微乎其微,且可讀性提升顯著。因此,**可讀性優先於極端微調的性能**。

---

### ✨ 改寫後的範例 (Refactored Example)

為了讓代碼安全且符合最佳實踐,我們需要先定義一個介面:

```typescript
// 1. 定義輸入資料的結構介面
interface Item {
  id: number | string;
  name: string;
  active: boolean;
  // 可以加入其他屬性,例如:amount: number;
}

// 2. 定義輸出結果的結構介面
interface ProcessedItem {
  id: number | string;
  name: string;
}

/**
 * 根據活動狀態篩選並轉換資料項目。
 * @param items 待處理的項目陣列。
 * @returns 經過篩選和轉換的項目陣列。
 */
function processItems(items: Item[]): ProcessedItem[] {
  // 使用 filter 和 map 的組合,極大地提高了代碼的可讀性和慣用性。
  return items
    .filter(item => item.active) // 步驟一:先篩選出 active 為 true 的項目
    .map(item => ({             // 步驟二:再將篩選後的項目進行轉換
      id: item.id,
      name: item.name.toUpperCase()
    }));
}

// --- 範例使用 ---
const sampleItems: Item[] = [
    { id: 1, name: "apple", active: true },
    { id: 2, name: "banana", active: false }, // 會被過濾掉
    { id: 3, name: "CHERRY", active: true },
    { id: 4, name: "date", active: false },
];

const result = processItems(sampleItems);
console.log(result);
// [ { id: 1, name: 'APPLE' }, { id: 3, name: 'CHERRY' } ]
```

### 💡 總結與建議重點

| 項目 | 原本做法 | 建議做法 | 提升重點 |
| :--- | :--- | :--- | :--- |
| **型別安全** | `items: any[]` | 定義 `Item` 和 `ProcessedItem` 介面 | 必須做法,保證代碼的健壯性。 |
| **可讀性** | `for` 迴圈 | `filter().map()` 鏈式調用 | 遵循慣用寫法,讓代碼的「意圖」一目瞭然。 |
| **效能** | $O(N)$ | 保持 $O(N)$ (可接受) | 性能差異忽略,以極大化可讀性為優先。 |
| **優勢** | 簡單直白 | 強類型、函數式、高可讀性 | 提升代碼品質和可維護性。 |

總結

這趟 M2 Pro 16GB 重跑 RTX 3070 實驗的重點整理:

  • Apple Silicon 跑 Gemma 4 的生成速度比 RTX 3070 略勝,約快 8–18%,冷啟動快 20 倍以上。
  • RTX 3070 的強項是熱快取短 prompt 處理(662 vs 279 t/s),長 prompt batch 情境 M2 Pro 反而更有效率。
  • num_ctx 跟實際 prompt 長度是兩件事:前者只是預分配空間,後者才會影響 decode 速度。之前的教學都講了一半。
  • 長 prompt 會讓生成速度顯著衰減:E4B 從 31 t/s 掉到 7.6 t/s(128K),E2B 從 52 t/s 掉到 8.6 t/s。
  • 長 context 場景 E2B 全面勝出 E4B,完全推翻了「短任務用 E4B」的直覺,前提是記得加 think=False
  • M2 Pro 16GB 的 E4B 實用上限約 64K tokens、E2B 約 100K,128K 帳面能開但跑一次要 4–10 分鐘。
  • Ollama 對 E2B 偷偷注入 <|think|> 的 bug 在 macOS + Metal 完全重現,確認是 renderer 層級的行為,跟硬體無關。

從結論看,「筆電族跑本地 LLM 夠不夠用」的答案很明確:MacBook Pro M2 Pro 16GB 不但夠用,在很多情境下比遊戲顯卡還舒服。原本以為 Apple Silicon 會是「勉強能跑」的級距,結果實測發現是「正常可用」甚至「長 context 優於獨顯」。對已經有一台 Mac 筆電的開發者來說,不用再買額外硬體就能把本地 LLM 接進日常工作流,這是一個比想像中更低的門檻。

如果還沒看過前兩篇,建議依序閱讀 本地跑 Claude Code 實戰|Ollama + Gemma 4 + RTX 3070 使用心得Gemma 4 E2B vs E4B 完整實測|Thinking 模式的祕密 ,三篇合起來是完整的「Gemma 4 本地部署從桌機到筆電」系列。下一步想探索的方向包含 gemma3:12b 在 16GB 統一記憶體下的極限、Ollama prompt cache 在長對話的實際效果、以及電池供電下的降頻影響,之後會陸續整理出來。


Sponsored Links