Sáu tháng trước, đội ngũ của chúng tôi đã rơi vào tình thế bế tắc với các API LLM thương mại. Pipeline trích xuất dữ liệu của chúng tôi xử lý khoảng 40.000 hợp đồng pháp lý và log đo từ xa (telemetry) của server mỗi ngày. Dù đã bật chế độ JSON mode từ nhà cung cấp và ghim temperature về 0.0, vẫn có khoảng 2,3% số request âm thầm thất bại. Chúng tôi liên tục gặp phải các dấu phẩy thừa (trailing comma), các khối code markdown tự sinh ngoài ý muốn, cùng những key ảo (hallucinated key) khiến các validator của Pydantic nổ lỗi lúc 3 giờ sáng.
Chúng tôi quyết định ngừng vật lộn với hiện tượng trôi lệch prompt (prompt drift) và chuyển toàn bộ khối lượng công việc trích xuất cốt lõi sang các model tự host chạy Outlines. Outlines xử lý việc định dạng ngay ở cấp độ token trong quá trình sampling thay vì dựa vào các mẹo can thiệp logit bias hay soft prompt. Khi một token xung đột với ngữ pháp đã định nghĩa, Outlines sẽ lập tức gán xác suất của nó về 0. Từng kết quả sinh ra đều đảm bảo hợp lệ về mặt cấu trúc—cam kết 100%.
Bắt đầu nhanh (5 phút)
Khởi chạy generator Outlines đầu tiên của bạn chỉ mất khoảng năm phút. Thư viện này kết nối mượt mà với hầu hết runtime cục bộ phổ biến, bao gồm Hugging Face Transformers, llama.cpp và vLLM.
Bắt đầu bằng cách cài đặt Outlines cùng backend Transformers và PyTorch:
pip install outlines transformers torch
Đoạn script dưới đây buộc một model chạy cục bộ phải trả về chính xác tổ hợp địa chỉ IPv4 và port—không thừa, không thiếu một ký tự nào:
import outlines
# 1. Tải một model open-weights nhỏ gọn
model_name = "Qwen/Qwen2.5-1.5B-Instruct"
model = outlines.models.transformers(
model_name,
model_kwargs={"device_map": "auto"}
)
# 2. Định nghĩa regex rõ ràng cho địa chỉ IPv4 kèm port
regex_pattern = r"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?):[0-9]{2,5}"
# 3. Biên dịch generator định hướng bằng regex
generator = outlines.generate.regex(model, regex_pattern)
# 4. Sinh văn bản có cấu trúc
prompt = "Xác định proxy host được đề cập tại đây: Server đã chuyển tiếp request qua 10.0.4.15 trên port lắng nghe 8443 ngày hôm qua.\nProxy address: "
result = generator(prompt, max_tokens=30)
print(f"Output đã ép chuẩn: {result}")
# Kết quả: 10.0.4.15:8443
Hãy chú ý những gì đã được loại bỏ: không còn câu chào rườm rà, không có Here is the IP address:, và không có dấu ngoặc kép thừa ở cuối. Vòng lặp giải mã (decoding loop) ngăn chặn triệt để mọi token không giúp máy trạng thái (state machine) chuyển bước hợp lệ.
Phân tích chuyên sâu
Phần lớn các API thương mại xử lý structured output bằng cách kết hợp system prompt với các vòng lặp retry. Khi các API này áp dụng token mask, chúng thường so khớp chuỗi theo thời gian thực (on the fly). Cách tiếp cận đó thường xuyên bỏ sót ranh giới giữa nhiều token (multi-token boundaries), khiến độ trễ tăng vọt và gây ra các lỗi 500 bất ngờ.
Masking ở cấp độ token thông qua máy trạng thái hữu hạn
Các LLM tự hồi quy (autoregressive) hiện đại chọn token bằng cách tính xác suất softmax trên toàn bộ từ vựng (vocabulary) khổng lồ—thường từ 32.000 đến hơn 128.000 token. Outlines biên dịch schema của bạn, dù được viết bằng Pydantic hay regex thuần, thành một Otomat hữu hạn đơn định (Deterministic Finite Automaton – DFA).
Dưới đây là những gì diễn ra trong từng bước giải mã:
- Khởi tạo trạng thái: Outlines xây dựng một chỉ mục (index) ánh xạ từ vựng của model với các trạng thái trong DFA đã biên dịch trước khi quá trình sinh bắt đầu.
- Che logit (Logit masking): Tại bước t, engine kiểm tra trạng thái DFA hiện tại. Nó tìm tất cả các token trong từ vựng thỏa mãn điều kiện chuyển trạng thái hợp lệ.
- Cắt tỉa (Pruning): Outlines gán điểm logit của mọi token không hợp lệ thành
-infinity. - Lấy mẫu (Sampling): Model chỉ lấy mẫu từ tập hợp các token hợp lệ còn lại bằng các tham số temperature và top-p tiêu chuẩn của bạn.
- Chuyển trạng thái (Transition): Otomat chuyển sang trạng thái tiếp theo dựa trên token vừa được chọn. Vòng lặp này tiếp diễn cho đến khi chuỗi chạm tới trạng thái kết thúc hợp lệ.
Hiệu quả mang lại là thấy rõ ngay lập tức. Model không lãng phí bất kỳ token nào cho các câu chào mở đầu hay cú pháp sai lệch. Vì quá trình sinh dừng lại ngay đúng mili-giây cấu trúc hoàn tất, độ trễ trung vị (median latency) giảm từ 15% đến 30% so với sampling không ràng buộc.
Xác thực kiểu dữ liệu với Pydantic
Regex thuần hoạt động rất tốt cho các chuỗi phẳng, nhưng các pipeline trong môi trường production thường đòi hỏi dữ liệu có kiểu rõ ràng và lồng nhau. Outlines chuyển đổi trực tiếp các schema Pydantic thành bộ parser JSON có định hướng:
from typing import List, Literal
from pydantic import BaseModel, Field
import outlines
class VulnerabilityReport(BaseModel):
cve_id: str = Field(pattern=r"^CVE-\d{4}-\d{4,7}$")
severity: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
affected_ports: List[int]
summary: str = Field(max_length=120)
# Biên dịch Pydantic schema thành generator có định hướng
generator = outlines.generate.json(model, VulnerabilityReport)
prompt = "Phân tích báo cáo: Chúng tôi phát hiện truy cập trái phép qua CVE-2023-38606 nhắm vào port 22 và port 8080 với mức độ ảnh hưởng nghiêm trọng.\nStructured output: "
report: VulnerabilityReport = generator(prompt)
print(f"Đối tượng đã parse: {report.cve_id} | {report.severity}")
print(f"Các port: {report.affected_ports}")
Kết quả trả về dưới dạng một instance của model Pydantic đã được xác thực. Bạn có thể loại bỏ hoàn toàn các đoạn bọc json.loads() phòng thủ và các decorator retry schema.
Sử dụng nâng cao
Các pipeline trong thực tế thường tạo ra output mà regex không thể biểu diễn một cách trọn vẹn. Nếu hệ thống của bạn cần xuất ra các biểu thức lồng nhau, khối mã lệnh hoặc truy vấn tùy chỉnh, bạn sẽ cần đến Ngữ pháp phi ngữ cảnh (Context-Free Grammars – CFG).
Ràng buộc output bằng ngữ pháp Lark
CFG xử lý các cú pháp đệ quy một cách mượt mà, chẳng hạn như đóng mở ngoặc cân bằng, công thức số học và mệnh đề SQL. Outlines tích hợp trực tiếp với thư viện phân tích cú pháp Lark, cho phép bạn áp đặt các ngữ pháp EBNF tùy chỉnh.
Ví dụ: bạn có thể buộc một agent trích xuất phải trả về chuỗi bộ lọc boolean hợp lệ cho cụm Elasticsearch:
import outlines
# Định nghĩa ngữ pháp EBNF cho các truy vấn tìm kiếm lồng nhau
boolean_grammar = """
?start: expr
?expr: term ((" AND " | " OR ") term)*
?term: NOT? atom
NOT: "NOT "
?atom: FIELD ":" VALUE | "(" expr ")"
FIELD: "status" | "tag" | "env"
VALUE: "\"" /[a-zA-Z0-9_-]+/ "\""
%import common.WS
%ignore WS
"""
cfg_generator = outlines.generate.cfg(model, boolean_grammar)
prompt = "Tạo một Elasticsearch filter query để tìm các lỗi production chưa được lưu trữ (archived):\nQuery: "
query_result = cfg_generator(prompt, max_tokens=50)
print(f"Query đã xác thực qua ngữ pháp: {query_result}")
# Kết quả: env:"prod" AND NOT status:"archived"
Nếu model cố tình bỏ sót một dấu ngoặc kép hoặc chèn một tên trường không được hỗ trợ, Outlines sẽ loại bỏ token đó ngay trước khi nó kịp xuất hiện trong context window.
Phục vụ throughput cao với vLLM
Chạy Outlines trên các model Transformers độc lập rất phù hợp cho mục đích phát triển hoặc các tác vụ batch. Tuy nhiên, các hệ thống thực tế phục vụ nhiều người dùng đồng thời đòi hỏi continuous batching và PagedAttention. vLLM đã tích hợp sẵn Outlines vào engine guided decoding cốt lõi của nó.
Khởi chạy một instance vLLM tương thích chuẩn OpenAI với Outlines được bật trên port 8000:
python3 -m vllm.entrypoints.openai.api_server \
--model mistralai/Mistral-7B-Instruct-v0.3 \
--guided-decoding-backend outlines \
--port 8000
Tiếp theo, truy vấn instance này bằng thư viện client OpenAI tiêu chuẩn thông qua tham số guided_choice:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="empty")
response = client.completions.create(
model="mistralai/Mistral-7B-Instruct-v0.3",
prompt="Trích xuất sắc thái cảm xúc của khách hàng: 'Quá trình migration quá tệ hại và đã làm hỏng cơ sở dữ liệu của chúng tôi.'\nSentiment: ",
extra_body={
"guided_choice": ["POSITIVE", "NEUTRAL", "NEGATIVE"]
}
)
print(response.choices[0].text.strip())
# Kết quả: NEGATIVE
Những lưu ý khi triển khai production
Việc xử lý hàng triệu token trong môi trường production đã mang lại cho chúng tôi nhiều bài học thực tế về việc vận hành các guided decoder ở quy mô lớn.
1. Cache chỉ mục DFA đã biên dịch
Việc biên dịch các biểu thức chính quy hoặc schema lớn với các tokenizer hiện đại tốn một lượng tài nguyên tính toán không nhỏ. Trên những model như Llama 3 với 128.256 token từ vựng, việc tạo index có thể mất từ 1,5 đến 4 giây trong quá trình cold start. Hãy biên dịch trước và khởi tạo mọi generator trong bước khởi động dịch vụ. Tuyệt đối không tạo mới chúng theo từng request trong request handler.
2. Chú ý tiền tố của tokenizer và khoảng trắng
Mỗi tokenizer mã hóa khoảng trắng theo một cách khác nhau. Một số dùng các token có khoảng trắng đứng đầu, trong khi số khác lại sử dụng biểu diễn ở cấp byte. Việc hardcode khoảng trắng quá cứng nhắc như r" [A-Z]+ " thường làm gián đoạn quá trình sinh dữ liệu. Hãy viết các pattern regex linh hoạt với khoảng trắng như r"\s*([A-Z]+)\s*" để tránh hiện tượng bế tắc token (token deadlock) ngoài ý muốn.
3. Ngăn ngừa tình trạng cắt ngắn token quá sớm
Nếu schema của bạn cần 80 token để đi đến dấu đóng ngoặc hợp lệ, nhưng request của bạn lại giới hạn max_tokens ở mức 50, model sẽ bị ngắt quãng giữa chừng. Output sinh ra sẽ không hoàn chỉnh về mặt cú pháp. Hãy đo kiểm (benchmark) kích thước payload trong điều kiện tải cao nhất và thiết lập max_tokens với mức dự phòng an toàn khoảng 25%.
4. Định hướng các model nhỏ bằng prompt rõ ràng
Guided decoding đảm bảo cú pháp hợp lệ nhưng không thể khắc phục khả năng suy luận ngữ nghĩa kém. Một ngữ pháp quá chặt chẽ áp dụng cho model 1.5B có thể ép nó rẽ vào các nhánh token phi logic nếu prompt thiếu rõ ràng. Hãy thêm hai hoặc ba ví dụ few-shot ngắn gọn vào prompt để vừa duy trì chất lượng suy luận vừa giữ throughput ở mức cao.

