Gọi một LLM API từ Python nghe đơn giản: gửi danh sách tin nhắn, nhận văn bản về. Khi đưa vào sản phẩm, ba câu hỏi xuất hiện ngay: làm sao người dùng thấy chữ chạy ra thay vì chờ cả chục giây, làm sao đầu ra parse được thành dữ liệu, và làm sao hoá đơn cuối tháng không là bất ngờ. Bài này trả lời bằng code chạy thật: streaming, structured output với Pydantic, timeout, retry và kiểm soát chi phí.

Bài nối tiếp Prompt engineering có hệ thống: vai trò, ví dụ mẫu và kiểm thử prompt 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, SDK openai 1.51.0, Pydantic 2.9.2. Code viết đúng SDK OpenAI nhưng chạy bằng Ollama 0.40.15 (qwen2.5:1.5b) qua endpoint tương thích http://localhost:11434/v1, không cần khoá thật. Lưu ý: json_schema và include_usage trên /v1 của Ollama chỉ có từ các bản tháng 12/2024 (v0.5.x); với Ollama cũ hơn, hãy chạy hai phần đó trên API thương mại. Nguồn chuẩn cho hành vi LLM API là tài liệu OpenAI; Ollama chỉ là phương tiện chạy thử.

Gọi LLM API: một request, một response và những trường cần đọc

Điểm vào của mọi LLM API dạng chat là danh sách messages với vai trò system, user, assistant. client.py đọc cấu hình từ biến môi trường để khoá không nằm trong mã nguồn:

import os
from openai import OpenAI

MODEL = os.environ.get("LLM_MODEL", "qwen2.5:1.5b")

def make_client(**kw):
    return OpenAI(base_url=os.environ.get("OPENAI_BASE_URL", "http://localhost:11434/v1"),
                  api_key=os.environ.get("OPENAI_API_KEY", "ollama"), **kw)

Đổi OPENAI_BASE_URL và khoá là chuyển được sang nhà cung cấp khác; riêng structured output cần chú ý thêm (xem dưới). Kết quả của basic.py:

[Gọi thường]
  finish_reason: stop
  usage        : 56 token vào, 24 token ra, tổng 80
  độ trễ tổng  : 0.40s

Có ba trường nên đọc ở mọi lời gọi LLM API. finish_reason là stop khi mô hình tự kết thúc, và length khi bị cắt vì chạm max_tokens: bỏ qua trường này, câu trả lời cụt sẽ thành lỗi khó truy. usage cho số token vào, ra và tổng, là cơ sở của mọi tính chi phí. Hãy đo độ trễ ở phía bạn vì nó gồm cả mạng, hàng đợi và thời gian sinh token của LLM API.

Streaming với LLM API: trả chữ ngay khi mô hình sinh ra

Mô hình sinh từng token một (xem bài LLM hoạt động thế nào), nên LLM API không cần đợi sinh xong mới trả về. Với stream=True, LLM API trả luồng sự kiện (server-sent events), mỗi chunk chứa một mảnh delta. Hai chỉ số cần đo là time-to-first-token (TTFT, thời gian tới token đầu) và tổng thời gian. Tham số stream_options={"include_usage": True} khiến chunk cuối mang theo usage:

stream = client.chat.completions.create(model=MODEL, messages=msgs, temperature=0, max_tokens=60,
                                        stream=True, stream_options={"include_usage": True})
first = usage = None
for chunk in stream:
    if chunk.usage: usage = chunk.usage                    # chunk cuối
    if chunk.choices and chunk.choices[0].delta.content:
        if first is None: first = time.perf_counter() - t0   # TTFT
        text.append(chunk.choices[0].delta.content)
[Streaming]
  time-to-first-token: 0.02s | tổng: 0.38s | số chunk: 29
  usage ở chunk cuối : 56 vào, 30 ra

Hai lần gọi cùng prompt và temperature=0 nhưng ra 24 và 30 token, câu chữ khác nhau: temperature=0 không đảm bảo đầu ra giống hệt giữa các lần hay các chế độ gọi (batch, số thực). Hãy coi nó là "gần tất định".

Với prompt ngắn, token đầu đến sau 0,02 giây. Nhưng TTFT phụ thuộc mạnh vào độ dài prompt vì mô hình phải xử lý toàn bộ prompt trước. Thí nghiệm sau dùng prompt hơn 2.000 token (120 dòng đánh giá), mỗi lần có tiền tố khác để không dùng lại cache prompt của máy chủ:

[Prompt dài, đầu ra tối đa 250 token]
  đọc hết                prompt=2394 | chunk=127 | token đầu tiên sau 3.15s | tổng 5.02s
  người dùng bấm dừng    prompt=None | chunk= 15 | token đầu tiên sau 3.11s | tổng 3.42s

Prompt 2.394 token làm TTFT vượt 3 giây, còn sinh 127 token mất thêm khoảng 1,9 giây: ở prompt dài, phần lớn thời gian chờ là xử lý prompt. Streaming còn cho phép ngắt sớm: khi người dùng bấm "Dừng" sau 15 chunk, ta gọi stream.close() và dừng ở 3,42 giây thay vì 5,02 giây. Usage ở chunk cuối không tới nếu đóng luồng sớm (cột prompt=None), nên hãy tự đếm phần đã nhận.

Khi triển khai, nhớ ba điều. README của openai-python nói luồng đọc không được tự động retry, nên mất kết nối giữa chừng thì bạn phải xử lý phần đã nhận. Proxy phải tắt buffering (nginx: proxy_buffering off). Chỉ parse JSON khi đã nhận đủ.

Structured output của LLM API: đầu ra là dữ liệu, không phải văn xuôi

Ứng dụng thường cần LLM API trả về dữ liệu cho code, ví dụ phân loại ý định và trích mã đơn. Có ba mức đảm bảo tăng dần. Mức một: xin JSON bằng lời trong prompt, không có đảm bảo nào. Mức hai: JSON mode (response_format={"type": "json_object"}), đảm bảo JSON hợp lệ nhưng không đảm bảo đúng khoá hay kiểu. Mức ba: Structured Outputs với json_schema. Tài liệu Structured Outputs của OpenAI viết gọn sự khác biệt: cả hai đảm bảo JSON hợp lệ, nhưng chỉ Structured Outputs đảm bảo đúng schema. Tính năng ra mắt tháng 8/2024 với gpt-4o-2024-08-06, dùng strict: true và có trường refusal khi mô hình từ chối.

Chế độ strict có điều kiện: theo tài liệu, mọi object phải đặt additionalProperties: false và mọi trường phải nằm trong required; trường tuỳ chọn khai báo bằng union với null. Lớp Pydantic dưới đây thoả điều kiện đó nhờ extra="forbid" và trường order_id không có giá trị mặc định (nên vẫn thuộc required, nhưng cho phép null):

class Ticket(BaseModel):
    model_config = ConfigDict(extra="forbid")      # additionalProperties: false
    intent: Literal["giao_hang", "hoan_tien", "thanh_toan", "khac"]
    order_id: str | None = Field(description="Mã đơn nếu có, ngược lại null")
    urgent: bool

schema = {"type": "json_schema",
          "json_schema": {"name": "ticket", "strict": True, "schema": Ticket.model_json_schema()}}
r = client.chat.completions.create(model=MODEL, temperature=0, response_format=schema, messages=msgs)
ticket = Ticket.model_validate_json(r.choices[0].message.content)   # kiểm lại bằng cùng lớp

Schema in ra từ model_json_schema() có "additionalProperties": false và required gồm cả ba khoá. Cùng một lớp vừa sinh schema gửi đi, vừa kiểm kết quả nhận về. Với API thật, cách gọn hơn là helper của SDK: client.beta.chat.completions.parse(response_format=Ticket) (có trong helpers.md của openai-python) tự chuyển lớp Pydantic thành schema strict, tự parse và trả về đối tượng ở message.parsed. Gửi schema của lớp thiếu extra="forbid" lên API thương mại sẽ bị từ chối vì thiếu additionalProperties: false; Ollama dễ tính hơn nên không bắt lỗi này.

Thí nghiệm gửi 10 tin nhắn tiếng Việt qua ba chế độ, temperature=0, hai kiểu prompt. Prompt "mơ hồ" chỉ nói "trả về JSON với các khoá intent, order_id, urgent", cố ý không liệt kê nhãn. Prompt "đầy đủ" liệt kê bốn nhãn, nói order_id là null nếu không có mã, urgent là true khi khách nói gấp. Pydantic kiểm từng đầu ra:

prompt mơ_hồ   prompt_only  JSON hợp lệ 10/10 | đúng schema 0/10
prompt mơ_hồ   json_mode    JSON hợp lệ 10/10 | đúng schema 0/10
prompt mơ_hồ   json_schema  JSON hợp lệ 10/10 | đúng schema 10/10 | intent 6, order_id 6, urgent 10
prompt đầy_đủ  prompt_only  JSON hợp lệ 1/10 | đúng schema 1/10 | intent 1, order_id 1, urgent 1
prompt đầy_đủ  json_mode    JSON hợp lệ 10/10 | đúng schema 10/10 | intent 8, order_id 10, urgent 10
prompt đầy_đủ  json_schema  JSON hợp lệ 10/10 | đúng schema 10/10 | intent 8, order_id 10, urgent 10

Cách đọc cần cẩn thận (cột intent, order_id, urgent là số tin đúng nội dung trên các tin đúng schema). Với prompt mơ hồ, mô hình tự đặt nhãn "request_delivery" nên 0/10 đúng schema ở hai chế độ không ràng buộc; đó là hệ quả của prompt thiếu thông tin, không chứng minh JSON mode "kém". Chỉ json_schema ép được nhãn nhờ constrained decoding, nhưng mô hình không thấy schema nên đoán nhãn: intent đúng 6/10. Khi prompt liệt kê nhãn, JSON mode đạt 10/10 đúng schema, ngang json_schema. Prompt chỉ xin JSON bằng lời thì chỉ 1/10 lần mô hình trả JSON thuần: kiểm tay 10 tin cho thấy 6 lần bọc trong khối ```json và 3 lần trả lời bằng văn xuôi. Vậy json_schema là đảm bảo hình dạng; độ đúng vẫn do prompt quyết định.

Đúng schema chưa phải đúng sự thật. Với prompt mơ hồ, mô hình từng điền order_id='123456' cho tin không có mã đơn và '' thay vì null ở tin khác. Với prompt đầy đủ, intent vẫn sai ở hai tin, ví dụ "thẻ bị trừ tiền hai lần" bị gán hoan_tien; output của helper parse:

[Helper parse của SDK, prompt đầy đủ]
  intent='hoan_tien' order_id='55102' urgent=True | đáp án: ('thanh_toan', '55102', True)

Ba bài học. (1) Structured output giải quyết hình dạng, không phải độ đúng; vẫn cần tập kiểm thử như bài trước. (2) Cho phép null, nêu trong prompt khi nào dùng nó, và kiểm bằng code (mã đơn khớp biểu thức chính quy hoặc có trong cơ sở dữ liệu). (3) Dùng Literal để khép giá trị và liệt kê chúng trong prompt. Với API thương mại, kiểm tra refusal và finish_reason trước khi parse; schema chỉ hỗ trợ một tập con JSON Schema.

Timeout và retry khi gọi LLM API

Mọi LLM API là dịch vụ mạng nên sẽ có lúc chậm, quá tải hoặc lỗi. Theo README của openai-python, SDK mặc định retry hai lần với exponential backoff cho lỗi kết nối, mã 408, 409, 429 và 5xx; timeout mặc định là 10 phút; cả hai chỉnh bằng max_retries và timeout. Ta kiểm tra bằng máy chủ giả dựng với httpx.MockTransport (không cần mạng), đếm số request thực sự gửi đi:

def make_fake(statuses):
    calls = {"n": 0}
    def handler(request):
        calls["n"] += 1
        s = statuses[min(calls["n"] - 1, len(statuses) - 1)]
        if s == 200: return httpx.Response(200, json=OK)      # OK: JSON chat.completion giả
        return httpx.Response(s, json={"error": {"message": "giả lập"}}, headers={"retry-after-ms": "10"})
    return OpenAI(api_key="fake", base_url="http://fake.local/v1", max_retries=2,
                  http_client=httpx.Client(transport=httpx.MockTransport(handler))), calls
[Timeout 5 ms, max_retries=0] APITimeoutError sau 0.03s

[Retry mặc định của SDK, max_retries=2, máy chủ giả]
  429, 429, 200        -> thành công                   số request thực tế gửi đi: 3
  429 mãi mãi          -> RateLimitError (429)         số request thực tế gửi đi: 3
  400 (lỗi do client)  -> BadRequestError (400)        số request thực tế gửi đi: 1

Máy chủ trả 429 hai lần rồi 200 thì SDK tự thử lại và thành công sau 3 request; 429 liên tục thì sau 3 request (1 gốc, 2 retry) mới ném RateLimitError; còn 400 chỉ gửi 1 request vì là lỗi phía client (401 cũng vậy, xem đủ trong reliability_output.txt). Máy chủ giả gửi header retry-after-ms: 10 (phần mở rộng không chuẩn mà SDK đọc trước Retry-After tính bằng giây), nên khoảng chờ chỉ ~10 ms; ngoài thực tế backoff dài hơn nhiều. Nguyên tắc khi gọi LLM API: đặt timeout theo SLA thay vì 10 phút; retry nhân chi phí và tải; tôn trọng Retry-After và thêm jitter; luôn đặt max_tokens.

Kiểm soát chi phí khi dùng LLM API

Hầu hết LLM API tính tiền theo token, giá vào và ra tách riêng. Chi phí là hàm của độ dài prompt (kể cả ví dụ mẫu và lịch sử gửi lại mỗi lượt), độ dài đầu ra và số lần gọi. Bảng giá trong bài là giả định (0,50 và 1,50 USD mỗi triệu token vào và ra), chỉ để minh hoạ; hãy thay bằng bảng giá hiện hành.

Đếm token trước khi gọi LLM API

Để chặn prompt quá dài, bạn phải ước lượng trước khi gửi. tiktoken làm việc này cho mô hình OpenAI; với mô hình khác chỉ là xấp xỉ vì tokenizer khác nhau. Ta so ước lượng cl100k_base với usage thật của Qwen2.5:

[tiktoken cl100k_base so với usage thật của Qwen2.5]
  0 ví dụ mẫu: ước lượng  95 | thật  74 | token ra 5
  4 ví dụ mẫu: ước lượng 204 | thật 165 | token ra 5

Ước lượng cao hơn thực tế 24 đến 28% trên tiếng Việt với cặp tokenizer này; cặp khác sẽ lệch khác. Dùng được cho ngưỡng chặn, không dùng để tính tiền: số thanh toán lấy từ usage của chính API. Bảng cũng cho thấy giá của few-shot: bốn ví dụ mẫu đưa prompt từ 74 lên 165 token.

Chi phí LLM API nhân với quy mô

Với 100.000 tin nhắn mỗi ngày:

[Chi phí 100.000 lượt/ngày, giá GIẢ ĐỊNH 0.5/1.5 USD mỗi triệu token vào/ra]
  0 ví dụ mẫu: 74 token vào, 5 ra ->    4.45 USD/ngày
  2 ví dụ mẫu: 124 token vào, 3 ra ->    6.65 USD/ngày
  4 ví dụ mẫu: 165 token vào, 5 ra ->    9.00 USD/ngày

Từ 0 lên 4 ví dụ mẫu, chi phí mỗi ngày gấp đôi, từ 4,45 lên 9,00 USD. Nếu bốn ví dụ đó nâng độ chính xác thì vẫn có thể đáng giá; điều quan trọng là tính được. Đòn bẩy: rút gọn system prompt, giới hạn lịch sử gửi lại, dùng mô hình nhỏ cho tác vụ dễ, đặt max_tokens, dùng prompt caching của nhà cung cấp nếu có.

Cache yêu cầu LLM API theo nội dung

Nhiều yêu cầu lặp y hệt, như câu hỏi thường gặp. Lớp Cached băm (mô hình, messages, tham số) bằng SHA-256 và chỉ gọi LLM API khi chưa có kết quả. Cache nghĩa là "cùng đầu vào thì dùng lại kết quả đã lưu", không hứa mô hình luôn cho cùng đáp án, và chỉ hợp khi câu trả lời không phụ thuộc dữ liệu đổi theo thời gian:

class Cached:
    def __init__(self): self.store = {}; self.hits = self.calls = self.tokens = 0
    def ask(self, messages):
        key = hashlib.sha256(json.dumps([MODEL, messages, 0], ensure_ascii=False, sort_keys=True).encode()).hexdigest()
        if key in self.store: self.hits += 1; return self.store[key]
        r = client.chat.completions.create(model=MODEL, messages=messages, temperature=0, max_tokens=8)
        self.calls += 1; self.tokens += r.usage.total_tokens
        self.store[key] = r.choices[0].message.content.strip(); return self.store[key]
[Cache: 40 yêu cầu, chỉ 3 câu khác nhau]
  gọi API thật: 3 | trúng cache: 37 | token tiêu thụ: 447

Trên 40 yêu cầu chỉ có 3 câu khác nhau, ta gọi API thật 3 lần và trúng cache 37 lần. Hãy đặt thời hạn cho cache và đừng lưu dữ liệu cá nhân trong cache dùng chung.

Ngân sách cứng cho LLM API

Lớp Budget chặn lời gọi LLM API khi tổng token ước tính vượt hạn mức, và cộng usage thật sau mỗi lần gọi:

class Budget:
    def __init__(self, max_tokens): self.max, self.used = max_tokens, 0
    def charge(self, usage): self.used += usage.total_tokens
    def check(self, est):
        if self.used + est > self.max:
            raise RuntimeError(f"vượt ngân sách: đã dùng {self.used}, cần thêm ~{est}, hạn mức {self.max}")
[Ngân sách 1.000 token] đã gửi 6 yêu cầu rồi dừng: vượt ngân sách: đã dùng 907, cần thêm ~191, hạn mức 1000

Thực tế nên đặt ngân sách theo khách hàng hoặc tính năng, lưu ở Redis hoặc cơ sở dữ liệu, cảnh báo khi quá 80%. Ngân sách còn phòng vòng lặp lỗi (agent tự gọi lại chính nó hàng nghìn lần) và lạm dụng.

Bảo mật khi gọi LLM API

Khoá LLM API chỉ nằm trong biến môi trường hoặc kho bí mật, không commit, không đưa vào mã phía trình duyệt. Che số điện thoại, số thẻ, địa chỉ trước khi đưa vào prompt. Log chỉ ghi mô hình, số token, độ trễ, finish_reason.

Khi nào không nên gọi LLM API

Tác vụ có quy tắc rõ (kiểm tra định dạng mã đơn, tra bảng giá) thì code thường rẻ và tất định hơn; dữ liệu quá nhạy cảm thì dùng mô hình nội bộ như Ollama.

Lỗi thường gặp khi dùng LLM API

  1. Không đọc finish_reason: câu trả lời bị cắt do length bị coi là hoàn chỉnh.
  2. Gửi schema thiếu điều kiện strict: quên additionalProperties: false hoặc để trường ngoài required thì bị từ chối; dùng extra="forbid" hoặc helper parse.
  3. Đổ lỗi cho JSON mode khi prompt thiếu thông tin: không nêu nhãn thì chế độ nào mô hình cũng phải đoán; prompt đầy đủ đưa JSON mode từ 0/10 lên 10/10 đúng schema.
  4. Tưởng đúng schema là đúng nội dung: mô hình điền cả trường không có thông tin; hãy cho phép null và kiểm bằng code.
  5. Để timeout LLM API mặc định 10 phút: người dùng bỏ đi từ lâu, còn worker vẫn bị giữ.
  6. Dùng ước lượng token để tính tiền LLM API: sai lệch có thể lớn, như 24 đến 28% với tiếng Việt trong thí nghiệm này.

Checklist

  • Cấu hình client LLM API qua biến môi trường; đặt timeout, max_retries và max_tokens tường minh.
  • Đọc finish_reason và usage ở mọi lời gọi LLM API.
  • Dùng streaming của LLM API cho giao diện người dùng; đo TTFT và tổng thời gian; hỗ trợ ngắt.
  • Dùng json_schema với kiểu Pydantic (extra="forbid", enum, cho phép null), liệt kê giá trị trong prompt và kiểm nội dung bằng code.
  • Đếm token trước khi gửi, đặt ngân sách theo khách hàng hoặc tính năng, cache yêu cầu lặp lại.

Các đoạn code là bản rút gọn của sáu script chạy thật (client.py, basic.py, stream_demo.py, structured.py, reliability.py, cost.py) với openai==1.51.0, httpx==0.27.2, pydantic==2.9.2, tiktoken==0.7.0. Output là output thật (có rút bớt vài dòng cho gọn); độ trễ và số token lệch chút ít giữa các lần chạy. Dữ liệu giả lập.

Bài tiếp theo

Chạy LLM local với Ollama và llama.cpp: chọn mô hình, lượng tử hoá, hiệu năng. Bài này đã dùng Ollama như một máy chủ LLM API tương thích OpenAI; bài sau đi vào bên trong: chọn mô hình, lượng tử hoá và đo hiệu năng khi tự chạy LLM.