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

Streamlit + Ollama:打造本地 LLM Chatbot App

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

featured

一. 前言:本地 LLM 需要一個舒服的入口
#

前幾天拍拍君整理過 Ollama + Python 小助手。那篇比較像工程骨架:CLI、串流、JSONL 記憶、把本地模型接進自己的 workflow。 但有些時候,你不想打指令。你只是想打開瀏覽器,貼一段文字,調一下模型,按 Enter,得到一個乾淨的聊天介面。 這時候 Streamlit 就很適合。它不是最華麗的前端框架,也不是要取代完整 Web app;它可愛的地方是:你用 Python 寫幾十行,就能得到一個可以互動、可以分享給同事、可以放在內網的小工具。 今天我們要把兩個高 CP 值工具接起來:Ollama 負責本機 LLM 與 HTTP API,Streamlit 負責聊天介面,Python 把中間的 request、串流、狀態管理串起來。 最後會做出一個本地 Chatbot App。它可以選模型、設定 system prompt、保留對話紀錄、串流顯示回答、清除對話,也會處理常見錯誤。 不是玩具,也不是企業級平台。它剛好站在中間:足夠簡單,可以一個下午做完;足夠實用,可以每天真的拿來用。

二. 先準備環境
#

這篇假設你已經裝好 Ollama。如果還沒有,可以到官方網站下載: https://ollama.com/download macOS 也可以用 Homebrew:

brew install ollama

啟動 Ollama server:

ollama serve

如果你用桌面版,通常背景服務已經起來了。可以用這個指令確認:

curl http://localhost:11434/api/tags

看到 JSON 回應,就代表 Ollama API 正常。接著下載一個模型,這裡用 llama3.2 當範例:

ollama pull llama3.2

如果你的機器比較小,可以試 qwen2.5:3bgemma3:4b 之類的輕量模型。如果記憶體比較充裕,也可以玩 8B 或 14B。 接著建立 Python 專案。拍拍君偏好用 uv,因為快、乾淨、lockfile 也舒服:

uv init streamlit-ollama-chatbot
cd streamlit-ollama-chatbot
uv add streamlit requests

不用 uv 也沒問題,傳統 venv 一樣可以:

python -m venv .venv
source .venv/bin/activate
pip install streamlit requests

建立檔案並啟動:

touch app.py
uv run streamlit run app.py

如果你還不熟 uv,可以先看 Python uv 入門uv workspace 進階篇

三. 先確認 Streamlit 能跑
#

先不要急著接 LLM。當 LLM App 出問題時,可能是模型、API、網路、Python package、Streamlit rerun 機制造成的。先把 UI 啟動確認好,後面比較好 debug。 在 app.py 放入:

import streamlit as st
st.set_page_config(page_title="拍拍君本地 Chatbot", page_icon="🤖")
st.title("🤖 拍拍君本地 Chatbot")
st.write("Streamlit 已經準備好了。下一步要接 Ollama。")

執行:

uv run streamlit run app.py

瀏覽器應該會打開 http://localhost:8501。如果你看到標題,代表第一關完成。

四. 呼叫 Ollama 的 chat API
#

Ollama 的 chat API 在:

POST http://localhost:11434/api/chat

最小 payload 長這樣:

{
  "model": "llama3.2",
  "messages": [
    {"role": "user", "content": "請用一句話介紹 Streamlit"}
  ],
  "stream": false
}

我們先寫一個 Python 函式,不串流,只拿完整回答:

import requests
OLLAMA_URL = "http://localhost:11434/api/chat"
def ask_ollama(model: str, messages: list[dict[str, str]]) -> str:
    response = requests.post(
        OLLAMA_URL,
        json={"model": model, "messages": messages, "stream": False},
        timeout=120,
    )
    response.raise_for_status()
    return response.json()["message"]["content"]

可以先在 app.py 裡寫一個按鈕測試:

if st.button("測試 Ollama"):
    answer = ask_ollama(
        "llama3.2",
        [{"role": "user", "content": "請用一句話介紹 Streamlit"}],
    )
    st.write(answer)

如果按下去有回答,就代表 Streamlit 已經能呼叫本地 LLM。如果失敗,常見原因有三個:Ollama server 沒啟動、模型還沒下載、或 App 跑在容器裡所以連不到 host 的 localhost:11434。 第三點很容易踩雷。localhost 永遠是「目前程式所在的那台機器」。如果 Streamlit 在 Docker 裡,localhost 指的是容器,不是你的 Mac。

五. 用 session_state 保存對話
#

Streamlit 最重要的觀念是:使用者每次互動,script 會從頭到尾重新執行一次。 所以你不能只用一般 list 來保存對話。這樣會失敗:

messages = []
messages.append({"role": "user", "content": "你好"})

下一次 rerun,messages 又變回空 list。正確做法是用 st.session_state

if "messages" not in st.session_state:
    st.session_state.messages = []

每次使用者輸入,就 append 到這個狀態裡:

prompt = st.chat_input("想問拍拍君什麼?")
if prompt:
    st.session_state.messages.append({"role": "user", "content": prompt})

顯示歷史訊息可以用 st.chat_message

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

這樣就有聊天介面的基本骨架了。st.chat_input 很適合這種 app,因為它會固定在底部,看起來比一般 text input 更像聊天工具。

六. 串流回覆:讓答案一段一段長出來
#

如果用 stream: false,模型回答完之前,畫面只會轉圈圈。LLM 回答如果花十幾秒,使用者會覺得 App 卡住。所以我們要做串流。 Ollama 的 stream: true 會回傳一行一行 JSON,每一行可能包含一小段 token。用 requests.post(..., stream=True) 可以逐行讀取。

import json
def stream_ollama(model: str, messages: list[dict[str, str]]):
    with requests.post(
        OLLAMA_URL,
        json={"model": model, "messages": messages, "stream": True},
        stream=True,
        timeout=120,
    ) as response:
        response.raise_for_status()
        for line in response.iter_lines():
            if not line:
                continue
            data = json.loads(line)
            if data.get("done"):
                break
            chunk = data.get("message", {}).get("content", "")
            if chunk:
                yield chunk

Streamlit 可以用 st.write_stream 顯示 generator:

with st.chat_message("assistant"):
    answer = st.write_stream(stream_ollama(model, ollama_messages))
st.session_state.messages.append({"role": "assistant", "content": answer})

st.write_stream 會把 token 接起來,最後回傳完整文字。這樣體驗差很多,因為使用者會看到回答逐字出現,而不是等一整段卡住。

七. 加上模型列表
#

剛才模型名稱是手打的。實務上,我們可以呼叫 Ollama 的 /api/tags 取得已下載模型。

def list_models() -> list[str]:
    response = requests.get("http://localhost:11434/api/tags", timeout=10)
    response.raise_for_status()
    data = response.json()
    return [model["name"] for model in data.get("models", [])]

然後在 sidebar 裡改成 selectbox:

try:
    models = list_models()
except requests.RequestException:
    models = []
if models:
    model = st.selectbox("模型", models, index=0)
else:
    model = st.text_input("模型名稱", value="llama3.2")
    st.warning("連不到 Ollama 或目前沒有已下載模型。")

這個小功能很實用。當你一邊比較 llama3.2qwen2.5gemma3,不需要重新改程式。

八. 完整 app.py
#

下面是完整版本,包含模型列表、system prompt、對話記憶、串流回答、清除對話與基本錯誤處理。

import json
import requests
import streamlit as st
OLLAMA_BASE_URL = "http://localhost:11434"
CHAT_URL = f"{OLLAMA_BASE_URL}/api/chat"
TAGS_URL = f"{OLLAMA_BASE_URL}/api/tags"
DEFAULT_SYSTEM_PROMPT = "你是拍拍君,回答請使用繁體中文,語氣清楚、友善、直接。"
st.set_page_config(page_title="拍拍君本地 Chatbot", page_icon="🤖", layout="centered")
st.title("🤖 拍拍君本地 Chatbot")
st.caption("Streamlit + Ollama:把本地 LLM 變成舒服的小工具。")
if "messages" not in st.session_state:
    st.session_state.messages = []
@st.cache_data(ttl=10)
def list_models() -> list[str]:
    response = requests.get(TAGS_URL, timeout=10)
    response.raise_for_status()
    return [m["name"] for m in response.json().get("models", [])]
def stream_ollama(model: str, messages: list[dict[str, str]]):
    with requests.post(CHAT_URL, json={"model": model, "messages": messages, "stream": True}, stream=True, timeout=120) as response:
        response.raise_for_status()
        for line in response.iter_lines():
            if not line:
                continue
            data = json.loads(line)
            if data.get("done"):
                break
            yield data.get("message", {}).get("content", "")
with st.sidebar:
    st.header("設定")
    try:
        available_models = list_models()
    except requests.RequestException:
        available_models = []
    if available_models:
        model = st.selectbox("模型", available_models)
    else:
        model = st.text_input("模型名稱", value="llama3.2")
        st.warning("目前讀不到 Ollama 模型列表,請確認 server 是否啟動。")
    system_prompt = st.text_area("System prompt", value=DEFAULT_SYSTEM_PROMPT, height=140)
    if st.button("清除對話", use_container_width=True):
        st.session_state.messages = []
        st.rerun()
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])
prompt = st.chat_input("輸入訊息...")
if prompt:
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)
    recent_messages = st.session_state.messages[-12:]
    ollama_messages = [{"role": "system", "content": system_prompt}, *recent_messages]
    try:
        with st.chat_message("assistant"):
            answer = st.write_stream(stream_ollama(model, ollama_messages))
        st.session_state.messages.append({"role": "assistant", "content": answer})
    except requests.RequestException as exc:
        st.error(f"呼叫 Ollama 失敗:{exc}")

把這個檔案存成 app.py,然後啟動:

uv run streamlit run app.py

到這裡,你已經有一個真的可以用的本地 LLM Chatbot。

九. 對話記憶不要無限長
#

目前我們把所有對話都丟回模型。這很直覺,但有兩個問題:context window 會爆掉,而且越長越慢。 最簡單的做法是只保留最近 N 則訊息:

MAX_HISTORY = 12
recent_messages = st.session_state.messages[-MAX_HISTORY:]
ollama_messages = [{"role": "system", "content": system_prompt}, *recent_messages]

這不是完美記憶,但很好用。如果你想更進階,可以把舊對話摘要成一段 memory summary,再放進 system message。 先不要一開始就做太複雜。本地 LLM App 最怕的不是功能太少,而是還沒穩定就長出十個半壞功能。

十. 加一點「任務模式」
#

單純聊天很好,但很多時候我們會重複做幾種任務:摘要文章、改寫文字、解釋程式碼、產生 commit message、翻譯成繁體中文。 你可以在 sidebar 加一個 mode selector:

mode = st.selectbox("任務模式", ["一般聊天", "摘要", "改寫", "程式碼解釋", "Commit message"])
mode_prompts = {
    "一般聊天": "你是拍拍君,回答清楚、友善、直接。",
    "摘要": "請把使用者提供的內容整理成重點摘要,使用條列式。",
    "改寫": "請保留原意,讓文字更清楚、自然、精簡。",
    "程式碼解釋": "請逐段解釋程式碼用途,指出可能的問題與改善方向。",
    "Commit message": "請根據使用者描述產生清楚的英文 commit message。",
}
system_prompt = mode_prompts[mode]

這個功能很小,但會讓 App 從「聊天框」變成「工作台」。尤其是本地模型,最適合處理日常小任務。

十一. 常見問題與解法
#

1. 回答很慢
#

先換小模型。例如 3B 或 4B 模型通常比 8B 快很多。也可以縮短對話歷史,避免每次都塞一長串 messages。

recent_messages = st.session_state.messages[-8:]

如果你只是要摘要短文,小模型通常已經夠用。

2. 中文品質不好
#

換模型。有些模型英文強,中文普通。你可以比較:

ollama pull qwen2.5:7b
ollama pull llama3.2
ollama pull gemma3:4b

同一個 prompt,用不同模型測試幾次,會很有感。

3. Streamlit 一直重新執行
#

這是 Streamlit 的正常行為,不要跟它打架。該放 session 的資料放 st.session_state;昂貴且可重用的資料讀取放 st.cache_data;長時間存在的 client 或 model resource 放 st.cache_resource。 如果你還不熟,可以回頭看 Streamlit 進階篇

4. 想讓手機也能打開
#

如果在同一個區網,可以讓 Streamlit 綁定所有介面:

uv run streamlit run app.py --server.address 0.0.0.0

然後用手機打開:

http://你的電腦區網IP:8501

但不要把它直接暴露到公開網路。這個 App 沒有登入、沒有權限控管、也沒有 rate limit,放在家裡區網或 Tailscale 內比較合理。

十二. 專案結構建議
#

app.py 開始變長,可以拆成這樣:

streamlit-ollama-chatbot/
├── app.py
├── src/
│   ├── ollama_client.py
│   ├── prompts.py
│   └── history.py
├── pyproject.toml
└── uv.lock

src/ollama_client.py 放 API 呼叫,src/prompts.py 放任務模式,src/history.py 放對話保存與裁切。app.py 就只負責 UI。 這樣後面要加測試、加設定檔、加 Docker,都比較不會痛苦。小工具的健康程度,通常取決於你有沒有在它變成大工具前先整理一下。

十三. 可以再加什麼功能?
#

這個 App 已經能用,但還有很多自然延伸。

1. 上傳檔案
#

Streamlit 有 st.file_uploader,你可以讓使用者上傳 .txt.md.py,再把內容交給模型摘要。

uploaded_file = st.file_uploader("上傳文字檔", type=["txt", "md", "py"])
if uploaded_file:
    content = uploaded_file.read().decode("utf-8")
    st.text_area("檔案內容", content, height=240)

記得限制檔案大小,不要一口氣把巨大 log 丟進本地模型。

2. 保存聊天紀錄
#

最簡單可以存 JSONL。每次 append session_state 時,也寫一份到檔案。但請注意隱私,本地保存不是沒有風險,只是風險換成「你的電腦上的檔案管理」。

import json
from pathlib import Path
HISTORY_PATH = Path("chat_history.jsonl")
def save_message(message: dict[str, str]) -> None:
    with HISTORY_PATH.open("a", encoding="utf-8") as f:
        f.write(json.dumps(message, ensure_ascii=False) + "\n")

3. Docker Compose
#

如果你想固定部署環境,可以把 Streamlit 包進容器。但要注意它怎麼連到 Ollama。如果 Ollama 在 host 上,容器內的 localhost 不是 host。 macOS Docker Desktop 可以用:

http://host.docker.internal:11434

這也是為什麼一開始先本機跑比較好。等功能穩了,再容器化。

十四. 小型安全清單
#

本地 AI App 很容易被低估安全問題。因為它在本機,所以大家會覺得沒差;但只要你把它開給手機、同事、內網,事情就不一樣了。 拍拍君建議至少檢查:

  • 不要暴露到公開網路
  • 不要讓 App 任意讀取伺服器檔案
  • 不要把聊天紀錄存到共享資料夾而不自知
  • 如果多人使用,要有身份驗證
  • 如果接工具執行命令,要做 allowlist
  • 如果上傳檔案,要限制大小與類型 尤其最後一點。「LLM 可以幫我執行 shell command」聽起來很帥;但沒有權限邊界的 agent,就是一台自動化事故產生器。 先讓它聊天、摘要、改寫。真的要接工具時,再慢慢設計安全層。

十五. 什麼時候 Streamlit 不夠用?
#

Streamlit 很適合原型、內部工具、個人 dashboard、小型 data app,但它不是萬能。如果你需要複雜前端互動、多使用者權限管理、長期背景任務、WebSocket 自訂協定、大量 concurrent users,那就該考慮正式 Web stack。 例如 FastAPI 做後端、React / Vue / Svelte 做前端、PostgreSQL 保存資料、Redis 或 queue 處理背景任務。 不過,拍拍君會建議不要一開始就跳到那裡。先用 Streamlit 做出可用版本,確認需求真的存在。等你知道使用者每天會點什麼、貼什麼、抱怨什麼,再重寫也不遲。 先求清楚,再求完整。

十六. 本地 LLM 工作流的真正價值
#

這篇表面上是在做 Chatbot,但真正的重點不是聊天框,而是把本地模型接到你每天會碰到的入口。 CLI 適合工程師工作流。Streamlit 適合瀏覽器互動與小型工具。Apple Shortcuts 適合手機與分享選單。FastAPI 適合把模型包成內部服務。Ollama 則像底層 engine。 你可以先從一個簡單 Chatbot 開始,然後慢慢長成自己的 AI 工作台:文章摘要器、程式碼解釋器、prompt 測試台、issue triage 工具、release note 草稿機、log 分析小助手。 這些東西不一定需要大型平台。有時候一個 app.py,加上一個本地模型,就已經很夠用。

結語
#

Streamlit + Ollama 是很舒服的組合。Ollama 負責把模型跑起來,Streamlit 負責把工具變得能用,Python 則把兩邊接起來。 今天的版本已經有聊天、模型切換、system prompt、串流回覆與基本記憶。下一步可以依照自己的需求加功能:上傳檔案、任務模式、JSONL 紀錄、內網部署,或接更多本地工具。 拍拍君的建議是:先把最小版本跑起來,真的每天用幾次。你會很快知道哪個功能值得加,哪個只是看起來很厲害。 本地 AI 不需要一開始就變成巨大 agent。一個穩定、順手、可控的小工具,反而更容易留在工作流裡。

延伸閱讀
#

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

相關文章

在 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
在LLM的時代,為什麽我還要寫部落格?
·3 分鐘· loading · loading
LLM AI Blog
Python tomllib 實戰:內建 TOML 解析、設定檔管理與 pyproject.toml 完全攻略
·7 分鐘· loading · loading
Python Tomllib TOML 設定檔 Pyproject.toml
Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具