一. 前言:本地 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:3b、gemma3: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.2、qwen2.5、gemma3,不需要重新改程式。
八. 完整 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。一個穩定、順手、可控的小工具,反而更容易留在工作流裡。