Published on

Day 29: Local LLM - Ollama, llama.cpp, vLLM

Authors

Mục Tiêu

Sau bài này, bạn cần làm được các việc sau:

  • Hiểu vì sao team chọn local/self-host LLM thay vì luôn gọi cloud LLM API.
  • Phân biệt đúng vai trò của Ollama, llama.cpp, vLLM, TGI và LM Studio.
  • Hiểu các biến ảnh hưởng serving: model size, tokenizer, context window, quantization, KV cache, batching, concurrency, RAM/VRAM.
  • Gọi được local model qua HTTP API và OpenAI-compatible client.
  • Thiết kế được LLM gateway để business code không phụ thuộc trực tiếp vào một runtime.
  • Biết benchmark latency, throughput, memory và chất lượng ở mức đủ để ra production decision.
  • Trả lời rõ: dùng được trong production không, nếu có thì cần điều kiện gì.

TL;DR

Local LLM không chỉ là "tải model về máy". Nó là một serving stack gồm model weights, tokenizer, runtime inference, quantization kernels, API server, scheduler, hardware, observability, security policy và deployment process.

Ollama phù hợp nhất cho local development, prototype và internal tool vì setup nhanh. llama.cpp mạnh ở GGUF, CPU, Apple Silicon, edge và môi trường hạn chế memory. vLLM phù hợp GPU serving có concurrency cao nhờ scheduler, batching và OpenAI-compatible server. TGI phù hợp team đã dùng Hugging Face ecosystem và muốn containerized serving. LM Studio rất tốt cho học, demo, manual evaluation nhưng không nên là production backend mặc định.

Production answer ngắn: local LLM dùng được trong production, nhưng chỉ khi bạn kiểm soát được model license, eval quality, hardware capacity, API gateway, timeout/retry, observability, security, fallback, rollout và vận hành GPU/CPU như một service thật.

1. Bài Này Nằm Ở Đâu Trong Phase 4

Phase 4 đi từ quyết định fine-tuning tới local deployment:

Day 25: Khi nào dùng RAG, fine-tuning, distillation
Day 26: Chuẩn bị dataset instruction tuning
Day 27: LoRA/QLoRA hands-on
Day 28: Evaluation trước/sau fine-tune
Day 29: Local LLM runtime và model serving API
Day 30: Quantization và deploy local model API

Day 29 không tập trung vào train model. Trọng tâm là inference/runtime: sau khi có base model hoặc fine-tuned model, bạn phục vụ nó cho app như thế nào, đo hiệu năng ra sao, và chọn runtime nào theo context.

2. Vì Sao Dùng Local LLM

Privacy

Local LLM giúp dữ liệu không phải rời khỏi network bạn kiểm soát. Điều này quan trọng khi prompt chứa source code private, tài liệu nội bộ, dữ liệu khách hàng, thông tin pháp lý, dữ liệu y tế, dữ liệu tài chính hoặc policy nội bộ.

Nhưng local không tự động an toàn. Nếu bạn log prompt thô chứa PII, mount volume sai quyền, expose endpoint không auth, hoặc lưu KV cache/debug dump không kiểm soát, dữ liệu vẫn có thể leak. Privacy là property của toàn bộ system, không phải chỉ của model.

Cost

Cloud API tính tiền theo token. Local LLM tốn GPU/CPU, RAM/VRAM, điện, storage, ops, monitoring và thời gian engineer. Local có thể rẻ hơn khi workload ổn định, traffic lớn, model vừa đủ nhỏ, hardware được tận dụng tốt và team có năng lực vận hành.

Nếu traffic thấp, yêu cầu chất lượng cao, hoặc team chưa có GPU ops, cloud API thường rẻ hơn về total cost of ownership.

Latency

Local LLM có thể giảm network latency nếu service nằm gần app hoặc user. Với internal app trong cùng datacenter, request không phải đi qua internet. Tuy nhiên inference latency vẫn phụ thuộc model size, context length, quantization, hardware, batching và số request đồng thời.

Local nhỏ hơn không luôn nhanh hơn theo cách hữu ích. Model quá nhỏ có thể trả lời sai, khiến phải retry, fallback hoặc human review, làm latency end-to-end tăng.

Offline

Local LLM hữu ích cho air-gapped environment, factory, lab, field device, laptop dev khi mất mạng, hoặc nơi không được gọi external API. Use case offline thường cần artifact management chặt: model file, tokenizer, config, container image, eval set và rollback bundle phải đi cùng nhau.

Control và Compliance

Local giúp bạn pin model version, pin runtime version, chọn quantization, giới hạn log retention, enforce data residency, audit request path và thử nghiệm model nội bộ. Đổi lại, bạn sở hữu trách nhiệm security patch, model upgrade, incident response và capacity planning.

3. Mental Model Của Local Serving

Client/App
  -> LLM Gateway hoặc Provider Adapter
  -> Runtime API Server
      -> Request validation
      -> Tokenizer
      -> Model weights
      -> Quantization kernels
      -> KV cache
      -> Scheduler / batching
  -> Stream hoặc JSON response

Map về tư duy Senior Software Engineer:

AI serving conceptSE analogyCần quan tâm
Model weightsBuild artifactVersion, checksum, license, storage
TokenizerParser/input contractToken count, special tokens, chat template
RuntimeApplication serverPerformance, compatibility, observability
QuantizationCompression/optimized binaryQuality loss, memory, kernel support
KV cacheRuntime memory stateVRAM tăng theo context và concurrency
Context windowRequest payload budgetPrompt dài làm chậm và tốn memory
Chat templateSerialization contractSai template làm model trả lời kém
Model serving APIInternal service contractTimeout, retry, streaming, auth, schema

4. Các Biến Quyết Định Hiệu Năng

Model size

Model càng nhiều parameters thì thường chất lượng tốt hơn nhưng cần nhiều RAM/VRAM hơn và decode chậm hơn. Model 7B quantized có thể chạy trên laptop mạnh; model 70B cần GPU server nghiêm túc nếu muốn latency tốt.

Dtype và quantization

FP16/BF16 thường dùng cho GPU serving chất lượng cao. INT8/INT4, GGUF, AWQ, GPTQ giúp giảm memory. Trade-off là chất lượng có thể giảm, kernel có thể không tối ưu cho mọi runtime, và một số quantization phù hợp runtime này nhưng không phù hợp runtime khác.

Context window và KV cache

Prompt dài làm prefill chậm. Mỗi token trong context sinh thêm KV cache. Khi batch/concurrency tăng, KV cache có thể trở thành nguyên nhân hết VRAM trước cả model weights.

Rule thực tế: luôn benchmark với prompt dài đúng production, không chỉ prompt "hello".

Throughput và latency

  • Latency: thời gian một request hoàn thành.
  • Time-to-first-token: thời gian tới token đầu tiên khi streaming.
  • Throughput: token/second tổng hoặc request/second.
  • p95/p99: tail latency, quan trọng hơn average với user-facing app.

Batching tăng throughput nhưng có thể tăng latency cho request đơn lẻ. Runtime phục vụ chatbot realtime và runtime phục vụ batch summarization có tuning khác nhau.

5. Runtime Options

Ollama

Ollama là lựa chọn nhanh nhất để bắt đầu local LLM. Nó quản lý pull model, run model, local API và có OpenAI-compatible endpoint.

Ví dụ:

ollama pull llama3.2
ollama run llama3.2

Native API:

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [
    {"role": "user", "content": "Giải thích local LLM trong 3 bullet"}
  ],
  "stream": false
}'

OpenAI-compatible endpoint thường dùng dạng:

http://localhost:11434/v1

Dùng Ollama khi:

  • Cần setup nhanh trên laptop.
  • Cần test prompt, RAG, tool calling local.
  • Cần internal demo hoặc dev offline.
  • Muốn app dùng OpenAI-compatible client để giảm lock-in.

Không nên mặc định dùng Ollama khi:

  • Cần throughput GPU cao với nhiều user đồng thời.
  • Cần autoscaling, multi-replica, metrics production đầy đủ.
  • Cần tuning sâu scheduler/batching.

llama.cpp

llama.cpp là runtime C/C++ inference, nổi bật với GGUF, CPU inference, Apple Silicon, GPU offload và edge deployment.

Ví dụ server:

./build/bin/llama-server \
  -m ./models/model-q4_k_m.gguf \
  --host 0.0.0.0 \
  --port 8080 \
  -c 4096 \
  -ngl 99

Dùng llama.cpp khi:

  • Cần chạy CPU hoặc edge.
  • Cần artifact GGUF nhỏ, dễ ship.
  • Cần kiểm soát memory tốt.
  • Cần runtime ít dependency.
  • Cần Apple Silicon hoặc máy dev không có NVIDIA GPU.

Trade-off:

  • Throughput scale lớn cần tự thiết kế thêm gateway, queue, metrics, replica và load balancing.
  • Chọn GGUF quantization sai có thể làm chất lượng giảm rõ.
  • Với model lớn, CPU latency có thể không đáp ứng realtime chat.

vLLM

vLLM là inference engine/server tối ưu cho GPU throughput, OpenAI-compatible serving, scheduler và batching.

Ví dụ:

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --host 0.0.0.0 \
  --port 8000

Dùng vLLM khi:

  • Có GPU server và traffic đồng thời.
  • Cần throughput cao.
  • Cần OpenAI-compatible endpoint cho app.
  • Cần serving controls, metrics và production deployment rõ hơn dev tool.
  • Cần phục vụ chat/completions nhiều request.

Trade-off:

  • Cần hiểu GPU memory, tensor parallel, max model len, batch sizing và rollout.
  • Setup phức tạp hơn Ollama.
  • Không phải model/quantization nào cũng chạy tốt như nhau.

TGI

TGI, Text Generation Inference, phù hợp khi team đã dùng Hugging Face ecosystem, model registry, container deployment và muốn serving stack có production orientation.

Dùng TGI khi:

  • Model nằm trong Hugging Face workflow.
  • Team quen Docker/Kubernetes.
  • Cần containerized serving và monitoring.
  • Muốn tiêu chuẩn hóa deployment nhiều model HF.

Trade-off:

  • Ops phức tạp hơn Ollama.
  • Cần kiểm tra compatibility từng model, dtype, quantization và GPU.

LM Studio

LM Studio là desktop UX tốt để tải model, chat thử, so sánh model và expose local API trong lúc học hoặc demo.

Dùng LM Studio khi:

  • Người học muốn nhìn thấy model chạy ngay.
  • Product/PM/engineer cần manual evaluation nhanh.
  • Cần thử nhiều GGUF model trên desktop.

Không nên coi LM Studio là production backend mặc định vì deployment, security, observability, scaling và automation không phải trọng tâm chính của nó.

6. Runtime Matrix

RuntimeMạnh ở đâuHạn chếHardware thường gặpAPIProduction fit
OllamaSetup nhanh, dev UX tốt, pull/run đơn giảnThroughput cao và ops production cần bổ sungLaptop, workstation, single serverNative + OpenAI-compatibleInternal tool nhỏ, dev, prototype; production nhỏ nếu bọc gateway/monitoring
llama.cppGGUF, CPU, Apple Silicon, edge, memory thấpScale lớn cần tự xây nhiều phầnCPU, Apple Silicon, GPU offloadServer API/OpenAI-compatible tùy buildEdge, offline, embedded, workload nhỏ/vừa
vLLMGPU throughput, batching, OpenAI-compatible servingCần GPU ops và tuningNVIDIA GPU serverOpenAI-compatibleProduction GPU serving mạnh
TGIHugging Face ecosystem, container servingSetup/ops nặng hơn dev toolsNVIDIA GPU serverHTTP/generation APIProduction nếu team dùng HF stack
LM StudioDesktop UX, manual eval, học nhanhKhông phải backend production chuẩnLaptop/desktopLocal APIDev/manual evaluation
Cloud LLM APIQuality/SLA/time-to-marketCost/token, data boundary, provider lock-inProvider-managedProvider APIProduction nhanh nếu compliance cho phép

7. Best Solution Theo Context

ContextLựa chọn nên bắt đầuVì sao
Học local LLM trong 1 giờOllama hoặc LM StudioSetup nhanh, ít ops
Dev RAG app offlineOllamaDễ chạy OpenAI-compatible endpoint
Laptop Apple Siliconllama.cpp hoặc OllamaGGUF/Metal/local UX tốt
Edge device không có GPU mạnhllama.cpp + GGUF quantizedMemory thấp, artifact dễ đóng gói
Internal assistant traffic thấpOllama sau LLM gatewayĐơn giản, đủ dùng nếu benchmark đạt
Chat API nhiều user đồng thờivLLMScheduler/batching tốt cho GPU throughput
Team đã chuẩn HF/K8sTGI hoặc vLLMDễ đưa vào platform hiện có
Batch summarization traffic ổn địnhvLLM/TGI với batchingTối ưu cost/token và throughput
Data không được rời khỏi VPCSelf-host vLLM/TGI/llama.cppKiểm soát network và data residency
Cần model chất lượng cao nhất ngayCloud LLM API hoặc hybridLocal model nhỏ có thể chưa đạt quality

8. Model Serving API Nên Thiết Kế Thế Nào

Không hardcode Ollama, vLLM hoặc llama.cpp vào business logic. App nên gọi một interface nội bộ:

Product App
  -> LLM Gateway
      -> policy: model, runtime, timeout, max_tokens, fallback
      -> ProviderAdapter: OpenAI cloud
      -> ProviderAdapter: Ollama
      -> ProviderAdapter: vLLM
      -> ProviderAdapter: llama.cpp
  -> Response + trace

Contract tối thiểu:

class ChatRequest(BaseModel):
    messages: list[dict[str, str]]
    model_policy: str = "default"
    temperature: float = 0.2
    max_tokens: int = 512
    timeout_s: float = 30.0


class ChatResponse(BaseModel):
    text: str
    model: str
    runtime: str
    latency_ms: float
    input_tokens: int | None = None
    output_tokens: int | None = None
    trace_id: str

Gateway chịu trách nhiệm:

  • Validate input và giới hạn max prompt size.
  • Chọn runtime/model theo policy.
  • Timeout và retry có kiểm soát.
  • Streaming nếu UX cần.
  • Logging không leak PII.
  • Rate limit, auth, tenant isolation.
  • Fallback sang model khác hoặc cloud provider.
  • Chuẩn hóa response schema.
  • Ghi metric latency, token, error, memory signal.

9. Performance, Throughput, Latency Và VRAM Concern

Những metric cần đo

MetricÝ nghĩa
Time-to-first-tokenUser cảm thấy model bắt đầu trả lời nhanh hay chậm
Total latencyRequest hoàn thành mất bao lâu
Output tokens/secondDecode speed
Requests/secondService capacity
p50/p95/p99 latencyTail latency
RAM/VRAM usedCó còn headroom không
Error rateTimeout, OOM, 5xx, invalid response
Quality scoreGolden eval, human rating, task success

Các nguyên nhân latency cao

  • Model quá lớn so với hardware.
  • Prompt quá dài làm prefill chậm.
  • max_tokens quá rộng khiến decode dài.
  • Concurrency vượt capacity, queue tăng.
  • KV cache đầy VRAM.
  • Quantization format không tối ưu cho runtime.
  • Cold start hoặc model unload/reload.
  • Không streaming nên user phải chờ toàn bộ output.

VRAM estimation ở mức trực giác

VRAM không chỉ chứa model weights. Nó còn chứa KV cache, activation workspace, runtime overhead và fragmentation. Với production, đừng dùng 100% VRAM lý thuyết. Cần headroom để tránh OOM khi prompt dài hoặc concurrency tăng.

Checklist capacity:

  • Model weights fit vào VRAM với dtype/quantization đã chọn.
  • Context length production không vượt ngân sách KV cache.
  • Batch/concurrency benchmark đúng traffic thật.
  • Có guardrail max_tokens, max_context, request timeout.
  • Có canary và rollback khi đổi model/runtime.

10. Production Answer

Dùng được trong production không?

Có, local LLM dùng được trong production. Nhiều use case production hợp lý gồm internal assistant, private RAG, code assistant nội bộ, batch summarization, document extraction, offline assistant và domain workflow có data boundary nghiêm ngặt.

Nếu có thì cần điều kiện gì?

Cần tối thiểu các điều kiện sau:

  • Model license cho phép use case hiện tại, đặc biệt commercial/internal distribution.
  • Có golden eval để chứng minh chất lượng đủ tốt so với cloud baseline hoặc human baseline.
  • Có runtime phù hợp context: Ollama cho internal nhỏ/dev, llama.cpp cho edge/CPU/GGUF, vLLM/TGI cho GPU production.
  • Có LLM gateway với auth, timeout, retry, max token, logging, policy routing và fallback.
  • Có observability: latency p50/p95/p99, throughput, token count, error rate, RAM/VRAM, OOM, queue time, quality sample.
  • Có security policy: không log PII thô, tenant isolation, network boundary, secret management, audit log.
  • Có deployment process: pin model revision, runtime version, container image, config, canary, rollback.
  • Có capacity planning: benchmark theo prompt thật, concurrency thật, context thật và output length thật.
  • Có response validation nếu output đi vào downstream automation.

Không nên production nếu:

  • Chỉ mới chạy được demo trên laptop.
  • Chưa đọc license model.
  • Chưa có eval dataset.
  • Chưa đo p95 latency và memory.
  • Endpoint không auth hoặc log prompt nhạy cảm.
  • Không có fallback khi model timeout/OOM.
  • Team không có năng lực vận hành hardware/runtime đã chọn.

11. Checklist Học Xong

  • Giải thích được local LLM khác gì cloud LLM API.
  • Nêu được privacy benefit và privacy risk còn lại.
  • Phân biệt được Ollama, llama.cpp, vLLM, TGI, LM Studio.
  • Biết vì sao OpenAI-compatible API giúp giảm lock-in.
  • Biết KV cache ảnh hưởng memory và latency như thế nào.
  • Biết đo latency, throughput, p95 và RAM/VRAM.
  • Có decision note chọn runtime theo context.
  • Trả lời được điều kiện để dùng local LLM trong production.

12. Câu Hỏi Tự Kiểm Tra

  1. Local LLM giải quyết privacy theo cách nào, và không giải quyết phần nào?
  2. Vì sao traffic thấp thường chưa nên self-host LLM?
  3. Ollama khác vLLM ở mục tiêu thiết kế nào?
  4. GGUF liên quan gì tới llama.cpp?
  5. Vì sao prompt dài làm prefill chậm và KV cache lớn?
  6. Khi nào chọn TGI thay vì vLLM?
  7. Vì sao LM Studio tốt cho learning nhưng không phải production backend mặc định?
  8. OpenAI-compatible API giúp app architecture như thế nào?
  9. Nếu local model trả lời kém hơn cloud model, bạn đo và quyết định ra sao?
  10. Điều kiện tối thiểu để đưa local LLM vào production là gì?

13. Tài Liệu Liên Quan Trong Folder

  • document.md: code và cấu hình triển khai.
  • exercise.md: bài tập 60-120 phút, benchmark và decision note.

Tài liệu

Tài liệu này đưa phần lý thuyết Day 29 thành một skeleton thực tế: config, OpenAI-compatible client abstraction, FastAPI proxy, health check, logging, timeout/retry và benchmark.

Các ví dụ dùng OpenAI Python client với base_url, timeoutmax_retries theo API hiện hành, phù hợp khi runtime local expose OpenAI-compatible endpoint như Ollama hoặc vLLM.

1. Cấu Hình Runtime

Ollama

ollama pull llama3.2
ollama serve

Endpoint tham khảo:

Native: http://localhost:11434/api/chat
OpenAI-compatible: http://localhost:11434/v1

vLLM

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --served-model-name local-chat

Endpoint tham khảo:

OpenAI-compatible: http://localhost:8000/v1

llama.cpp

./build/bin/llama-server \
  -m ./models/model-q4_k_m.gguf \
  --host 0.0.0.0 \
  --port 8080 \
  -c 4096 \
  -ngl 99

Tùy version/build, API có thể khác nhau. Trước khi viết adapter production, luôn kiểm tra endpoint /health, /v1/models, /v1/chat/completions hoặc endpoint native mà runtime cung cấp.

2. Environment Config

Ví dụ .env:

LOCAL_LLM_BASE_URL=http://localhost:11434/v1
LOCAL_LLM_API_KEY=local
LOCAL_LLM_MODEL=llama3.2
LOCAL_LLM_RUNTIME=ollama
LOCAL_LLM_TIMEOUT_S=30
LOCAL_LLM_MAX_RETRIES=2
LOCAL_LLM_MAX_TOKENS=512
LOCAL_LLM_TEMPERATURE=0.2
LOG_LEVEL=INFO

Production nên pin thêm:

MODEL_ID=llama3.2
MODEL_REVISION=exact-revision-or-digest
MODEL_LICENSE=check-model-card
RUNTIME_VERSION=ollama-or-vllm-version
QUANTIZATION=q4_k_m-or-fp16
MAX_CONTEXT_TOKENS=4096

3. OpenAI-compatible Client Abstraction

File ví dụ local_llm_client.py:

from __future__ import annotations

import logging
import os
import time
import uuid
from dataclasses import dataclass
from typing import Any

from openai import OpenAI
from openai import APIConnectionError, APITimeoutError, RateLimitError, APIStatusError

logger = logging.getLogger("local_llm")


@dataclass(frozen=True)
class LocalLLMConfig:
    base_url: str
    api_key: str
    model: str
    runtime: str
    timeout_s: float = 30.0
    max_retries: int = 2
    temperature: float = 0.2
    max_tokens: int = 512

    @classmethod
    def from_env(cls) -> "LocalLLMConfig":
        return cls(
            base_url=os.getenv("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1"),
            api_key=os.getenv("LOCAL_LLM_API_KEY", "local"),
            model=os.getenv("LOCAL_LLM_MODEL", "llama3.2"),
            runtime=os.getenv("LOCAL_LLM_RUNTIME", "ollama"),
            timeout_s=float(os.getenv("LOCAL_LLM_TIMEOUT_S", "30")),
            max_retries=int(os.getenv("LOCAL_LLM_MAX_RETRIES", "2")),
            temperature=float(os.getenv("LOCAL_LLM_TEMPERATURE", "0.2")),
            max_tokens=int(os.getenv("LOCAL_LLM_MAX_TOKENS", "512")),
        )


class LocalLLMClient:
    def __init__(self, config: LocalLLMConfig) -> None:
        self.config = config
        self.client = OpenAI(
            base_url=config.base_url,
            api_key=config.api_key,
            timeout=config.timeout_s,
            max_retries=config.max_retries,
        )

    def chat(self, messages: list[dict[str, str]], trace_id: str | None = None) -> dict[str, Any]:
        trace_id = trace_id or str(uuid.uuid4())
        start = time.perf_counter()

        try:
            response = self.client.chat.completions.create(
                model=self.config.model,
                messages=messages,
                temperature=self.config.temperature,
                max_tokens=self.config.max_tokens,
            )
            latency_ms = (time.perf_counter() - start) * 1000
            text = response.choices[0].message.content or ""
            usage = getattr(response, "usage", None)

            logger.info(
                "llm_request_success",
                extra={
                    "trace_id": trace_id,
                    "runtime": self.config.runtime,
                    "model": self.config.model,
                    "latency_ms": round(latency_ms, 2),
                    "input_tokens": getattr(usage, "prompt_tokens", None),
                    "output_tokens": getattr(usage, "completion_tokens", None),
                },
            )

            return {
                "trace_id": trace_id,
                "runtime": self.config.runtime,
                "model": self.config.model,
                "text": text,
                "latency_ms": latency_ms,
                "input_tokens": getattr(usage, "prompt_tokens", None),
                "output_tokens": getattr(usage, "completion_tokens", None),
            }

        except (APITimeoutError, APIConnectionError, RateLimitError, APIStatusError) as exc:
            latency_ms = (time.perf_counter() - start) * 1000
            logger.warning(
                "llm_request_failed",
                extra={
                    "trace_id": trace_id,
                    "runtime": self.config.runtime,
                    "model": self.config.model,
                    "latency_ms": round(latency_ms, 2),
                    "error_type": type(exc).__name__,
                },
            )
            raise

Điểm production quan trọng:

  • base_url giúp trỏ cùng client tới Ollama, vLLM, llama.cpp hoặc cloud-compatible endpoint.
  • timeout tránh request treo vô hạn.
  • max_retries xử lý lỗi tạm thời, nhưng không nên retry quá nhiều với request sinh text dài.
  • Log có trace_id, runtime, model và latency; không log full prompt nếu có dữ liệu nhạy cảm.

4. FastAPI Proxy/Gateway

File ví dụ app.py:

from __future__ import annotations

import logging
import time
import uuid

from fastapi import FastAPI, HTTPException, Request
from openai import APIConnectionError, APITimeoutError, RateLimitError, APIStatusError
from pydantic import BaseModel, Field

from local_llm_client import LocalLLMClient, LocalLLMConfig

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("llm_gateway")

config = LocalLLMConfig.from_env()
llm = LocalLLMClient(config)
app = FastAPI(title="Local LLM Gateway", version="0.1.0")


class ChatMessage(BaseModel):
    role: str = Field(pattern="^(system|user|assistant)$")
    content: str = Field(min_length=1, max_length=20_000)


class ChatRequest(BaseModel):
    messages: list[ChatMessage] = Field(min_length=1, max_length=30)
    model_policy: str = "default"


class ChatResponse(BaseModel):
    trace_id: str
    runtime: str
    model: str
    text: str
    latency_ms: float
    input_tokens: int | None = None
    output_tokens: int | None = None


@app.get("/health")
def health() -> dict[str, str]:
    return {
        "status": "ok",
        "runtime": config.runtime,
        "model": config.model,
    }


@app.post("/chat", response_model=ChatResponse)
def chat(payload: ChatRequest, request: Request) -> dict[str, object]:
    trace_id = request.headers.get("x-trace-id") or str(uuid.uuid4())
    start = time.perf_counter()

    try:
        messages = [message.model_dump() for message in payload.messages]
        result = llm.chat(messages=messages, trace_id=trace_id)
        return result
    except APITimeoutError as exc:
        raise HTTPException(status_code=504, detail="LLM timeout") from exc
    except (APIConnectionError, RateLimitError) as exc:
        raise HTTPException(status_code=503, detail="LLM unavailable") from exc
    except APIStatusError as exc:
        raise HTTPException(status_code=502, detail=f"LLM upstream error: {exc.status_code}") from exc
    finally:
        logger.info(
            "gateway_request_finished",
            extra={
                "trace_id": trace_id,
                "path": str(request.url.path),
                "latency_ms": round((time.perf_counter() - start) * 1000, 2),
            },
        )

Chạy local:

pip install -U fastapi uvicorn openai
uvicorn app:app --host 0.0.0.0 --port 9000

Gọi thử:

curl http://localhost:9000/chat \
  -H 'content-type: application/json' \
  -H 'x-trace-id: demo-001' \
  -d '{
    "messages": [
      {"role": "user", "content": "Tóm tắt local LLM trong 3 bullet."}
    ]
  }'

Production cần thêm:

  • AuthN/AuthZ.
  • Rate limit theo user/tenant.
  • Request body size limit.
  • Prompt redaction trước logging.
  • Streaming endpoint nếu UX cần.
  • Fallback policy.
  • Circuit breaker nếu runtime lỗi liên tục.
  • Metrics endpoint Prometheus hoặc OpenTelemetry.

5. Health Check Thật Hơn

/health chỉ kiểm tra process gateway sống. Với LLM serving, nên có thêm /ready hoặc scheduled probe:

@app.get("/ready")
def ready() -> dict[str, object]:
    started = time.perf_counter()
    try:
        result = llm.chat(
            messages=[{"role": "user", "content": "Reply with exactly: ok"}],
            trace_id="readiness-probe",
        )
        return {
            "status": "ready",
            "runtime": config.runtime,
            "model": config.model,
            "latency_ms": round((time.perf_counter() - started) * 1000, 2),
            "sample_ok": "ok" in result["text"].lower(),
        }
    except Exception:
        logger.exception("llm_readiness_failed")
        raise HTTPException(status_code=503, detail="LLM not ready")

Không nên gọi generation nặng trong health check quá thường xuyên. Với Kubernetes, tách:

  • Liveness: process còn sống.
  • Readiness: model đã load và endpoint nhận request.
  • Synthetic probe: chạy định kỳ ngoài request path để kiểm tra chất lượng/latency.

6. Benchmark Script

File ví dụ benchmark_local_llm.py:

from __future__ import annotations

import concurrent.futures
import os
import statistics
import time
from dataclasses import dataclass

from openai import OpenAI


@dataclass(frozen=True)
class Sample:
    name: str
    prompt: str


samples = [
    Sample("short", "Tóm tắt local LLM trong 3 bullet."),
    Sample("json", "Trả lời JSON với keys: runtime, strengths, risks."),
    Sample("long_context", "Giải thích KV cache cho senior software engineer. " * 80),
]

client = OpenAI(
    base_url=os.getenv("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1"),
    api_key=os.getenv("LOCAL_LLM_API_KEY", "local"),
    timeout=float(os.getenv("LOCAL_LLM_TIMEOUT_S", "60")),
    max_retries=int(os.getenv("LOCAL_LLM_MAX_RETRIES", "1")),
)
model = os.getenv("LOCAL_LLM_MODEL", "llama3.2")


def run_once(sample: Sample) -> dict[str, object]:
    started = time.perf_counter()
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": sample.prompt}],
        temperature=0.2,
        max_tokens=256,
    )
    latency_ms = (time.perf_counter() - started) * 1000
    text = response.choices[0].message.content or ""
    usage = getattr(response, "usage", None)
    return {
        "sample": sample.name,
        "latency_ms": latency_ms,
        "chars": len(text),
        "input_tokens": getattr(usage, "prompt_tokens", None),
        "output_tokens": getattr(usage, "completion_tokens", None),
    }


def percentile(values: list[float], pct: float) -> float:
    if not values:
        return 0.0
    values = sorted(values)
    index = min(len(values) - 1, int(round((pct / 100) * (len(values) - 1))))
    return values[index]


def main() -> None:
    iterations = int(os.getenv("BENCH_ITERATIONS", "10"))
    concurrency = int(os.getenv("BENCH_CONCURRENCY", "1"))
    jobs = [sample for sample in samples for _ in range(iterations)]

    started = time.perf_counter()
    with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as executor:
        results = list(executor.map(run_once, jobs))
    total_s = time.perf_counter() - started

    latencies = [float(item["latency_ms"]) for item in results]
    print(
        {
            "model": model,
            "requests": len(results),
            "concurrency": concurrency,
            "total_s": round(total_s, 2),
            "rps": round(len(results) / total_s, 2),
            "latency_avg_ms": round(statistics.mean(latencies), 2),
            "latency_p50_ms": round(percentile(latencies, 50), 2),
            "latency_p95_ms": round(percentile(latencies, 95), 2),
            "latency_p99_ms": round(percentile(latencies, 99), 2),
        }
    )


if __name__ == "__main__":
    main()

Chạy:

BENCH_ITERATIONS=5 BENCH_CONCURRENCY=1 python benchmark_local_llm.py
BENCH_ITERATIONS=10 BENCH_CONCURRENCY=4 python benchmark_local_llm.py

Khi benchmark production candidate, ghi thêm bằng tool hệ thống:

nvidia-smi
docker stats
top

7. Decision Record Template

# Local LLM Decision Record

## Use case
- Task:
- User:
- Data sensitivity:
- Expected traffic:
- Latency SLO:

## Runtime tested
- Ollama:
- llama.cpp:
- vLLM:
- TGI:
- Cloud baseline:

## Model
- model_id:
- revision/digest:
- license:
- quantization:
- context window:
- runtime version:

## Benchmark
- prompt short p50/p95:
- prompt long p50/p95:
- concurrency tested:
- RAM/VRAM observed:
- error rate:
- quality score:

## Decision
- Dev runtime:
- Production candidate:
- Fallback:
- Rollout plan:
- Rollback plan:

## Risks
- Quality:
- Latency:
- Security/privacy:
- License:
- Ops:

8. Common Pitfalls

  • Chọn model theo leaderboard nhưng không chạy golden eval của use case.
  • Benchmark prompt ngắn rồi production lại dùng RAG context dài.
  • Quên kiểm tra license model.
  • Log full prompt chứa PII.
  • Không giới hạn max_tokens, dẫn tới latency và cost compute khó kiểm soát.
  • Retry request generation dài quá nhiều lần, làm tải tăng khi runtime đang lỗi.
  • Không tách gateway khỏi runtime, khiến đổi Ollama sang vLLM phải sửa business code.
  • Không có fallback khi GPU OOM hoặc runtime restart.
  • Nhầm average latency với user experience; p95/p99 mới phản ánh sự khó chịu của user.

Bài tập

Thời lượng gợi ý: 60-120 phút.

Mục tiêu: chạy ít nhất một local model, gọi bằng OpenAI-compatible client, đo latency cơ bản, ghi decision note và trả lời có dùng production được không.

Phần 1: Setup Runtime

Chọn một trong hai hướng.

Option A: Ollama

ollama pull llama3.2
ollama run llama3.2

Kiểm tra API:

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [
    {"role": "user", "content": "Trả lời một câu: local LLM là gì?"}
  ],
  "stream": false
}'

Option B: vLLM

Chỉ chọn nếu có GPU và môi trường phù hợp.

vllm serve Qwen/Qwen2.5-7B-Instruct \
  --host 0.0.0.0 \
  --port 8000 \
  --served-model-name local-chat

Kiểm tra OpenAI-compatible endpoint:

curl http://localhost:8000/v1/models

Phần 2: Gọi Bằng OpenAI-compatible Client

Cài dependency:

pip install -U openai

Tạo file local_llm_probe.py trong workspace tạm hoặc thư mục thực hành của bạn:

from __future__ import annotations

import os
import time

from openai import OpenAI

base_url = os.getenv("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1")
api_key = os.getenv("LOCAL_LLM_API_KEY", "local")
model = os.getenv("LOCAL_LLM_MODEL", "llama3.2")

client = OpenAI(
    base_url=base_url,
    api_key=api_key,
    timeout=30.0,
    max_retries=2,
)

prompts = [
    "Tóm tắt local LLM trong 3 bullet.",
    "Trả lời JSON với keys: runtime, strengths, risks.",
    "Giải thích KV cache cho senior software engineer.",
]

for prompt in prompts:
    started = time.perf_counter()
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=0.2,
        max_tokens=300,
    )
    latency_ms = (time.perf_counter() - started) * 1000
    text = response.choices[0].message.content or ""
    print({"model": model, "latency_ms": round(latency_ms, 2), "chars": len(text)})
    print(text[:500])

Chạy:

LOCAL_LLM_BASE_URL=http://localhost:11434/v1 \
LOCAL_LLM_MODEL=llama3.2 \
python local_llm_probe.py

Với vLLM:

LOCAL_LLM_BASE_URL=http://localhost:8000/v1 \
LOCAL_LLM_MODEL=local-chat \
python local_llm_probe.py

Phần 3: Benchmark Nhỏ

Chạy mỗi prompt ít nhất 5 lần và ghi lại:

Promptp50 latencyp95 latencyOutput có đúng khôngGhi chú
Short summary
JSON output
KV cache explanation
Long context

Thêm một prompt dài:

Bạn là AI engineer. Hãy đọc context sau và trả lời ngắn gọn...

Sau đó lặp lại cùng nội dung 30-50 lần để mô phỏng RAG context dài. So sánh latency với prompt ngắn.

Phần 4: So Sánh Runtime Theo Context

Điền bảng sau:

ContextRuntime chọnVì saoRủi ro
Dev laptop offline
Internal assistant 20 user
Batch summarize 100k docs/ngày
Edge device không internet
Chatbot public traffic cao

Gợi ý:

  • Dev laptop thường bắt đầu bằng Ollama hoặc LM Studio.
  • Edge thường cân nhắc llama.cpp + GGUF.
  • Throughput GPU cao thường cân nhắc vLLM hoặc TGI.
  • Public traffic cần auth, rate limit, monitoring, fallback và security review.

Phần 5: Production Readiness Checklist

  • Đã ghi model id, revision/digest và quantization.
  • Đã đọc license/model card.
  • Đã đo prompt ngắn và prompt dài.
  • Đã đo p50/p95, không chỉ một lần chạy.
  • Đã ghi RAM/VRAM observed.
  • Đã thử concurrency tối thiểu 2-4 request song song.
  • Đã giới hạn max_tokens.
  • Đã có timeout.
  • Đã có retry có kiểm soát.
  • Đã không log full prompt nhạy cảm.
  • Đã có health/readiness check.
  • Đã có fallback plan.
  • Đã có golden eval hoặc ít nhất sample set cho chất lượng.
  • Đã trả lời được: production được không, điều kiện gì.

Phần 6: Quiz

  1. Local LLM có đảm bảo dữ liệu không leak không? Vì sao?
  2. Vì sao cùng một model nhưng runtime khác nhau có latency khác nhau?
  3. Quantization giảm VRAM bằng cách nào và rủi ro chính là gì?
  4. Tại sao prompt dài ảnh hưởng prefill nhiều hơn decode?
  5. KV cache tăng theo những yếu tố nào?
  6. Khi nào Ollama đủ dùng, khi nào nên chuyển sang vLLM?
  7. Khi nào llama.cpp là lựa chọn tốt hơn GPU serving?
  8. Vì sao OpenAI-compatible API không đủ để production nếu thiếu gateway?
  9. Những metric nào cần xem trước khi mở traffic thật?
  10. Nếu p95 latency vượt SLO, bạn sẽ thử 5 hướng tối ưu nào?

Phần 7: Bài Tập Thiết Kế

Thiết kế một local LLM service cho internal policy Q&A:

  • 200 nhân viên.
  • Tài liệu nội bộ không được gửi ra cloud.
  • 80% request là hỏi đáp ngắn.
  • 20% request có RAG context dài.
  • SLO: p95 dưới 5 giây cho câu hỏi ngắn, dưới 12 giây cho câu hỏi dài.
  • Cần audit log nhưng không được lưu PII thô.

Bạn cần nộp:

  • Runtime chọn cho dev.
  • Runtime chọn cho production.
  • Model candidate.
  • API contract.
  • Logging fields.
  • Benchmark plan.
  • Fallback plan.
  • Rủi ro còn lại.

Phần 8: Decision Note

# Day 29 Local LLM Decision Note

## Use case
- Task:
- Data sensitivity:
- Users/concurrency:
- Latency SLO:

## Runtime candidates
- Ollama:
- llama.cpp:
- vLLM:
- TGI:
- LM Studio:

## Model
- model_id:
- revision/digest:
- license:
- quantization:
- context window:

## Benchmark result
- short prompt p50/p95:
- long prompt p50/p95:
- concurrency:
- RAM/VRAM:
- quality notes:

## Production decision
- Dùng được trong production không:
- Điều kiện bắt buộc:
- Runtime chọn:
- Fallback:
- Rollback:
- Rủi ro còn lại: