Khi tích hợp AI vào hệ thống backend, chúng ta không thể dùng kết quả dạng văn bản tự do của LLM làm đầu vào cho khối xử lý logic tiếp theo. Nếu AI trả về lời dẫn giải thích lê thê, hệ thống lập tức vỡ ở bước phân tích cú pháp (Parsing Error).
Bài học này lắp hai lớp bảo đảm cho mắt xích đó. Structured Outputs ép mô hình trả về JSON khớp một JSON Schema định sẵn — và chúng ta sẽ xem vì sao nó không thể sai, bằng cách tự cài đặt đúng cơ chế chặn ở tầng logits. Function Calling cho phép mô hình yêu cầu chạy hàm trong hệ thống của bạn, qua một chu trình bốn bước mà bạn sẽ tự viết. Dự án cuối bài là một trình xử lý email chạy được ngay, kèm phép kiểm chứng minh nó thực sự đọc email chứ không bịa ra kết quả.
json, re,
math và random trong thư viện chuẩn Python. Không cần mạng, không cần API key.
Kiến thức cần có: Bài 11 — mảng
messages phân vai, và quan trọng hơn: hàm
softmax() biến logits thành xác suất. Mục 12.2 dùng lại đúng hàm đó. Bài 10 cho ý tưởng
mặt nạ: gán -1e9 vào một vị trí trước khi chạy Softmax thì xác suất của nó thành
đúng 0 — Structured Outputs chính là kỹ thuật ấy, áp lên từ vựng thay vì lên vị trí.
12.1 Ép LLM trả về dữ liệu có cấu trúc: JSON Mode & Structured Outputs
Trong lập trình truyền thống, ta cần các kiểu dữ liệu tĩnh và cấu trúc đối tượng tường minh (như Class, Struct) để trao đổi thông tin giữa các dịch vụ. Mặc định, LLM sinh từ tự do và thích giao tiếp bằng ngôn ngữ tự nhiên. Để giải quyết bài toán này, các nhà phát triển API cung cấp hai tùy chọn:
-
JSON Mode: Cấu hình hệ thống bắt buộc mô hình phải trả về một chuỗi có cú pháp JSON hợp
lệ (ví dụ: mở ngoặc nhọn
{và đóng ngoặc nhọn}đầy đủ). Tuy nhiên, JSON Mode không đảm bảo cấu trúc các trường bên trong (keys) sẽ đúng 100% như bạn mong muốn. - Structured Outputs (Đầu ra có cấu trúc bắt buộc): Đây là cơ chế cao cấp hơn. Nhà phát triển gửi kèm một sơ đồ đặc tả JSON Schema chi tiết. Mô hình được cam kết chỉ chọn các từ sinh ra sao cho khớp tuyệt đối với JSON Schema này. Mục 12.2 sẽ mổ xẻ đúng cơ chế đứng sau lời cam kết đó.
EMAIL_SCHEMA chính là một schema
như vậy, và hàm validate() tự viết sẽ kiểm dữ liệu theo đúng nó.
Điểm mấu chốt mà rất nhiều người bỏ qua:
phân tích cú pháp và kiểm tra cấu trúc là hai việc khác nhau.
json.loads() chỉ trả lời câu hỏi "chuỗi này có phải JSON không". Nó hoàn toàn không quan tâm
đối tượng thu được có đủ trường bạn cần hay không. Dự án cuối bài đưa bốn câu trả lời điển hình của mô
hình qua cả hai bước, và in ra kết quả thật:
=== JSON Mode guarantees syntax, not shape ===
complete and correct
json.loads : ok
validate : OK
valid JSON, missing a required field
json.loads : ok
validate : missing required field 'urgency'
valid JSON, urgency outside the enum
json.loads : ok
validate : field 'urgency' must be one of ['low', 'high'], got 'khan cap'
wrapped in a markdown fence, as models love to do
json.loads : CRASHED - Expecting value
validate : never reached
Ba dòng giữa là toàn bộ vấn đề của JSON Mode. Trường hợp thứ hai và thứ ba đều
vượt qua json.loads() trót lọt — không có exception nào, không có cảnh báo nào — rồi
mới vỡ ở bước kiểm schema. Nếu code của bạn không có bước kiểm đó, thứ đi tiếp vào database là một bản ghi
thiếu trường urgency, và lỗi sẽ nổ ở một chỗ hoàn toàn khác, muộn hơn nhiều.
response_format của OpenAI). Mô hình vẫn lịch sự dẫn dắt một câu rồi bọc JSON trong khối
markdown ```json. Chuỗi đó không phải JSON, nên json.loads() ném exception
ngay tại ký tự đầu tiên — đúng như dòng CRASHED - Expecting value ở trên. Prompt là lời đề
nghị; response_format mới là ràng buộc.
12.2 Vì sao Structured Outputs không thể sai: chặn ngay ở tầng logits
"Mô hình được cam kết trả về đúng schema" nghe như một lời hứa tiếp thị. Thực ra nó là một khẳng định kỹ thuật rất cụ thể, và bạn đã có đủ công cụ để hiểu nó từ hai bài trước.
Nhớ lại Bài 11: ở mỗi bước sinh từ, mô hình tính một điểm số thô (logit) cho mọi token trong từ vựng, rồi chạy Softmax để ra xác suất. Và nhớ lại Bài 10: muốn triệt tiêu hoàn toàn một lựa chọn, ta gán logit của nó thành $-10^9$ trước khi Softmax — hàm mũ biến số đó thành 0 tuyệt đối, không phải "rất nhỏ".
Grammar-constrained decoding (giải mã có ràng buộc ngữ pháp) ghép đúng hai ý đó lại: ở mỗi bước, một bộ máy trạng thái xác định những token nào còn giữ được chuỗi đầu ra trên đường hợp lệ, và mọi token khác bị gán $-10^9$. Mô hình không phải "cố gắng nghe lời" — nó không có đường nào khác để đi.
Dự án cuối bài cài đặt đúng cơ chế này trên một từ vựng đồ chơi gồm 11 token, với ngữ pháp chỉ chấp nhận
đối tượng dạng {"urgency": "low"}. Nó sinh 500 chuỗi ở nhiệt độ cao theo hai chế độ và đếm
xem bao nhiêu chuỗi vừa phân tích được vừa khớp schema:
=== Constrained decoding: masking logits before softmax ===
free sampling : 0/500 parsed and matched the schema
first failure looked like: "summary":"low"}"summary"
constrained : 500/500 parsed and matched the schema
Masking makes an invalid token unreachable, not just unlikely.
Hai con số này là toàn bộ luận điểm của mục 12.1, đo được thay vì phải tin. Không mặt nạ:
0 trên 500 — chuỗi hỏng đầu tiên trông như "summary":"low"}"summary", đúng
kiểu rác mà json.loads() sẽ ném exception. Có mặt nạ: 500 trên 500, và tỷ lệ
đó không phải may mắn — nó là 100% ở mọi lần chạy, vì token sai đã bị loại khỏi phân phối. Điều duy nhất
mô hình còn được quyết định là "low" hay "high": đúng phần ngữ nghĩa mà ta muốn
nó suy nghĩ, không phải phần cú pháp.
finish_reason: "length" ở Bài 11 — chuỗi bị cắt thì không còn là JSON hợp lệ). Hàm
validate() ở phía bạn tốn vài mili-giây và biến một lỗi âm thầm thành một lỗi ồn ào. Giữ nó
lại.
12.3 Cầu nối với thế giới ngoài: Cơ chế Function Calling
Đến đây ta đã ép được mô hình trả lời đúng hình dạng. Nhưng nó vẫn chỉ biết những gì có trong dữ liệu huấn luyện: một mô hình ngôn ngữ thuần túy giống như một bộ óc bị cô lập — không thể tự truy cập Internet thời gian thực, không đọc được cơ sở dữ liệu nội bộ của bạn, không gửi được email.
Function Calling (Gọi hàm) là cơ chế cho phép ta mô tả các hàm phần mềm cục bộ (tên hàm, chức năng, danh sách tham số) cho LLM biết. Khi người dùng đưa ra một câu hỏi yêu cầu hành động hoặc thông tin thời gian thực, LLM tự động nhận diện và thay vì trả lời trực tiếp bằng văn bản, nó sẽ trả về một yêu cầu gọi hàm (Tool Call).
Đầu ra của mô hình lúc này là một cấu trúc JSON chỉ định:
- Tên hàm cần gọi (ví dụ:
send_alert_email). -
Các giá trị đối số (arguments) được trích xuất trực tiếp từ câu hỏi của người dùng (ví
dụ:
{"recipient_email": "[email protected]"}).
Để LLM "biết" hàm send_alert_email tồn tại, lập trình viên phải mô tả nó dưới dạng JSON
Schema và gửi kèm trong tham số tools của request — không phải gửi mã nguồn Python thật:
{
"type": "function",
"function": {
"name": "send_alert_email",
"description": "Send an urgent alert email to the systems administrator.",
"parameters": {
"type": "object",
"properties": {
"recipient_email": { "type": "string" },
"subject": { "type": "string" },
"alert_content": { "type": "string" }
},
"required": ["recipient_email", "subject", "alert_content"]
}
}
}
Chú ý đây lại là JSON Schema — cùng đúng tiêu chuẩn ở mục 12.1, giờ dùng để mô tả tham số của hàm thay vì
mô tả kết quả trả về. LLM chỉ đọc phần mô tả (description) và cấu trúc tham số
(parameters) này để quyết định có nên gọi hàm hay không và điền giá trị nào vào từng trường —
nó không hề nhìn thấy, và cũng không cần biết, bên trong hàm send_alert_email thực sự làm gì.
Vì vậy description không phải chú thích cho vui: nó là toàn bộ thông tin mô hình có để chọn
đúng công cụ.
12.4 Chu trình Tool Call hoàn chỉnh (Loop)
Để hoàn thành một tác vụ gọi hàm, hệ thống của bạn phải vận hành theo một vòng lặp 4 bước khép kín như sau:
🔄 Sơ đồ chu trình gọi hàm 4 bước:
- Bước 1 (User → LLM): Người dùng gửi yêu cầu kèm theo danh sách mô tả các công cụ (Tools) mà ứng dụng cung cấp.
-
Bước 2 (LLM → App): LLM nhận diện cần dùng công cụ, trả về cấu
trúc yêu cầu gọi hàm
tool_calls(chứa tên hàm và tham số JSON). Trạng thái suy luận tạm dừng. -
Bước 3 (App → Local DB/API): Code của bạn phân tích
tool_calls, thực thi hàm thực tế trong môi trường an toàn và thu về kết quả thô (Observation). -
Bước 4 (App → LLM): Gửi kết quả thô của hàm ngược lại cho LLM kèm
nhãn vai trò
toolđể LLM tổng hợp, đọc hiểu và sinh ra câu trả lời tự nhiên cuối cùng cho người dùng.
Bốn bước đó kết thúc bằng một mảng messages có bốn vai trò, chứ không phải
ba như ở Bài 11. Vai trò mới là tool, và nó phải mang tool_call_id khớp với
id mà mô hình đã sinh ra ở bước 2 — nếu không, máy chủ không biết kết quả này trả lời cho lời
gọi nào. Đây là hình dạng mảng mà dự án in ra ở cuối:
=== The message array after the tool loop ===
system | You classify support email precisely.
user | Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ l
assistant | tool_calls=send_alert_email
tool | {"status": "delivered", "recipient": "admin_ops@comp
Để ý lượt assistant: trường content của nó là rỗng. Mô hình không nói gì với
người dùng ở lượt đó — nó chỉ đưa ra một yêu cầu gọi hàm. Đây là điểm hay bị viết sai khi tự dựng vòng
lặp: nhiều người bỏ qua lượt assistant rỗng đó và nhét thẳng kết quả hàm vào, khiến lịch sử hội thoại
không còn dấu vết mô hình đã yêu cầu gì.
12.5 Dự án thực hành bài 12: Trình phân loại email và chu trình gọi hàm
Dự án nhận vào nội dung email thô của khách hàng, trích xuất thông tin có cấu trúc (người gửi, tóm tắt, độ
khẩn cấp) theo EMAIL_SCHEMA, và nếu độ khẩn cấp là high thì kích hoạt chu
trình Function Calling để gửi cảnh báo tới quản trị viên.
File gồm bốn phần khớp với bốn mục trên: validate() và bộ ví dụ của mục 12.1;
generate() với mặt nạ logits của mục 12.2; đặc tả TOOL_SPECS của mục 12.3; và
EmailProcessingPipeline chạy vòng lặp bốn bước của mục 12.4.
MockStructuredLLM không phải LLM thật; nó dùng biểu thức chính quy và từ khóa để trích
xuất. Cái nó minh họa trung thực là hình dạng của luồng dữ liệu: schema, tool spec, vòng lặp
bốn bước, vai trò tool. Cái nó không minh họa là khả năng hiểu ngôn ngữ — một mô
hình thật sẽ tóm tắt tốt hơn nhiều và không cần danh sách từ khóa. Chỗ duy nhất cần đổi khi chuyển sang
API thật là thân hàm process_request.
"""Lesson 12 project: structured outputs and function calling, made checkable.
Run: python3 email_processor.py
No API key and no network. The model is mocked, but everything the lesson
claims is executed here: JSON Schema validation actually runs and actually
fails, constrained decoding actually masks logits, and the extracted fields
actually come from the input email rather than from a constant.
"""
import json
import math
import random
import re
# ---------------------------------------------------------------------------
# Part 1 - the schema, and what "valid JSON" does not buy you
# ---------------------------------------------------------------------------
EMAIL_SCHEMA = {
"type": "object",
"properties": {
"sender": {"type": "string"},
"urgency": {"type": "string", "enum": ["low", "high"]},
"summary": {"type": "string"},
},
"required": ["sender", "urgency", "summary"],
"additionalProperties": False,
}
TYPES = {"string": str, "number": (int, float), "boolean": bool, "object": dict}
def validate(obj, schema):
"""Check obj against a small subset of JSON Schema. Returns a list of errors.
An empty list means valid. This is deliberately hand-written: the point is
that the check is separate from parsing, not that it is production-grade.
"""
errors = []
if not isinstance(obj, dict):
return [f"expected an object, got {type(obj).__name__}"]
for field in schema.get("required", []):
if field not in obj:
errors.append(f"missing required field '{field}'")
for key, value in obj.items():
rule = schema["properties"].get(key)
if rule is None:
if not schema.get("additionalProperties", True):
errors.append(f"unexpected field '{key}'")
continue
expected = TYPES[rule["type"]]
if not isinstance(value, expected):
errors.append(f"field '{key}' should be {rule['type']}")
elif "enum" in rule and value not in rule["enum"]:
errors.append(f"field '{key}' must be one of {rule['enum']}, got '{value}'")
return errors
def demo_json_mode_gap(schema):
"""Three replies a model might return, put through parse + validate."""
replies = [
('complete and correct',
'{"sender": "[email protected]", "urgency": "high", "summary": "DB is down"}'),
('valid JSON, missing a required field',
'{"sender": "[email protected]", "summary": "DB is down"}'),
('valid JSON, urgency outside the enum',
'{"sender": "[email protected]", "urgency": "khan cap", "summary": "DB is down"}'),
('wrapped in a markdown fence, as models love to do',
'Here is your result:\n```json\n{"sender": "[email protected]"}\n```'),
]
print("=== JSON Mode guarantees syntax, not shape ===")
for label, raw in replies:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
print(f" {label}")
print(f" json.loads : CRASHED - {exc.msg}")
print(f" validate : never reached\n")
continue
errors = validate(parsed, schema)
verdict = "OK" if not errors else "; ".join(errors)
print(f" {label}")
print(f" json.loads : ok")
print(f" validate : {verdict}\n")
# ---------------------------------------------------------------------------
# Part 2 - why Structured Outputs cannot fail: constrained decoding
# ---------------------------------------------------------------------------
# A toy vocabulary. A real model has ~100k of these; the mechanism is identical.
VOCAB = ['{', '}', ':', ',', '"urgency"', '"summary"', '"low"', '"high"',
'Chao', 'ban', 'nhe']
# The grammar as a state machine: at step i, only these tokens keep the output
# on a path that can still finish as a valid object matching the schema.
ALLOWED = [
['{'],
['"urgency"'],
[':'],
['"low"', '"high"'],
['}'],
]
def softmax(logits, temperature=1.0):
"""Same function as Lesson 11, repeated here so this file stands alone."""
scaled = [z / temperature for z in logits]
ceiling = max(scaled)
exps = [math.exp(s - ceiling) for s in scaled]
total = sum(exps)
return [e / total for e in exps]
def generate(rng, constrained, temperature=2.0):
"""Emit five tokens. With constrained=True, illegal tokens are masked off."""
out = []
for step in range(len(ALLOWED)):
# Pretend the model's raw preferences are mildly random each step.
logits = [rng.uniform(0.0, 4.0) for _ in VOCAB]
if constrained:
# This is the whole trick: drive illegal tokens to -infinity BEFORE
# softmax, so their probability is exactly zero, not merely small.
legal = set(ALLOWED[step])
logits = [z if t in legal else -1e9 for z, t in zip(logits, VOCAB)]
probs = softmax(logits, temperature)
out.append(rng.choices(VOCAB, weights=probs, k=1)[0])
return "".join(out)
def demo_constrained_decoding(trials=500):
"""Count how many generations parse, with and without the mask."""
print("=== Constrained decoding: masking logits before softmax ===")
for label, constrained in (("free sampling", False), ("constrained", True)):
rng = random.Random(42)
valid = 0
first_bad = None
for _ in range(trials):
text = generate(rng, constrained)
try:
parsed = json.loads(text)
except json.JSONDecodeError:
if first_bad is None:
first_bad = text
continue
if not validate(parsed, {"type": "object",
"properties": EMAIL_SCHEMA["properties"],
"required": ["urgency"]}):
valid += 1
elif first_bad is None:
first_bad = text
print(f" {label:<14}: {valid}/{trials} parsed and matched the schema")
if first_bad is not None:
print(f" first failure looked like: {first_bad}")
print(" Masking makes an invalid token unreachable, not just unlikely.\n")
# ---------------------------------------------------------------------------
# Part 3 - the local tools the model is allowed to ask for
# ---------------------------------------------------------------------------
TOOL_SPECS = [
{
"type": "function",
"function": {
"name": "send_alert_email",
"description": "Send an urgent alert email to the systems administrator.",
"parameters": {
"type": "object",
"properties": {
"recipient_email": {"type": "string"},
"subject": {"type": "string"},
"alert_content": {"type": "string"},
},
"required": ["recipient_email", "subject", "alert_content"],
},
},
}
]
SENT_MAILBOX = [] # so the test at the end can check the tool really ran
def send_alert_email(recipient_email, subject, alert_content):
"""The real local action. In production this would hit an SMTP server."""
print(f" [ACTION] sending alert email")
print(f" to : {recipient_email}")
print(f" subject : {subject}")
print(f" body : {alert_content[:60]}")
SENT_MAILBOX.append({"to": recipient_email, "subject": subject})
return json.dumps({"status": "delivered", "recipient": recipient_email})
# ---------------------------------------------------------------------------
# Part 4 - a mock model that extracts from the input instead of inventing it
# ---------------------------------------------------------------------------
URGENT_WORDS = ["khẩn cấp", "sập nguồn", "sự cố", "không thể kết nối", "treo"]
class MockStructuredLLM:
"""Stands in for a model called with response_format + tools.
Every field it returns is derived from the text it was given. That matters:
a mock that returns constants would still print a convincing transcript
while proving nothing about extraction.
"""
def process_request(self, messages, tools=None):
body = messages[-1]["content"]
# sender: the first address actually present in the email text
match = re.search(r"[\w.+-]+@[\w-]+\.[\w.]+", body)
sender = match.group(0) if match else "unknown@unknown"
# urgency: keyword evidence from the text, not a hardcoded branch
lowered = body.lower()
hits = [w for w in URGENT_WORDS if w in lowered]
urgency = "high" if hits else "low"
# summary: the first sentence of the email, trimmed
first = re.split(r"(?<=[.!?])\s", body.strip())[0]
summary = first if len(first) <= 90 else first[:87] + "..."
data = {"sender": sender, "urgency": urgency, "summary": summary}
errors = validate(data, EMAIL_SCHEMA)
if errors:
raise ValueError(f"the model broke its own schema: {errors}")
if urgency == "low" or not tools:
return {"type": "structured", "content": data, "evidence": hits}
return {
"type": "tool_call",
"content": data,
"evidence": hits,
"tool_call": {
"id": "call_0001",
"name": "send_alert_email",
"arguments": {
"recipient_email": "[email protected]",
"subject": f"URGENT: {summary[:40]}",
"alert_content": f"Reported by {sender}: {summary}",
},
},
}
class EmailProcessingPipeline:
"""Runs the four-step tool loop and keeps the message array it built."""
def __init__(self):
self.model = MockStructuredLLM()
self.available_tools = {"send_alert_email": send_alert_email}
def run(self, raw_email_body):
print(f" input: '{raw_email_body[:72]}...'")
# Step 1 - send the request, declaring which tools exist.
messages = [
{"role": "system", "content": "You classify support email precisely."},
{"role": "user", "content": raw_email_body},
]
# Step 2 - the model answers with structured data, and maybe a tool call.
response = self.model.process_request(messages, tools=TOOL_SPECS)
data = response["content"]
print(f" extracted sender : {data['sender']}")
print(f" extracted urgency : {data['urgency']}"
f" evidence: {response['evidence'] or 'none'}")
print(f" extracted summary : {data['summary'][:60]}")
if response["type"] != "tool_call":
print(" no tool needed; the record goes straight to the database\n")
return messages, data
call = response["tool_call"]
function = self.available_tools.get(call["name"])
if function is None:
raise KeyError(f"the model asked for an unknown tool: {call['name']}")
# Step 3 - our code runs the function. The model never touches it.
observation = function(**call["arguments"])
# Step 4 - hand the result back, tagged with the role "tool".
messages.append({"role": "assistant", "content": None,
"tool_calls": [call]})
messages.append({"role": "tool", "tool_call_id": call["id"],
"content": observation})
print(f" observation returned to the model: {observation}\n")
return messages, data
SAMPLE_EMAILS = [
"Xin chào, tôi là [email protected], tôi cảm ơn đội ngũ kỹ thuật rất "
"nhiều. Tôi muốn hỏi thêm thông tin về lịch khai giảng khóa sau.",
"Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ liệu chính đang bị treo "
"và sập nguồn, không thể kết nối từ 3 giờ sáng!",
]
def main():
demo_json_mode_gap(EMAIL_SCHEMA)
demo_constrained_decoding()
print("=== The four-step tool loop, on two real emails ===")
transcripts = [EmailProcessingPipeline().run(body) for body in SAMPLE_EMAILS]
print("=== Did extraction really read the input? ===")
for body, (_, data) in zip(SAMPLE_EMAILS, transcripts):
found = data["sender"] in body
print(f" sender '{data['sender']}' appears in its own email: {found}")
assert found, "the extracted sender is not in the email it came from"
assert len(SENT_MAILBOX) == 1, "exactly one alert should have been sent"
print(" PASS - every extracted address came out of the text it belongs to,")
print(" and exactly one alert email was sent.\n")
print("=== The message array after the tool loop ===")
for message in transcripts[1][0]:
content = message.get("content")
if content:
shown = content[:52]
else:
shown = f"tool_calls={message['tool_calls'][0]['name']}"
print(f" {message['role']:<10} | {shown}")
if __name__ == "__main__":
main()
Điểm đáng chú ý nhất trong file là những gì MockStructuredLLM không làm: nó
không chứa một giá trị trả về nào được viết sẵn. Địa chỉ người gửi lấy từ biểu thức chính quy chạy trên
chính nội dung email; độ khẩn cấp đến từ danh sách từ khóa tìm thấy được trong văn bản (và chương trình in
ra bằng chứng — đúng những từ nào đã khớp); phần tóm tắt là câu đầu tiên của email. Trước khi trả
về, nó tự chạy validate() lên kết quả của mình và ném lỗi nếu vi phạm schema.
Chạy chương trình, phần vòng lặp in ra như sau:
=== The four-step tool loop, on two real emails ===
input: 'Xin chào, tôi là [email protected], tôi cảm ơn đội ngũ kỹ thuật rất ...'
extracted sender : [email protected]
extracted urgency : low evidence: none
extracted summary : Xin chào, tôi là [email protected], tôi cảm ơn đội ngũ k
no tool needed; the record goes straight to the database
input: 'Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ liệu chính đang bị tr...'
extracted sender : [email protected]
extracted urgency : high evidence: ['khẩn cấp', 'sập nguồn', 'không thể kết nối', 'treo']
extracted summary : Cảnh báo khẩn cấp từ [email protected]: cơ sở dữ liệu chín
[ACTION] sending alert email
to : [email protected]
subject : URGENT: Cảnh báo khẩn cấp từ [email protected]
body : Reported by [email protected]: Cảnh báo khẩn cấp từ monito
observation returned to the model: {"status": "delivered", "recipient": "[email protected]"}
=== Did extraction really read the input? ===
sender '[email protected]' appears in its own email: True
sender '[email protected]' appears in its own email: True
PASS - every extracted address came out of the text it belongs to,
and exactly one alert email was sent.
appears in its own email: True trông tầm thường, nhưng chúng là thứ phân biệt một
bản demo thật với một bản demo diễn. Một chương trình trả về hằng số
"sender": "[email protected]" cho mọi email vẫn in ra bản ghi trông y hệt,
vẫn kích hoạt tool call, vẫn kết thúc bằng "thành công" — mà không hề đọc email nào. Phép kiểm
assert data["sender"] in body làm loại demo đó thất bại ngay lập tức. Câu hỏi cần đặt cho
bất kỳ pipeline AI nào không phải "nó có chạy không", mà "nếu nó bỏ qua hoàn toàn đầu vào thì tôi có
phát hiện ra không".
Cách chạy dự án này trên máy bạn
- Không cần cài gì:
python3 email_processor.py. Toàn bộ dùng thư viện chuẩn. -
Tải
email_processor.pyở cuối bài, hoặc gõ lại đoạn code trên. Nhờrandom.Random(42), hai lần chạy cho kết quả giống hệt nhau. -
Rồi thử phá nó theo ba cách:
-
Trong
generate(), đổi-1e9thành-5. Mặt nạ trở thành một lời khuyên thay vì một ràng buộc, và tỷ lệ hợp lệ tụt khỏi 500/500 — cho thấy vì sao con số phải là âm vô cùng chứ không phải "một số âm khá lớn". -
Trong
EMAIL_SCHEMA, thêm"critical"vàoenumcủaurgency. Trường hợp thứ ba ở mục 12.1 sẽ ngừng báo lỗi — schema là thứ định nghĩa "đúng", nên nới schema là nới luôn cả phép kiểm. -
Sửa
MockStructuredLLM.process_requestđể trả về hằng số"sender": "[email protected]"thay vì kết quả biểu thức chính quy. Bản ghi in ra vẫn đẹp như cũ, nhưngassertở cuối sẽ đổ — đây chính là lỗi mà phiên bản đầu tiên của bài học này mắc phải.
-
Trong
Tóm tắt bài học & Cầu nối kiến thức
-
Đạt được: Phân biệt được JSON Mode và Structured Outputs, và tự viết hàm
validate()bắt đúng những lỗi màjson.loads()để lọt. - Đạt được: Hiểu và tự cài đặt grammar-constrained decoding — mặt nạ logits biến token sai thành bất khả thay vì ít khả năng, đo được 0/500 so với 500/500.
- Đạt được: Mô tả một hàm cục bộ cho mô hình bằng JSON Schema, và nắm rõ ranh giới: mô hình đề nghị, code của bạn quyết định thực thi.
-
Đạt được: Chạy trọn chu trình Tool Call bốn bước, kể cả lượt
assistantrỗng và vai tròtoolkèmtool_call_id.
Cầu nối bài tiếp theo: Mọi thứ ở bài này đều gửi dữ liệu ra một máy chủ của bên thứ ba — tốn phí, và với email khách hàng thì còn là vấn đề riêng tư. Bài 13 kéo mô hình về chạy ngay trên máy bạn bằng Ollama, và đo xem cái giá phải trả là gì.
Tải file code thực hành minh họa bài học
File Python email_processor.py — mã nguồn xây dựng quy trình phân loại email có cấu trúc và
tích hợp chu trình gọi hàm khẩn cấp (chạy python email_processor.py):
📖 Tài liệu tham khảo
- Structured Outputs — Hướng dẫn đặc tả cấu hình ép kiểu dữ liệu đầu ra JSON Schema (OpenAI Docs)
- JSON Schema Specification — Trang chủ đặc tả tiêu chuẩn cấu trúc dữ liệu JSON Schema chính quy
- Function Calling via API — Tài liệu hướng dẫn thiết lập danh sách công cụ và chu trình xử lý tool loop (OpenAI Docs)
Bình luận