Năm bài trước đã đi hết đường đi bên trong: chữ thành token, token qua mô hình, vì sao nhanh hay chậm, trọng số từ đâu ra, và phục vụ nhiều người ra sao. Bài cuối này quay sang phía người làm sản phẩm, với một nhận xét làm nền cho mọi thứ: mô hình chỉ biết làm một việc, là đoán token tiếp theo. Mọi thứ khác mà một "ứng dụng AI" làm được (làm theo lời dặn, tính toán chính xác, tra cứu dữ liệu, làm nhiều bước) đều là văn bản được đặt quanh mô hình, và code chạy quanh văn bản đó.
Lộ trình: lời dặn hệ thống thực chất là gì, cho mô hình gọi công cụ, vòng lặp agent (và chỗ một mô hình nhỏ làm sai), RAG để mô hình trả lời bằng dữ liệu của bạn, cái giá của mỗi kỹ thuật, rồi nhìn lại cả seri từ góc độ một sản phẩm.
Mọi con số trong bài đều đo trên máy thật, với Python 3.12, PyTorch 2.14, transformers 5.20, mô hình Qwen2.5-0.5B-Instruct chạy bằng CPU với 2 luồng. Mọi câu trả lời là sinh tham lam, đã tắt repetition penalty (cái bẫy ở bài 2), nên chạy lại sẽ ra đúng như vậy.
Lời dặn hệ thống chỉ là văn bản
Bài 1 đã cho thấy khi gửi "Xin chào", thứ đi vào Qwen là 32 token, trong đó có một lời dặn mặc định: You are Qwen, created by Alibaba Cloud. You are a helpful assistant. Đó là system prompt. Nó không có quyền lực đặc biệt nào: nó là những token đứng đầu dãy, trong khung <|im_start|>system. Mô hình làm theo nó vì trong lúc fine-tune (bài 4), nó đã thấy rất nhiều đoạn hội thoại mà câu trả lời tuân theo phần system.
Hai hệ quả thực tế:
- Lời dặn tốn token ở mọi yêu cầu. Một system prompt 1.000 token là 1.000 token prefill (bài 3) cho mỗi câu hỏi. Đặt phần cố định ở đầu để tận dụng prompt caching.
- Lời dặn không phải là hàng rào. Người dùng có thể viết bất cứ điều gì trong tin nhắn của họ, kể cả "bỏ qua mọi lời dặn trước đó". Mô hình chỉ thấy một dãy token, và không có cơ chế nào đảm bảo phần system luôn thắng. Mọi giới hạn thật sự (ai được xem dữ liệu gì, được làm gì) phải nằm trong code của bạn, không nằm trong prompt.
Công cụ: để code làm phần mô hình làm kém
Hỏi Qwen một phép nhân:
Hỏi: 1234 nhân 5678 bằng bao nhiêu?
Đáp: ... 1234 x 5678 = 6,888,032 ...
Đáp án đúng là 7.006.652. Bài 1 đã giải thích một phần lý do: Qwen cắt số thành từng chữ số, và mô hình phải "tính nhẩm" bằng cách đoán từng token kết quả, không có bảng cửu chương nào bên trong. Một câu trả lời sai nhưng rất tự tin.
Lời giải không phải là một mô hình giỏi tính hơn, mà là để code tính. Ta mô tả một hàm cho mô hình:
tools = [{"type": "function", "function": {
"name": "multiply", "description": "Multiply two numbers exactly.",
"parameters": {"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]},
}}]
inputs = tokenizer.apply_chat_template(messages, tools=tools, add_generation_prompt=True, return_tensors="pt", return_dict=True)
Mô tả đó đi vào mô hình bằng cách nào? Xem văn bản mà chat template tạo ra:
<|im_start|>system
You are Qwen, created by Alibaba Cloud. You are a helpful assistant.
# Tools
You may call one or more functions to assist with the user query.
You are provided with function signatures within <tools></tools> XML tags:
<tools>
{"type": "function", "function": {"name": "multiply", "description": "Multiply two numbers exactly.", ...}}
</tools>
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
<tool_call>
{"name": <function-name>, "arguments": <args-json-object>}
</tool_call><|im_end|>
<|im_start|>user
1234 nhân 5678 bằng bao nhiêu?<|im_end|>
<|im_start|>assistant
"Công cụ" chỉ là văn bản trong system prompt. Một đoạn hướng dẫn bằng tiếng Anh, mô tả hàm dạng JSON, và một quy ước định dạng. Prompt từ 44 token thành 187. Mô hình đáp lại:
<tool_call>
{"name": "multiply", "arguments": {"a": 1234, "b": 5678}}
</tool_call>
Mô hình không chạy gì cả. Nó chỉ sinh ra những token trông giống một lời gọi hàm, vì trong lúc fine-tune nó đã học quy ước này. Việc chạy hàm là của code ta: tìm khối <tool_call>, đọc JSON, gọi hàm thật, rồi đưa kết quả lại cho mô hình. Các API như của OpenAI hay Anthropic trả về lời gọi công cụ dưới dạng dữ liệu có cấu trúc thay vì văn bản thô, nhưng bên dưới, mô hình vẫn đang làm đúng việc này: sinh token theo một định dạng đã học.
Vòng lặp agent
Ghép lại: hỏi mô hình; nếu nó yêu cầu công cụ, chạy công cụ, đưa kết quả vào hội thoại, rồi hỏi lại; lặp tới khi nó trả lời mà không yêu cầu gì nữa.
import json
import re
TOOLS = {"multiply": lambda a, b: a * b, "add": lambda a, b: a + b}
CALL = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
def agent(question: str, max_turns: int = 5) -> str:
messages = [{"role": "user", "content": question}]
for _ in range(max_turns):
reply = complete(messages) # apply_chat_template(..., tools=SCHEMAS) + generate, as above
calls = [json.loads(c) for c in CALL.findall(reply)]
messages.append({"role": "assistant", "content": reply})
if not calls:
return reply # no tool requested: this is the answer
for call in calls: # the model asked; *our* code runs the tool
result = TOOLS[call["name"]](**call["arguments"])
messages.append({"role": "tool", "content": json.dumps(result)})
return reply
Kết quả của công cụ quay lại mô hình cũng chỉ là văn bản. Với Qwen, nó thành một lượt user mới:
<|im_start|>user
<tool_response>
7006652
</tool_response><|im_end|>
<|im_start|>assistant
Chạy ba câu hỏi, cùng hai công cụ multiply và add:
| Câu hỏi | Mô hình gọi | Trả lời |
|---|---|---|
| 1234 nhân 5678 bằng bao nhiêu? | multiply(1234, 5678) → 7006652 |
"… là 7006652." ✓ |
| Thủ đô của Việt Nam là gì? | (không gọi gì) | "Thủ đô của Việt Nam là Hà Nội." ✓ |
| Tính (12 + 30) nhân 7. | multiply(12, 30) → 360, rồi add(7, 0) → 7 |
"The result … is 360." ✗ |
Hai câu đầu đúng: mô hình biết khi nào cần công cụ và khi nào không. Câu thứ ba cho thấy giới hạn: phép tính cần hai bước theo đúng thứ tự (cộng trước, nhân sau), và mô hình 0,5 tỉ tham số không lập được kế hoạch đó. Nó nhân nhầm hai số đầu, cộng hai số vô nghĩa, rồi trả lời bằng tiếng Anh một kết quả sai (đúng là 294). Định dạng thì hoàn hảo; suy luận thì không. Mô hình lớn hơn làm tốt hơn hẳn ở việc chia bài toán thành nhiều bước, và đó chính là thứ làm cho "agent" (mô hình tự quyết định chuỗi hành động) dùng được trong thực tế.
Vài điều cần có trong mọi vòng lặp agent thật:
- Giới hạn số vòng (
max_turns). Một mô hình có thể gọi công cụ mãi không dừng. - Kiểm tra trước khi chạy. JSON mô hình sinh ra có thể sai cú pháp, gọi hàm không tồn tại, hoặc truyền tham số nguy hiểm. Mô hình chỉ đề nghị; code của bạn quyết định có chạy hay không, với quyền gì. Một công cụ "xóa file" hay "gửi email" cần được đối xử như một ô nhập liệu từ người lạ.
- Mọi kết quả công cụ đều tốn token. Mỗi vòng, cả hội thoại (gồm các lời gọi và kết quả trước đó) lại đi qua prefill.
RAG: trả lời bằng dữ liệu của bạn
Hỏi Qwen về chính trang web này:
Hỏi: Website này chạy sau reverse proxy nào?
Đáp: Website này chạy sau reverse proxy là Nginx. Nginx là một trong những công cụ reverse proxy
phổ biến nhất ...
Hỏi: Huấn luyện mô hình nhỏ trên Wikipedia tiếng Việt mất bao lâu?
Đáp: Huấn luyện mô hình nhỏ trên Wikipedia tiếng Việt mất khoảng 1-2 ngày. ...
Cả hai sai, và sai rất tự tin. Mô hình chưa bao giờ đọc các bài viết này, nên nó đoán theo những gì phổ biến nhất (Nginx là reverse proxy phổ biến; "huấn luyện mô hình" nghe như mất nhiều ngày). Đây là cùng cơ chế với "Johny von Neumann" ở bài 2: mô hình chọn token hợp lý, không tra cứu sự thật.
RAG (retrieval-augmented generation) sửa điều đó bằng cách đơn giản nhất có thể: trước khi hỏi, tìm những đoạn tài liệu liên quan, rồi dán chúng vào prompt. Mô hình không cần biết câu trả lời; nó chỉ cần đọc và chép lại.
Tìm kiếm bằng BM25
Phần "tìm" không cần mô hình nào. BM25 là một công thức xếp hạng cổ điển của các công cụ tìm kiếm: một đoạn văn được điểm cao nếu nó chứa nhiều từ của câu hỏi, từ hiếm được tính nặng hơn từ phổ biến, và đoạn dài bị trừ bớt để không thắng chỉ vì dài.
import math
import re
from collections import Counter
WORD = re.compile(r"\w+")
class BM25:
"""Score pieces by shared words, weighting rare words more and long pieces less."""
def __init__(self, docs, k1=1.5, b=0.75):
self.docs = docs
self.tf = [Counter(WORD.findall(d.lower())) for _, d in docs]
self.len = [sum(t.values()) for t in self.tf]
self.avg = sum(self.len) / len(self.len)
df = Counter(w for t in self.tf for w in t)
self.idf = {w: math.log(1 + (len(docs) - n + 0.5) / (n + 0.5)) for w, n in df.items()}
self.k1, self.b = k1, b
def search(self, query: str, k: int = 3):
words = WORD.findall(query.lower())
scores = []
for i, tf in enumerate(self.tf):
norm = self.k1 * (1 - self.b + self.b * self.len[i] / self.avg)
scores.append(sum(self.idf.get(w, 0) * tf[w] * (self.k1 + 1) / (tf[w] + norm) for w in words if w in tf))
best = sorted(range(len(scores)), key=scores.__getitem__, reverse=True)[:k]
return [self.docs[i] for i in best]
Tài liệu là phần chữ (bỏ code) của mọi bài tiếng Việt trên trang này, cắt thành các đoạn khoảng 800 ký tự: 189 đoạn. Lập chỉ mục mất 4 ms; mỗi lần tìm dưới 1 ms. Ba đoạn điểm cao nhất được đặt vào system prompt, kèm một lời dặn:
context = "\n\n---\n\n".join(text for _, text in index.search(question))
messages = [
{"role": "system", "content": "Trả lời ngắn gọn bằng tiếng Việt, chỉ dựa vào tài liệu dưới đây. "
"Nếu tài liệu không có câu trả lời, hãy nói không biết.\n\n" + context},
{"role": "user", "content": question},
]
| Câu hỏi | Không có RAG | Có RAG | Đoạn tìm được từ |
|---|---|---|---|
| Website này chạy sau reverse proxy nào? | Nginx ✗ | "Website này chạy sau Caddy." ✓ | bài streaming |
| Huấn luyện mô hình nhỏ trên Wikipedia tiếng Việt mất bao lâu? | 1–2 ngày ✗ | "… mất khoảng 1 phút." ✓ | bài huấn luyện |
| Với tokenizer của GPT-4, tiếng Việt tốn gấp bao nhiêu lần số token so với tiếng Anh? | "tôi cần biết cụ thể…" | "… tốn gấp 69% so với tiếng Anh." ~ | bài token |
Hai câu đầu từ sai thành đúng. Câu thứ ba gần đúng: tài liệu nói "nhiều hơn 69%" (1,69 lần), mô hình nhỏ chép lại thành "gấp 69%", một cách nói lẫn lộn. Đó là điều đáng nhớ về RAG: nó giải quyết chuyện mô hình không biết, nhưng không giải quyết chuyện mô hình đọc hiểu kém. Mô hình lớn hơn đọc và diễn đạt lại chính xác hơn nhiều; với mô hình nào, cũng nên cho người dùng thấy đoạn nguồn để họ tự kiểm tra.
BM25 tìm theo từ giống nhau. Nếu người dùng hỏi "web này dùng máy chủ trung gian gì?" thay vì "reverse proxy", nó có thể trượt. Các hệ thống RAG thường thêm tìm kiếm bằng embedding: một mô hình biến mỗi đoạn văn thành một vector sao cho các đoạn cùng nghĩa nằm gần nhau, để tìm theo nghĩa thay vì theo chữ. Hai cách thường được dùng cùng nhau, vì BM25 giỏi với tên riêng, mã sản phẩm, con số, những thứ mà embedding hay làm nhòe.
Cái giá của RAG
| Không có RAG | Có RAG | |
|---|---|---|
| Token của prompt | 37–55 | 657–898 |
| Prefill (thời gian tới token đầu tiên) | 71–103 ms | 481–682 ms |
Mỗi câu hỏi giờ kèm khoảng 2.500 ký tự tài liệu, nên prompt dài gấp 16–18 lần, và token đầu tiên đến chậm hơn 6–8 lần. Đây là bài 3 áp dụng trực tiếp: prefill tăng theo độ dài prompt, và với API, token đầu vào là tiền. Vì vậy chất lượng của phần "tìm" quan trọng ngang phần "sinh": tìm đúng 3 đoạn tốt hơn nhiều so với dán 30 đoạn "cho chắc".
Ghép vào một endpoint
Mọi mảnh của bài này nằm gọn trong chatbot của seri Python. Hàm answer ở đó nhận question và stream từng token về trình duyệt; chỉ cần thay dòng tạo inputs:
async def answer(question: str):
context = "\n\n---\n\n".join(text for _, text in index.search(question)) # retrieval: under 1 ms
messages = [
{"role": "system", "content": RAG_INSTRUCTIONS + "\n\n" + context},
{"role": "user", "content": question},
]
inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt", return_dict=True)
... # the rest is unchanged: model in a thread, bounded queue, async generator, SSE
Phần còn lại giữ nguyên: mô hình chạy trong thread, token đi qua queue có giới hạn, finally dừng mô hình khi người dùng rời trang. Với vòng lặp agent, các bước gọi công cụ chạy trước, và chỉ câu trả lời cuối cùng được stream; hoặc stream cả các bước ("đang tính 1234 × 5678…") để người dùng biết hệ thống đang làm gì.
Nhìn lại cả seri
Mỗi quyết định khi làm một sản phẩm dùng LLM đều quay về một trong năm bài trước:
| Câu hỏi của sản phẩm | Câu trả lời nằm ở |
|---|---|
| Tốn bao nhiêu tiền? Prompt dài bao nhiêu là quá dài? | Bài 1: token; tiếng Việt và NFC/NFD |
| Vì sao câu trả lời khác nhau giữa hai nơi chạy? Temperature đặt bao nhiêu? | Bài 2: sampling, generation_config.json |
| Vì sao token đầu tiên chậm? Nên dùng phần cứng nào? | Bài 3: prefill, decode, băng thông bộ nhớ |
| Vì sao mô hình bịa? Nó biết gì và không biết gì? | Bài 2 và 4: đoán token, dữ liệu huấn luyện |
| Phục vụ nhiều người thì tốn bao nhiêu? Có nên lượng tử hóa? | Bài 5: batching, KV cache, 8-bit và 4-bit |
| Làm sao cho nó tính đúng, dùng dữ liệu riêng, làm nhiều bước? | Bài này: công cụ, RAG, agent |
Tóm lại
| Khái niệm | Thực chất |
|---|---|
| System prompt | Những token đứng đầu dãy; mô hình theo nó vì đã học như vậy, không phải vì bị bắt buộc |
| Công cụ | Mô tả JSON chèn vào system prompt; mô hình sinh <tool_call>, code của bạn chạy hàm |
| Kết quả công cụ | Một lượt hội thoại mới bằng văn bản (<tool_response>) |
| Agent | Vòng lặp hỏi → chạy công cụ → hỏi lại; cần giới hạn vòng và kiểm tra mọi lời gọi |
| Giới hạn của mô hình nhỏ | Định dạng đúng, kế hoạch nhiều bước sai |
| RAG | Tìm đoạn liên quan rồi dán vào prompt; sửa chuyện "không biết", không sửa chuyện "đọc kém" |
| BM25 và embedding | Tìm theo từ và tìm theo nghĩa; thường dùng cùng nhau |
| Cái giá | RAG làm prompt dài gấp 16–18 lần, token đầu tiên chậm hơn 6–8 lần |
Seri bắt đầu với câu "mô hình không đọc chữ, nó đọc số" và giờ đã tới một ứng dụng biết tính toán và tra cứu. Ở giữa là một chuỗi ý tưởng không có gì huyền bí: một bảng tra, vài phép nhân ma trận, một vòng lặp đoán token, và rất nhiều văn bản được đặt đúng chỗ.
Bài cuối nhìn vào phần bao quanh các agent ngày nay: skills, subagent, MCP và hooks, những thứ biến vòng lặp 20 dòng ở trên thành một công cụ làm việc thật.