FDEAI 工程實戰課AI Engineering for FDEs
中級篇 · 第 7 章Intermediate · Chapter 7

部署上線:做成別人能用的網站Deploying: a website others can use

到目前為止,機器人只能在你的終端機裡跑。作品集裡如果有一個別人點開就能用的網址,說服力差很多。這一章把機器人做成網站、包成 Docker,部署到雲端,並加上公開網址一定要有的防護。

So far the bot only runs in your terminal. A portfolio piece with a link people can just open is far more convincing. This chapter turns the bot into a website, packages it with Docker, deploys it to the cloud, and adds the protections any public URL needs.

⏱ 30 分鐘min🧪 web.py · Dockerfile

這章要加的功能What this chapter adds

  • 用 FastAPI 把機器人包成網頁與 API。
  • 公開網址的防護:每個 IP 的頻率限制、全站每日上限、輸入長度限制、金鑰不進程式。
  • 用 Docker 打包,部署到 Google Cloud Run 或 Hugging Face Spaces。
  • Wrapping the bot as a web page and API with FastAPI.
  • Protections for a public URL: per-IP rate limits, a daily site-wide cap, input length limits, keys kept out of code.
  • Packaging with Docker and deploying to Google Cloud Run or Hugging Face Spaces.

架構Architecture

網站只是在機器人前面加了一層。bot.py 完全沒改,這又是 I1 把零件分開的好處。用哪個模型由環境變數 LLM_BACKEND 決定。

The website is just a layer in front of the bot; bot.py is untouched, another payoff from separating parts in I1. The environment variable LLM_BACKEND picks the model.

先在本機跑起來Run it locally first

terminal
pip install -r requirements.txt
uvicorn labs.intermediate.web:app --reload       # 打開 http://127.0.0.1:8000

網頁本身是一個 HTML 檔(labs/intermediate/static/index.html),用 JavaScript 呼叫 POST /api/ask。API 的核心只有幾行:

The page itself is one HTML file (labs/intermediate/static/index.html) that calls POST /api/ask from JavaScript. The core of the API is a few lines:

labs/intermediate/web.py
class AskRequest(BaseModel):
    question: str = Field(min_length=1, max_length=300)     # 輸入長度限制
    session_id: str | None = Field(default=None, max_length=64)


@app.post("/api/ask")
def ask(req: AskRequest, request: Request):
    blocked = limiter.check(client_ip(request))              # 先限流,再呼叫模型
    if blocked:
        raise HTTPException(status_code=429, detail=blocked)
    sid, bot = get_bot(req.session_id)                      # 每個訪客一段自己的對話
    turn = bot.ask(req.question.strip())
    return {"session_id": sid, "answer": ..., "citations": ..., "tool_calls": ..., "error": turn.error}

公開網址一定要有的防護Protections every public URL needs

在自己電腦上跑和放到網路上,最大的差別是:任何人都能呼叫它。如果背後接的是付費的 API,有人寫個迴圈一直呼叫,帳單就會失控。

The big difference between running locally and going public is that anyone can call it. If a paid API sits behind it, someone looping requests can run up the bill.

防護Protection這個專案的做法How this project does it
預設不花錢Free by default LLM_BACKEND 預設是 fake。要用 Claude 必須明確設定,而且一定要搭配下面的上限。LLM_BACKEND defaults to fake. Claude must be enabled explicitly, and always with the limits below.
每個 IP 的頻率限制Per-IP rate limit 預設每分鐘 10 次(RATE_LIMIT_PER_MINUTE),超過回傳 HTTP 429。10 per minute by default (RATE_LIMIT_PER_MINUTE); beyond that, HTTP 429.
全站每日上限Daily site-wide cap 預設每天 200 次(DAILY_LIMIT)。這是「最壞情況的帳單上限」:200 次乘上每次的成本(I5)。200 per day by default (DAILY_LIMIT). This is your worst-case bill: 200 × cost per request (I5).
輸入長度Input length 問題最多 300 字,避免有人貼超長文字灌爆 token。Questions capped at 300 characters, so no one can flood tokens with huge pastes.
金鑰不進程式Keys stay out of code ANTHROPIC_API_KEY 只從環境變數讀取,在部署平台設定成 secret。不寫進程式、不寫進 Dockerfile、不推上 GitHub。ANTHROPIC_API_KEY is read only from the environment and set as a platform secret. Never in code, the Dockerfile or GitHub.
不收真實資料No real data 頁面明確告知「手冊是虛構的、請勿輸入個資」。示範版沒有登入,所有訪客都是示範員工 E001。The page says the handbook is fictional and asks for no personal data. There's no login; every visitor is demo employee E001.
這是示範等級的防護Demo-grade protection

限流資料存在記憶體裡,只適用於單一執行個體;平台開了多個執行個體,每個都會各自計算。X-Forwarded-For 也可能被偽造。正式系統要用共用的儲存(例如 Redis)、真正的登入,並在 API 供應商的後台設定花費上限。另外別忘了:真正上線時,user_id 必須來自登入系統(I4)。

Rate-limit state lives in memory, so it only works for a single instance; with several instances each counts separately. X-Forwarded-For can be spoofed. Production needs shared storage (such as Redis), real authentication, and a spending limit set in your API provider's console. And remember: in production, user_id must come from a login system (I4).

用 Docker 打包Packaging with Docker

Docker 把程式、Python 版本和套件包成一個「映像檔」,在你的電腦和雲端跑起來會一模一樣。專案根目錄的 Dockerfile:

Docker packages the code, Python version and libraries into an image that runs identically on your machine and in the cloud. The Dockerfile at the repo root:

Dockerfile
FROM python:3.12-slim

# 以非 root 使用者執行(Hugging Face Spaces 要求 uid 1000;對其他平台也比較安全)
RUN useradd -m -u 1000 user
USER user
ENV HOME=/home/user \
    PATH=/home/user/.local/bin:$PATH \
    PYTHONUNBUFFERED=1 \
    LLM_BACKEND=fake \
    PORT=7860
WORKDIR $HOME/app

COPY --chown=user requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt

# 只複製網頁版需要的程式,不包含測試與其他 lab
COPY --chown=user labs/__init__.py labs/__init__.py
COPY --chown=user labs/intermediate labs/intermediate

EXPOSE 7860
# Cloud Run 會用環境變數 PORT 指定連接埠(覆寫上面的 7860)
CMD ["sh", "-c", "uvicorn labs.intermediate.web:app --host 0.0.0.0 --port ${PORT}"]
本機測試映像檔(需要安裝 Docker Desktop)Test the image locally (needs Docker Desktop)
docker build -t handbook-bot .
docker run -p 7860:7860 handbook-bot              # 打開 http://localhost:7860

部署到雲端Deploying to the cloud

兩個常見的選擇。兩者都可以直接用上面的 Dockerfile。費用與免費額度會變動,部署前請查看各平台的官方說明。

Two common choices, both using the Dockerfile above. Pricing and free allowances change, so check each platform's official documentation before deploying.

A. Hugging Face Spaces(最簡單simplest)

  1. 在 Hugging Face 建立新的 Space,SDK 選 Docker。
  2. Space 的 README.md 開頭要有這段設定(app_port 對應 Dockerfile 的 7860)。
  3. 把 Dockerfile、requirements-web.txt、labs/ 推到 Space 的 git repo。
  4. 要用 Claude 的話,在 Space 的 Settings 新增 secret ANTHROPIC_API_KEY,並新增變數 LLM_BACKEND=claude。
  1. Create a new Space on Hugging Face and choose the Docker SDK.
  2. The Space's README.md needs this front matter (app_port matches the Dockerfile's 7860).
  3. Push Dockerfile, requirements-web.txt and labs/ to the Space's git repo.
  4. To use Claude, add a secret ANTHROPIC_API_KEY and a variable LLM_BACKEND=claude in the Space settings.
README.md(Space)
---
title: Handbook Bot
emoji: 📘
colorFrom: green
colorTo: gray
sdk: docker
app_port: 7860
---

B. Google Cloud Run

需要 Google Cloud 帳號與 gcloud 指令列工具。在專案根目錄執行,Cloud Run 會用 Dockerfile 建置並部署:

Needs a Google Cloud account and the gcloud CLI. Run from the repo root; Cloud Run builds from the Dockerfile and deploys:

terminal
gcloud run deploy handbook-bot --source . --region asia-east1 --allow-unauthenticated

# 改用 Claude:金鑰先存進 Secret Manager,再掛到服務上
gcloud run services update handbook-bot --region asia-east1 \
    --set-env-vars LLM_BACKEND=claude,DAILY_LIMIT=100 \
    --set-secrets ANTHROPIC_API_KEY=anthropic-api-key:latest

asia-east1 是台灣的機房區域。--allow-unauthenticated 讓任何人都能開啟網址,所以前面的防護一定要先做好。

asia-east1 is the Taiwan region. --allow-unauthenticated lets anyone open the URL, which is why the protections above come first.

那 GitHub Pages、Cloudflare Pages 呢?What about GitHub Pages or Cloudflare Pages?

它們只能放靜態檔案(HTML、CSS、JS),不能執行 Python。這門課的網站就放在 GitHub Pages,但機器人需要伺服器跑 Python,所以要用上面兩種平台。也可以把網頁放在 GitHub Pages、API 放在 Cloud Run,但就要另外處理跨網域(CORS)設定。

They host static files (HTML, CSS, JS) only and can't run Python. This course's site lives on GitHub Pages, but the bot needs a Python server, hence the two platforms above. You could put the page on GitHub Pages and the API on Cloud Run, but then you must handle cross-origin (CORS) settings.

網站也要測試Test the website too

tests/test_web.py 用 FastAPI 的 TestClient 測試,不需要真的啟動伺服器。最重要的是測「防護有沒有生效」:

tests/test_web.py uses FastAPI's TestClient, so no real server is needed. Above all, test that the protections work:

labs/intermediate/tests/test_web.py
def test_rate_limit_per_minute(client):           # client 設定為每分鐘 5 次
    codes = [client.post("/api/ask", json={"question": "事假幾天?"}).status_code for _ in range(6)]
    assert codes[:5] == [200] * 5 and codes[5] == 429


@pytest.mark.parametrize("payload", [{"question": ""}, {"question": "x" * 301}, {}])
def test_input_validation(client, payload):
    assert client.post("/api/ask", json=payload).status_code == 422


def test_claude_backend_requires_key(monkeypatch):
    monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
    with pytest.raises(RuntimeError, match="ANTHROPIC_API_KEY"):
        make_llm("claude")

練習Exercises

  1. 在本機用 uvicorn 跑起來,連續送出 11 個問題,看看第 11 個會出現什麼。
  2. 把機器人部署到 Hugging Face Spaces(使用預設的假模型,不花錢),把網址放進你的作品集 README。
  3. 加一個 /api/stats,回傳今天已經用了幾次、還剩幾次。想一想:這個資訊應該公開給所有人看嗎?
  1. Run it locally with uvicorn, send 11 questions in a row, and see what happens on the 11th.
  2. Deploy to Hugging Face Spaces (with the default fake model, at no cost) and add the URL to your portfolio README.
  3. Add /api/stats returning today's used and remaining requests. Should that be public to everyone?

小測驗Quiz

1. 公開網址的機器人接上付費 API,最重要的一道防護是?1. A public bot is wired to a paid API. The most important protection?
放在瀏覽器的金鑰任何人都看得到。防護一定要在伺服器端,並且有最壞情況的上限。
A key in the browser is visible to everyone. Protection must live server-side, with a worst-case ceiling.
2. 為什麼不能把機器人直接放在 GitHub Pages?2. Why can't the bot go straight onto GitHub Pages?
搜尋、呼叫模型、執行工具都需要伺服器端程式,所以要用 Cloud Run、Hugging Face Spaces 這類能跑容器的平台。
Search, model calls and tools need server-side code, so you need a platform that runs containers, such as Cloud Run or Hugging Face Spaces.