快轉到主要內容
  1. 教學文章/

本地 AI App 架構:Streamlit、Ollama、MLX 怎麼分工

·10 分鐘· loading · loading · ·
LLM Local AI Streamlit Ollama Mlx Python Architecture
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 5: 本文

featured

一. 前言:本地 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 UI
  • ollama_client.py:Ollama HTTP 呼叫
  • services.py:摘要、問答、改寫等 use cases
  • retrieval.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 不一定要一開始就上。 拍拍君比較推薦的路線是:

  1. 先把 services.py 寫乾淨
  2. 用 Streamlit 做 prototype
  3. 等到第二個入口出現,再加 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。 中間那條路,通常才是最舒服的工程路線。

延伸閱讀
#

科技觀點 - 本文屬於一個選集。
§ 5: 本文

相關文章

Streamlit + Ollama:打造本地 LLM Chatbot App
·11 分鐘· loading · loading
LLM Ollama Streamlit Python Local AI Chatbot
在 Mac/iPhone 生態跑本地 AI:Ollama、MLX 與行動端工作流
·9 分鐘· loading · loading
LLM Ollama Mlx Apple-Silicon IPhone Local AI
本地 LLM 實戰:Ollama + Python 打造自己的小助手
·9 分鐘· loading · loading
LLM Ollama Python AI Cli
MLX + Embeddings:在 Apple Silicon 上打造本地語意搜尋
·7 分鐘· loading · loading
Mlx Embeddings Semantic Search Apple-Silicon Python
Docker Compose + Ollama:一鍵啟動本地 AI 開發環境
·8 分鐘· loading · loading
Docker Docker-Compose Ollama LLM Local AI Devops
在LLM的時代,為什麽我還要寫部落格?
·3 分鐘· loading · loading
LLM AI Blog