Review Paper Efficient Guided Generation for Large Language Models
Đây là một bài review và làm rõ kiến thức trong bài báo liên quan: Efficient Guided Generation for Large Language Models
1. Vấn đề và động lực
- Bối cảnh: Việc bắt buộc các mô hình ngôn ngữ lớn (LLM) sinh văn bản tuân thủ chính xác các định dạng cấu trúc (như biểu thức chính quy - Regular Expression, hoặc ngữ pháp phi bối cảnh - Context-Free Grammar/CFG như JSON, Python, SQL) là rất quan trọng để xây dựng các giao diện ứng dụng tin cậy và tích hợp tri thức chuyên ngành.
- Thách thức hiện tại:
- Các phương pháp sinh văn bản có hướng dẫn (guided generation) hiện nay thường áp dụng mặt nạ (Boolean mask) lên các logit đầu ra để triệt tiêu xác suất của các token không hợp lệ.
- Các công cụ hiện có (ví dụ: thư viện Guidance) thực hiện kiểm tra mặt nạ bằng cách lặp qua toàn bộ tập từ vựng (có kích thước hoặc lớn hơn) tại mỗi bước sinh token, hoặc khớp với một phần chuỗi tính từ đầu câu.
- Điều này dẫn đề chi phí tính toán cố định cho mỗi token được sinh ra (và có thể tăng tuyến tính theo chiều dài chuỗi), tạo ra điểm nghẽn nghiêm trọc về tốc độ và chi phí khi mở rộng.
2. Phương pháp đề xuất
Tác giả tái cấu trúc bài toán có hướng dẫn thành bài toán chuyển trạng thái trong Máy trạng thái hữu hạn (Finite-State Machine - FSM) và Tự động đẩy xuống (Pushdown Automaton - PDA)
- Biểu diễn bằng FSM cho biểu thức chính quy (Regex Expression):
- Định nghĩa biểu thức chính quy dưới dạng tự động hữu hạn 5-thành phần .
- Thay vì duyệt qua từ vựng tại thời gian chạy (runtime), phương pháp lập chỉ mục trước (indexing) được áp dụng trước quá trình sinh.
- Xây dựng một ánh ánh bản đồ kết nối từng trạng thái của FSM tới tập hợp các token trong từ vựng mà FSM chấp nhận khi bắt đầu ở trạng thái đó.
- Tại thời điểm sinh token, việc tra cứu mặt nạ qua bảng băm chỉ tốn độ phức tạp trung bình .
- Mở rộng sang Ngữ pháp phi bối cảnh (CFG) & Bộ phân tích LALR(1):
- Mở rộng FSM sang Pushdown Automata (PDA) 6-thành phần để hỗ trợ các cú pháp phức tạp (JSON, Python, SQL, v.v.).
- Kết hợp FSM của các ký hiệu kết thúc (terminal symbols) được phép tại từng trạng thái phân tích.
- Sử dụng cấu trúc dữ liệu Trie để lập chỉ mục các giá trị ngăn xếp (stack) của PDA, cho phép truy vấn nhanh các cấu hình bộ phân tích hợp lệ.
Làm rõ hơn về phương pháp
Cách thức Thực Hiện Hàm của Mô Hình (Workflow chi tiết)
Thông thường, quá trình sinh token của LLM diễn ra qua vòng lặp:
Phương pháp của bài báo chia quy trình thành 2 Giai đoạn chính
- Giai đoạn 1: Ngoại tuyến (Tiền xử lý/Indexing)
- Tạo FSM từ Regex -> Lập chỉ mục cho σ(q) cho mọi trạng thái q ∈ Q -> Lưu vào bảng băm (HashMap)
- Bước này chỉ làm một lần duy nhất trước khi mô hình bắt đầu sinh chuỗi (Giai đoạn tiền sinh - Pre-Generation)
- Chuyển đổi biểu thức chính quy (Regex Expression) mong muốn thành một Máy trạng thái hữu hạn FSM: .
- Duyệt qua từng chuỗi token trong toàn bộ Tập từ vựng .
- Với mỗi token , kiểm tra xem token này có thể đóng vai trò là cầu nối dịch chuyển từ trạng thái sang trạng thái nào trong FSM hay không.
- Xây dựng bảng băm chỉ mục : Lưu lại thông tin "Nếu FSM đang đứng ở trạng thái q, thì tập hợp các token hợp lệ tiếp theo có thể chọn là "
- Giai đoạn 2: Trực tuyến (Sinh Token - Runtime/Online Generation)
- (LLM() -> Logits ) + Chỉ mục sẵn -> Lấy m = -> = m ⊙ α -> Samples s̃ đối chiếu với bảng băm đã tạo -> Cập nhật
- Sau khi đã có bảng chỉ mục , việc sinh token diễn ra vô cùng nhanh:
- Khởi tạo: Đặt trạng thái FSM hiện tại (trạng thái bắt đầu).
- Tại bước (khi cần sinh token tiếp theo):
- Cho chuỗi hiện tại qua LLM để thu được vector điểm số Logits . (Attention)
- Tra cứu trực tiếp bảng băm đã xây dựng để lấy danh sách các token hợp lệ cho trạng thái . Tạo mặt nạ nhị phân (Binary Mask) ( nếu , ngược lại ).
- Áp mặt nạ: Nhân mặt nạ với logits: . Các token vi phạm sẽ có giá trị xác xuất bị triệt tiêu về 0.
- Chọn token tiếp theo từ phân phối xác xuất .
- Đưa token vừa chọn chạy qua FSM để cập nhật trạng thái mới .
- Ghép vào chuỗi kết quả và lặp lại cho đến khi gặp token EOS.
Phân tích chi tiết Bản chất Ánh xạ
Đây chính là Trái tim của phương pháp này
Sự khác biệt cốt lõi:
- Cách làm cũ (như thư viện Guidance): Khi LLM đứng trước một chuỗi đã sinh, nó lấy từng token trong 50.000 token của tập từ vựng , ghép thử vào chuỗi rồi chạy regex matching từ đầu. Chi phí mỗi bước là .
- Cách làm mới trong paper này: Vì token trong tập từ vựng của LLM thường là chuỗi nhiều ký tự (subwords như
"42",".2","def"), một token có thể giúp FSM bước đi nhiều bước chuyển trạng thái liên tiếp. Tác giả tính toán trước mọi bước chuyển trạng thái mà từng token có thể tạo ra trên FSM.
Tìm chuỗi con trạng thái (FIND_SUB_SEQUENCES)
Thuật toán này nhận vào một FSM và một chuỗi từ vựng .
# Giả định hoạt động của Thuật toán 3
def FIND_SUB_SEQUENCES(FSM, token_v):
results = []
# Tìm tất cả trạng thái 'r' trong FSM có thể đọc ký tự đầu tiên v[0]
for r in states_that_read(v[0]):
path = [r]
current_state = r
valid_path = True
# Duyệt tiếp các ký tự còn lại v[1], v[2]... trong token v
for char in v[1:]:
next_state = transition(current_state, char)
if next_state is None: # FSM không khớp ký tự này
valid_path = False
break
current_state = next_state
path.append(current_state)
if valid_path:
results.append(path) # Lưu đường đi trạng thái (z_0, z_1, ..., z_k)
return results
Ý nghĩa: Thuật toán tìm ra cặp — tức là nếu FSM đang ở trạng thái bắt đầu , khi nạp toàn bộ các ký tự của token , FSM sẽ di chuyển mượt mà đến trạng thái kết thúc mà không bị tắc nghẽn.
- Máy trạng thái hữu hạn (FSM) hoạt động theo kiểu đọc từng ký tự đơn lẻ (character-by-character). Nhưng tập từ vựng (Vocabulary) của LLM (như BPE Tokenizer) lại chứa các chuỗi gồm nhiều ký tự (subwords).
- FSM chỉ biết đọc ký tự
'f', rồi đến'o', rồi đến'o', rồi đến'('. - Nhưng từ vựng của LLM lại có sẵn một token duy nhất chứa cả chuỗi:
v = "foo(". - Thuật toán này ra đời để trả lời câu hỏi: "Nếu tôi nạp toàn bộ chuỗi ký tự của token này vào FSM, FSM sẽ đi qua những trạng thái nào? Nó có bị 'kẹt' giữa chừng cho vi phạm Regex không?" Ví dụ từng bước:
- Giả sử ta có Regex:
[a-z]+\((tên hàm theo sau là dấu mở ngoặc). FSM của Regex này gồm 3 trạng thái:- Trạng thái 0(): Bắt đầu. Chờ kí tự đầu tiên
[a-z]. - Trạng thái 1(): Đang đọc tên hàm (tiếp tục nhận
[a-z]). - Trạng thái 2(): Đã đọc dấu
(. (Trạng thái chấp nhận - Accept State)
- Trạng thái 0(): Bắt đầu. Chờ kí tự đầu tiên
- Các bước chuyển trạng thái (Hàm ):
- Tại , không nhận thêm ký tự nào nữa. Bây giờ, ta lấy Token trong tập từ vựng của LLM ra để chạy thử qua thuật toán
Token v = "foo(" # Gồm 4 ký tự: v[0]='f', v[1]='o', v[2]='o', v[3]='('
Thuật toán 3 sẽ thử cho xuất phát từ MỌI trạng thái khả thi của FSM:
-
TH1: Thử xuất phát từ Trạng thái 0 ():
-
Đọc : chuyển sang .
-
Đọc : giữ nguyên ở .
-
Đọc : giữ nguyên ở .
-
Đọc : chuyển sang .
-
KẾT QUẢ TH1: Thành công! Chuỗi trạng thái thu được là .
-
Ý nghĩa: Token
"foo("giúp FSM đi từ trạng thái đến . Do đó, token"foo("hợp lệ nếu FSM đang ở Thêm"foo("vào .
-
-
TH2: Thử xuất phát từ Trạng thái 1 ():
-
Đọc 'f':
-
Đọc 'o':
-
Đọc 'o':
-
Đọc '(':
-
KẾT QUẢ TH2: Thành công! Chuỗi trạng thái .
-
Ý nghĩa: Token
"foo("cũng giúp FSM đi từ đến Thêm"foo("vào .
-
-
TH3: Thử xuất phát từ Trạng thái 2 ():
- Đọc : (FSM không chấp nhận 'f' khi đang ở ).
- KẾT QUẢ TH3: Bị ngắt giữa chừng (Fail)! Không tạo ra chuỗi trạng thái nào.
Kết luận của Thuật toán: Token
"foo("sẽ được xếp vào bảng ánh xạ:
Khi LLM đang ở hoặc , nó biết ngay token "foo(" là hợp lệ. Nếu LLM chọn "foo(", FSM lập tức nhảy thẳng đến trạng thái mà không cần tính toán lại!
Thuật toán: Dựng Ánh xạ Chỉ mục (MAP_STATES_TO_VOCAB)
Dùng kết quả của thuật toán trước đó để gán token vào trạng thái FSM tương ứng:
Ví dụ minh họa trực quan
Giả sử ta cần sinh số thực bằng Regex: ==([0-9]*)?\.?[0-9]*.==
FSM có 4 trạng thái Q = {0, 1, 2, 3}:
- Trạng thái 0: Khởi đầu.
- Trạng thái 1: Đã đọc chữ số trước dấu chấm.
- Trạng thái 2: Vừa đọc dấu chấm
.. - Trạng thái 3: Đã đọc chữ số sau dấu chấm. Xét tập từ vựng thu nhỏ :
- Token
"A": Không đọc được ở bất kì trạng thái nào -> Bị loại bỏ. - Token
".2": Đọc ký tự.(chuyển sang trạng thái 2) rồi đọc2(chuyển sang trạng thái 3). Đường đi là . Token".2"bắt đầu từ trạng thái . Do đó, thêm".2"vào tập - Token
"42": Đọc từ trạng thái HOẶC từ trạng thái . Thêm"42"vào tập , và . Bảng Chỉ mục thu được:
- Khi Mô hình Sinh chuỗi
- Tại bước đầu (), lấy trực tiếp , mặt nạ lập tức chặn token
"A". - Nếu LLM chọn token
".2", FSM nhảy sang trạng thái . - Ở bước tiếp theo (), lấy trực tiếp , chặn sạch dấu chấm hay chữ cái. LLM bắt buộc chỉ được sinh số tiếp theo.
Mở rộng cho Ngữ pháp Cú pháp Phức tạp (CFG / Pushdown Automata)
Đối với mã nguồn như Python, JSON hay SQL, biểu thức chính quy (FSM) không đủ sức biểu dễ do có cú pháp đóng/mở ngoặc lồng nhau. Bài báo mở rộng cơ chế trên sang Pushdown Automation (PDA):
- Khác biệt: PDA có thêm một Ngăn xếp (Stack ) để ghi nhớ ngữ cảnh (ví dụ: đã mở bao nhiêu dấu ngoặc
(). - Cách xử lý:
- Trạng thái hợp lệ hiện tại không chỉ là một số , mà là một Cấu hình Bộ phân tích (Parser Configuration) gồm:
(Trạng thái PDA hiện tại, Trạng thái FSM của ký hiệu kết thúc, Trạng thái ngăn xếp Stack)
- Trạng thái hợp lệ hiện tại không chỉ là một số , mà là một Cấu hình Bộ phân tích (Parser Configuration) gồm:
- Cấu trúc dữ liệu Trie: Bài báo lưu bảng chỉ mục các trạng thái ngăn xếp bằng cây Trie. Điều này cho phép truy vấn xem cấu hình ngăn xếp hiện tại của bộ phân tích cú pháp có khớp với chuỗi token sắp sinh ra hay không mà vẫn giữ được hiệu năng tối ưu.
Phân tích chi tiết
Lấy ví dụ cách xử lý từ code này:
from enum import Enum
from pydantic import BaseModel
class Name(str, Enum):
john = "John"
paul = "Paul"
class Age(int, Enum):
twenty = 20
thirty = 30
class Character(BaseModel):
name: Name
age: Age
MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
model = outlines.from_transformers(
AutoModelForCausalLM.from_pretrained(MODEL_NAME, device_map="cuda"),
AutoTokenizer.from_pretrained(MODEL_NAME),
)
# generator = model(model, Character)
char = model("Generate a young character named Paul.", Character)
print(char)
Đối với Pydantic Character, đầu ra phải là JSON:
{"name": "Paul", "age": 20}
Regex/FSM thông thường không thể xử lý cấu trúc mở/đóng ngoặc lồng nhau { ... }. Do đó bài báo mở rộng sang Tự động đẩy xuống (Pushdown Automation - PDA) có sử dụng Ngăn xếp (Stack).
Cơ chế 2 tầng (Two-Layer Architecture):
- Tầng vĩ mô (PDA Stack / Parser): Quản lý cấu trúc ngữ pháp (Ví dụ: Sau dấu
{phải là Tên trường, sau Tên trường phải là dấu:, sau dấu:phải là Giá trị). - Tầng vi mô (FSM / Lexer): Bản thân mỗi thành phần ngữ pháp (như
STRING,NUMBER) lại là một FSM Regex nhỏ.
Diễn tiến từng bước khi chạy mã Character
Giả sử Pydantic sinh ra một Ngữ pháp chi phí bối cảnh (CFG) đơn giản hóa:
ROOT{KEY_NAME:VAL_STRING,KEY_AGE:VAL_INT}Hãy xem toàn bộ quá trình xử lý của Thuật toán khi LLM bắt đầu sinh dữ liệu:
[!NOTE] BƯỚC 1: Khởi tạo
- Trạng thái Ngăn xếp (Stack): [ROOT]
- Bộ phân tích (Parser) kỳ vọng ký hiệu đầu tiên:
{- FSM tương ứng với
{chỉ chấp nhận các token bắt đầu bằng dấu{(vd:{,{",{\n)- Ánh xạ thu gọn tập từ vựng: Chỉ giữ lại các token có chứa
{LLM lấy mẫu được token:{\n- Cập nhật Stack: [ROOT, WAIT_KEY_NAME] (Đã nuốt xong
{, giờ chờ KEY_NAME)
BƯỚC 2: Sinh Key thứ nhất
- Trạng thái Ngăn xếp: [ROOT, WAIT_KEY_NAME]
- Parser bắt buộc ký hiệu tiếp theo phải khớp Regex:
"name"- FSM cho
"name"tạo ra tập : Chỉ chứa các token khớp 1 phần hoặc toàn bộ chuỗi"name"(vd:","name,"name","name":) LLM lấy mẫu được token:"name": "- Cập nhật Stack: [ROOT, WAIT_VAL_STRING] (Đã nuốt xong
"name": ", giờ chờ Giá trị String)
BƯỚC 3: Sinh Giá trị Tên ("Paul")
- Trạng thái Ngăn xếp: [ROOT, WAIT_VAL_STRING]
- Parser kỳ vọng một chuỗi chữ cái kết thúc bằng dấu kép
"và dấu phẩy,- FSM cho
VAL_STRINGchấp nhận mọi chuỗi ký tự bất kì, MIỄN LÀ kết thúc bằng",LLM lấy mẫu được token:Paul",\n- Cập nhật Stack: [ROOT, WAIT_KEY_AGE] (Đã nuốt xong tên và phẩy, giờ chờ KEY_AGE)
BƯỚC 4: Sinh Key thứ hai
- Trạng thái Ngăn xếp: [ROOT, WAIT_KEY_AGE]
- Parser bắt buộc ký tự tiếp theo phải là
"age"- FSM lọc tập từ vựng : Chỉ cho phép token khớp
"age"LLM lấy mẫu được token:"age":- Cập nhật Stack: [ROOT, WAIT_VAL_INT]
BƯỚC 5: Sinh giá trị Tuổi (Number)
- Trạng thái ngăn xếp: [ROOT, WAIT_VAL_INT]
- Parser kỳ vọng một Số nguyên (Regex
[0-9]+) theo sau là dấu đóng ngoặc}- FSM lọc tập từ vựng : CHẶN SẠCH CHỮ CÁI, chỉ giữ lại các token là Chữ số (vd:
20,25},30}\n) LLM lấy mẫu được token:20}- Cập nhật Stack: [] (Rỗng - Hoàn thành Parsing)
- Gặp token EOS -> Dừng quá trình sinh
Điểm mấu chốt kỹ thuật ở mở rộng PDA:
- Hành động REDUCE trong LALR(1) Parser:
Khi LLM sinh xong giá trị
"Paul", bộ phân tích cú pháp phải thực hiện hành động REDUCE (Thu gọn chuỗi"Paul"thành symbolVAL_STRINGvà xóa nó khỏi Stack). - Cấu trúc dữ liệu Trie: Do bộ nhớ Ngăn xếp (Stack) thay đổi liên tục khi thực hiện các phép PUSH và REDUCE, việc tra cứu xem "Stack hiện tại có hợp lệ với token hay không" phức tạp hơn FSM rất nhiều. Tác giả sử dụng Cây Trie để lưu trữ tất cả các trạng thái ngăn xếp hợp lệ, giúp tra cứu trạng thái Stack thì mất chi phí cực nhỏ lúc runtime.
Tóm lại:
- FSM (Regex): Dùng bảng băm đơn giản vì trạng thái chỉ là số nguyên .
- PDA (CFG/Pydantic/JSON): Dùng Cây Trie kết hợp giữa
[Trạng thái Stack + FSM trạng thái hiện tại]để tra cứu tập token hợp lệ . Nhờ cơ chế này, lệnhchar = model("...", Character)trong đoạn code trên luôn đảm bảo về đúng định dạng JSON chuẩn 100% không bao giờ bị lỗi parse cú pháp, đồng thời tốc độ sinh nhanh vượt trội so với các thư viện cũ.
3. Dữ liệu và thực nghiệm
- Mô hình thử nghiệm: GPT-2 và GPT2-medium (335 triệu tham số).
- Thư viện triển khai: Mã nguồn mở Outlines và thư viện Guidance (Microsoft).
- Tác vụ thực nghiệm: Sinh tên biến Python tuân theo biểu thức chính quy
[^\W\d]\w* - Đo lường: Sử dụng module
timeitđể ghi lại thời gian chạy thực tế tương ứng với số lượng token sinh ra.
4. Kết quả và đóng góp
- Hiệu năng vượt trội:
- Giảm độ phức tạp tính toán kiểm tra mặt nạ từ xuống trung bình cho mỗi token sinh ra.
- Trong thực nghiệp sinh 100 token, thư viện Guidance mất khoảng 120 giây, trong khi Outline chỉ mất khoảng 2 giây (tốc độ gần như không bị ảnh hưởng bởi độ dài chuỗi sinh).
- Đảm bảo tính chính xác cú pháp: Đảm bảo 100% đầu ra của LLM tuân thủ cấu trúc mong muốn mà không cần fine-tuning hay lo ngại về việc mô hình vi phạm định dạng.
- Đóng góp mã nguồn mở: Đóng góp thư viện mã nguồn mở Outlines bằng Python, giúp cộng động dễ dàng tích hợp vào các quy trình phát triển ứng dụng LLM.
- Tính độc lập mô hình (Model-agnostic): Phương pháp hoạt động trực tiếp trên phân phối xác suất token của bất kỳ mô hình ngôn ngữ nào mà không cần thay đổi kiến trúc hay trọng số mô hình.
5. Hạn chế và chi phí
- Đánh đổi Bộ nhớ và Tính toán (Memory vs Computation Trade-off):
- Để đạt tốc độ lúc runtime, phương pháp chuyển chi phí tính toán sang bộ nhớ để lưu trữ bảng chỉ mục .
- Dung lượng bộ nhớ phụ thuộc và số lượng trạng thái của FSM/PDA () và kích thước tập từ vựng .
- Kích thước chỉ mục: Thử nghiệm với ngữ pháp Python sơ khai cho thấy kích thước chỉ mục khoảng 50MB. Mặc dù con số này tương đối nhỏ, nếu ngữ pháp rất phức tạp hoặc sử dụng DFA chưa tối ưu (chưa giảm số trạng thái dư thừa), kích thước chỉ mục có thể gia tăng.
- Mức độ can thiệp kiến trúc: Phương pháp hiện tại mới chỉ áp dụng mặt nạ ở tầng logit cuối cùng. Nhóm tác giả lưu ý rằng trong tương lai, việc đưa mặt nạ lên các tầng sâu hơn trong kiến trúc transformer để bỏ qua các phép tính không cần thiết có thể giúp tiết kiệm thêm chi phí tính toán.
All rights reserved