前幾天在桌機(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_count、eval_duration、prompt_eval_count、prompt_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。
| 測試 | 生成 tokens | M2 Pro 生成 (t/s) | RTX 3070 生成 (t/s) |
|---|---|---|---|
| 短句 | 15 | 40.1 | 34.0 |
| 中等 | 372 | 35.0 | 31.1 |
| 長文 | 2,557 | 32.1 | 29.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 tokens | Prompt eval 耗時 | Prompt TPS | 生成 TPS | 總耗時 |
|---|---|---|---|---|
| 767 | 3.1s | 244 | 31.1 | 12.8s |
| 2,780 | 4.7s | 597 | 27.6 | 9.8s |
| 10,893 | 20.5s | 531 | 23.9 | 26.2s |
| 21,751 | 30.8s | 706 | 22.2 | 31.6s |
| 43,467 | 74.4s | 585 | 14.9 | 83.6s |
| 67,867 | 113.5s | 598 | 10.8 | 126.3s |
| 81,409 | 82.1s | 992 | 9.9 | 96.2s |
| 122,096 | 620.5s | 197 | 7.6 | 647.1s |
這組數據有三個值得注意的觀察:
- 生成速度隨 context 變大顯著衰減,從 31 t/s 掉到 7.6 t/s,約 4 倍差距。這才是 context length 對速度的真實影響——每 decode 一個新 token 都要對整個 KV cache 做一次 attention,context 越長單 token 計算量越大。
- Prompt eval 速度在中長度 prompt 時反而最快。短 prompt 只有 244 t/s,塞到 32K 時衝到 706 t/s。原因是長 prompt 可以 batch 起來一次算,batch size 夠大時 GPU 利用率反而更高。這也推翻了「prompt 越長越慢」的直覺印象——在中段區間是越長越快。
- 128K 上限前的懸崖。從 81K 塞到 122K tokens 時,prompt eval 從 82 秒暴增到 10 分 20 秒(慢 7.5 倍),prompt TPS 從 992 掉到 197。M2 Pro 16GB 帳面能撐 128K,但實際逼近上限時會撞到記憶體頻寬或壓縮瓶頸,慢到完全不能用。
gemma4:e2b 在長 context 全面反轉
同樣的測試再跑一次 e2b,結果是這次實驗最大的反轉:
| 實際 prompt tokens | e4b Prompt TPS | e2b Prompt TPS | e4b 生成 TPS | e2b 生成 TPS |
|---|---|---|---|---|
| 767 | 244 | 859 | 31.1 | 52.4 |
| 10,893 | 531 | 977 | 23.9 | 37.7 |
| 43,467 | 585 | 886 | 14.9 | 19.6 |
| 81,409 | 992 | 1,489 | 9.9 | 11.9 |
| 122,096 | 197 | 504 | 7.6 | 8.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=False、think=True、raw=true 手動塞 no-think template、raw=true 手動塞 with-think template:
| 設定 | 耗時 | tokens | thinking 欄位 |
|---|---|---|---|
| E2B 預設 | 10.50s | 573 | 0(被過濾) |
E2B think=False | 0.58s | 18 | 0 |
E2B think=True | 9.98s | 536 | 1,623 字元 |
| E2B raw no-think template | 0.60s | 19 | 0 |
| E2B raw with-think template | 10.96s | 581 | 0 |
結論跟 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 16GB | RTX 3070 8GB | 勝者 |
|---|---|---|---|
| E4B 生成速度(短句) | 40.1 t/s | 34.0 t/s | M2 Pro |
| E4B 生成速度(長文) | 32.1 t/s | 29.7 t/s | M2 Pro |
| E4B Prompt eval(熱快取短 prompt) | 279 t/s | 662 t/s | RTX 3070 |
| E4B Prompt eval(32K 長 prompt) | 706 t/s | n/a* | — |
| E4B 冷啟動 load | ~0.2s | ~4.4s | M2 Pro |
| E4B 短句 TTFT | 264ms | ~390ms | M2 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 在長對話的實際效果、以及電池供電下的降頻影響,之後會陸續整理出來。