Việc phụ thuộc hoàn toàn vào các API đám mây (như OpenAI hay Anthropic) mang lại nhiều rủi ro lớn cho các ứng dụng doanh nghiệp: rò rỉ dữ liệu nội bộ nhạy cảm, chi phí sử dụng tăng vọt theo số lượng token đầu vào, và rủi ro ngừng hoạt động khi mất kết nối mạng Internet.
Giải pháp là Cục bộ hóa AI (Local LLM) — chạy trực tiếp các mô hình ngôn ngữ lớn nguồn mở ngay trên máy tính của bạn, qua công cụ Ollama. Đây cũng là bài đầu tiên trong series mà chương trình gọi tới một máy chủ LLM thật, chứ không phải bản giả lập như Bài 11 và 12. Chúng ta sẽ dựng máy chủ đó, đo xem mô hình chiếm bao nhiêu bộ nhớ và sinh được bao nhiêu token mỗi giây, rồi tự viết một chat client stream chữ thời gian thực hoàn toàn offline.
ollama pull qwen2.5:7b chẳng hạn. Lưu ý dung lượng: một mô hình 7B lượng
tử hóa 4-bit nặng khoảng 4-5 GB, nên lần tải đầu tiên mất vài phút và cần chừng đó dung lượng đĩa trống.
Đây là lần duy nhất trong series cần tải dữ liệu lớn. Thư viện Python: không cần cài gì — dự án chỉ dùng
urllib và
json trong thư viện chuẩn, cố ý không dùng requests hay gói
ollama, để bạn nhìn thấy chính giao thức HTTP thay vì để thư viện che đi. Kiến thức cần có: Bài 11 cho mảng
messages phân vai — Ollama dùng đúng
định dạng đó, nên mọi thứ bạn học ở Bài 11 chuyển sang đây nguyên vẹn.
13.1 Vì sao cần chạy LLM cục bộ (Local LLM)?
Sự bùng nổ của các mô hình nguồn mở chất lượng cao (như Llama của Meta, Gemma của Google, Qwen của Alibaba, Mistral của Pháp) đã thay đổi hoàn toàn cuộc chơi. Giờ đây, chỉ với một máy tính cá nhân tầm trung, bạn đã có thể sở hữu một trợ lý AI hoạt động độc lập.
Các ưu điểm khi cục bộ hóa AI:
- Bảo mật dữ liệu tuyệt đối: Mọi thông tin trò chuyện, tài liệu doanh nghiệp hay mã nguồn bảo mật chỉ chạy trong RAM của máy tính cục bộ. Không một byte dữ liệu nào bị gửi lên các máy chủ bên ngoài. Với dữ liệu như email khách hàng ở Bài 12, đây không phải tiện lợi mà là điều kiện pháp lý.
- Không có hóa đơn theo token: Không còn nỗi lo về hóa đơn API cuối tháng. Bạn suy luận bao nhiêu tùy ý mà chi phí biên bằng không.
- Không phụ thuộc mạng: Hoạt động ổn định ở những khu vực không có sóng Internet hoặc trên các hệ thống mạng nội bộ cô lập (Intranet).
13.2 Giới thiệu Ollama & Quản lý mô hình
Trong quá khứ, việc tự cấu hình để chạy một mô hình AI cục bộ là cực kỳ phức tạp: bạn phải cài đặt driver đồ họa CUDA, thư viện PyTorch, tải các file trọng số hàng chục GB và biên dịch code C++.
Ollama đóng gói tất cả các thư viện suy luận tối ưu nhất (nhân là llama.cpp)
vào một ứng dụng chạy nền gọn nhẹ duy nhất, và tự động phát hiện phần cứng để kích hoạt tăng tốc:
- Trên macOS: dùng Metal, API đồ họa cấp thấp của Apple, để tận dụng GPU tích hợp của chip Apple Silicon (M1 trở lên).
- Trên Windows/Linux: dùng CUDA cho card đồ họa NVIDIA, hoặc ROCm cho card AMD.
- Không có GPU phù hợp: vẫn chạy được bằng CPU, chỉ là chậm hơn nhiều — mục 13.3 sẽ cho thấy chậm đến mức nào và vì sao.
Các câu lệnh quản lý Ollama CLI kinh điển:
# Download a model and start chatting with it immediately
ollama run qwen2.5:7b
# Only download it, without starting a chat
ollama pull gemma2
# List the models stored on this machine
ollama list
# Show which models are loaded in memory right now, and on what hardware
ollama ps
# Unload a model from memory (the file stays on disk)
ollama stop qwen2.5:7b
# Delete a model to reclaim disk space
ollama rm qwen2.5:7b
Hai lệnh hay bị bỏ qua là ollama ps và ollama stop, và chúng chính là công cụ để
hiểu mục tiếp theo: mô hình nằm ở đâu, chiếm bao nhiêu, và ai đang tính toán cho nó.
13.3 Bộ nhớ và tốc độ: con số quyết định mọi thứ
Mọi hướng dẫn về local LLM đều nói "cần đủ VRAM", nhưng ít chỗ nói rõ đủ là bao nhiêu và vì sao con số đó lớn hơn dung lượng file. Hãy bắt đầu từ lý thuyết rồi đối chiếu với số đo thật.
Q4_K_M bạn thấy trong ollama list chính là tên một sơ đồ lượng tử hóa 4-bit cụ
thể.
- FP32 (32-bit): $\approx 30.40$ GB.
- FP16 (16-bit): $\approx 15.20$ GB.
- INT8 (8-bit): $\approx 7.60$ GB.
- INT4 (4-bit): $\approx 3.80$ GB.
Q4_K_M không lượng tử hóa đồng loạt: các lớp
nhạy cảm (đặc biệt là lớp nhúng từ Embedding và một phần các lớp attention) được giữ ở độ chính xác cao
hơn 4-bit để tránh suy giảm chất lượng. Chữ "M" trong tên chính là "Medium" — mức thỏa hiệp giữa dung
lượng và chất lượng.
Đến đây mới là phần thường bị bỏ sót. Dung lượng file không phải lượng bộ nhớ mô hình chiếm khi
chạy. Chạy ollama ps ngay sau khi hỏi mô hình một câu, trên chính máy viết bài này:
$ ollama ps
NAME ID SIZE PROCESSOR CONTEXT UNTIL
qwen2.5-coder:7b dae161e27b0e 6.6 GB 100% GPU 32768 4 minutes from now
File trên đĩa là 4.68 GB, nhưng khi chạy nó chiếm 6.6 GB. Gần 2 GB chênh lệch là KV cache: bộ đệm lưu các vector Key và Value của mọi token trong cửa sổ ngữ cảnh, đúng những K và V bạn đã gặp ở Bài 10. Cửa sổ càng dài thì bộ đệm càng lớn — ở đây cấu hình 32768 token, và đó là lý do quy tắc ngón tay cái luôn đòi bộ nhớ trống nhiều hơn dung lượng file.
100% GPU ở trên là con số quan trọng nhất trong cả bài. Nếu bộ nhớ đồ họa không đủ
chứa trọn mô hình cộng KV cache, Ollama buộc phải để một phần chạy trên CPU, và
ollama ps sẽ hiện dạng 60% GPU / 40% CPU. Khi đó tốc độ không giảm 40% mà giảm
hàng chục lần, vì mỗi token sinh ra phải chờ phần chạy trên CPU — vốn có băng thông bộ nhớ thấp hơn
nhiều lần. Quy tắc ước lượng bộ nhớ trống cần có:
- Mô hình 7B/8B lượng tử 4-bit (file 4-5 GB): cần tối thiểu 8 GB.
- Mô hình 70B lượng tử 4-bit (file khoảng 40 GB): cần tối thiểu 48 GB.
ollama ps trước khi đổ lỗi cho mô hình —
cột PROCESSOR trả lời câu hỏi đó ngay.
13.4 Tích hợp API Ollama vào ứng dụng
Khi khởi động, Ollama tự động mở một REST API nội bộ tại http://localhost:11434. Đây là điểm
làm cho Ollama hữu ích với lập trình viên: mọi thứ bạn viết ở Bài 11 và 12 chuyển sang đây gần như nguyên
vẹn, chỉ đổi URL và bỏ header xác thực.
Endpoint chính để gửi yêu cầu chat là /api/chat, và cấu trúc thân request rất quen thuộc:
{
"model": "qwen2.5:7b",
"messages": [{ "role": "user", "content": "Why is the sky blue?" }],
"stream": true
}
Đúng mảng messages ba vai trò của Bài 11. Điểm khác nằm ở "stream": true: thay
vì đợi mô hình viết xong cả câu trả lời rồi mới trả về một khối JSON, Ollama gửi về
từng dòng JSON độc lập, mỗi dòng chứa một mẩu chữ vừa sinh ra. Với tốc độ khoảng 40 token
mỗi giây, một câu trả lời 200 token mất gần 5 giây — stream biến 5 giây im lặng thành chữ chạy dần ngay
lập tức.
model và
created_at lặp lại ở mọi dòng cho dễ đọc):
{"message": {"role": "assistant", "content": "The"}, "done": false}
{"message": {"role": "assistant", "content": " sky"}, "done": false}
{"message": {"role": "assistant", "content": " appears"}, "done": false}
Ghép tuần tự: "The" → "The sky" → "The sky appears". Vòng lặp
for raw in response đọc từng dòng, trích content rồi ghi thẳng ra console bằng
sys.stdout.write — không gộp chuỗi trong bộ nhớ, nên chữ hiện ra đúng nhịp mô hình sinh.
Dòng cuối cùng (
done: true) có nội dung rỗng, nhưng không hề vô dụng — nó
mang toàn bộ đồng hồ đo:
{"total_duration": 2067325583, "load_duration": 1502167333,
"prompt_eval_count": 43, "prompt_eval_duration": 203854000,
"eval_count": 13, "eval_duration": 358924000}
Đơn vị là nano-giây. Từ đây tính ra tốc độ thật: $13 \div (358924000 \div 10^9) \approx 36.2$ token mỗi
giây. Đây là cách bạn đo máy mình thay vì tra bảng benchmark của người khác.
13.5 Dự án thực hành bài 13: Chat client offline có đo đạc
Dự án là một script Python nói chuyện trực tiếp với Ollama trên máy bạn. Nó làm bốn việc: hỏi máy chủ xem có những mô hình nào, chọn một mô hình thực sự tồn tại, stream câu trả lời ra màn hình, và in ra tốc độ đo được từ dòng cuối của luồng dữ liệu.
"model": "llama3". Nếu máy bạn
chưa pull đúng cái tên đó, máy chủ trả về HTTP 404 kèm thông báo
model 'llama3' not found — Ollama vẫn đang chạy hoàn hảo, chỉ là thiếu mô hình. Nhưng trong
Python, HTTPError là lớp con của URLError, nên một khối
except urllib.error.URLError đặt trước sẽ nuốt luôn lỗi 404 và in ra "không kết nối được
tới Ollama". Người học đi khởi động lại Ollama, và tất nhiên không giải quyết được gì. Vì vậy script này gọi
/api/tags trước để lấy danh sách mô hình có thật, và bắt
HTTPError trước URLError để hai tình huống ra hai thông báo khác
nhau.
"""Lesson 13 project: talk to a local Ollama server, and measure it.
Run: python3 local_chat.py
Needs: Ollama running, plus at least one model pulled (`ollama pull qwen2.5:7b`).
Standard library only - no `requests`, no `ollama` package. The point is to see
the HTTP stream itself rather than have a library hide it.
"""
import json
import sys
import urllib.error
import urllib.request
OLLAMA = "http://localhost:11434"
PREFERRED = ["qwen2.5-coder:7b", "qwen2.5:7b", "llama3.2", "llama3.1", "gemma2"]
def list_models():
"""Ask the server which models are pulled. Returns [] if it is not running."""
try:
with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
payload = json.loads(response.read())
except urllib.error.URLError as exc:
print(f"Cannot reach Ollama at {OLLAMA} - {exc.reason}")
print("Start the Ollama app (or run `ollama serve`) and try again.")
return []
return payload.get("models", [])
def pick_model(models):
"""Choose a model that actually exists here, instead of hardcoding a name.
Hardcoding "llama3" is the most common way this script fails for a reader:
the server is running fine, the model simply was never pulled.
"""
names = [m["name"] for m in models]
for wanted in PREFERRED:
for name in names:
if name == wanted or name.startswith(wanted + ":"):
return name
return names[0] if names else None
def report_models(models):
"""Print what is installed, with the size and quantisation of each."""
print("=== Models available on this machine ===")
for model in models:
details = model.get("details", {})
print(f" {model['name']:<30} {model['size'] / 1e9:5.2f} GB"
f" params={details.get('parameter_size', '?'):>7}"
f" quant={details.get('quantization_level', '?')}")
print()
def stream_chat(prompt, model, show_raw_lines=0):
"""Send one chat request and print tokens as they arrive.
Returns the final `done: true` object, which carries the timing counters.
"""
body = json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": True,
}).encode("utf-8")
request = urllib.request.Request(
f"{OLLAMA}/api/chat", data=body,
headers={"Content-Type": "application/json"},
)
print(f"[{model}] {prompt}")
final = {}
kept = []
try:
with urllib.request.urlopen(request) as response:
print(" ", end="")
sys.stdout.flush()
for raw in response:
line = raw.decode("utf-8").strip()
if not line:
continue # a blank line is not JSON; json.loads() would raise
chunk = json.loads(line)
if len(kept) < show_raw_lines:
# Same line, minus two fields that repeat on every chunk,
# so the part that changes stays readable at this width.
kept.append({k: v for k, v in chunk.items()
if k not in ("model", "created_at")})
sys.stdout.write(chunk.get("message", {}).get("content", ""))
sys.stdout.flush()
if chunk.get("done"):
final = chunk
except urllib.error.HTTPError as exc:
# NOT the same failure as the server being down, and the message must
# say so. HTTPError is a subclass of URLError, so the order matters:
# catching URLError first would swallow this and blame the connection.
detail = json.loads(exc.read() or b"{}").get("error", "no detail given")
print(f"\n the server answered HTTP {exc.code}: {detail}")
print(f" Ollama is running. Pull the model first: `ollama pull {model}`")
return {}
except urllib.error.URLError as exc:
print(f"\n cannot reach Ollama at {OLLAMA} - {exc.reason}")
print(" Start the Ollama app (or run `ollama serve`) and try again.")
return {}
print()
for number, chunk in enumerate(kept, 1):
print(f" chunk {number}: {json.dumps(chunk, ensure_ascii=False)}")
if kept and final:
counters = {k: v for k, v in final.items()
if k.endswith(("_count", "_duration"))}
print(f" last chunk carries the counters: "
f"{json.dumps(counters, ensure_ascii=False)}")
return final
def report_speed(final):
"""Turn the counters in the last chunk into numbers you can compare."""
if not final:
return
tokens = final.get("eval_count", 0)
eval_ns = final.get("eval_duration", 0)
load_ns = final.get("load_duration", 0)
prompt_tokens = final.get("prompt_eval_count", 0)
if not eval_ns:
return
print(f" generated {tokens} tokens in {eval_ns / 1e9:.2f}s"
f" -> {tokens / (eval_ns / 1e9):.1f} tokens/s")
print(f" prompt was {prompt_tokens} tokens;"
f" loading the model took {load_ns / 1e9:.2f}s")
print()
def main():
models = list_models()
if not models:
print("No models found. Pull one first, for example:")
print(" ollama pull qwen2.5:7b")
return
report_models(models)
model = pick_model(models)
# First call: show the raw stream lines, so the wire format is visible.
print("=== What the stream actually looks like ===")
final = stream_chat("Why is the sky blue? Answer in under 12 words.",
model, show_raw_lines=3)
report_speed(final)
# Second call: the same model is already loaded, so load_duration collapses.
print("=== Same model, second call ===")
final = stream_chat("Name three primary colours, comma separated.", model)
report_speed(final)
# A model that is certainly not installed, to see the right error message.
print("=== Asking for a model that was never pulled ===")
stream_chat("hello", "definitely-not-a-real-model")
if __name__ == "__main__":
main()
Chạy nó ra như sau — đây là kết quả thật trên máy viết bài, không phải mô phỏng:
=== Models available on this machine ===
bge-m3:latest 1.16 GB params=566.70M quant=F16
qwen2.5-coder:7b 4.68 GB params= 7.6B quant=Q4_K_M
qwen2.5:14b-instruct-q4_K_M 8.99 GB params= 14.8B quant=Q4_K_M
translategemma:latest 3.30 GB params= 4.3B quant=Q4_K_M
=== What the stream actually looks like ===
[qwen2.5-coder:7b] Why is the sky blue? Answer in under 12 words.
The sky appears blue because of Rayleigh scattering of sunlight.
chunk 1: {"message": {"role": "assistant", "content": "The"}, "done": false}
chunk 2: {"message": {"role": "assistant", "content": " sky"}, "done": false}
chunk 3: {"message": {"role": "assistant", "content": " appears"}, "done": false}
generated 13 tokens in 0.36s -> 36.2 tokens/s
prompt was 43 tokens; loading the model took 1.50s
=== Same model, second call ===
[qwen2.5-coder:7b] Name three primary colours, comma separated.
Red, Blue, Green
generated 6 tokens in 0.13s -> 47.2 tokens/s
prompt was 37 tokens; loading the model took 0.14s
=== Asking for a model that was never pulled ===
[definitely-not-a-real-model] hello
the server answered HTTP 404: model 'definitely-not-a-real-model' not found
Ollama is running. Pull the model first: `ollama pull definitely-not-a-real-model`
loading the model took: lượt đầu 1.50 giây, lượt sau
0.14 giây — chênh nhau hơn 10 lần. Lượt đầu Ollama phải nạp 4.68 GB trọng số từ đĩa vào
bộ nhớ đồ họa; lượt sau mô hình đã nằm sẵn ở đó. Đây là lý do lần chat đầu tiên trong ngày luôn có cảm
giác "đơ" vài giây, và cũng là lý do Ollama giữ mô hình trong bộ nhớ thêm 5 phút sau lần dùng cuối thay
vì giải phóng ngay. Muốn tự kiểm chứng: chạy ollama stop <tên-mô-hình> rồi chạy lại
script — con số 1.50 giây sẽ quay lại. Lưu ý: khác với các bài trước, đầu ra của bài này không cố định. Đây là mô hình thật đang lấy mẫu thật, nên câu chữ và tốc độ trên máy bạn sẽ khác — phần cứng khác, mô hình khác. Cái cần giống là hình dạng: ba dòng chunk, đồng hồ đo ở dòng cuối, và lượt đầu chậm hơn lượt sau.
Cách chạy dự án này trên máy bạn
-
Cài Ollama, rồi tải một mô hình:
ollama pull qwen2.5:7b(khoảng 4.7 GB). Kiểm tra bằngollama list. -
Chạy:
python3 local_chat.py. Script tự tìm mô hình trong danh sáchPREFERRED; nếu không có cái nào khớp, nó lấy mô hình đầu tiên bạn có. -
Rồi thử phá nó theo ba cách:
- Tắt hẳn ứng dụng Ollama rồi chạy lại. Bạn nhận được thông báo "cannot reach Ollama" — khác hẳn thông báo 404 ở cuối màn hình trên. Hai lỗi khác nhau phải nói hai câu khác nhau.
-
Trong
stream_chat, đổi thứ tự hai khốiexcept, đặtURLErrorlên trước. Lỗi 404 lập tức bị báo nhầm thành lỗi kết nối — đúng cái bẫy mô tả ở trên, và bạn thấy nó bằng mắt thay vì phải tin. -
Nếu máy bạn có mô hình lớn hơn (14B trở lên), đưa tên nó lên đầu
PREFERREDrồi chạy lại và so tokens/s. Sau đó chạyollama psđể xem cột PROCESSOR — nếu nó không còn là100% GPUnữa, bạn vừa nhìn thấy đúng ranh giới ở mục 13.3.
Tóm tắt bài học & Cầu nối kiến thức
- Đạt được: Chạy một mô hình ngôn ngữ lớn offline 100% trên máy cá nhân bằng Ollama, và gọi nó từ Python chỉ bằng thư viện chuẩn.
- Đạt được: Đọc và xử lý luồng stream JSON theo dòng, kể cả dòng cuối chứa đồng hồ đo.
- Đạt được: Đo được tốc độ thật của máy mình (token/giây) và giải thích được vì sao lượt gọi đầu chậm hơn.
-
Đạt được: Phân biệt bộ nhớ file và bộ nhớ khi chạy, và dùng
ollama psđể biết mô hình đang chạy trên GPU hay đã tràn xuống CPU.
Cầu nối bài tiếp theo: Giờ mô hình đã chạy trên máy bạn và không gửi dữ liệu đi đâu cả — đúng điều kiện để cho nó đọc tài liệu nội bộ. Nhưng nhét hàng nghìn trang PDF vào cửa sổ ngữ cảnh là bất khả (Bài 11 đã cho thấy cái giá của token). Bài 14 giải bài toán đó bằng kiến trúc RAG.
Tải file code thực hành minh họa bài học
File Python local_chat.py — mã nguồn gọi API Ollama cục bộ và stream phản hồi trực tiếp ra
console Terminal không đệm dòng (chạy python local_chat.py):
📖 Tài liệu tham khảo
- Ollama Official Website — Trang chủ chính thức để tải về và xem tài liệu API của Ollama
- llama.cpp GitHub — Thư viện C++ cốt lõi tối ưu hóa suy luận mô hình AI cục bộ trên CPU/GPU dân dụng (Georgi Gerganov)
- QLoRA: Efficient Finetuning of Quantized LLMs — Bài báo khoa học chứng minh tính hiệu quả của mô hình lượng tử hóa 4-bit (Dettmers et al., 2023)
Bình luận