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

FastAPI + Streamlit 實戰:API 後端與互動前端分工

·9 分鐘· loading · loading · ·
Python Fastapi Streamlit Api Frontend Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 65: 本文

featured

一. 前言: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 或部署,都會更穩。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 65: 本文

相關文章

Python pydantic-settings 實戰:型別安全管理 .env 與設定檔
·6 分鐘· loading · loading
Python Pydantic Pydantic-Settings Dotenv Configuration Developer-Tools
Python tempfile 實戰:安全建立暫存檔案、目錄與測試資料
·9 分鐘· loading · loading
Python Tempfile Filesystem Testing Standard-Library Developer-Tools
Python difflib 實戰:文字差異比對、相似度比較與 patch 輸出完全攻略
·10 分鐘· loading · loading
Python Difflib Text-Processing Developer-Tools Cli
Python prompt_toolkit 實戰:打造互動式 CLI、Auto-Completion 與 REPL 完全攻略
·10 分鐘· loading · loading
Python Prompt_toolkit Cli REPL Developer-Tools
Python hypothesis 實戰:Property-Based Testing 與自動化找 bug 完全攻略
·7 分鐘· loading · loading
Python Hypothesis Testing Pytest Developer-Tools
Python watchdog 實戰:檔案變更監控與自動化完全攻略
·8 分鐘· loading · loading
Python Watchdog Automation Filesystem Developer-Tools