Inference-Time Scaling on a Budget: Making Small Local LLMs Reason Better with Self-Consistency and Verifiers
Tuần này khóa học video "build reasoning models from scratch" của Sebastian Raschka đang lên top Hacker News. Nhiều bạn xem xong sẽ hỏi: "Hay đấy, nhưng mình không có cụm GPU để train RL thì làm được gì?". Câu trả lời là có.
Trong các kỹ thuật làm model "suy luận tốt hơn", có một nhánh không cần train lại gì cả: inference-time scaling, tức là dùng thêm compute lúc chạy để đổi lấy độ chính xác. Mình đã áp dụng nó cho một tool nội bộ chạy model 8B trên một con RTX 3090.
Với các task có đáp án kiểm chứng được (toán, sinh SQL, sinh function có unit test), độ chính xác tăng rõ rệt mà không cần đổi model. Bài này chia sẻ cách làm thực tế: self-consistency, verifier loop, và lúc nào thì không nên dùng.
Inference-time scaling là gì và vì sao nó hiệu quả
Ý tưởng rất đơn giản: LLM là một sampler xác suất. Hỏi một lần thì có thể nó đi sai hướng ngay từ bước đầu. Hỏi N lần với temperature > 0 thì bạn có N "đường suy luận" khác nhau.
Nếu đáp án đúng xuất hiện thường xuyên hơn các đáp án sai (điều này thường đúng với model đủ tốt), lấy majority vote sẽ lọc được nhiễu. Kỹ thuật này gọi là self-consistency (Wang et al., 2022).
Nâng cấp hơn nữa là best-of-N với verifier: thay vì vote, bạn dùng một bộ kiểm tra bên ngoài (chạy unit test, chạy EXPLAIN cho SQL, parse JSON schema) để chọn ứng viên đúng. Verifier càng khách quan thì kết quả càng tốt.
flowchart LR
Q[Câu hỏi] --> S{Sample N lần temperature=0.7}
S --> A1[Đáp án 1]
S --> A2[Đáp án 2]
S --> A3[Đáp án ...N]
A1 --> V[Verifier / Majority vote]
A2 --> V
A3 --> V
V --> R[Đáp án cuối]
Điểm mấu chốt: chi phí tăng tuyến tính theo N, nhưng nhờ batching trên server inference, latency gần như không tăng tuyến tính. Đây là chỗ nhiều người bỏ qua.
Setup server: bật parallel slots để N lần sample gần như "miễn phí"
Mình dùng llama-server của llama.cpp vì nó nhẹ và có API tương thích OpenAI. Flag quan trọng nhất là -np (số parallel slot) kết hợp với continuous batching:
# Build llama.cpp với CUDA (cần CMake >= 3.14)
git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cmake -B build -DGGML_CUDA=ON && cmake --build build -j --config Release
# Chạy server: 8 slot song song, context tổng 32k => mỗi slot 4k
./build/bin/llama-server \
-m ~/models/Qwen3-8B-Q4_K_M.gguf \
-ngl 99 \
-c 32768 \
-np 8 \
--cont-batching \
--host 127.0.0.1 --port 8080
Lưu ý: -c là context tổng, chia đều cho các slot. Nếu prompt + output của bạn dài 6k token mà để -c 32768 -np 8 thì mỗi slot chỉ có 4k, output sẽ bị cắt.
Nếu bạn dùng vLLM (ví dụ 0.6+), batching được xử lý tự động, chỉ cần truyền tham số n trong request.
Trên RTX 3090 với model 8B Q4, mình đo được: 1 request sinh 500 token mất khoảng 5s, 8 request song song mất khoảng 8-9s. Tức là gấp 8 lần số mẫu nhưng chưa tới 2 lần thời gian chờ.
Code: self-consistency cho task có đáp án rời rạc
Đoạn Python dưới đây gửi N request song song, trích đáp án cuối và vote. Phần quan trọng nhất là hàm trích đáp án: nếu normalize kém thì "42", "42.0" và "42" bị tính là 3 phiếu khác nhau, và vote mất tác dụng.
# pip install openai==1.*
import asyncio, re
from collections import Counter
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none")
PROMPT = ("Giải bài toán sau, suy luận từng bước. "
"Dòng cuối cùng ghi đúng định dạng: ANSWER: <số>\n\n{q}")
def extract(text: str) -> str | None:
m = re.search(r"ANSWER:\s*\**\s*(-?[\d.,]+)", text)
if not m:
return None
num = m.group(1).replace(",", "").rstrip(".")
try:
f = float(num)
return str(int(f)) if f.is_integer() else f"{f:.4f}"
except ValueError:
return None
async def sample(q: str) -> str | None:
r = await client.chat.completions.create(
model="local",
messages=[{"role": "user", "content": PROMPT.format(q=q)}],
temperature=0.7,
top_p=0.95,
max_tokens=1500,
)
return extract(r.choices[0].message.content)
async def self_consistency(q: str, n: int = 8):
answers = [a for a in await asyncio.gather(*[sample(q) for _ in range(n)]) if a]
if not answers:
return None, 0.0
best, votes = Counter(answers).most_common(1)[0]
return best, votes / n # confidence = tỉ lệ đồng thuận
if __name__ == "__main__":
ans, conf = asyncio.run(self_consistency(
"Một cửa hàng giảm giá 20% rồi giảm thêm 15% trên giá mới. "
"Giá gốc 500000 đồng, giá cuối là bao nhiêu?"))
print(ans, f"confidence={conf:.0%}")
Cái hay mà ít người tận dụng là giá trị confidence. Khi 8/8 mẫu đồng thuận, bạn gần như có thể tin kết quả. Khi chỉ 3/8, đó là tín hiệu để escalate: đẩy sang model lớn hơn hoặc cho người review. Trong production, chính con số này giá trị hơn bản thân đáp án.
Verifier loop: khi đáp án có thể kiểm chứng bằng code
Với task sinh code, vote không còn hợp lý vì hai đoạn code đúng hiếm khi giống nhau từng ký tự. Lúc này bạn cần verifier. Flow mình dùng:
sequenceDiagram
participant App
participant LLM as llama-server
participant Sandbox
App->>LLM: Sinh N candidate (song song)
LLM-->>App: N đoạn code
loop Mỗi candidate
App->>Sandbox: Chạy unit test (timeout 5s)
Sandbox-->>App: pass/fail + số test pass
end
App->>App: Chọn candidate pass nhiều test nhất
import subprocess, tempfile, os
def run_tests(code: str, tests: str, timeout: int = 5) -> int:
"""Trả về số test pass. Chạy trong process riêng, có timeout."""
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "test_candidate.py")
with open(path, "w") as f:
f.write(code + "\n\n" + tests)
try:
r = subprocess.run(
["python", "-I", "-m", "pytest", "-q", path],
capture_output=True, text=True, timeout=timeout, cwd=d,
)
except subprocess.TimeoutExpired:
return 0
m = re.search(r"(\d+) passed", r.stdout)
return int(m.group(1)) if m else 0
def pick_best(candidates: list[str], tests: str) -> str | None:
scored = [(run_tests(c, tests), c) for c in candidates]
score, best = max(scored, key=lambda x: x[0])
return best if score > 0 else None
Hai lưu ý từ kinh nghiệm thực tế:
- Phải sandbox. Code do LLM sinh ra có thể chạy
rm -rfhoặc vòng lặp vô hạn. Tối thiểu là timeout + thư mục tạm; trên production nên chạy trong container với--network nonevà giới hạn CPU/RAM. - Test do chính LLM viết không phải verifier tốt. Model sai ở logic thì cũng viết test sai theo đúng logic đó. Verifier phải đến từ nguồn độc lập: test viết tay, schema có sẵn, hoặc so sánh với kết quả query trên DB staging.
Khi nào KHÔNG nên dùng
Inference-time scaling không phải thuốc tiên:
- Task mở (viết email, tóm tắt): không có đáp án để vote, không có verifier khách quan. Dùng LLM-as-judge thì lại tốn thêm một tầng chi phí và thiên kiến.
- Model quá yếu: nếu xác suất ra đáp án đúng của một lần sample dưới khoảng 30%, majority vote có thể còn khuếch đại đáp án sai phổ biến. Cứ đo trước trên tập eval nhỏ.
- Model reasoning đã có "thinking" dài: các model kiểu này đã tự scale bên trong. Thêm N=8 bên ngoài vẫn giúp được, nhưng tốn token gấp bội. Thường N=3-4 là điểm cân bằng.
Cách mình chọn N: chạy 50 câu trong tập eval với N = 1, 4, 8, 16, vẽ accuracy theo N. Thường thì đường cong bão hòa khá sớm, đâu đó ở N=8.
Kết luận
Không cần train RL hay thuê GPU cluster thì bạn vẫn có thể làm model nhỏ "suy luận" tốt hơn đáng kể. Các bước cụ thể:
- Bật parallel slots (
-nptrong llama-server, hoặc tham sốntrong vLLM) để N lần sample gần như không làm tăng latency. - Ép format đáp án (
ANSWER: ...) và viết hàm normalize cẩn thận. 80% bug của self-consistency nằm ở phần trích đáp án. - Dùng tỉ lệ đồng thuận làm confidence để định tuyến: đồng thuận cao thì trả luôn, thấp thì escalate.
- Ưu tiên verifier khách quan (unit test, schema, DB) thay vì vote khi task cho phép, và luôn sandbox code.
- Đo trước khi tin: tạo tập eval 50-100 câu từ dữ liệu thật của bạn, tìm N tối ưu cho từng loại task.
Nếu xem khóa của Raschka, bạn sẽ thấy self-consistency và verifier cũng là nền tảng của RL với verifiable rewards. Làm tốt phần inference trước thì sau này chuyển sang training sẽ dễ hơn nhiều.
Comments
No comments yet. Start the discussion.