Gửi toàn bộ nội dung chăm sóc khách hàng lên API bên ngoài không phải lúc nào cũng được phép, và hoá đơn token không phải lúc nào cũng rẻ. Chạy LLM local nghĩa là mô hình nằm ngay trên laptop hoặc máy chủ của bạn, dữ liệu không rời máy, chi phí là điện và RAM. Bài này đi qua hai công cụ phổ biến cho LLM local là llama.cpp và Ollama, cách ước lượng bộ nhớ trước khi tải mô hình, lượng tử hoá (quantization) ảnh hưởng thế nào tới dung lượng, độ chính xác và tốc độ, rồi gọi LLM local từ Python bằng SDK quen thuộc. Mọi con số là output thật trên Apple M4, 16 GB.

Bài nối tiếp Gọi LLM API từ Python: streaming, structured output và kiểm soát chi phí và LLM hoạt động thế nào: token, ngữ cảnh, sampling và giới hạn. Môi trường: Apple M4, 16 GB, Python 3.12, Ollama và llama.cpp cài bằng Homebrew, SDK openai 1.x, mô hình họ Qwen2.5 (phát hành 9/2024). Số liệu đo trên máy này chỉ có giá trị tham khảo cho đúng cấu hình đó; khi bạn chạy lại, kết quả sẽ khác tuỳ phiên bản và tải máy.

Khi nào nên chạy LLM local, khi nào không

Không có câu trả lời một chiều; bảng dưới tóm tắt các đánh đổi chúng tôi cân nhắc khi tư vấn.

Tiêu chíLLM localAPI thương mại
Dữ liệu nhạy cảm (hợp đồng, hồ sơ khách)Không rời máy, dễ giải trình khi kiểm toánCần xem điều khoản xử lý dữ liệu của nhà cung cấp
Chi phíCố định theo phần cứng, nhưng phải vận hànhTăng tuyến tính theo lượng dùng
Chất lượng tiếng Việt, suy luận khóMô hình nhỏ yếu hơn rõ rệtMô hình lớn thường tốt hơn

Với phân loại, trích xuất, tóm tắt ngắn trên dữ liệu nội bộ, một LLM local cỡ 1–8 tỉ tham số đáng thử. Với lập luận nhiều bước hoặc tiếng Việt trau chuốt, hãy đo trước khi cam kết: phần thực nghiệm cho thấy mô hình 1,5 tỉ tham số còn tự khẳng định chính sách đổi trả không có trong prompt.

llama.cpp, GGUF và Ollama: ai làm gì

llama.cpp là thư viện suy luận viết bằng C/C++ theo giấy phép MIT, không phụ thuộc thư viện ngoài, hỗ trợ nhiều backend (Metal cho Apple Silicon, CUDA cho NVIDIA, Vulkan, BLAS…) và lượng tử hoá nguyên (integer) từ 1,5 đến 8 bit. Nó đi kèm llama-server (máy chủ HTTP tương thích OpenAI), llama-bench, llama-perplexity và llama-quantize. Trên macOS, theo tài liệu build, Metal được bật mặc định.

Định dạng file mô hình là GGUF. Theo tài liệu Hugging Face, GGUF là định dạng nhị phân tối ưu cho nạp nhanh, chứa cả tensor lẫn bộ metadata chuẩn hoá (số lớp, số đầu attention, từ điển token…), khác với safetensors chỉ chứa tensor.

Ollama đặt một lớp vận hành lên trên: quản lý kho mô hình (~/.ollama/models trên macOS), tự nạp và giải phóng mô hình khỏi bộ nhớ, và cung cấp REST API tại cổng 11434. Có thể coi llama.cpp là động cơ của LLM local, Ollama là lớp đóng gói. Chọn llama.cpp khi cần kiểm soát tham số hoặc tự lượng tử hoá; chọn Ollama khi muốn ollama pull rồi gọi API ngay.

Chọn mô hình cho LLM local: bộ nhớ = trọng số + KV cache

Trước khi tải vài chục GB, hãy tính. Bộ nhớ của LLM local gồm trọng số (số tham số nhân số bit), KV cache (khoá và giá trị attention của mọi token trong ngữ cảnh) và phần dự phòng. Công thức KV cache: số lớp × 2 (K và V) × số đầu KV × head_dim × byte mỗi phần tử × số token; grouped-query attention (ít đầu KV hơn đầu query) giảm hẳn phần này. Cấu hình lấy từ config.json của từng mô hình trên Hugging Face.

    kv = L * 2 * kvh * hd * 2 * CTX / 2**20
    for q, b in BPW.items():
        w = p * b / 8 / 2**20
        tot = w + kv + OVERHEAD_MIB
        print(f"{name:14}{q:8}{w:9.0f}{kv:7.0f}{tot:9.0f}  {'có' if tot < LIMIT_MIB else 'không'}")

Ngưỡng 12.713 MiB là recommendedMaxWorkingSetSize Metal báo cho máy 16 GB này (khoảng 78% RAM thống nhất). Đoạn tính chính trong sizing.py:

ngữ cảnh 8192 token, KV f16, dự phòng 500 MiB, ngưỡng Metal 12713 MiB
model         quant     weights     KV    total  vừa?
Qwen2.5-7B    Q4_K_M       4442    448     5390  có
Qwen2.5-7B    Q8_0         7721    448     8669  có
Llama-3.1-8B  Q4_K_M       4681   1024     6205  có
Llama-3.1-8B  Q8_0         8137   1024     9661  có
Qwen2.5-14B   Q4_K_M       8610   1536    10646  có
Qwen2.5-14B   Q8_0        14966   1536    17002  không
Qwen2.5-32B   Q4_K_M      19097   2048    21645  không
Qwen2.5-32B   Q8_0        33195   2048    35743  không

Với LLM local, Q4_K_M của mô hình 14 tỉ tham số vừa (10.558 MiB, khoảng 10,3 GiB), 32 tỉ không vừa dù đã lượng tử hoá, 7–8 tỉ ở Q8_0 vẫn chạy được. Đây chỉ là ước lượng dùng bit/weight của Llama 3.1 8B; hãy kiểm tra bằng /api/ps sau khi nạp.

Kiểm chứng KV cache bằng Ollama: ctx_memory.py nạp qwen2.5:1.5b ở ba kích thước ngữ cảnh rồi đọc /api/ps (các trường size và size_vram):

num_ctx=  2048  load= 0.55s  size=   1057 MiB  vram=   1057 MiB
num_ctx=  8192  load= 0.53s  size=   1304 MiB  vram=   1304 MiB
num_ctx= 32768  load= 0.54s  size=   2024 MiB  vram=   2024 MiB
sau keep_alive=0: []

Từ 8.192 lên 32.768 token, bộ nhớ tăng 720 MiB, gần với phép tính KV f16 cho 24.576 token thêm (khoảng 672 MiB, xem kv_cache_output.txt trong thư mục code). Hệ quả: đừng đặt num_ctx theo tối đa của mô hình; với Llama 3.1 8B, KV f16 ở 32K token tốn 4 GiB. Theo FAQ của Ollama, OLLAMA_NUM_PARALLEL nhân bộ nhớ ngữ cảnh lên theo số yêu cầu đồng thời.

Lượng tử hoá LLM local là gì và chọn mức nào

Lượng tử hoá lưu trọng số bằng ít bit hơn (thường theo từng khối nhỏ, mỗi khối có hệ số tỉ lệ riêng) để giảm dung lượng và băng thông bộ nhớ khi suy luận. Với LLM local, theo bảng mô tả của Hugging Face, Q4_K tốn 4,5 bit mỗi trọng số và Q2_K tốn 2,625 bit; hậu tố _M là hỗn hợp trong đó vài tensor quan trọng dùng mức bit cao hơn. Trong README của llama-quantize, các bảng bit/weight của Llama 2 7B (số k-quant) cùng một bảng cũ có đánh dấu "outdated" (có F16, Q8_0 và perplexity trên 7B) cho thấy:

KiểuBit/weight (Llama 2 7B)Nguồn trong README
Q2_K3,35bảng k-quant
Q4_K_M4,84bảng k-quant
Q8_08,5bảng cũ (outdated)
F1616,0bảng cũ (outdated)

Bảng cũ trong README cho 7B có F16 và Q8_0 gần như trùng perplexity (5,9066 và 5,9070) và dung lượng file 13,0 GB so với 6,7 GB; bảng k-quant không kèm perplexity. Với mô hình và tiếng Việt của bạn, phần độ chính xác phải tự đo. Chúng tôi làm đúng việc đó với qwen2.5:0.5b-instruct ở bốn mức. Bit/weight tính ngược từ dung lượng file trong kho Ollama:

fp16 16.1 bit/weight (file)
q8_0 8.6 bit/weight (file)
q4_K_M 6.44 bit/weight (file)
q2_K 5.48 bit/weight (file)

Q4_K_M của mô hình này tốn 6,44 bit/weight, không phải 4,84 như bảng của Llama 2 7B. Với mô hình nhỏ, bảng embedding chiếm phần lớn (151.936 token × 896 chiều ≈ 136 triệu trong 494 triệu tham số); llama-gguf (output đầy đủ lưu trong gguf_tensors_output.txt) cho thấy trong file Q4_K_M của kho Ollama, token_embd.weight là Q8_0, 12 trong 24 tensor ffn_down là Q6_K và 12 là Q4_K, còn phần lớn tensor attention và ffn_gate/up là Q5_0; mô hình này không có tensor output.weight riêng (dùng chung embedding). Hậu tố "Q4_K_M" vì thế chỉ là tên gọi chung cho một hỗn hợp nhiều kiểu.

Khi tự chạy llama-quantize, nó còn cảnh báo "144 of 290 tensor(s) required fallback quantization" vì nhiều chiều tensor không chia hết cho siêu khối 256. Bài học cho LLM local: bảng bit/weight của mô hình 7 tỉ không áp thẳng cho mô hình dưới 2 tỉ.

Đo độ chính xác bằng perplexity trên văn bản tiếng Việt

Perplexity (PPL) đo mô hình "ngạc nhiên" thế nào trước một đoạn văn: càng thấp càng tốt. Chúng tôi dùng chính văn bản các bài đầu của series này đã bỏ thẻ HTML và code (120 KB đầu tiên, file corpus_vi.txt), cắt 40 đoạn 512 token bằng llama-perplexity:

MứcDung lượng filePPL (± sai số)Tăng so với F16
F16994 MB43,98 ± 1,38–
Q8_0531 MB44,15 ± 1,39+0,4%
Q4_K_M398 MB46,89 ± 1,50+6,6%
Q2_K339 MB60,50 ± 1,93+37,6%

Q8_0 gần như không mất gì; Q4_K_M mất khoảng 7% PPL; Q2_K hỏng rõ rệt (PPL tăng gần 38%). PPL tuyệt đối cao vì mô hình 0,5 tỉ tham số yếu với tiếng Việt, nhưng thứ tự giữa các mức vẫn đáng tin. Với LLM local cỡ nhỏ, Q8_0 đáng cân nhắc vì chênh lệch dung lượng chỉ vài trăm MB.

Chúng tôi cũng tự lượng tử hoá từ file F16 sang Q4_K_M bằng llama-quantize (mất 86,6 giây). File ra đúng 397.807.936 byte, bằng bản trong kho Ollama, nhưng PPL là 45,68 ± 1,44 thay vì 46,89 ± 1,50. Hai số nằm trong vùng sai số nên không kết luận bản nào tốt hơn; điều chắc chắn là cùng tên "Q4_K_M" vẫn có thể hơi khác tuỳ cách tạo (ví dụ importance matrix). Hãy đo lại trên bản LLM local bạn triển khai.

Đo hiệu năng LLM local: prefill, decode và nhiễu đo

Suy luận LLM local có hai pha. Prefill (pp) xử lý toàn bộ prompt song song, bị giới hạn bởi sức tính. Decode (tg) sinh từng token một, mỗi token phải đọc lại toàn bộ trọng số nên chủ yếu bị giới hạn bởi băng thông bộ nhớ. Người dùng cảm nhận pha đầu qua độ trễ token đầu tiên, pha sau qua tốc độ chữ chạy. Bài Transformer và cơ chế attention giải thích cơ chế attention và KV cache đứng sau hai pha này. Lệnh llama-bench -p 512 -n 128 -r 5 -ngl 99 (512 token prompt, sinh 128 token, lặp 5 lần, mọi lớp lên GPU Metal) cho:

| model                          |       size |     params | backend    | threads |            test |                  t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | --------------: | -------------------: |
| qwen2 1B F16                   | 942.43 MiB |   494.03 M | BLAS,MTL   |       4 |           pp512 |     2092.02 ± 473.27 |
| qwen2 1B F16                   | 942.43 MiB |   494.03 M | BLAS,MTL   |       4 |           tg128 |         22.62 ± 0.95 |
| qwen2 1B Q8_0                  | 500.79 MiB |   494.03 M | BLAS,MTL   |       4 |           pp512 |      1330.04 ± 67.00 |
| qwen2 1B Q8_0                  | 500.79 MiB |   494.03 M | BLAS,MTL   |       4 |           tg128 |        39.79 ± 11.86 |
| qwen2 1B Q4_K - Medium         | 373.71 MiB |   494.03 M | BLAS,MTL   |       4 |           pp512 |     1904.76 ± 452.91 |
| qwen2 1B Q4_K - Medium         | 373.71 MiB |   494.03 M | BLAS,MTL   |       4 |           tg128 |        49.91 ± 11.56 |
| qwen2 1B Q2_K - Medium         | 317.25 MiB |   494.03 M | BLAS,MTL   |       4 |           pp512 |     1340.81 ± 183.00 |
| qwen2 1B Q2_K - Medium         | 317.25 MiB |   494.03 M | BLAS,MTL   |       4 |           tg128 |         44.19 ± 4.24 |

Về độ tin cậy của phép đo LLM local: máy đo đang chạy tiến trình khác (load average khoảng 8) nên độ lệch chuẩn lớn, có dòng tới ±11,9 t/s (Q8_0 tg128, lần hai) và ±19,5 t/s (Q2_K tg128, lần đầu). Lần chạy đầu (3 lần lặp, lưu trong llama_bench_output_run1.md) cho tg128 của Q4_K_M là 56,8 t/s và Q2_K là 57,3 t/s; lần hai là 49,9 và 44,2. Kết luận chắc chắn chỉ là F16 chậm nhất ở decode (khoảng 22–26 t/s) và các bản lượng tử hoá nhanh hơn khoảng 1,7–2,5 lần; phân biệt Q4_K_M với Q2_K nằm trong vùng nhiễu.

Ollama cho sẵn số liệu trong phản hồi của /api/generate. Theo tài liệu API, eval_count là số token sinh ra, eval_duration là thời gian sinh tính bằng nano giây, và tốc độ là eval_count / eval_duration × 109; load_duration là thời gian nạp mô hình. Script bench_ollama.py ép dỡ mô hình (keep_alive=0) để đo lần nguội, rồi chạy 4 lần nóng với temperature=0; phần gọi API là:

def generate(model, **opts):
    r = requests.post(f"{BASE}/api/generate", json={
        "model": model, "prompt": PROMPT, "stream": False,
        "options": {"temperature": 0, "seed": 7, "num_predict": 128, **opts}}, timeout=300)
    r.raise_for_status()
    return r.json()
model                             load_s prompt_tok gen_tok   tok/s    sd
qwen2.5:0.5b-instruct-fp16          8.76         56     128    75.8   5.3
qwen2.5:0.5b-instruct-q8_0          2.09         56     128   126.4   4.7
qwen2.5:0.5b-instruct-q4_K_M        3.03         56     128   111.7  27.7
qwen2.5:0.5b-instruct-q2_K          1.34         56     128    56.3   5.3
qwen2.5:1.5b                        4.16         56     128    67.9  10.8

Độ lệch chuẩn của Q4_K_M lên tới 27,7 t/s trên trung bình 111,7, và Q8_0 nhanh hơn Q4_K_M trái với llama-bench: nhiễu hệ thống lấn át chênh lệch. Điều rút ra cho LLM local: đừng suy ra tốc độ từ số bit; lượng tử hoá luôn giảm bộ nhớ, còn tốc độ phụ thuộc kernel, kích thước mô hình và phần cứng. qwen2.5:1.5b (Q4_K_M) sinh khoảng 68 t/s, thừa nhanh cho chatbot nội bộ.

Độ trễ nguội của LLM local (load_duration) dao động 1,3–8,8 giây. Theo FAQ, Ollama giữ mô hình 5 phút theo mặc định (OLLAMA_KEEP_ALIVE hoặc keep_alive của yêu cầu); với dịch vụ cho người dùng, hãy đặt dài hoặc âm để mô hình luôn nóng.

Gọi LLM local từ Python bằng SDK OpenAI

Ollama và llama-server đều cho LLM local một endpoint tương thích OpenAI. Theo bài giới thiệu của Ollama, chỉ cần đổi base_url sang http://localhost:11434/v1 và đặt api_key là giá trị bất kỳ ("required, but unused"). Nhờ vậy cùng một đoạn code chạy được với cả API thương mại lẫn LLM local, rất tiện để thử prompt không tốn tiền như ở Prompt engineering có hệ thống.

client = OpenAI(base_url=os.getenv("LLM_BASE_URL", "http://localhost:11434/v1"),
                api_key=os.getenv("LLM_API_KEY", "ollama"))   # Ollama bỏ qua giá trị này
MODEL = os.getenv("LLM_MODEL", "qwen2.5:1.5b")

Chạy python openai_client.py với Ollama:

model : qwen2.5:1.5b
usage : 45 prompt + 80 completion tokens
thời gian: 1.02 s
trả lời: Nếu đơn hàng của bạn bị giao chậm 3 ngày, bạn nên:

1. Kiểm tra lịch giao hàng: Đảm bảo đơn hàng đã được giao đúng ngày và thời gian đã được xác nhận.

2. Liên hệ nhà cung cấp: Nếu đơn hàng đã được giao, hãy liên hệ với nhà cung cấp để hỏi về tình hình giao
--- streaming ---
time-to-first-token: 0.068 s, 60 chunk

Và cùng script đó trỏ vào llama-server (llama-server -m q05-q4_K_M.gguf --port 8089 -c 4096, sau đó LLM_BASE_URL=http://localhost:8089/v1) cho cùng định dạng kết quả (lưu trong llama_server_client_output.txt); cả hai câu trả lời đều bị cắt ở max_tokens=80, nhưng câu của mô hình 0,5 tỉ kém mạch lạc hơn bản 1,5 tỉ (liệt kê chung chung, ý lặp lại).

Trường usage báo 45 token prompt và 80 token sinh ở cả hai máy chủ vì dùng chung tokenizer Qwen; time-to-first-token chỉ 43–69 mili giây khi mô hình đã nóng. Lưu ý llama-server chạy LLM local với mặc định 4 slot xử lý song song (log báo n_slots = 4, n_ctx_slot = 4096), nên khi tăng -c hoặc số slot hãy tính lại KV cache như ở mục bộ nhớ.

Đóng gói cấu hình LLM local bằng Modelfile

Thay vì lặp lại tham số ở mọi lời gọi LLM local, Ollama cho phép tạo mô hình mới từ Modelfile: nền là mô hình có sẵn, cộng system prompt và tham số sinh.

FROM qwen2.5:1.5b
PARAMETER temperature 0.2
PARAMETER num_ctx 4096
PARAMETER num_predict 120
SYSTEM """Bạn là trợ lý CSKH của một cửa hàng online. Trả lời tối đa 3 câu, tiếng Việt, lịch sự."""

Sau ollama create cskh-demo -f Modelfile, gọi lại qua /api/chat:

$ ollama create cskh-demo -f Modelfile
success
$ ollama show cskh-demo --parameters
num_ctx                        4096
num_predict                    120
temperature                    0.2
$ curl /api/chat (model cskh-demo, user: "Tôi có đổi size được không?")
Rất tiếc, tôi không thể giúp bạn với điều đó. Việc đổi size là một dịch vụ riêng biệt và thường không được cung cấp trên các trang web bán hàng trực tuyến. Bạn nên liên hệ với bộ phận chăm sóc khách hàng của cửa hàng để được hỗ trợ trực tiếp.
eval_count 62

Hãy đọc kỹ câu trả lời: mô hình tự khẳng định đổi size "thường không được cung cấp trên các trang web bán hàng", trong khi system prompt không hề nói gì về chính sách đổi trả. Đó là hallucination điển hình của mô hình nhỏ; ở các lần gọi khác (nhiệt độ 0,2 vẫn còn ngẫu nhiên) câu chữ đổi khác, có lần mô hình còn khẳng định cửa hàng không hỗ trợ đổi trả. Với nghiệp vụ thật, đừng để một LLM local nhỏ trả lời chính sách từ trí nhớ; hãy đưa tài liệu chính sách vào ngữ cảnh (kỹ thuật RAG, bắt đầu từ embedding ở bài sau).

Lỗi thường gặp khi chạy LLM local

  1. Đặt num_ctx quá lớn làm KV cache ăn hết RAM; trên máy này 32.768 token đã chiếm 2.024 MiB cho mô hình chỉ 1,5 tỉ tham số.
  2. Chọn mô hình theo số tham số mà quên KV cache và chạy song song: 14B Q4_K_M đã chiếm khoảng 10,3 GiB theo bảng trên.
  3. Tin vào một lần đo tốc độ: nhiễu nền có thể đảo thứ tự các mức lượng tử hoá. Đo nhiều lần, ghi độ lệch chuẩn.
  4. Dùng Q2/Q3 để "nhét vừa" mô hình lớn mà không đo chất lượng: PPL của Q2_K tăng 38% trong thí nghiệm của chúng tôi.
  5. Mở LLM local ra mạng không xác thực: Ollama mặc định chỉ nghe 127.0.0.1:11434; đặt OLLAMA_HOST=0.0.0.0 là công khai API không mật khẩu. Hãy đặt sau reverse proxy có xác thực.
  6. Bỏ qua giấy phép mô hình: Qwen2.5 0,5B và 1,5B dùng Apache 2.0 nhưng bản 3B và 72B thì không; xem trang công bố Qwen2.5 trước khi dùng thương mại.

Checklist triển khai LLM local

  • Xác định lý do chạy LLM local (dữ liệu, chi phí, ngoại tuyến) và ngưỡng chất lượng chấp nhận được bằng bộ câu hỏi thật.
  • Ước lượng RAM cho LLM local bằng trọng số + KV cache + dự phòng; kiểm tra lại bằng /api/ps sau khi nạp.
  • Bắt đầu với Q4_K_M cho mô hình từ vài tỉ tham số; thử Q8_0 nếu mô hình nhỏ hoặc cần chất lượng cao.
  • Đo perplexity hoặc bộ test riêng trên dữ liệu tiếng Việt của bạn.
  • Đo tốc độ LLM local nhiều lần, tách riêng prefill, decode và thời gian nạp.
  • Dùng endpoint tương thích OpenAI để code đổi được giữa LLM local và API thương mại.
  • Với LLM local, không mở cổng suy luận ra Internet nếu chưa có xác thực; ghi rõ phiên bản mô hình và kiểu lượng tử hoá trong tài liệu dự án.

Mã nguồn đầy đủ trong code/s7-34 (run.sh, script .py, file output). Mọi số liệu là output thật trên Apple M4, 16 GB, khi máy còn tác vụ nền nên số tốc độ có độ lệch lớn.

Bài tiếp theo

Embedding và semantic search: sentence-transformers và FAISS. LLM local nhỏ hay bịa khi thiếu dữ liệu, nên bước đầu của RAG là tìm đúng đoạn tài liệu; bài sau biến văn bản thành vector và tìm theo nghĩa bằng FAISS.