Lab 01 — Model Access: Local vs Cloud, One Client
模型接入:本地與雲端 · Hands-on track: AI Infrastructure for Real Business Problems
Track: AI Infrastructure for Real Business Problems · Code:
labs/01/· Shared client:labs/common/
Business Problem — 商業問題
Kettle & Kiln (an online tea-ware shop in Oakland; our running example) wants AI help with product copy, customer emails and support. The owner has three questions before spending anything:
- Can we run a model on our own hardware (a Mac mini or the TrueNAS box in the office) so customer emails never leave the building?
- When is a cloud model worth paying for?
- How do we avoid rewriting every script when we switch?
The store's privacy policy (data/docs/privacy.md) says customer personal data must not be sent to a third-party AI service unless approved. So the decision is not only "which model is smartest", it is also where each request is allowed to go.
In this lab you build the foundation every later lab uses: one client (llmkit) that talks to any OpenAI-compatible backend, a doctor script, a latency comparison, and a privacy router that keeps PII on the local model.
Architecture — 系統架構
flowchart LR
subgraph App["Your scripts / services"]
C[chat.py / compare.py] --> K[llmkit.LLM]
R[router.py] -->|PII found| L
R -->|no PII| F[FallbackLLM cloud→local]
end
K --> L[(Local: Ollama :11434 / llama.cpp :8080 / LM Studio :1234)]
K --> CL[(Cloud: OpenAI or any compatible API)]
K --> M[(mock: offline, for tests/CI)]
F --> CL
F -.fallback.-> L
Every backend speaks the same OpenAI-compatible /v1/chat/completions and /v1/embeddings API, so switching is a .env change:
LLM_PROVIDER |
Default base URL | Notes |
|---|---|---|
ollama |
http://localhost:11434/v1 |
easiest local option |
llamacpp |
http://localhost:8080/v1 |
llama-server from llama.cpp |
lmstudio |
http://localhost:1234/v1 |
desktop app |
openai |
OpenAI default | needs LLM_API_KEY |
compat |
set LLM_BASE_URL |
any other compatible server (e.g. the Lab 09 gateway) |
mock |
in-process | deterministic, offline; every test suite uses it |
Prerequisites — 先決條件
- Python 3.12 and
uv(or plainvenv+pip). - One local backend — recommended: Ollama (
brew install ollamaon macOS, orcurl -fsSL https://ollama.com/install.sh | shon Linux). On Apple Silicon run Ollama natively: Docker on macOS cannot use the Metal GPU. - Optional: a cloud API key. The lab works without one (the router then always uses local).
- ~6 GB free disk for an 8B model (quantised GGUFs are roughly 5 GB — check
ollama listfor the real size of what you pull).
Step-by-Step Setup — 逐步設定
cd hands-on/labs/01
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -e ../common -r requirements.txt
cp .env.example .env
# local model
ollama serve & # skip if the Ollama app is already running
ollama pull qwen3:8b # or any chat model your hardware can hold
ollama pull nomic-embed-text # used from Lab 04 on
python doctor.py # checks every configured prefix (LLM_, LOCAL_, CLOUD_)
python chat.py "Write a 1-line tagline for a celadon tea cup set"
python chat.py --json "Classify this message: 'my mug arrived cracked'"
python chat.py --prefix CLOUD_ "Same tagline, please" # if you set CLOUD_* in .env
Then compare backends and try the router:
python compare.py --prefixes LOCAL_ CLOUD_ --repeat 3 # writes compare_results.csv
python router.py
router.py sends two prompts. The policy summary (no PII) goes cloud-first, falling back to local if the cloud call fails. The draft reply to chloe.lin@example.com contains an email address, so it is forced local.
Code Walkthrough — 程式碼導覽
common/llmkit/config.py—Settings.from_env(prefix)reads<PREFIX>PROVIDER/MODEL/BASE_URL/API_KEY/EMBED_MODEL. Because of the prefix, one.envcan describe several backends (LLM_,LOCAL_,CLOUD_,JUDGE_).common/llmkit/client.pyLLM.chat()returns text, tool calls, usage and latency.LLM.chat_json(messages, PydanticModel)asks for JSON, validates it, and if validation fails sends the errors back to the model for a repair turn (up toretries). If it still fails it raisesStructuredOutputError. Labs 03–07 depend on this.FallbackLLM([a, b])tries each backend in order.
common/llmkit/mock.py+mock_server.py— a deterministic fake model. Runpython -m llmkit.mock_server --port 8099for an OpenAI-compatible server for demos/CI (ordocker compose --profile mock up).router.py— regex PII detection (email, phone, card) and the routing rule. The regexes are deliberately over-cautious: a date like2026-10-15also matches "phone". A false positive only means "stay local", which is the safe direction.
How to Test — 測試方法
LLM_PROVIDER=mock pytest -q # offline: 8 tests
RUN_LIVE=1 pytest -q # + 1 live test against the backend in .env
The tests cover:
- mock chat + usage
- JSON validation
- repair-then-succeed
- give-up after retries
- the real OpenAI SDK against the mock HTTP server
- fallback
- the router keeping PII local
- settings validation
What was run on the build box — 實測紀錄
- ✅
pytest: 8 passed, 1 skipped (the skip is the live test, which only runs withRUN_LIVE=1). - ✅ Live, against Ollama on a CPU-only Linux box with
qwen2.5:0.5b/qwen2.5:1.5b(small models picked so they would fit the build box; in class use the 7–8B class or larger):doctor.pyandchat.py(incl.--json) worked.compare.pyworked.RUN_LIVE=1test passed.
- ⚠️ Not run: a real cloud provider (no key on the build box), llama.cpp server, LM Studio, the Dockerised Ollama in
docker-compose.yml(no Docker daemon on the build box).
Cost & Performance Notes — 成本與效能
- Local cost is mostly hardware you already own plus electricity. Cloud cost is per token:
cost = prompt_tokens × input_price + completion_tokens × output_price. Take prices from your vendor's pricing page on the day you decide; this course does not quote them because they change often. compare.pyrecords tokens and latency for your prompts on your hardware. Multiply the token counts by your expected monthly volume to get a real estimate instead of a guess.- The first local call includes model load time, so run
--repeat 3and ignore the first row. A model that does not fit in GPU/unified memory spills to CPU and gets much slower. If latency suddenly jumps, checkollama ps. - Rule of thumb (an estimate, verify on your machine): quantised 7–8B models are the smallest that follow structured-output instructions reliably. The 0.5–1.5B models used on the build box often did not (see the Lab 02 eval report).
Common Failures & Fixes — 常見錯誤與修正
| Symptom | Cause | Fix |
|---|---|---|
Connection refused localhost:11434 |
Ollama not running | ollama serve (or start the app); curl localhost:11434/api/tags |
model "qwen3:8b" not found |
not pulled | ollama pull qwen3:8b; ollama list shows exact tags |
| Container can't reach Ollama on the host | localhost inside Docker is the container |
use http://host.docker.internal:11434/v1 (+ extra_hosts: host-gateway on Linux) |
401 from cloud |
wrong/missing key, or key for another org/project | re-create key; check CLOUD_API_KEY is read (python doctor.py) |
StructuredOutputError |
model too small or prompt unclear | larger model, lower temperature, simpler schema; read the repair errors in the exception |
| Very slow local replies | model larger than VRAM → CPU offload | smaller/quantised model, shorter context, check ollama ps |
ollama pull fails behind a corporate proxy/firewall |
registry blocked | set HTTPS_PROXY, or download a GGUF and ollama create <name> -f Modelfile (this is what the build box had to do) |
Stretch Exercise — 延伸練習
- Add a fourth backend (llama.cpp
llama-server -m model.gguf --port 8080) to.envasALT_and include it incompare.py --prefixes LOCAL_ ALT_. - Extend
router.pyso that when the cloud is used, aX-Data-Class: publicheader is logged. Write a test proving a message with a phone number never reaches the cloud backend (hint: give the router a cloudLLMwhose mock handler raises).
Deliverable Checklist — 交付清單
- [ ]
python doctor.pyshows ✅ for your local backend (and cloud, if used) - [ ]
compare_results.csvwith ≥3 runs per backend on your hardware - [ ]
python router.pyoutput showing the PII prompt routed local - [ ]
LLM_PROVIDER=mock pytest -qpasses;RUN_LIVE=1 pytest -qpasses - [ ] One paragraph: which backend you'd use for which task at Kettle & Kiln, and why (cost, privacy, quality)