一. 前言:Streamlit 很快,但不要什麼都塞進同一支檔案 #
Streamlit 很適合快速做出互動介面。
幾行 st.slider、st.dataframe、st.button,馬上就能把資料工具變成可以點、可以調、可以分享的小 App。
但工具長大之後,常見問題也會冒出來:
- 資料處理邏輯塞在 app.py 裡,越改越難測
- 前端按一次按鈕,整個頁面重新執行,狀態很容易亂掉
- 之後想把同一套邏輯給 CLI、排程器、其他服務用,卻發現只有 Streamlit 能呼叫
- 錯誤處理、權限、背景工作、快取全都混在 UI 程式碼裡
這時候,拍拍君會建議你把系統切成兩層:
- FastAPI:負責穩定的 API、資料驗證、商業邏輯、可測試的後端入口
- Streamlit:負責互動介面、表單、圖表、使用者狀態與展示
這篇不是要重講 FastAPI 或 Streamlit 的基本語法。
如果你還沒看過,可以先補一下前面的 FastAPI 入門 與 Streamlit 入門。今天這篇的重點是:兩個工具放在一起時,邊界要怎麼切,才不會變成一團麵。
二. 什麼時候需要 FastAPI + Streamlit? #
不是每個 Streamlit App 都需要後端。
如果你的工具只是讀一個 CSV、畫幾張圖、調幾個 filter,那單一 Streamlit 檔案就很好。簡單就是優點,不要為了架構感硬拆。
但下面幾種情況,很適合把 FastAPI 拉進來:
- 同一套核心邏輯要給多個入口使用
- 需要清楚的 request/response schema
- 需要把耗時任務包成 API
- 想替資料處理邏輯寫單元測試
- 之後可能會接 CLI、排程、Slack bot、前端網站
- Streamlit 只是一個內部工具介面,不應該承擔全部責任
拍拍君的判斷方式很簡單:
如果你開始想在 Streamlit 裡面建立「服務層」,那就可以考慮真的建立一個服務層。
也就是 FastAPI。
三. 範例目標:一個文字分析小工具 #
今天我們做一個很小但完整的範例。
使用者在 Streamlit 介面貼上一段文字,按下分析,前端會呼叫 FastAPI。後端負責計算幾個結果:
- 字數
- 行數
- 最常出現的詞
- 簡單的閱讀時間估計
這個範例故意不接資料庫、不接 LLM、不接複雜部署。
因為今天重點不是炫技,而是把前後端分工講清楚。
四. 專案結構:先把邊界切出來 #
先建立專案:
mkdir fastapi-streamlit-stack
cd fastapi-streamlit-stack
uv init
uv add fastapi uvicorn streamlit httpx pydantic
推薦結構如下:
fastapi-streamlit-stack/
├── pyproject.toml
├── server/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py
│ └── services.py
└── ui/
├── app.py
└── api_client.py
這個切法背後的意思是:
- server/models.py 放 API 契約
- server/services.py 放真正的邏輯
- server/main.py 放 FastAPI route
- ui/api_client.py 放 Streamlit 呼叫 API 的細節
- ui/app.py 只處理畫面與使用者互動
只要記住一個原則:
Streamlit 不直接知道後端邏輯怎麼做,它只知道 API 怎麼呼叫。
這樣未來要改後端、補測試、換 UI,都比較不會互相拖累。
五. 先寫資料模型:API 契約要明確 #
先建立 server/models.py:
from pydantic import BaseModel, Field
class AnalyzeTextRequest(BaseModel):
text: str = Field(min_length=1, description="要分析的文字")
top_k: int = Field(default=5, ge=1, le=20)
class WordCount(BaseModel):
word: str
count: int
class AnalyzeTextResponse(BaseModel):
characters: int
lines: int
words: int
estimated_reading_minutes: float
top_words: list[WordCount]
很多人會想:「這麼小的工具,需要 Pydantic model 嗎?」
拍拍君的答案是:如果你已經拆成 API,就值得。
因為這些 model 不是多餘的包裝,而是前後端之間的合約。Streamlit 知道送什麼,FastAPI 知道回什麼,錯誤也會比較早被抓出來。
這也是 FastAPI 很舒服的地方:你寫出型別,文件和驗證就自動跟上。
六. 把邏輯放進 service,不要塞在 route 裡 #
接著建立 server/services.py:
from collections import Counter
import re
from .models import AnalyzeTextResponse, WordCount
WORD_RE = re.compile(r"[A-Za-z0-9_]+")
def analyze_text(text: str, top_k: int = 5) -> AnalyzeTextResponse:
lines = text.splitlines() or [text]
words = [word.lower() for word in WORD_RE.findall(text)]
counter = Counter(words)
top_words = [
WordCount(word=word, count=count)
for word, count in counter.most_common(top_k)
]
reading_minutes = max(len(words) / 200, 0.1)
return AnalyzeTextResponse(
characters=len(text),
lines=len(lines),
words=len(words),
estimated_reading_minutes=round(reading_minutes, 2),
top_words=top_words,
)
這裡有一個很重要的設計習慣:
route 不等於邏輯本體。
FastAPI route 應該像門口櫃台:收 request、檢查格式、呼叫服務、回 response。真正的資料處理、規則、計算,盡量放在 services.py 或更明確的 domain module。
這樣做有三個好處:
- 後端邏輯可以直接寫測試,不必啟動 Web server
- 之後 CLI 或排程器也可以重用 analyze_text
- API route 會變薄,錯誤比較好追
七. 建立 FastAPI 後端 #
建立 server/main.py:
from fastapi import FastAPI
from .models import AnalyzeTextRequest, AnalyzeTextResponse
from .services import analyze_text
app = FastAPI(title="Text Analyzer API")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/analyze", response_model=AnalyzeTextResponse)
def analyze(payload: AnalyzeTextRequest) -> AnalyzeTextResponse:
return analyze_text(payload.text, top_k=payload.top_k)
啟動後端:
uv run uvicorn server.main:app --reload --port 8000
打開:
http://127.0.0.1:8000/docs
你會看到 FastAPI 自動產生的 API 文件。這一點很實用,因為當 Streamlit 前端出問題時,你可以先直接在 /docs 測後端,確認問題是在 API 還是在 UI。
也可以用 curl 測:
curl -X POST http://127.0.0.1:8000/analyze \
-H 'Content-Type: application/json' \
-d '{"text":"pypy likes clean tools\npypy likes APIs","top_k":3}'
如果這裡能正常回應,後端就先站穩了。
八. Streamlit 不要直接寫 httpx.post #
接著處理前端。
很多人會直接在 ui/app.py 裡寫 httpx.post。這在小 demo 可以,但 App 稍微長大就會亂。拍拍君比較建議另外做一個 API client。
建立 ui/api_client.py:
from dataclasses import dataclass
from typing import Any
import httpx
class ApiError(RuntimeError):
pass
@dataclass
class TextAnalyzerClient:
base_url: str = "http://127.0.0.1:8000"
timeout: float = 10.0
def health(self) -> bool:
try:
response = httpx.get(self.base_url + "/health", timeout=self.timeout)
response.raise_for_status()
except httpx.HTTPError:
return False
return response.json().get("status") == "ok"
def analyze(self, text: str, top_k: int = 5) -> dict[str, Any]:
try:
response = httpx.post(
self.base_url + "/analyze",
json={"text": text, "top_k": top_k},
timeout=self.timeout,
)
response.raise_for_status()
except httpx.TimeoutException as exc:
raise ApiError("後端回應逾時,請稍後再試。") from exc
except httpx.HTTPStatusError as exc:
raise ApiError("後端回傳錯誤:" + str(exc.response.status_code)) from exc
except httpx.HTTPError as exc:
raise ApiError("無法連線到後端 API。") from exc
return response.json()
把 API 呼叫包起來之後,app.py 會乾淨很多。
而且之後如果 API URL 改了、要加 token、要改 timeout、要補 retry,你只需要改 client,不需要到處搜尋 Streamlit 頁面裡的 httpx.post。
九. 建立 Streamlit 前端 #
建立 ui/app.py:
import streamlit as st
from api_client import ApiError, TextAnalyzerClient
st.set_page_config(
page_title="Text Analyzer",
layout="wide",
)
@st.cache_resource
def get_client(base_url: str) -> TextAnalyzerClient:
return TextAnalyzerClient(base_url=base_url)
st.title("Text Analyzer")
with st.sidebar:
st.header("API")
base_url = st.text_input("Base URL", value="http://127.0.0.1:8000")
top_k = st.slider("Top words", min_value=1, max_value=20, value=5)
client = get_client(base_url)
if not client.health():
st.warning("後端 API 目前連不上。請先啟動 FastAPI server。")
st.stop()
text = st.text_area(
"貼上要分析的文字",
height=220,
placeholder="Paste text here...",
)
analyze_clicked = st.button("分析", type="primary", disabled=not text.strip())
if analyze_clicked:
try:
result = client.analyze(text=text, top_k=top_k)
except ApiError as exc:
st.error(str(exc))
st.stop()
col1, col2, col3, col4 = st.columns(4)
col1.metric("Characters", result["characters"])
col2.metric("Lines", result["lines"])
col3.metric("Words", result["words"])
col4.metric("Reading", str(result["estimated_reading_minutes"]) + " min")
st.subheader("Top words")
st.dataframe(result["top_words"], use_container_width=True)
啟動前端:
uv run streamlit run ui/app.py --server.port 8501
現在你有兩個服務:
- FastAPI:http://127.0.0.1:8000
- Streamlit:http://127.0.0.1:8501
這就是最小但清楚的分層版本。
十. 狀態管理:哪些狀態放 Streamlit,哪些放後端? #
FastAPI + Streamlit 最容易混亂的地方,是狀態。
拍拍君會用這個分法:
| 狀態類型 | 建議位置 | 例子 |
|---|---|---|
| UI 暫存狀態 | Streamlit | 目前選到的 tab、slider 值、表單輸入 |
| 使用者操作結果 | Streamlit session state | 最近一次分析結果、目前顯示的資料表 |
| 核心資料 | 後端或資料庫 | 任務、文件、使用者設定、分析紀錄 |
| 可重用規則 | 後端 service | 分析邏輯、驗證規則、權限檢查 |
如果資料只影響畫面,放 Streamlit。
如果資料代表系統事實,放後端。
例如「目前 sidebar 裡 top_k slider 是 5」是 UI 狀態;但「某份文件分析完成並存成紀錄」就應該由後端處理。
這條線畫清楚,App 會少很多奇怪 bug。
十一. 用環境變數管理 API URL #
本機開發時,API URL 通常是 http://127.0.0.1:8000。
但部署到內網、雲端或容器後,URL 很可能會變。
最簡單的做法是讓 Streamlit 讀環境變數:
import os
DEFAULT_API_URL = os.getenv("TEXT_ANALYZER_API_URL", "http://127.0.0.1:8000")
然後改掉 sidebar 的預設值:
base_url = st.text_input("Base URL", value=DEFAULT_API_URL)
如果設定開始變多,可以接著用前一篇的 pydantic-settings 來整理。
小工具一開始用環境變數就夠了;等設定變複雜,再補完整 settings layer。架構不是越早越厚越好,剛好才是重點。
十二. 本機開發流程:兩個 terminal 就夠 #
開發時可以開兩個 terminal。
第一個跑後端:
uv run uvicorn server.main:app --reload --port 8000
第二個跑前端:
uv run streamlit run ui/app.py --server.port 8501
修改 server/services.py 時,Uvicorn 會 reload。
修改 ui/app.py 時,Streamlit 會重新執行。
如果你覺得兩個指令麻煩,可以加一個 Makefile:
.PHONY: api ui
api:
uv run uvicorn server.main:app --reload --port 8000
ui:
uv run streamlit run ui/app.py --server.port 8501
然後用:
make api
make ui
保持簡單。
這個階段還不需要急著上 Docker Compose,除非你還有資料庫、Redis、worker,或團隊需要完全一致的本機環境。
十三. 測試:先測 service,再測 API #
如果你的邏輯都塞在 Streamlit 頁面裡,測試會很痛苦。
但現在 analyze_text 是純 Python 函式,就很好測。
安裝 pytest:
uv add --dev pytest
建立 tests/test_services.py:
from server.services import analyze_text
def test_analyze_text_counts_words() -> None:
result = analyze_text("pypy likes APIs\npypy likes tools", top_k=2)
assert result.lines == 2
assert result.words == 6
assert result.top_words[0].word == "pypy"
assert result.top_words[0].count == 2
再建立 tests/test_api.py:
from fastapi.testclient import TestClient
from server.main import app
def test_analyze_endpoint() -> None:
client = TestClient(app)
response = client.post(
"/analyze",
json={"text": "hello hello pypy", "top_k": 1},
)
assert response.status_code == 200
assert response.json()["top_words"] == [{"word": "hello", "count": 2}]
執行:
uv run pytest
你會發現,真正需要測的大多在後端。
Streamlit 前端通常測到「主要流程能手動跑通」就夠了,除非它已經變成很重的產品介面。
十四. 常見錯誤:先知道會省很多時間 #
14.1 Streamlit 顯示連不上 API #
先打開:
http://127.0.0.1:8000/health
如果這裡都不通,問題在後端沒有啟動、port 錯誤,或 server crash。
14.2 API 在 /docs 可以跑,Streamlit 不行 #
通常是前端 API URL 設錯,或 ui/api_client.py 的 request body 跟後端 model 不一致。
這也是為什麼 API client 要獨立出來。錯誤集中,才好修。
14.3 後端邏輯更新了,前端還像沒變 #
如果你用了 st.cache_data 或 st.cache_resource,記得 cache 可能會保留舊結果。
API client 可以 cache,但真正會變的 API 回應不要亂 cache。尤其是使用者輸入不同、後端狀態會變的情況。
14.4 一直想從 Streamlit 直接 import 後端 service #
技術上可以,但要小心。
如果 Streamlit 直接從 server.services import analyze_text,那就不是前後端分離,而是同一個 Python process 裡的模組呼叫。
這在非常小的工具可以接受,但如果你已經決定用 FastAPI 當邊界,就盡量真的透過 HTTP 呼叫。這樣本機、部署、測試才會比較接近真實狀況。
十五. 什麼時候不要這樣做? #
FastAPI + Streamlit 很好,但不是萬靈丹。
下面情況就不一定需要:
- App 只有你自己用,而且功能很小
- 沒有其他系統需要呼叫同一套邏輯
- 沒有長期維護需求
- UI 和資料處理都很簡單
- 多開一個 API server 反而增加操作成本
拍拍君的偏好是:先用最小可行架構,等痛點真的出現再拆。
但如果痛點已經出現,就不要再硬把所有東西塞進 streamlit_app.py。那不是簡潔,那只是技術債還沒爆炸。
結語 #
FastAPI + Streamlit 的組合很適合內部工具、資料產品原型、模型 demo、營運面板和小型自動化介面。
關鍵不在於「用了兩個框架」,而在於分工清楚:
- FastAPI 管 API 契約、驗證、後端邏輯與可測試性
- Streamlit 管互動、視覺呈現、使用者輸入與快速迭代
- api_client.py 管兩邊的連接細節
這樣做之後,你的工具會比較容易長大,也比較不容易在第十個 feature 之後變成一坨難以維護的 if st.button。
今天就先把邊界畫出來。後面要接資料庫、背景任務、LLM 或部署,都會更穩。