一. 前言:本地 AI App 最難的不是模型 #
最近本地 AI 工具越來越好裝。
Ollama 幾分鐘就能跑起 LLM。
Streamlit 幾十行 Python 就能做出介面。
MLX 讓 Apple Silicon 上的推論、embedding、微調實驗變得很親切。
每個工具單獨看都很可愛。
但只要你想把它們組成一個真的每天會用的 App,問題就會變成:到底誰該負責什麼?
全部塞進一個 app.py 當然最快。
可是過幾天你會開始遇到這些狀況:
- UI rerun 一次,模型請求也跟著重跑
- prompt、模型參數、檔案處理散在各處
- embedding 計算很慢,不知道該放哪裡快取
- Ollama 可以聊天,但檢索、分類、批次處理又像另一套世界
- 想從 prototype 變成小工具,卻不知道怎麼整理目錄 拍拍君今天想做的不是「再寫一個 chatbot」。 而是把本地 AI App 的分工講清楚。 你可以把這篇當成架構地圖:
- Streamlit:負責使用者介面與互動流程
- Ollama:負責通用 LLM chat/completion API
- MLX:負責 Apple Silicon 友善的 Python 推論、embeddings、批次任務
- Python service layer:負責把邏輯整理乾淨
- storage/cache:負責讓 App 不要每次都從零開始 目標不是企業級平台。 目標是個人、小團隊、研究室、內部工具都能維護的本地 AI stack。
二. 先畫出最小架構 #
先不要急著裝工具。 本地 AI App 可以從這個最小架構開始:
Browser
|
v
Streamlit UI
|
v
Python app layer
|\
| \-- Ollama API -> chat / summarize / rewrite
|
\---- MLX scripts -> embeddings / local model experiments
|
v
Local storage -> files / SQLite / vector index / cache
這張圖的重點是:UI 不直接等於 AI 邏輯。 Streamlit 很適合快速做介面,但它的 rerun 模型會讓程式從上到下重新跑。 如果你把所有 LLM 呼叫、資料讀寫、embedding 建立都寫在同一層,後面會很難 debug。 比較舒服的做法是把 App 拆成三層:
ui.py # Streamlit 畫面與互動
services.py # prompt、LLM 呼叫、工作流程
storage.py # 檔案、SQLite、快取、向量索引
小專案不需要一開始就很抽象。 但只要先有這個邊界,之後要加模型、換 UI、搬到 FastAPI,都不會痛到想把鍵盤丟出去。
三. Streamlit:讓人願意使用的入口 #
Streamlit 的角色很明確:它是入口,不是大腦。 它適合做這些事:
- 表單輸入
- 檔案上傳
- 參數切換
- 顯示 markdown、表格、圖表
- 串流輸出 LLM 回覆
- 管理簡單 session 狀態 例如一個本地文件摘要工具,UI 可以長這樣:
import streamlit as st
from services import summarize_text
st.set_page_config(page_title="拍拍君本地摘要器", page_icon="📝")
st.title("📝 拍拍君本地摘要器")
model = st.sidebar.selectbox(
"模型",
["llama3.2", "qwen2.5:7b", "gemma3:4b"],
)
style = st.sidebar.radio(
"摘要風格",
["條列重點", "短文說明", "行動項目"],
)
text = st.text_area("貼上要摘要的文字", height=280)
if st.button("產生摘要", type="primary"):
if not text.strip():
st.warning("先貼一點內容啦,拍拍君還不能讀心。")
else:
with st.spinner("本地模型努力中……"):
result = summarize_text(text, model=model, style=style)
st.markdown(result)
這段 code 裡,Streamlit 只負責收 input、顯示 output。
真正的 prompt 組合與模型呼叫放在 services.py。
這樣有兩個好處。
第一,UI 變乾淨,改版比較不會牽一髮動全身。
第二,未來你想把同一套摘要邏輯接到 CLI、FastAPI、排程任務,也不用複製貼上。
拍拍君的經驗是:Streamlit prototype 寫得快沒錯,但你越早把 AI 邏輯抽出來,越不容易變成一團可愛的義大利麵。
四. Ollama:通用 LLM API 的穩定底座 #
Ollama 很適合擔任本地 AI App 的「通用語言模型層」。 它的優點是:
- 安裝簡單
- 模型切換方便
- 有 HTTP API
- 支援串流
- 很適合 chat、摘要、改寫、分類、簡單 extraction 在 service layer 裡,我們可以先寫一個薄薄的 client。
# ollama_client.py
from __future__ import annotations
import requests
OLLAMA_CHAT_URL = "http://localhost:11434/api/chat"
def chat_ollama(
messages: list[dict[str, str]],
*,
model: str = "llama3.2",
temperature: float = 0.2,
) -> str:
response = requests.post(
OLLAMA_CHAT_URL,
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"temperature": temperature},
},
timeout=180,
)
response.raise_for_status()
data = response.json()
return data["message"]["content"]
接著把摘要邏輯寫在另一層:
# services.py
from ollama_client import chat_ollama
def summarize_text(text: str, *, model: str, style: str) -> str:
system = (
"你是拍拍君,一個友善但精準的技術筆記助手。"
"請用繁體中文回答,避免空泛廢話。"
)
user = f"""
請把下面內容整理成「{style}」。
要求:
- 保留重要專有名詞
- 不要捏造原文沒有的資訊
- 如果資訊不足,明確說不足
內容:
{text}
""".strip()
return chat_ollama(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
model=model,
)
這樣的分工很樸素,但已經足夠解決很多混亂。
Streamlit 不知道 prompt 細節。
Ollama client 不知道摘要規則。
services.py 負責把「使用者要做的事」轉成模型能理解的 messages。
如果明天你想把 Ollama 換成雲端 API,也只要改 client;如果想換 UI,也不用動 prompt。
五. MLX:Apple Silicon 上的推論與 embeddings 工具箱 #
那 MLX 應該放在哪裡? 拍拍君會把 MLX 當成「比較工程化、比較 Python-native 的模型工具箱」。 Ollama 很適合一般 chat。 MLX 則適合你想在 Python 裡更細緻地控制模型流程時使用,例如:
- 建立 embeddings
- 做語意搜尋
- 批次分類
- 測試不同模型 checkpoint
- 跑 Apple Silicon 友善的本地推論實驗
- 把模型運算包進自己的 pipeline 如果你的 App 需要「先搜尋相關文件,再讓 LLM 回答」,MLX 很適合放在 retrieval 那一側。 例如最小的資料流可以是:
User question
|
v
MLX embedding model -> query vector
|
v
local vector index -> top-k documents
|
v
Ollama chat model -> final answer with context
這裡 Ollama 和 MLX 不是競爭關係。
它們比較像不同工具:Ollama 是好用的對話 API,MLX 是可控制的本地推論零件。
如果你已經看過 MLX + Embeddings 語意搜尋,那篇比較偏實作;今天這篇則是在講它應該放在整體架構的哪一層。
一個簡化版 retrieval.py 可以長這樣:
# retrieval.py
from pathlib import Path
def load_documents(folder: Path) -> list[str]:
docs: list[str] = []
for path in folder.glob("*.md"):
docs.append(path.read_text(encoding="utf-8"))
return docs
def search_related_docs(question: str, *, top_k: int = 4) -> list[str]:
"""範例介面:實際 embedding/index 可以用 MLX 實作。"""
# 這裡可以呼叫你的 MLX embedding pipeline:
# 1. 把 question 轉成 vector
# 2. 在本地 index 找相近文件
# 3. 回傳 top-k chunks
raise NotImplementedError
先定義介面,再替換實作,是讓 prototype 長大的好方法。 你不用一開始就把 vector database、embedding cache、chunking 策略全部做到完美。 但你要知道它們不該塞在 Streamlit 按鈕底下。
六. RAG App 的實用分工 #
很多本地 AI App 最後都會走向 RAG,也就是 retrieval-augmented generation。 講白一點,就是「先找資料,再讓模型回答」。 拍拍君建議先用這個目錄結構:
local-ai-app/
├── app.py
├── ollama_client.py
├── services.py
├── retrieval.py
├── storage.py
├── data/
│ ├── raw/
│ ├── chunks/
│ └── index/
└── pyproject.toml
每個檔案只負責一件事:
app.py:Streamlit UIollama_client.py:Ollama HTTP 呼叫services.py:摘要、問答、改寫等 use casesretrieval.py:切 chunk、embedding、搜尋storage.py:快取、SQLite、檔案讀寫data/:本地資料與索引 接著services.py可以組合 RAG 流程:
from ollama_client import chat_ollama
from retrieval import search_related_docs
def answer_with_local_context(question: str, *, model: str) -> str:
docs = search_related_docs(question, top_k=4)
context = "\n\n---\n\n".join(docs)
messages = [
{
"role": "system",
"content": (
"你是本地知識庫助手。"
"請只根據提供的 context 回答;如果找不到答案,就說不知道。"
),
},
{
"role": "user",
"content": f"""
Context:
{context}
Question:
{question}
""".strip(),
},
]
return chat_ollama(messages, model=model, temperature=0.1)
這樣做有一個很重要的優點:失敗時比較好定位。 如果回答亂講,你可以問:
- retrieval 有沒有找對文件?
- context 有沒有太長或太短?
- prompt 有沒有要求模型不要亂補?
- model 是否適合這個任務?
- temperature 是否太高? 如果所有東西都寫在同一個 callback 裡,你只會覺得「AI 又壞了」。 但拆層之後,你可以逐段測。 這就是架構的價值。
七. 快取與狀態:不要讓模型一直重跑 #
本地模型雖然不用按 token 付費,但時間也是成本。 尤其 embeddings、長文件摘要、批次分類,重跑一次可能就是幾十秒到幾分鐘。 Streamlit 有內建快取可以先救急:
import streamlit as st
@st.cache_data(show_spinner=False)
def cached_summary(text: str, model: str, style: str) -> str:
from services import summarize_text
return summarize_text(text, model=model, style=style)
但拍拍君會建議:快取策略不要全部綁死在 UI 層。 如果是比較正式的小工具,可以用 SQLite 記錄任務結果:
# storage.py
import hashlib
import sqlite3
def fingerprint(text: str, *, model: str, task: str) -> str:
raw = f"{task}\0{model}\0{text}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def get_cached_result(db_path: str, key: str) -> str | None:
with sqlite3.connect(db_path) as conn:
row = conn.execute(
"select result from cache where key = ?",
(key,),
).fetchone()
return None if row is None else row[0]
真正專案裡還要補上 schema migration、created_at、metadata、錯誤狀態等等。 但概念很簡單:同樣輸入、同樣模型、同樣任務,不要傻傻重跑。 對 embeddings 來說更是如此。 文件只要沒有變,chunk 的 vector 就應該重用。 你可以用檔案 hash 判斷是否需要重新索引。 這些看起來像小細節,但會決定一個 App 是「demo 很酷」還是「每天真的想打開」。
八. 什麼時候需要 FastAPI? #
很多人會問:既然 Streamlit 可以跑,還需要 FastAPI 嗎? 答案是:看你的入口有幾個。 如果只有瀏覽器 UI,Streamlit 就夠了。 如果你想讓其他程式也呼叫同一套 AI 邏輯,例如:
- CLI 指令
- Apple Shortcuts
- iPhone 分享選單
- Raycast / Alfred workflow
- 排程任務
- 其他內部工具 那就可以考慮加一層 FastAPI。 架構會變成:
Streamlit UI ----\
CLI ---------------> FastAPI service -> services.py -> Ollama / MLX / storage
Shortcuts --------/
注意,FastAPI 不一定要一開始就上。 拍拍君比較推薦的路線是:
- 先把
services.py寫乾淨 - 用 Streamlit 做 prototype
- 等到第二個入口出現,再加 FastAPI 這樣不會過度設計,也不會把自己綁死。 如果你後來想把 Streamlit 放在同一台機器上,它可以呼叫本地 API;如果只在 local 使用,甚至不需要暴露到外網。 本地 AI 的美感之一,就是邊界可以由你決定。
九. 模型選擇:不要只問哪個最強 #
做本地 AI App 時,模型選擇常常被簡化成「哪個模型最好」。 但架構上更重要的是:哪個任務需要哪種能力。 拍拍君會這樣分:
| 任務 | 優先考量 | 常見選擇 |
|---|---|---|
| 聊天與草稿 | 語感、速度、上下文長度 | Ollama chat model |
| 摘要與改寫 | 穩定遵守格式 | 低溫度 LLM |
| 分類與 extraction | 可重現、schema 清楚 | 小模型或 prompt 嚴格化 |
| 語意搜尋 | embedding 品質、索引速度 | MLX embedding pipeline |
| 批次任務 | throughput、快取、錯誤重試 | Python service + queue |
| 如果你的 App 是「問自己的文件」,不要只升級 chat model。 | ||
| 很多時候 retrieval 找得準,比最後回答模型大一號更有用。 | ||
| 如果你的 App 是「大量整理文字」,快取、分批、重試,比模型排行榜更重要。 | ||
| 如果你的 App 是「給非工程使用者用」,介面與錯誤訊息又比模型參數重要。 | ||
| 模型很重要,但它只是系統的一部分。 | ||
| 這句話有點掃興,不過很實用。 |
十. 一個推薦的開發順序 #
最後整理成一條拍拍君會實際採用的開發順序。
第一步,先做最小 Streamlit UI。
只要能輸入文字、選模型、顯示結果就好。
第二步,把 Ollama client 抽出來。
不要讓 HTTP request 散落在 UI callback 裡。
第三步,把任務寫成 service function。
例如 summarize_text()、rewrite_note()、answer_with_local_context()。
第四步,需要搜尋時,再加入 MLX embeddings 與本地 index。
先做簡單 chunk,不要一開始就追求完美 RAG。
第五步,加快取與任務紀錄。
讓 App 可以重開、可以查歷史、可以避免重算。
第六步,如果出現多個入口,再加 FastAPI。
不要為了「看起來很架構」而一開始就把系統拆成十個服務。
本地 AI App 最好的狀態是:小而清楚,能長大,但還沒有被自己的架構壓扁。
結語:把工具變成系統 #
Streamlit、Ollama、MLX 都是很棒的工具。 但工具堆在一起,不會自動變成好用的 App。 真正的關鍵是分工:
- Streamlit 讓人可以舒服地操作
- Ollama 提供穩定簡單的本地 LLM API
- MLX 負責更細緻的 Apple Silicon 推論與 embeddings
- Python service layer 保存你的任務邏輯
- storage/cache 讓結果可重用、可追蹤、可維護
如果你正在做第一個本地 AI 小工具,拍拍君的建議很簡單:先讓它跑起來,再把邊界整理乾淨。
不要一開始就追求完美架構。
也不要讓 prototype 永遠停在一個巨大
app.py。 中間那條路,通常才是最舒服的工程路線。
延伸閱讀 #
- Streamlit + Ollama:打造本地 LLM Chatbot App
- 本地 LLM 實戰:Ollama + Python 打造自己的小助手
- MLX + Embeddings:在 Apple Silicon 上打造本地語意搜尋
- Docker Compose + Ollama:一鍵啟動本地 AI 開發環境
- Streamlit 官方文件:https://docs.streamlit.io/
- Ollama API 文件:https://github.com/ollama/ollama/blob/main/docs/api.md
- MLX 官方文件:https://ml-explore.github.io/mlx/