Đánh giá hội thoại nhiều lượt: Session → Turn → Coherence
TL;DR
- Vòng Analyze → Measure → Improve và nhãn nhị phân Pass/Fail vẫn giữ nguyên. Cái mới của multi-turn là đo ở cấp nào và lấy dữ liệu hội thoại ở đâu.
- Đánh giá theo 3 cấp như một quy trình drill-down: session (Pass/Fail cả cuộc) cho mọi hội thoại trước; chỉ xuống turn và coherence/memory với các session đã Fail.
- Multi-turn hỏng chủ yếu vì model chốt đáp án quá sớm rồi không sửa (thiếu ổn định), chứ không phải vì model "kém đi". Ba nguyên nhân gốc của lỗi coherence: anchoring, context truncation, instruction drift — mỗi cái một cách sửa.
- Luôn cô lập lỗi trước: nhiều lỗi xuất hiện giữa hội thoại thật ra là lỗi single-turn trá hình. Lỗi multi-turn thật → dựng dataset prefix N-1 lượt và sample nhiều lần.
- Bộ công cụ riêng: user simulator (có giới hạn: user ảo quá ngoan), phân tầng theo độ dài, perturbation testing + brittleness score; evaluator tự động: judge cấp session trước, turn-level chỉ cho failure mode cụ thể.
Trang này gồm hai lớp: phần diễn giải cô đọng ý của sách (không phải bản dịch), và phần mở rộng tự soạn — ví dụ với dữ liệu bịa để minh hoạ, code Python chạy được, bài tập có lời giải, một case study xuyên suốt về chatbot đặt vé du lịch, và 4 lab tương tác. Mọi con số trong ví dụ/lab là minh hoạ, không đến từ sách.
6.1Turn, session và vì sao multi-turn khó
Một tin nhắn của user + một phản hồi của agent. Eval ở các chương trước ngầm định chỉ có một turn.
Toàn bộ cuộc hội thoại từ tin nhắn đầu đến khi kết thúc (user đạt mục tiêu, bỏ đi, hoặc hết giờ).
Giữ ngữ cảnh qua nhiều lượt, bám chỉ dẫn trong thời gian dài, trả lời mạch lạc khi hội thoại "trôi" theo hướng mới.
Trực giác: cùng thông tin, khác cách "rót"
Hãy tưởng tượng hai cách giao việc cho một nhân viên mới. Cách 1: đưa một tờ giấy ghi đủ yêu cầu. Cách 2: nói dần từng ý qua cả buổi sáng, xen giữa là những câu nói chuyện phiếm. Nhân viên giỏi vẫn làm đúng ở cách 1; nhưng ở cách 2, nếu họ vội bắt tay làm ngay sau câu đầu tiên và không quay lại xem xét khi có ý mới, kết quả sẽ lệch. LLM cũng vậy.
Sách dẫn nghiên cứu của Laban et al. (2025): hơn 200 nghìn hội thoại mô phỏng, nhiều LLM hàng đầu (cả thương mại lẫn open-source), 6 loại tác vụ như code, toán lời văn, tóm tắt. Mỗi tác vụ chạy hai kiểu: đưa hết thông tin trong một tin nhắn vs "nhỏ giọt" qua nhiều tin nhắn. Ở kiểu nhiều lượt, độ chính xác giảm trung bình khoảng 39%. Điểm then chốt nằm ở phần phân tích nguyên nhân: năng lực thuần của model chỉ giảm chút ít, còn phần lớn mức sụt đến từ thiếu ổn định (unreliability) — model đoán sớm sau tin nhắn đầu, bám vào dự đoán đó và không sửa khi thông tin mới đến.
Bạn muốn biết bot đặt vé của mình có bị hiện tượng trên không. Lấy 50 yêu cầu đặt vé có đáp án chuẩn (chuyến bay đúng), mỗi yêu cầu gồm 4 mẩu thông tin: điểm đến, ngày, số khách, ràng buộc giờ bay.
- Bản single-turn: gộp 4 mẩu vào một câu: "Đi Đà Nẵng 25/10, 3 người, không bay sau 20h". Chạy mỗi yêu cầu 10 lần.
- Bản multi-turn (sharded): chia 4 mẩu thành 4 tin nhắn user, xen giữa là câu trả lời của bot. Cũng chạy 10 lần mỗi yêu cầu.
- Đo: với mỗi yêu cầu có 10 điểm số. Tính trung bình, phân vị 90 (lúc bot làm tốt nhất — xấp xỉ "năng lực") và khoảng P90−P10 (độ dao động — xấp xỉ "thiếu ổn định"). Cách tách này là của paper gốc, không phải của sách.
- Đọc kết quả: trung bình tụt từ ~88 xuống ~60, nhưng P90 chỉ tụt từ ~94 xuống ~88 trong khi độ dao động tăng từ ~12 lên ~60. Bot vẫn biết cách đặt đúng — nó chỉ "lúc được lúc không" tuỳ vào việc có chốt sớm hay không.
- Hệ quả hành động: đổi sang model to hơn chưa chắc giải quyết; cần sửa hành vi chốt sớm (hỏi đủ thông tin trước khi đề xuất, hoặc rà lại ràng buộc trước mỗi đề xuất).
# Tách "năng lực" (aptitude) và "độ bất ổn" (unreliability) — số liệu minh hoạ tự sinh.
# Ý tưởng (theo paper gốc Laban et al.): chạy CÙNG một yêu cầu nhiều lần;
# aptitude ~ điểm ở phân vị 90 (lúc model làm tốt nhất),
# unreliability ~ khoảng cách P90 - P10 (độ dao động giữa lần tốt và lần tệ).
import random, statistics
random.seed(6)
def percentile(xs, p):
xs = sorted(xs)
k = (len(xs) - 1) * p / 100
lo, hi = int(k), min(int(k) + 1, len(xs) - 1)
return xs[lo] + (xs[hi] - xs[lo]) * (k - lo)
def simulate_runs(mode, n=10):
"""Điểm 0-100 của 10 lần chạy cho một yêu cầu (giả lập)."""
runs = []
for _ in range(n):
if mode == "single":
runs.append(min(100, random.gauss(88, 6)))
else: # multi-turn: đôi khi model "chốt sớm" và sai hẳn
locked_early = random.random() < 0.4
runs.append(random.gauss(30, 10) if locked_early else min(100, random.gauss(84, 7)))
return runs
for mode in ("single", "multi"):
A, U, M = [], [], []
for task in range(50): # 50 yêu cầu khác nhau
runs = simulate_runs(mode)
A.append(percentile(runs, 90))
U.append(percentile(runs, 90) - percentile(runs, 10))
M.append(statistics.mean(runs))
print(f"{mode:6s} mean={statistics.mean(M):5.1f} "
f"aptitude(P90)={statistics.mean(A):5.1f} unreliability(P90-P10)={statistics.mean(U):5.1f}")
# Kết quả: mean tụt mạnh, aptitude chỉ tụt nhẹ, unreliability tăng vọt
# -> vấn đề là "lúc được lúc không", không phải "model kém đi".
Hiểu nhầm thường gặp
"Hội thoại dài thì model kém đi, cứ nâng model là xong." Không hẳn. Nếu vấn đề là thiếu ổn định, model mạnh hơn cũng có thể chốt sớm y như vậy. Trước khi chi thêm tiền cho model, hãy xem đáp án có đúng ở một số lần chạy không — nếu có, đó là vấn đề ổn định/quy trình hỏi, không phải năng lực. (Sách cũng lưu ý lỗi quên ràng buộc thô thiển dễ gặp hơn ở model nhỏ, và thường chỉ lộ ra sau khá nhiều lượt.)Một yêu cầu được chạy 10 lần ở chế độ multi-turn, điểm: 95, 92, 90, 20, 94, 25, 91, 18, 93, 96. Không cần tính chính xác phân vị: năng lực (P90) cao hay thấp? Độ bất ổn cao hay thấp? Bạn sẽ ưu tiên sửa gì?
Xem lời giải
P90 ≈ 95 → năng lực cao. P10 ≈ 20 → khoảng P90−P10 ≈ 75 → bất ổn rất cao. 3/10 lần "sập" hẳn (~20 điểm) — dấu hiệu chốt sớm sai hướng. Ưu tiên: đọc 3 transcript điểm thấp, tìm lượt bot đưa đề xuất đầu tiên; thêm chỉ dẫn "chưa đề xuất khi còn thiếu thông tin bắt buộc" hoặc "rà lại mọi ràng buộc user đã nêu trước khi trả lời", rồi đo lại độ dao động — không phải đổi model.
6.2Ba cấp đánh giá — quy trình drill-down
Ba cấp không loại trừ nhau; chúng là ba "lăng kính" nhìn vào cùng một hội thoại, dùng theo thứ tự từ rộng đến hẹp.
| Cấp | Câu hỏi | Khi nào dùng | Đầu ra điển hình |
|---|---|---|---|
| Session | Cả cuộc hội thoại có đạt mục tiêu user không? | Cho mọi session — tín hiệu chính | Pass/Fail + 1 câu lý do |
| Turn | Lượt nào làm hội thoại đi chệch? Lượt đó đúng/liên quan/đúng giọng không? | Công cụ chẩn đoán, chỉ trên session Fail | "Lượt hỏng đầu tiên" + loại lỗi |
| Coherence & memory | Có nhớ điều user nói trước đó? Có nhất quán? Qua nhiều session có nhớ đúng? | Khi lỗi liên quan tới thứ user đã nói ở lượt/session trước | Ràng buộc bị rơi, lượt mâu thuẫn, fact memory sai |
Lý do: một nhãn Pass/Fail mỗi hội thoại đã cho biết hệ thống làm được việc bao nhiêu phần trăm thời gian — đúng câu hỏi quan trọng nhất — với chi phí thấp nhất. Chấm từng lượt cho mọi hội thoại vừa tốn gấp nhiều lần vừa nhiễu. Sách lấy ví dụ một trợ lý thu thập góp ý: nếu cuộc trò chuyện kết thúc mà không lấy được góp ý hữu ích nào, cả session Fail, dù từng câu bot viết đều trôi chảy.
U1Mình cần vé Hà Nội → Đà Nẵng ngày 25/10, 2 người lớn, mình không bay sau 8 giờ tối nhé.
B1Dạ, có 3 chuyến phù hợp: 07:10, 11:45, 18:30. Anh/chị muốn chuyến nào?
U2Chuyến sáng. À mà có thêm bé 4 tuổi đi cùng.
B2Dạ, chuyến 07:10 cho 2 hành khách, tổng 3.380.000đ. Xác nhận ạ?
U3Ơ, 3 người mà?
B3Xin lỗi, đã cập nhật 2 người lớn + 1 trẻ em, tổng 4.850.000đ. Xác nhận ạ?
U4Ok đặt đi.
B4Đã giữ chỗ, mã GX7Q2, hạn thanh toán 23:59 hôm nay.
- Cấp session: mục tiêu (giữ chỗ đúng chuyến, đúng số khách, không bay tối) có đạt? Có → PASS. Dừng ở đây nếu chỉ cần đo tỉ lệ thành công.
- Nhưng rubric của bạn có thể có điều kiện "user không phải tự sửa lỗi của bot". Nếu có, session này thành FAIL — đây là lý do phải viết rubric trước khi chấm (xem 6.5).
- Cấp turn (chỉ khi FAIL): đi lần lượt. B1 ✓. B2 ✗ — bỏ qua thông tin "thêm bé 4 tuổi" vừa nêu ở U2. Đây là lượt hỏng đầu tiên.
- Cấp coherence: lỗi ở B2 thuộc loại "quên thông tin user vừa bổ sung". Thông tin nằm ngay lượt trước nên không phải truncation; B1 đã đưa đề xuất trước khi có đủ thông tin → nghiêng về anchoring (đề xuất lượt 1 được giữ nguyên số khách).
- Hành động: ghi nhãn "anchoring — số khách" vào sổ lỗi (open coding kiểu Chương 3), chưa vội viết evaluator.
Hiểu nhầm thường gặp
"Session Pass = mọi lượt tốt" và "mọi lượt tốt = session Pass" đều sai. Hai cấp đo hai thứ khác nhau. Một hệ quả thực tế: đừng tính "tỉ lệ turn Pass" rồi báo cáo như chất lượng sản phẩm — user trải nghiệm theo session. Và đừng bắt nhóm label chấm cả hai cấp cho mọi hội thoại ngay từ đầu; chấm session cho tất cả, turn cho mẫu Fail.Bạn có 400 session log và ngân sách label 6 giờ. Chấm một session mất ~45 giây; chấm chi tiết từng turn của một session mất ~4 phút. Session Fail chiếm khoảng 30%. Phân bổ thời gian thế nào?
Xem lời giải
Chấm session cho cả 400: 400 × 45s = 5 giờ. Còn 1 giờ = 60 phút → chấm turn cho ~15 session Fail (trong ~120 Fail). Như vậy bạn có tỉ lệ thành công trên toàn bộ dữ liệu, cộng 15 lần drill-down để tìm nguyên nhân gốc — đủ để thấy các cụm lỗi lớn nhất. Ngược lại, chấm turn cho mọi session chỉ được ~90 session và không còn thời gian cho cái gì khác. Nếu 15 là quá ít cho các cụm lỗi, giảm số session chấm cấp session (vd. lấy mẫu 300) để dồn thêm thời gian drill-down.
6.3Lỗi coherence và ba nguyên nhân gốc
Lỗi coherence là lỗi chỉ xuất hiện xuyên lượt, thường phải sau vài lượt mới lộ, và các eval một lượt không bắt được. Ba triệu chứng hay gặp:
User nói "không bay đêm" ở lượt 2; lượt 9 bot đề xuất chuyến 23:40.
Lượt 3 bot nói "vé này hoàn được"; lượt 7 bot nói "vé này không hoàn".
Đã nhập tên 3 hành khách xong, bot lại hỏi tên hành khách thứ nhất.
Khi thấy triệu chứng, đừng dừng ở "bot quên". Phân loại nguyên nhân, vì mỗi nguyên nhân cần một kiểu sửa khác nhau:
Chuyện gì: model chốt đáp án sớm và không cập nhật dù user bổ sung thông tin ở lượt sau. Theo Laban et al., đây là nguyên nhân phổ biến nhất.
Dấu hiệu trong trace: bot đưa đề xuất trước khi có đủ thông tin; sau khi user bổ sung, đề xuất gần như giữ nguyên.
Sửa: prompt yêu cầu model rà lại toàn bộ ràng buộc user đã nêu trước khi trả lời; hoặc không đề xuất khi còn thiếu slot bắt buộc.
Chuyện gì: hội thoại vượt context window, hoặc app cắt bớt lượt cũ để tiết kiệm token → model thật sự không nhìn thấy ràng buộc cũ.
Dấu hiệu trong trace: kiểm tra prompt thực tế gửi đi ở lượt hỏng — lượt chứa ràng buộc không còn trong đó. Lỗi tập trung ở hội thoại dài.
Sửa: kiến trúc tóm tắt các lượt cũ, "ghim" ràng buộc thành một khối trạng thái, hoặc thêm hệ thống memory.
Chuyện gì: system prompt vẫn nằm trong context, nhưng model tuân thủ lỏng dần khi hội thoại dài ra — sự "chú ý" đến chỉ dẫn yếu đi.
Dấu hiệu trong trace: các lượt đầu tuân thủ quy tắc (định dạng, giọng, chính sách), các lượt muộn bắt đầu vi phạm, dù prompt đầy đủ.
Sửa: prompt nhắc lại các chỉ dẫn then chốt định kỳ trong hội thoại (vd. chèn nhắc nhở mỗi N lượt).
Ba trace của bot đặt vé, cả ba đều có triệu chứng "đề xuất vi phạm điều user đã nói". Metadata log: lượt nêu ràng buộc, lượt vi phạm, các lượt thực sự có trong prompt, lượt bot đề xuất lần đầu.
- Trace A: ràng buộc ở lượt 2, vi phạm ở lượt 11; app dùng cửa sổ trượt chỉ giữ 6 lượt gần nhất → prompt ở lượt 11 chứa lượt 6–11. Ràng buộc không có trong prompt → truncation. Sửa prompt vô ích — model không thấy thì không thể tuân theo.
- Trace B: bot đề xuất ngay lượt 1 (khi mới biết điểm đến), ràng buộc được nêu ở lượt 4, lượt 5 bot vẫn đề xuất y như cũ → anchoring.
- Trace C: hội thoại 15 lượt, prompt đầy đủ, nhưng từ lượt 14 bot không còn trả giá theo định dạng "tổng tiền + phí" mà system prompt yêu cầu → instruction drift.
- Kiểm chứng: với A, thử tăng cửa sổ lên 20 lượt và chạy lại prefix → lỗi biến mất thì chẩn đoán đúng. Với B, thêm chỉ dẫn "rà lại ràng buộc" → đo lại. Với C, chèn nhắc định dạng mỗi 5 lượt.
Có thể tự động hoá bước phân loại sơ bộ bằng vài quy tắc trên metadata — chỉ để xếp hàng cho người đọc, không thay việc đọc trace:
# Phân loại sơ bộ nguyên nhân gốc của một lỗi coherence (heuristic, tự viết).
# Input: metadata của trace mà hầu hết hệ thống log sẵn được.
from dataclasses import dataclass
@dataclass
class CoherenceFailure:
constraint_turn: int # lượt user nêu ràng buộc (vd. "không bay đêm")
fail_turn: int # lượt agent vi phạm
turns_in_context: list # các lượt THỰC SỰ nằm trong prompt ở lượt fail
first_answer_turn: int # lượt agent lần đầu đưa ra đáp án/đề xuất
answer_changed_after_new_info: bool # đáp án có đổi khi user bổ sung?
system_rules_broken: bool # vi phạm quy tắc trong system prompt?
def diagnose(f: CoherenceFailure) -> str:
# 1) Model có NHÌN THẤY ràng buộc không? Không thấy -> truncation.
if f.constraint_turn not in f.turns_in_context:
return "context truncation -> sửa kiến trúc (tóm tắt / memory / pin ràng buộc)"
# 2) Đoán sớm TRƯỚC khi user nói đủ, rồi không đổi -> anchoring.
if f.first_answer_turn < f.constraint_turn and not f.answer_changed_after_new_info:
return "anchoring -> prompt: rà lại mọi ràng buộc trước khi trả lời"
# 3) Luật hệ thống vẫn trong context nhưng bị lơi ở lượt muộn -> drift.
if f.system_rules_broken and f.fail_turn >= 8:
return "instruction drift -> nhắc lại chỉ dẫn then chốt định kỳ"
return "chưa rõ -> đọc trace thủ công, có thể là lỗi single-turn trá hình"
cases = {
"A: cửa sổ trượt 6 lượt": CoherenceFailure(2, 11, list(range(6, 12)), 3, False, False),
"B: đề xuất ở lượt 1": CoherenceFailure(4, 5, list(range(0, 6)), 1, False, False),
"C: lượt 14 quên format": CoherenceFailure(0, 14, list(range(0, 15)), 2, True, True),
}
for name, f in cases.items():
print(f"{name:26s} => {diagnose(f)}")
Hiểu nhầm thường gặp
"System prompt bị bỏ qua ở lượt muộn → chắc là truncation." Chỉ đúng nếu system prompt thực sự bị cắt khỏi prompt gửi đi. Nếu nó vẫn còn nguyên, đó là drift. Cách phân biệt duy nhất đáng tin: log lại prompt thực tế (hoặc ít nhất danh sách lượt/khối được đưa vào) ở mỗi lượt. Không có log này, bạn đang đoán.Phân loại (anchoring / truncation / drift / chưa đủ thông tin): (a) Bot tóm tắt các lượt cũ bằng một LLM; bản tóm tắt ở lượt 12 không nhắc tới "dị ứng hải sản" user nói ở lượt 3; lượt 13 bot gợi ý nhà hàng hải sản. (b) System prompt yêu cầu luôn trả lời bằng tiếng Việt; ở lượt 18 bot trả lời bằng tiếng Anh sau khi user dán một đoạn email tiếng Anh. (c) Bot gợi ý khách sạn 5 sao ngay lượt 1; lượt 3 user nói ngân sách 800k/đêm; lượt 4 bot vẫn liệt kê cùng 3 khách sạn 5 sao.
Xem lời giải
(a) Truncation dạng tinh vi: lượt gốc bị thay bằng tóm tắt, và tóm tắt làm rơi ràng buộc → model không thấy. Sửa ở bộ tóm tắt (bắt buộc giữ ràng buộc/sở thích), và nên có eval riêng cho bộ tóm tắt. (b) Có thể là drift (chỉ dẫn còn nhưng lơi), nhưng cũng có thể là một lỗi single-turn: thử gửi một prompt gồm system prompt + đoạn email tiếng Anh — nếu vẫn trả lời tiếng Anh, lỗi không liên quan độ dài hội thoại. (c) Anchoring kinh điển: đề xuất sớm, không cập nhật khi có ràng buộc mới.
6.4Memory xuyên session
Một số ứng dụng giữ trạng thái giữa các session: trợ lý cá nhân nhớ bạn thích họp buổi sáng, bot du lịch nhớ bạn thích ghế cửa sổ. Thường memory được lưu dưới dạng bản tóm tắt hoặc cặp key–value rồi chèn vào context đầu mỗi session mới. Sách không bàn cách xây memory, mà bàn cách đánh giá nó qua 4 câu hỏi:
| Khía cạnh | Câu hỏi | Test mẫu (tự soạn, bot du lịch) |
|---|---|---|
| Storage | User nói một sự thật mới → có được lưu không? | Session 1: "Mình luôn chọn ghế cửa sổ". Sau session, kiểm tra memory store có fact seat=window. |
| Retrieval | Session mới có lấy đúng fact liên quan ngữ cảnh và bỏ qua fact không liên quan? | Session 2 hỏi đặt vé: context phải có seat=window, không cần kéo theo "thích phở bò". |
| Update | User nói ngược lại → sửa bản cũ hay giữ cả hai mâu thuẫn? | Session 3: "Giờ mình thích ghế lối đi cho tiện". Session 4: đề xuất phải là lối đi, và store chỉ còn 1 giá trị. |
| Staleness | Fact đã lỗi thời có còn bị áp dụng? Có cơ chế hết hạn / xác nhận lại? | Fact "hộ chiếu hết hạn 03/2026" — sang tháng 4/2026 bot không được dùng nó như còn hiệu lực mà phải hỏi lại. |
Khuôn test chung: session 1 đặt fact → session 2 sửa/phủ định → session 3 kiểm tra hành vi. Sách lưu ý cấu trúc này giống đánh giá retrieval ở Chương 7: đúng thông tin, đúng lúc.
Đội bạn cài memory store đơn giản: mỗi lần user nói fact mới thì thêm một dòng. Chạy 4 test theo 4 khía cạnh:
- Storage: sau session 1, store có "ăn chay" → PASS.
- Retrieval: khi hỏi về chuyến bay, chỉ lấy fact chủ đề
flight(ghế cửa sổ), không kéo fact ăn uống → PASS. - Update: session 3 user nói "ăn cả cá"; khi truy xuất chủ đề ăn uống, store trả về cả hai "ăn chay" và "ăn cả cá" → FAIL. Agent nhận hai fact mâu thuẫn và có thể chọn cái cũ.
- Staleness: 14 tháng sau, fact ghế ngồi quá hạn 365 ngày không còn được áp dụng → PASS.
- Kết luận: bug nằm ở tầng lưu trữ (append), không phải ở prompt. Nếu chỉ chấm cuộc hội thoại cuối, bạn sẽ thấy "bot gợi ý sai món" mà không biết vì sao — test theo khía cạnh chỉ thẳng vào tầng lỗi.
# Bộ test memory xuyên session: Storage / Retrieval / Update / Staleness.
# MemoryStore là bản "có bug" cố ý: update bằng cách APPEND thay vì ghi đè.
import datetime as dt
class MemoryStore:
def __init__(self):
self.facts = [] # (key, value, topic, saved_at)
def save(self, key, value, topic, when):
self.facts.append((key, value, topic, when)) # BUG: không xoá bản cũ
def retrieve(self, topic, now, max_age_days=365):
return [(k, v) for k, v, t, w in self.facts
if t == topic and (now - w).days <= max_age_days]
def run_suite(store):
d0 = dt.date(2026, 1, 5)
results = {}
# Session 1: user nói sự thật mới
store.save("diet", "ăn chay", "food", d0)
store.save("seat", "ghế cửa sổ", "flight", d0)
results["storage"] = any(v == "ăn chay" for _, v, *_ in store.facts)
# Session 2 (hỏi về chuyến bay): chỉ lấy fact liên quan
got = store.retrieve("flight", d0 + dt.timedelta(days=3))
results["retrieval"] = got == [("seat", "ghế cửa sổ")]
# Session 3: user phủ định fact cũ
store.save("diet", "ăn cả cá", "food", d0 + dt.timedelta(days=40))
diet = [v for k, v in store.retrieve("food", d0 + dt.timedelta(days=41)) if k == "diet"]
results["update"] = diet == ["ăn cả cá"] # mong đợi đúng 1 giá trị mới
# Session 4 (14 tháng sau): fact ghế ngồi đã quá hạn, không được áp dụng
old = store.retrieve("flight", d0 + dt.timedelta(days=430))
results["staleness"] = old == []
return results
for aspect, ok in run_suite(MemoryStore()).items():
print(f"{aspect:10s} {'PASS' if ok else 'FAIL'}")
# -> update FAIL: store trả về cả "ăn chay" lẫn "ăn cả cá" -> agent có thể gợi ý sai.
Hiểu nhầm thường gặp
"Retrieval memory càng lấy nhiều fact càng an toàn." Không. Kéo fact không liên quan vào context vừa tốn token vừa gây nhiễu: bot có thể nhắc "anh ăn chay" khi user đang hỏi hành lý, nghe rất kỳ và xâm phạm. Đánh giá retrieval memory cần cả hai chiều như RAG (Chương 7): có lấy đủ fact cần (recall) và không lấy fact thừa (precision).Thiết kế một chuỗi test 3 session cho khía cạnh Staleness với fact "địa chỉ nhận vé giấy: 12 Lý Thường Kiệt, Hà Nội". Mô tả: session nào nói gì, kiểm tra gì, tiêu chí Pass.
Xem lời giải
Session 1 (tháng 1): user cung cấp địa chỉ → kiểm tra store có fact kèm saved_at. Session 2 (tháng 2): user nhắc "mình chuyển vào Sài Gòn rồi" nhưng không đưa địa chỉ mới → kỳ vọng hệ thống đánh dấu fact cũ là nghi ngờ/hết hiệu lực (không nhất thiết xoá). Session 3 (tháng 3): user đặt vé cần gửi vé giấy → Pass nếu bot hỏi xác nhận địa chỉ thay vì tự điền 12 Lý Thường Kiệt; Fail nếu tự điền. Biến thể: bỏ session 2, để 18 tháng trôi qua — kiểm tra cơ chế hết hạn theo thời gian.
6.5Thu trace và định nghĩa "session thành công"
Thu trace ban đầu: rẻ và nhanh
Không cần hạ tầng lớn: một giao diện chat đơn giản là đủ. Vài thành viên team đóng vai user, mỗi người làm 10–15 tác vụ với ý định và persona khác nhau → nhanh chóng có 100+ trace đa dạng. Sau đó đọc thủ công, gom cả ca thành công lẫn thất bại, hỏi các câu cơ bản như với mọi trace: có đạt mục tiêu? có theo chỉ dẫn suốt cuộc? nếu hỏng thì hỏng ở đâu?
- Người: 6 thành viên (PM, 2 dev, CS lead, designer, QA). Mỗi người 12 tác vụ → 72 session trong ~3 giờ.
- Ma trận ý định × persona (mỗi người bốc thăm): ý định = {tra cứu hành lý, vé 1 chiều, khứ hồi + khách sạn, đổi ngày bay, hoàn vé, gia đình có trẻ nhỏ}; persona = {cộc lốc, dài dòng, hay đổi ý, bực bội, không rành công nghệ, gõ không dấu}.
- Luật chơi: ít nhất 1/3 tác vụ phải kéo dài ≥ 10 lượt (bổ sung thông tin nhỏ giọt, đổi ý giữa chừng) — vì lỗi hay nằm ở hội thoại dài.
- Ghi chú ngay sau mỗi session: mục tiêu là gì, có đạt không, cảm giác chỗ nào "sai sai". Ghi chú này là nguyên liệu open coding (Chương 3).
- Buổi đọc chung ngày hôm sau: mỗi người đọc trace của người khác, gắn nhãn Pass/Fail cấp session trước khi thảo luận — để lộ ra chỗ rubric còn mơ hồ.
Định nghĩa "session thành công" cho cụ thể
Nhãn Pass/Fail cấp session cần định nghĩa rõ, và định nghĩa phụ thuộc ứng dụng. Sách đưa vài hướng: bot CSKH — vấn đề được giải quyết và user không phải liên hệ lại trong một khoảng thời gian; trợ lý code — code sinh ra pass test ngay lần đầu; trợ lý góp ý — session tạo ra ít nhất vài góp ý cụ thể, hành động được. Dù chọn gì, viết thành rubric và kiểm chứng bằng quy trình gán nhãn cộng tác (Chương 4): nếu team không thống nhất được một session Pass hay Fail, định nghĩa còn thiếu chi tiết.
Rubric v1: "Session PASS nếu user đặt được vé mong muốn."
- Hai người chấm cùng 40 session; bất đồng ở 9 session (đồng thuận 78%).
- Đọc 9 ca bất đồng: (a) user tự bỏ đi vì hết chuyến phù hợp và bot nói rõ điều đó — người A chấm Fail, người B chấm Pass; (b) bot đặt đúng nhưng user phải sửa bot 3 lần; (c) bot đặt đúng nhưng báo giá sai ở giữa rồi tự sửa.
- Rubric v2 (bổ sung): PASS nếu tất cả: ① kết thúc bằng giữ chỗ khớp mọi ràng buộc cứng user nêu (ngày, số khách, ngân sách, giờ bay), hoặc bot kết luận đúng rằng không có lựa chọn thỏa mãn và đề xuất phương án thay thế; ② không có thông tin sai về giá/chính sách chưa được đính chính trước khi user xác nhận; ③ user không phải nhắc lại cùng một thông tin quá 1 lần.
- Chấm lại 40 session với v2: bất đồng còn 2 → đồng thuận 95%. Hai ca còn lại được ghi vào phần "ví dụ biên" của rubric.
Hiểu nhầm thường gặp
"Session thành công = user nói cảm ơn / không phàn nàn." Tín hiệu cảm xúc rất nhiễu: người Việt hay cảm ơn lịch sự dù không được việc, và nhiều người bỏ đi im lặng. Định nghĩa thành công nên bám vào kết quả kiểm chứng được (có giữ chỗ khớp ràng buộc, có quay lại hỏi lại trong 48h không…), còn cảm xúc là tín hiệu phụ.Viết rubric session (3–4 điều kiện nhị phân) cho một trợ lý đổi lịch khám bệnh qua chat: user muốn dời lịch hẹn sang ngày khác, có thể có ràng buộc giờ, bác sĩ cụ thể, và phải xác thực danh tính.
Xem lời giải (một phương án)
PASS nếu tất cả: ① danh tính được xác thực trước khi lộ bất kỳ thông tin lịch hẹn nào; ② kết thúc bằng lịch mới đã xác nhận trong hệ thống, khớp mọi ràng buộc user nêu (ngày/giờ/bác sĩ), hoặc bot nói rõ không có slot phù hợp và đưa lựa chọn gần nhất hoặc chuyển nhân viên; ③ lịch cũ được huỷ đúng một lần (không trùng hai lịch, không mất cả hai); ④ không có thông tin sai về giờ/địa điểm chưa được đính chính. Kèm 2–3 ví dụ biên: user tự đổi ý giữ lịch cũ → PASS nếu bot không huỷ gì.
6.6Cô lập lỗi: multi-turn thật hay single-turn trá hình?
Khi thấy một lỗi trong trace nhiều lượt, bước đầu tiên là hỏi: lỗi này có thật sự cần hội thoại để xảy ra không? Rất nhiều lỗi lộ ra giữa cuộc trò chuyện nhưng bản chất là lỗi một lượt. Cách thử: lấy lượt hỏng, dựng lại bằng một prompt duy nhất chứa đủ ngữ cảnh liên quan, chạy lại. Sách minh hoạ bằng một bot mua sắm trả lời sai chính sách đổi trả (hỏi thẳng vẫn sai → lỗi grounding) và một bot gợi ý phim quên thể loại user thích (không thể tái hiện bằng một lượt → lỗi nhiều lượt thật). Dưới đây là hai ví dụ tương tự, tự soạn, cho bot đặt vé.
U1Chào em, mình bay Hà Nội – Phú Quốc tuần sau.
B1Dạ, em hỗ trợ được ạ. Anh/chị cần thông tin gì?
U2Vé phổ thông tiết kiệm được ký gửi bao nhiêu kg?
B2Dạ hạng phổ thông tiết kiệm được 30kg hành lý ký gửi. (chính sách hiện hành: 23kg)
- Gộp thành 1 prompt: "Vé phổ thông tiết kiệm chặng nội địa được ký gửi bao nhiêu kg?" (đưa kèm đúng tài liệu mà hệ thống retrieval lấy về).
- Chạy 5 lần: 5/5 lần vẫn trả lời 30kg → lỗi không phụ thuộc hội thoại.
- Đào tiếp: tài liệu được retrieve là bảng chính sách năm cũ → lỗi retrieval/grounding, sửa bằng kỹ thuật một lượt (cập nhật kho tài liệu, eval retrieval ở Chương 7).
- Tiết kiệm được gì: không cần xây dataset hội thoại, không cần sửa cơ chế context — một test single-turn là đủ bắt hồi quy.
U1Cho mình vé Sài Gòn – Hà Nội 2 người, có bé nhỏ nên đừng bay đêm nha.
B1Dạ, anh/chị đi ngày nào ạ?
U2 … U7(bàn về ngày, đổi ngày một lần, hỏi thêm về hành lý, hỏi ghế ngồi cạnh nhau, hỏi suất ăn trẻ em)
U8Ok, giờ lấy chuyến rẻ nhất ngày 14 giúp mình.
B8Chuyến rẻ nhất ngày 14 là 23:05, 2.150.000đ/người ạ.
- Gộp thành 1 prompt: "2 người, có bé nhỏ, không bay đêm, ngày 14, lấy chuyến rẻ nhất" → 10/10 lần bot chọn chuyến 14:20 hợp lệ. Lỗi không tái hiện → multi-turn thật.
- Cắt prefix: lấy U1…U8 (bỏ B8) — tức "N-1 lượt" đầy đủ trước lượt hỏng — làm một test case.
- Sample nhiều lần: gọi model 10 lần trên prefix đó; đếm bao nhiêu lần đề xuất tôn trọng ràng buộc. Một lần chạy không nói lên gì — thứ ta đo là tỉ lệ giữ ràng buộc.
- Nhân rộng: gom 31 prefix tương tự từ log (hoặc sinh tổng hợp bằng cách chèn ràng buộc ở lượt đầu và kéo dài hội thoại) → một bộ test trí nhớ ngắn hạn có quy mô, dùng để so sánh các cách sửa.
Code dưới mô phỏng việc so sánh hai phiên bản trên 31 prefix × 10 sample, kèm khoảng tin cậy Wilson (nhắc lại từ Chương 5: tỉ lệ luôn đi kèm độ bất định):
# Dataset prefix "N-1 lượt": cắt trace ngay trước lượt hỏng, gọi model k lần,
# đo tỉ lệ GIỮ ràng buộc. call_model() là mock — thay bằng API thật của bạn.
import math, random
random.seed(42)
def call_model(prefix, variant):
"""Mock: xác suất giữ ràng buộc giảm theo khoảng cách (số lượt) tới ràng buộc."""
distance = len(prefix) - prefix.index("CONSTRAINT")
p_keep = {"v1_sliding_window": 0.95 - 0.07 * distance,
"v2_pinned_trip_sheet": 0.93 - 0.005 * distance}[variant]
return "respects" if random.random() < max(p_keep, 0.05) else "drops"
def wilson(k, n, z=1.96):
if n == 0:
return (0.0, 0.0)
p = k / n
c = (p + z * z / (2 * n)) / (1 + z * z / n)
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
return (c - h, c + h)
# 31 prefix giả lập: ràng buộc ở lượt 1-3, lượt hỏng ở lượt 6-13
prefixes = []
for _ in range(31):
c, fail = random.randint(1, 3), random.randint(6, 13)
prefixes.append(["turn"] * c + ["CONSTRAINT"] + ["turn"] * (fail - c - 1))
K = 10 # số lần sample mỗi prefix
for variant in ("v1_sliding_window", "v2_pinned_trip_sheet"):
kept = sum(call_model(p, variant) == "respects" for p in prefixes for _ in range(K))
n = len(prefixes) * K
lo, hi = wilson(kept, n)
print(f"{variant:22s} retention = {kept}/{n} = {kept/n:.0%} (95% CI {lo:.0%}-{hi:.0%})")
Kết quả (mock): cửa sổ trượt giữ ràng buộc ~43% số lần, bản "ghim trip sheet" ~88% — hai khoảng tin cậy không chồng nhau nên khác biệt là thật, trong giới hạn của mock.
Hiểu nhầm thường gặp
"Gộp thành 1 prompt" = chỉ hỏi lại câu cuối. Sai. Phải đưa đủ ngữ cảnh liên quan (mọi ràng buộc, tài liệu retrieve được) vào một prompt. Nếu chỉ hỏi câu cuối trần trụi, bot tất nhiên sẽ "sai" vì thiếu thông tin, và bạn kết luận nhầm là single-turn. Ngược lại, nếu prompt gộp chạy đúng, đó cũng là gợi ý sửa: cấu trúc lại trạng thái hội thoại thành một khối gọn (như trip sheet) thường giải quyết lỗi multi-turn.Đọc hội thoại. Bấm vào bong bóng bot nơi hội thoại hỏng lần đầu (bấm lại để bỏ chọn; không chọn nếu không có lỗi), chọn nhãn session và loại lỗi, rồi bấm Kiểm tra.
Bot trả lời sai "phí đổi ngày bay là 0đ" ở lượt 6 của một hội thoại. Bạn gộp prompt: "Phí đổi ngày cho vé Eco Saver là bao nhiêu?" + tài liệu chính sách → bot trả lời đúng "350.000đ". Nhưng trong hội thoại gốc, ở lượt 3 user đã nói "mình mua vé Eco Plus". Kết luận của bạn có vấn đề gì? Làm lại thế nào?
Xem lời giải
Prompt gộp đã đổi dữ kiện: hỏi về Eco Saver trong khi user mua Eco Plus. Một prompt gộp hợp lệ phải giữ đúng mọi thông tin liên quan từ hội thoại (hạng vé Eco Plus). Làm lại: "Phí đổi ngày cho vé Eco Plus là bao nhiêu?" + đúng tài liệu. Nếu vẫn trả lời 0đ và chính sách Eco Plus thật sự thu phí → single-turn (grounding). Nếu trả lời đúng → bot đã nhầm hạng vé qua các lượt (có thể nhầm với một hạng vé bot tự nhắc ở lượt 4–5) → multi-turn thật, đưa vào dataset prefix.
6.7User simulator — sinh hội thoại test tự động
Tự đóng vai user thì chậm, nên nhiều team dùng một LLM thứ hai làm user. Cấu hình đơn giản nhất: viết system prompt mô tả persona và mục tiêu cho LLM giả lập, kèm một tín hiệu kết thúc (vd. trả về [DONE] khi nó thấy mục tiêu đã được đáp ứng), rồi chạy vòng lặp: simulator nói → agent trả lời → simulator nói tiếp… đến số lượt tối đa hoặc khi simulator báo xong.
- Đa dạng hoá personangân sách, vị trí, tính cách, mức hiểu biết domain.
- Đa dạng hoá mục tiêutra cứu đơn giản, yêu cầu nhiều bước, phàn nàn, câu hỏi mơ hồ.
- Đa dạng hoá phong cách giao tiếpcộc lốc, dài dòng, gây gổ, đổi ý giữa chừng.
- Tự động hoá persona (nâng cao): một pipeline nhiều LLM — một cái sinh cặp persona–mục tiêu, một cái đóng vai, một cái chấm (hướng AgentInstruct); hoặc học từ hội thoại người thật quy mô lớn (hướng Chatbot Arena) để hiệu chỉnh hành vi persona cho giống thật.
Code dưới thay hai LLM bằng hàm mock để thấy rõ khung: 3 persona, mục tiêu cần 4 thông tin (điểm đến, ngày, số khách, ngân sách). Agent mock có cửa sổ ngữ cảnh 6 lượt và hỏi từng thông tin một.
# Vòng lặp user simulator tối giản. simulator_turn() và agent_turn() là mock;
# thực tế mỗi hàm là một lời gọi LLM (simulator có system prompt persona + goal).
import random
PERSONAS = {
"kiem_loi": {"style": "cộc lốc", "changes_mind": 0.0, "patience": 6},
"doi_y": {"style": "hay đổi ý", "changes_mind": 0.5, "patience": 10},
"buc_boi": {"style": "gắt, ít kiên nhẫn", "changes_mind": 0.1, "patience": 4},
}
GOAL = {"slots": ["điểm đến", "ngày đi", "số khách", "ngân sách"]}
def simulator_turn(persona, state, rng):
missing = [s for s in GOAL["slots"] if s not in state["given"]]
if state["booked"]:
return "[DONE]"
if state["turn"] >= persona["patience"]:
return "[GIVE_UP]" # user thật cũng bỏ đi như vậy
if state["given"] and rng.random() < persona["changes_mind"]:
slot = rng.choice(sorted(state["given"]))
state["given"][slot] = "GIÁ TRỊ MỚI"
return f"À đổi {slot} nhé"
slot = missing[0] if missing else None
if slot:
state["given"][slot] = "ok"
return f"({persona['style']}) {slot}: ..."
return "Đặt luôn đi"
def agent_turn(user_msg, state, window=6):
# mock thô của cửa sổ trượt: quá `window` lượt thì các slot nói từ đầu rơi khỏi prompt
state["seen"] = state["given"].copy() if state["turn"] < window else {}
if user_msg == "Đặt luôn đi":
if len(state["seen"]) < len(GOAL["slots"]):
return "Bạn muốn đi đâu ạ?" # hỏi lại thứ đã được nói -> lỗi coherence
state["booked"] = True
return "Đã giữ chỗ!"
return "Ghi nhận. Còn gì nữa không ạ?"
def simulate(persona_name, seed):
rng, persona = random.Random(seed), PERSONAS[persona_name]
state, transcript = {"given": {}, "booked": False, "turn": 0}, []
while True:
u = simulator_turn(persona, state, rng)
if u in ("[DONE]", "[GIVE_UP]"):
return transcript, ("PASS" if u == "[DONE]" else "FAIL")
transcript += [("user", u), ("agent", agent_turn(u, state))]
state["turn"] += 1
for name in PERSONAS:
labels = [simulate(name, s)[1] for s in range(20)]
print(f"{name:9s} pass rate = {labels.count('PASS')}/20")
- Kết quả: "kiệm lời" 20/20, "hay đổi ý" 3/20, "bực bội" 0/20.
- Đọc "bực bội" 0/20: persona chỉ chịu 4 lượt, còn bot cần 5 lượt vì hỏi từng thông tin một → lỗi thiết kế luồng hỏi (nên hỏi gộp), không phải lỗi trí nhớ.
- Đọc "hay đổi ý" 3/20: đổi ý kéo dài hội thoại quá 6 lượt → rơi khỏi cửa sổ → bot hỏi lại điểm đến → truncation.
- Bài học: một con số pass rate tổng (23/60 = 38%) che mất hai nguyên nhân hoàn toàn khác nhau. Luôn tách pass rate theo persona và goal.
Giới hạn của user ảo
User do LLM đóng thường quá hợp tác, quá nhất quán, hội thoại ngắn và bám mục tiêu hơn người thật. Người thật gõ sai chính tả, lờ câu hỏi của bot, đổi chủ đề đột ngột, bực bội theo cách khó mô phỏng. Hệ quả: simulator dễ bỏ sót các lỗi "đuôi dài" chỉ xuất hiện ở hội thoại dài, lan man. → Dùng simulator để phủ ban đầu; khi có trace thật, luôn đối chiếu.Hiểu nhầm thường gặp
"Pass rate trên simulator = pass rate production." Hầu như luôn cao hơn thực tế, vì user ảo ngoan hơn. Cách kiểm tra đơn giản: so sánh phân phối giữa trace ảo và trace thật — số lượt, tỉ lệ đổi ý, độ dài tin nhắn, tỉ lệ bỏ ngang. Nếu trace ảo trung bình 5 lượt còn trace thật 11 lượt, simulator đang test một bài toán dễ hơn. Chỉnh persona (thêm "hay quên", "trả lời lạc đề", "không trả lời câu hỏi của bot") cho đến khi phân phối gần nhau.Trong Lab 2, với bot v1, thử "Khứ hồi + khách sạn" với persona "Hay đổi ý", kiên nhẫn 14. Rồi đổi sang bot v2. (a) Loại lỗi nào biến mất, loại nào còn? (b) Vì sao chạy batch nhiều seed quan trọng hơn nhìn một transcript?
Xem lời giải
(a) Với v1, lỗi chính là truncation: thông tin nói ở đầu rơi khỏi cửa sổ 6 lượt, bot hỏi lại hoặc dùng giá trị cũ. v2 ghim các thông tin đã chốt vào trip sheet nên hết truncation; lỗi còn lại chủ yếu là anchoring khi user đổi ý (bot đôi khi vẫn dùng giá trị trước khi đổi) và hết kiên nhẫn khi mục tiêu cần nhiều lượt. (b) Hành vi có yếu tố ngẫu nhiên (persona đổi ý lúc nào, bot có chốt sớm không). Một transcript chỉ là một mẫu; pass rate qua 30 seed mới là con số so sánh được giữa v1 và v2 — giống nguyên tắc sample nhiều lần ở 6.6.
6.8Phân tầng theo độ dài hội thoại
Kinh nghiệm của tác giả: lỗi thường tương quan với độ dài — agent ổn ở hội thoại ngắn có thể gãy khi ngữ cảnh tích tụ, chẳng hạn từ khoảng lượt 8–10. Vì vậy test set phải có đủ các độ dài; chia bucket (vd. 2–4, 5–9, 10+ lượt) và báo cáo tỉ lệ thành công riêng từng bucket. Nếu tỉ lệ sụt mạnh ở một độ dài nào đó, bạn biết cần soi đâu — và nghi phạm là truncation, drift hay thứ khác.
Release B đổi giao diện: thêm nút gợi ý nhanh nên user hỏi ngắn hơn. Pass rate tổng tăng từ 68% lên 82% — team định ăn mừng.
- Tách bucket: bucket 2–4 giữ ~92%, bucket 5–9 giữ ~66–67%. Bucket 10+ từ 34% còn 20%.
- Vì sao tổng tăng? Tỉ trọng session ngắn (dễ) tăng từ 38% lên 71% tổng số session. Tổng tăng là do trộn dữ liệu thay đổi, không phải do bot giỏi lên — một dạng nghịch lý Simpson.
- Nhưng cẩn thận: bucket 10+ của B chỉ có 15 session; khoảng tin cậy 7%–45% chồng lên 24%–47% của A. Chưa kết luận được "B tệ hơn" ở bucket dài — chỉ biết "không có bằng chứng B tốt hơn", và cần thêm mẫu (dùng simulator với persona dài dòng/hay đổi ý để bù).
- Báo cáo đúng: luôn in bảng theo bucket kèm n và CI; con số tổng chỉ là dòng cuối.
# Báo cáo pass rate theo bucket độ dài + minh hoạ "bẫy con số tổng" (số liệu minh hoạ).
import math
def wilson(k, n, z=1.96):
p = k / n
c = (p + z*z/(2*n)) / (1 + z*z/n)
h = z * math.sqrt(p*(1-p)/n + z*z/(4*n*n)) / (1 + z*z/n)
return c - h, c + h
def bucket(turns):
return "2-4" if turns <= 4 else "5-9" if turns <= 9 else "10+"
def report(name, sessions):
"""sessions: list[(so_luot, pass_bool)]"""
by = {}
for turns, ok in sessions:
by.setdefault(bucket(turns), []).append(ok)
total = sum(ok for _, ok in sessions)
print(f"{name}: overall {total}/{len(sessions)} = {total/len(sessions):.0%}")
for b in ("2-4", "5-9", "10+"):
xs = by.get(b, [])
if xs:
lo, hi = wilson(sum(xs), len(xs))
print(f" {b:4s} {sum(xs):3d}/{len(xs):<3d} = {sum(xs)/len(xs):4.0%} CI [{lo:.0%}, {hi:.0%}]")
def make(counts): # counts: {bucket_turns: (n_pass, n_total)}
out = []
for turns, (k, n) in counts.items():
out += [(turns, True)] * k + [(turns, False)] * (n - k)
return out
# Release A: nhiều hội thoại dài. Release B: đổi UI -> user hỏi ngắn hơn.
release_a = make({3: (88, 96), 7: (61, 92), 12: (22, 64)})
release_b = make({3: (170, 185), 7: (40, 60), 12: (3, 15)})
report("Release A", release_a)
report("Release B", release_b)
# Overall B > A, nhưng bucket 10+ của B tệ hơn -> con số tổng che mất sự xuống cấp.
Hiểu nhầm thường gặp
"Bucket theo số lượt là đủ." Số lượt là proxy tiện, nhưng thứ thật sự gây truncation là số token (một lượt dán nguyên email có thể dài bằng 10 lượt thường). Khi nghi truncation, thêm một chiều phân tầng theo token ngữ cảnh tại lượt hỏng, hoặc theo "khoảng cách (số lượt) từ lúc nêu ràng buộc đến lúc cần dùng".Bảng: bucket 2–4: 45/50 Pass; 5–9: 38/50; 10+: 39/50. Pass rate giảm từ 2–4 sang 5–9 nhưng không giảm tiếp ở 10+. Đưa ra hai giả thuyết và cách kiểm tra.
Xem lời giải
Giả thuyết 1 — lỗi không do độ dài mà do loại tác vụ: bucket 5–9 chứa nhiều tác vụ khó (đổi vé, khứ hồi + khách sạn), còn 10+ là hội thoại dài nhưng dễ (tán gẫu, hỏi nhiều câu nhỏ). Kiểm tra: phân tầng hai chiều (bucket × loại mục tiêu). Giả thuyết 2 — sai số mẫu: 38/50 (76%) và 39/50 (78%) có CI rộng (~63–86%) và chồng nhau với 90% ở mức vừa phải; cần thêm mẫu trước khi kể chuyện. Nếu thêm mẫu vẫn giữ hình dạng này, đọc 10 trace Fail ở 5–9 để tìm cụm lỗi chung.
6.9Perturbation testing & brittleness score
Khi đã có tập hội thoại đang Pass, thử độ bền bằng cách thay đổi một chi tiết theo kiểu user thật hay làm, rồi xem agent còn xử lý đúng không. Sách nêu ba kiểu biến đổi: đổi mục tiêu giữa chừng, thêm mơ hồ (viết lại một câu rõ ràng thành câu thiếu thông tin — agent hỏi lại hay đoán bừa?), và phản bác agent ("không, sai rồi" — agent sửa hay cố chấp?). Lợi ích: tạo trace Fail từ trace tốt mà không cần chờ lỗi production, và đặc biệt bắt được sự giòn — agent chạy đúng đúng những hội thoại trong test set nhưng gãy khi có gì đó hơi khác.
Sách phân biệt với adversarial robustness testing: hai hướng giao nhau, nhưng adversarial cố phá hệ thống bằng input xấu nhất, còn perturbation áp các biến đổi mà user thật hay làm lên hội thoại đang Pass — nên dễ bắt đầu hơn. Chỉ số gợi ý theo dõi qua các release: brittleness score — với mỗi trace đang Pass, cần trung bình bao nhiêu perturbation để agent Fail. Median = 1 (một thay đổi nhỏ đã gãy) mong manh hơn nhiều so với median = 5.
- Danh sách perturbation cố định (6 loại): gõ không dấu, đổi mục tiêu, mâu thuẫn thông tin trước (đổi số khách), thêm mơ hồ, phản bác agent, chêm chủ đề lạc.
- Với mỗi trace đang Pass (40 trace): xáo thứ tự perturbation theo seed, áp cộng dồn, sau mỗi bước chạy lại agent + judge; dừng khi Fail.
- v1: median = 3, không trace nào sống sót hết; "cú cuối" hay gặp nhất: mâu thuẫn thông tin (14 lần), đổi mục tiêu (9 lần) → đúng chỗ yếu anchoring.
- v2 (thêm bước rà ràng buộc + trip sheet): median = 5, 15/40 trace sống sót hết. Chỗ yếu còn lại chuyển sang "thêm mơ hồ" → việc tiếp theo là dạy bot hỏi lại khi thiếu thông tin.
# Brittleness score: với mỗi trace đang PASS, áp perturbation lần lượt (thứ tự ngẫu nhiên)
# cho tới khi agent FAIL; đếm số bước. Median thấp = mong manh. (Mock, số liệu minh hoạ.)
import random, statistics
PERTURBATIONS = ["typo_khong_dau", "doi_muc_tieu", "mau_thuan_thong_tin",
"them_mo_ho", "phan_bac_agent", "chem_chu_de_lac"]
# Xác suất agent CHỊU ĐƯỢC từng perturbation (giả định cho 2 phiên bản agent).
ROBUST = {
"v1": {"typo_khong_dau": .95, "doi_muc_tieu": .45, "mau_thuan_thong_tin": .40,
"them_mo_ho": .70, "phan_bac_agent": .60, "chem_chu_de_lac": .85},
"v2": {"typo_khong_dau": .97, "doi_muc_tieu": .85, "mau_thuan_thong_tin": .80,
"them_mo_ho": .80, "phan_bac_agent": .75, "chem_chu_de_lac": .90},
}
CAP = len(PERTURBATIONS) + 1 # sống sót hết 6 perturbation -> ghi 7 (bị "censored")
def still_passes(agent, applied, rng):
# mock: mỗi perturbation mới là một phép thử độc lập
return rng.random() < ROBUST[agent][applied[-1]]
def brittleness(agent, rng):
order = PERTURBATIONS[:]
rng.shuffle(order)
applied = []
for step, p in enumerate(order, start=1):
applied.append(p)
if not still_passes(agent, applied, rng):
return step, p
return CAP, None
for agent in ("v1", "v2"):
rng = random.Random(7)
scores, killers = [], {}
for trace in range(40): # 40 trace đang PASS
s, killer = brittleness(agent, rng)
scores.append(s)
if killer:
killers[killer] = killers.get(killer, 0) + 1
top = sorted(killers.items(), key=lambda kv: -kv[1])[:2]
print(f"{agent}: median brittleness = {statistics.median(scores)}, "
f"sống sót hết = {scores.count(CAP)}/40, hay gãy vì: {top}")
Mock giả định mỗi perturbation là phép thử độc lập — thực tế chúng tương tác (typo + mơ hồ khó hơn tổng hai cái), đó là lý do phải chạy lại agent thật sau mỗi bước.
Hiểu nhầm thường gặp
"Perturbation xong thì trace Fail là agent sai." Chưa chắc. Có perturbation làm thay đổi đáp án đúng (đổi mục tiêu sang Phú Quốc → đáp án đúng giờ là vé Phú Quốc), nên nhãn kỳ vọng phải cập nhật theo. Có perturbation làm hội thoại không còn giải được (mơ hồ đến mức không ai trả lời nổi) → hành vi đúng là hỏi lại, không phải đặt vé. Mỗi loại perturbation cần kèm quy tắc "kỳ vọng mới là gì", nếu không judge sẽ chấm nhầm.Hội thoại gốc (đang Pass): gia đình 3 người đi Đà Nẵng 25/10, ngân sách 9 triệu, không bay đêm. Bật các perturbation, chọn phiên bản bot, chạy 50 mẫu mô phỏng.
Release 12 có median brittleness = 4; release 13 = 2. Nhưng pass rate trên test set gốc của release 13 lại tăng từ 81% lên 88%. Giải thích tình huống này và việc cần làm.
Xem lời giải
Dấu hiệu kinh điển của overfit vào test set: team (hoặc prompt tuning) đã sửa để khớp đúng những hội thoại trong test set, nên pass rate gốc tăng, nhưng agent giòn hơn trước biến thể tự nhiên. Việc cần làm: xem perturbation nào là "cú cuối" nhiều nhất ở release 13 so với 12; đọc diff prompt để tìm chỉ dẫn quá cụ thể (vd. ví dụ few-shot trùng test case); thêm các trace đã perturb vào test set (với nhãn kỳ vọng cập nhật); và đưa brittleness vào tiêu chí release bên cạnh pass rate (Chương 9).
6.10Evaluator tự động cho multi-turn
Khi đã có các loại lỗi rõ ràng từ việc đọc thủ công, dựng evaluator tự động bằng đúng các kỹ thuật ở Chương 5: LLM-as-judge, bộ lọc bằng code, hoặc lai. Câu hỏi then chốt là chấm ở cấp nào.
Nhận toàn bộ hội thoại, trả Pass/Fail cho câu "mục tiêu của user có đạt không?" theo rubric 6.5. Đòn bẩy cao nhất vì đo đúng thứ quan trọng.
Chỉ cho failure mode cụ thể, đã thấy trong dữ liệu: check bằng code rằng bot không mâu thuẫn thông tin đã nói; judge xem mỗi tool call có được lượt user trước đó biện minh; trích các ràng buộc user nêu rồi kiểm tra đề xuất cuối có tôn trọng.
Không phải evaluator nào cũng cần chấm mọi lượt; với nhiều hệ thống, phân tích từng lượt chỉ cần khi debug một đường đi cụ thể.
Failure mode lớn nhất trong sổ lỗi là "đề xuất vi phạm ràng buộc đã nêu". Đây là việc có thể kiểm bằng code phần lớn: trích ràng buộc (ngân sách, giờ bay, số khách) từ các lượt user — lượt sau ghi đè lượt trước — rồi so với đề xuất cuối.
# Evaluator cấp turn có chủ đích: trích ràng buộc user nêu trong CẢ hội thoại,
# rồi kiểm tra đề xuất cuối có tôn trọng không. Phần trích xuất ở đây dùng regex
# cho gọn; thực tế thường là một LLM call trả JSON, còn phần KIỂM TRA vẫn là code.
import re
def extract_constraints(user_turns):
c = {}
for i, t in enumerate(user_turns):
t = t.lower()
if m := re.search(r"dưới (\d+) ?(tr|triệu)", t):
c["max_price"] = (int(m.group(1)) * 1_000_000, i) # lưu cả lượt nêu
if "không bay đêm" in t or "không bay tối" in t:
c["no_red_eye"] = (True, i)
if m := re.search(r"(\d+) (người|khách)", t):
c["pax"] = (int(m.group(1)), i) # lượt sau ghi đè lượt trước
return c
def check_offer(constraints, offer):
violations = []
if "max_price" in constraints and offer["total"] > constraints["max_price"][0]:
violations.append(f"vượt ngân sách nêu ở lượt {constraints['max_price'][1] + 1}")
if "no_red_eye" in constraints and not (6 <= offer["depart_hour"] < 21):
violations.append(f"bay đêm dù user dặn ở lượt {constraints['no_red_eye'][1] + 1}")
if "pax" in constraints and offer["pax"] != constraints["pax"][0]:
violations.append(f"sai số khách (lượt {constraints['pax'][1] + 1} nói {constraints['pax'][0]})")
return ("PASS", []) if not violations else ("FAIL", violations)
user_turns = [
"Mình muốn đi Đà Nẵng cuối tháng 10, 3 người",
"Ngân sách dưới 9 triệu cả nhà nhé, và không bay đêm vì có bé nhỏ",
"À bà ngoại đi cùng, thành 4 người",
"Ok chốt chuyến rẻ nhất giúp mình",
]
offer = {"total": 8_400_000, "depart_hour": 22, "pax": 3}
print(check_offer(extract_constraints(user_turns), offer))
# Judge cấp session (khung prompt tự viết — nhớ hiệu chỉnh với nhãn người, xem Ch.5):
SESSION_JUDGE = """Bạn chấm một phiên chat của trợ lý đặt vé.
Mục tiêu user: {goal}
Tiêu chí PASS (tất cả phải đúng): {rubric}
Hội thoại đầy đủ:
{transcript}
Trả về JSON: {{"reasoning": "...", "first_failing_turn": <số hoặc null>, "verdict": "PASS|FAIL"}}"""
- Kết quả: FAIL với 2 vi phạm — bay 22h dù user dặn ở lượt 2; sai số khách (lượt 3 đã đổi thành 4). Evaluator trả luôn lượt nêu ràng buộc → drill-down gần như miễn phí.
- Vì sao lai (hybrid): trích xuất ràng buộc từ ngôn ngữ tự nhiên thì LLM làm tốt hơn regex; còn so sánh số và giờ thì code chính xác và rẻ hơn LLM.
- Judge session (khung prompt ở cuối code) yêu cầu trả
first_failing_turn— lợi ích kép: chấm session và gợi ý chỗ drill-down.
Như Chương 5: judge chỉ đáng tin sau khi đo độ khớp với nhãn người trên tập dev/test tách biệt. 100 session có nhãn người (40 Fail, 60 Pass):
| Người: Fail | Người: Pass | |
|---|---|---|
| Judge: Fail | 36 | 10 |
| Judge: Pass | 4 | 50 |
- TPR (bắt được Fail thật): 36/40 = 0.90. TNR (nhận đúng Pass): 50/60 ≈ 0.83.
- Đọc 10 ca false-Fail: 7 ca judge phạt vì "bot hỏi lại một lần" — rubric cho phép nhắc lại 1 lần. Sửa prompt judge: đưa nguyên văn điều kiện ③ của rubric và 2 ví dụ biên.
- Đọc 4 ca false-Pass: đều là hội thoại > 20 lượt, vi phạm ràng buộc nằm ở giữa → judge cũng "quên"! Giải pháp: cho judge nhận thêm danh sách ràng buộc đã trích (từ check 6.10a) thay vì tự dò trong transcript dài.
- Ước lượng pass rate thật trên production từ tỉ lệ judge + TPR/TNR (công thức hiệu chỉnh ở Chương 5).
Hiểu nhầm thường gặp
"Judge LLM đọc được cả transcript nên chắc chấm tốt hội thoại dài." Judge cũng là LLM và cũng chịu lỗi ngữ cảnh dài y như agent. Hãy phân tầng độ khớp judge–người theo bucket độ dài: nếu TPR của judge ở bucket 10+ thấp hẳn, con số pass rate ở bucket đó đang bị thổi phồng đúng ở nơi bạn cần nó nhất.Team đề xuất xây 5 evaluator cấp turn chạy trên mọi lượt: độ liên quan, giọng điệu, độ dài, đúng chính tả, có emoji hay không. Hiện chưa có judge session. Bạn phản biện thế nào và đề xuất thứ tự khác?
Xem lời giải
Phản biện: (1) các evaluator này đo thứ chung chung, không gắn với failure mode đã quan sát (Chương 3: evaluator phải xuất phát từ lỗi thật); (2) chạy trên mọi lượt vừa tốn vừa nhiễu, và tổng các nhãn turn không trả lời "user có được việc không". Thứ tự đề xuất: ① judge session theo rubric, hiệu chỉnh với ~100 nhãn người; ② phân tầng pass rate theo độ dài; ③ đọc session Fail, đếm failure mode; ④ chỉ với 1–2 failure mode lớn nhất mới xây evaluator cấp turn chuyên biệt (vd. check ràng buộc bằng code, check mâu thuẫn giá). Giọng điệu/emoji chỉ đáng làm nếu nó xuất hiện trong sổ lỗi.
6.11Case study: đánh giá chatbot đặt vé "Bay Nhé" từ đầu đến cuối
Bối cảnh. Bay Nhé là chatbot đặt vé nội địa. Bản v1 giữ ngữ cảnh bằng cửa sổ trượt 6 lượt để tiết kiệm token, retrieval chính sách từ một kho tài liệu. CSKH phàn nàn "bot hay quên", nhưng không ai biết quên cái gì, bao nhiêu.
Bước 1 — Rubric session
Dùng rubric v2 ở Ví dụ 6.5b (khớp ràng buộc cứng / hoặc kết luận đúng là không có lựa chọn; không thông tin sai chưa đính chính; user không phải nhắc lại quá 1 lần). Hai người chấm chung 40 session, đồng thuận 95% trước khi chấm tiếp.
Bước 2 — Thu trace
72 session tự đóng vai (Ví dụ 6.5a) + 180 session lấy mẫu ngẫu nhiên từ production = 252 session.
Bước 3 — Nhãn session + phân tầng
| Bucket | n | Pass | Pass rate (95% CI) |
|---|---|---|---|
| 2–4 lượt | 96 | 88 | 92% (84–96%) |
| 5–9 lượt | 92 | 61 | 66% (56–75%) |
| 10+ lượt | 64 | 22 | 34% (24–47%) |
| Tổng | 252 | 171 | 68% |
Cú sụt ở 10+ lượt ngay lập tức gợi ý nghi phạm: cửa sổ 6 lượt.
Bước 4 — Drill-down 81 session Fail
Với mỗi session Fail, tìm lượt hỏng đầu tiên và gán nhãn (open coding → gom nhóm, Chương 3):
| Failure mode | Số session | Ghi chú |
|---|---|---|
| Quên ràng buộc (giờ bay, ngân sách, số khách) | 31 | tập trung ở bucket 10+ |
| Sai chính sách / giá (hành lý, phí đổi) | 17 | rải đều các bucket |
| Mâu thuẫn giá giữa các lượt | 12 | thường sau khi user hỏi đổi tiền tệ |
| Lạc tiến độ (hỏi lại tên hành khách…) | 11 | chỉ ở hội thoại > 8 lượt |
| Khác | 10 | lỗi tool đặt chỗ, user bỏ ngang không rõ lý do |
Bước 5 — Cô lập
- Sai chính sách (17): gộp thành prompt đơn → 15/17 vẫn sai → lỗi single-turn: kho tài liệu còn bảng hành lý cũ. Sửa dữ liệu + thêm 15 test single-turn. Không đụng tới cơ chế hội thoại.
- Quên ràng buộc (31): prompt gộp đều chạy đúng → multi-turn thật. Tra log prompt thực tế: 19 ca ràng buộc đã rơi khỏi cửa sổ (truncation), 8 ca bot đề xuất trước khi đủ thông tin rồi không cập nhật (anchoring), 4 ca vi phạm quy tắc định dạng giá ở lượt > 12 (drift).
- Lạc tiến độ (11): cũng do cửa sổ trượt — danh sách hành khách đã nhập nằm ở lượt cũ.
Bước 6 — Dataset prefix N-1 và thử cách sửa
31 prefix × 10 sample. v1 giữ ràng buộc 43% (38–48%). Ứng viên sửa: v2 = "trip sheet" — một khối trạng thái có cấu trúc (điểm đến, ngày, khách, ngân sách, ràng buộc, các bước đã xong) được ghim ở đầu prompt và cập nhật sau mỗi lượt, cộng một dòng chỉ dẫn "rà lại trip sheet trước khi đề xuất". v2 giữ ràng buộc 88% (84–91%) — xem code ở 6.6.
Bước 7 — User simulator bổ sung độ phủ
5 persona × 4 mục tiêu × 6 seed = 120 session ảo. Lần đầu, pass rate trên simulator là 83% — cao hơn hẳn 68% thực tế. So phân phối: session ảo trung bình 5,8 lượt, session thật 9,4 lượt; user ảo không bao giờ lờ câu hỏi của bot. Thêm các đặc điểm "hay quên, trả lời lạc đề, hỏi chen" vào persona → pass rate ảo còn 71%, số lượt trung bình 8,9 — đủ gần để dùng. Simulator tìm thêm một failure mode mới: đổi tiền tệ giữa chừng (user hỏi giá bằng USD) khiến các lượt sau lẫn VND/USD — khớp với cụm "mâu thuẫn giá" ở bước 4.
Bước 8 — Perturbation & brittleness
40 trace đang Pass, 6 perturbation cố định (như 6.9): median brittleness v1 = 3 → v2 = 5; % sống sót hết 0% → 38%. "Cú cuối" của v2 chủ yếu là "thêm mơ hồ" → backlog: dạy bot hỏi lại khi thiếu thông tin.
Bước 9 — Evaluator tự động
Judge session (TPR 0,90 / TNR 0,83 ban đầu; sau khi thêm ví dụ biên và đưa danh sách ràng buộc đã trích vào prompt judge: 0,92 / 0,90) + check ràng buộc bằng code (6.10a) + check mâu thuẫn giá bằng code (so mọi con số tiền bot đã nói cho cùng một lựa chọn). Không xây thêm evaluator turn nào khác.
Bước 10 — Kết quả v2 (mẫu mới, cùng cỡ mỗi bucket)
| Bucket | v1 | v2 |
|---|---|---|
| 2–4 lượt | 92% (88/96) | 94% (90/96) |
| 5–9 lượt | 66% (61/92) | 84% (77/92) |
| 10+ lượt | 34% (22/64) | 72% (46/64) |
| Tổng | 68% | 85% (213/252) |
Bài học rút ra
- Phân tầng độ dài chỉ ra nơi cần tìm; drill-down + log prompt thực tế chỉ ra nguyên nhân.
- 17 lỗi "trông như multi-turn" thật ra là dữ liệu cũ — cô lập trước đã tránh được việc sửa nhầm chỗ.
- Cách sửa tốt nhất (trip sheet) đến từ quan sát "prompt gộp thì chạy đúng": biến hội thoại thành trạng thái gọn.
- Simulator hữu ích nhưng phải hiệu chỉnh theo phân phối trace thật; brittleness bắt được điều pass rate không thấy.
- Bucket 10+ vẫn còn 28% Fail → vòng lặp tiếp theo bắt đầu lại từ bước 4.
6.12Cạm bẫy thường gặp
Sách nêu ba cạm bẫy riêng của multi-turn (ba mục đầu); các mục sau là bổ sung từ những gì đã bàn ở trên.
- Chỉ test hội thoại ngắn. Nhiều hệ thống ổn 2–3 lượt đầu rồi xuống dốc (quên ngữ cảnh, bỏ chỉ dẫn, tự mâu thuẫn). Test set phải có đủ các độ dài để bắt lỗi phụ thuộc vị trí.
- Đầu tư quá mức vào turn-level. Chấm từng lượt vừa đắt vừa nhiễu; phần lớn tín hiệu nằm ở session-level. Xác định session Fail trước, rồi chỉ drill xuống một nhóm trace đại diện.
- Nhầm lỗi single-turn thành multi-turn. Không cô lập trước → xây giải pháp phức tạp cho vấn đề có cách sửa đơn giản.
- Tin hoàn toàn vào user simulator. User ảo quá ngoan, hội thoại ngắn → pass rate bị thổi phồng; luôn so phân phối với trace thật.
- Kết luận từ một lần chạy. Lỗi multi-turn mang tính xác suất (chốt sớm lúc có lúc không) — đo tỉ lệ trên nhiều sample.
- Không log prompt thực tế. Không phân biệt được truncation với drift, nên không biết sửa ở kiến trúc hay prompt.
- Báo một con số tổng. Thay đổi phân phối độ dài giữa các release có thể làm con số tổng tăng trong khi bucket dài xấu đi.
- Perturb mà không cập nhật kỳ vọng. Đổi mục tiêu thì đáp án đúng cũng đổi; judge dùng nhãn cũ sẽ chấm sai.
6.13Quy trình tổng hợp & liên hệ các chương
- Định nghĩa thành công cấp session thành rubric cụ thể, kiểm chứng bằng gán nhãn cộng tác.
- Thu trace từ log production, tự đóng vai, hoặc user simulator.
- Phân tầng theo độ dài, báo cáo tỉ lệ thành công từng bucket.
- Chẩn đoán session Failanchoring, truncation hay drift?
- Cô lậpmulti-turn thật hay single-turn trá hình?
- Perturbation testing trên hội thoại đang Pass; theo dõi brittleness.
- Evaluator cấp session trước; cấp turn chỉ cho failure mode cụ thể.
| Chương | Nối với chương 6 ở đâu |
|---|---|
| Ch.3 Phân tích lỗi | Open coding / axial coding trên session Fail; "lượt hỏng đầu tiên" là đơn vị ghi chú. |
| Ch.4 Đánh giá cộng tác | Kiểm chứng rubric session bằng độ đồng thuận giữa người chấm. |
| Ch.5 Evaluator tự động | Judge session, hiệu chỉnh TPR/TNR, khoảng tin cậy cho tỉ lệ. |
| Ch.7 RAG | Memory xuyên session đánh giá như retrieval; nhiều lỗi "single-turn trá hình" là lỗi retrieval. |
| Ch.8 Tool & agent | Check "tool call có được lượt user trước biện minh?" là evaluator cấp turn điển hình. |
| Ch.9 CI/CD | Dataset prefix N-1 và brittleness score làm cổng hồi quy cho mỗi release. |
| Ch.10 Giao diện review | UI review nên hiển thị cả session, cho bấm chọn lượt hỏng đầu tiên (như Lab 1). |
6.14Áp dụng cho dự án model-routing
Hệ thống của bạn chọn model cho từng request để cân bằng chất lượng/chi phí/độ trễ. Trong ứng dụng chat, request = một lượt, nên router thực chất đưa ra quyết định cấp turn, còn user cảm nhận chất lượng ở cấp session. Mọi bài học của chương này đều chuyển thẳng sang: eval router trên từng request riêng lẻ là cần nhưng chưa đủ.
Năm điểm multi-turn riêng cho router
- Đơn vị quyết định ≠ đơn vị đánh giá. Nhãn "route đúng/sai cho lượt này" là eval cấp turn. Cần thêm judge cấp session trên các hội thoại đã được route, vì mỗi quyết định có thể hợp lý riêng lẻ mà tổng thể vẫn hỏng (giống sơ đồ 6.2: mọi turn ✓, session ✗).
- Đổi model giữa chừng = một loại perturbation. Model B nhận lịch sử do model A viết: khác giọng, khác định dạng, có thể khác quy ước tool call; nội dung suy luận nội bộ của A (nếu có) thường không được mang sang. Failure mode mới: handoff inconsistency — B mâu thuẫn với điều A đã hứa. Phân tầng pass rate theo số lần đổi model trong session và đặt check mâu thuẫn ngay sau ranh giới đổi.
- Context carry-over & truncation. Model rẻ thường có context window nhỏ hơn hoặc bạn cắt bớt lịch sử để tiết kiệm → route sang nó ở lượt 15 có thể rơi ràng buộc lượt 2. Ghi vào trace: model, số token context thực tế gửi đi, có cắt không. Đây chính là bài toán truncation của 6.3, nhưng do router gây ra.
- Cache. Prompt cache gắn với từng model (và prefix). Đổi model → cache của model mới lạnh hoặc chỉ khớp prefix cũ → trả giá input đầy đủ và TTFT cao hơn. Router tối ưu chi phí từng request bỏ qua chi phí này; phải hạch toán ở cấp session.
- Độ dài là feature của router. Nếu pass rate của model rẻ sụt ở bucket 10+ (6.8), đó là bằng chứng để thêm luật "hội thoại dài hoặc context > X token → model mạnh" — và cũng là thứ cần eval lại sau khi thêm luật.
Session có 3 lượt khó (lượt 3, 6, 10). So sánh per_turn (khó → mạnh, dễ → rẻ), sticky (chốt model theo lượt đầu), sticky_escalate (bắt đầu rẻ, gặp lượt khó thì lên model mạnh và ở luôn):
# Chi phí 1 session dưới 3 chính sách routing, có tính prompt cache.
# Giá/token là SỐ MINH HOẠ, không phải bảng giá thật của nhà cung cấp nào.
PRICE = { # USD / 1M token: input chưa cache, input đọc từ cache, output
"strong": {"in": 3.00, "cached": 0.30, "out": 15.0},
"cheap": {"in": 0.25, "cached": 0.025, "out": 1.25},
}
TURN_TOKENS, OUT_TOKENS, SYSTEM = 700, 250, 2000
# độ khó từng lượt của MỘT session (0 = dễ, 1 = khó), vd. từ classifier của router
DIFFICULTY = [0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0]
def route(policy, t, prev):
hard = DIFFICULTY[t] == 1
if policy == "per_turn":
return "strong" if hard else "cheap"
if policy == "sticky":
return prev or ("strong" if hard else "cheap") # chốt model ở lượt đầu
if policy == "sticky_escalate":
return "strong" if (hard or prev == "strong") else "cheap" # lên rồi không xuống
def route_seq(policy):
seq, prev = [], None
for t in range(len(DIFFICULTY)):
prev = route(policy, t, prev)
seq.append(prev)
return seq
def session_cost(policy):
seq = route_seq(policy)
cost, cached_tokens, total_in = 0.0, 0, 0
warm = {"strong": 0, "cheap": 0} # số token prefix đã nằm trong cache của từng model
for t, m in enumerate(seq):
ctx = SYSTEM + t * (TURN_TOKENS + OUT_TOKENS) + TURN_TOKENS
hit = min(warm[m], ctx) # đổi model -> cache model mới lạnh hoặc chỉ có prefix cũ
p = PRICE[m]
cost += (hit * p["cached"] + (ctx - hit) * p["in"] + OUT_TOKENS * p["out"]) / 1e6
warm[m] = ctx + OUT_TOKENS # (bỏ qua TTL của cache cho gọn)
cached_tokens, total_in = cached_tokens + hit, total_in + ctx
switches = sum(a != b for a, b in zip(seq, seq[1:]))
hard_on_cheap = sum(d == 1 and m == "cheap" for d, m in zip(DIFFICULTY, seq))
return cost, switches, cached_tokens / total_in, hard_on_cheap
for pol in ("per_turn", "sticky", "sticky_escalate"):
c, sw, hit, hc = session_cost(pol)
print(f"{pol:16s} cost=${c:.4f} switches={sw} cache-hit={hit:.0%} hard-turns-on-cheap={hc}")
- sticky rẻ nhất (~$0.009) và cache tốt nhất, nhưng cả 3 lượt khó rơi vào model rẻ → rủi ro chất lượng cao nhất.
- sticky_escalate không đưa lượt khó nào cho model rẻ và chỉ đổi 1 lần, nhưng đắt nhất (~$0.095) vì lên model mạnh từ lượt 3 và ở đó 10 lượt.
- per_turn ở giữa (~$0.054), không lượt khó nào vào model rẻ, nhưng đổi model 6 lần → 6 ranh giới có rủi ro handoff inconsistency, cache hit thấp nhất (77%).
- Kết luận: không chính sách nào thắng tuyệt đối; chi phí đã rõ, còn chất lượng phải đo bằng judge session trên nhiều session. Chỉ số so sánh hợp lý: chi phí trên mỗi session thành công = tổng chi phí / số session Pass.
Giả sử chạy 200 session ảo (simulator 6.7) mỗi chính sách, judge session cho pass rate: sticky 71%, per_turn 83%, sticky_escalate 86% (minh hoạ). Chi phí/session thành công: sticky ≈ 0.0085/0.71 ≈ $0.012; per_turn ≈ 0.0544/0.83 ≈ $0.066; sticky_escalate ≈ 0.0951/0.86 ≈ $0.111. Nếu yêu cầu chất lượng tối thiểu là 80%, per_turn thắng; và bước tiếp theo là thử biến thể "per_turn nhưng cấm đổi model trong 2 lượt sau lượt khó" để giảm số lần đổi.
Thiết kế eval cho router trong hội thoại
| Tầng | Cách làm | Đo gì |
|---|---|---|
| Turn (rẻ, nhanh) | Dataset prefix N-1 từ log: với mỗi prefix, chạy lượt kế tiếp trên từng model ứng viên, judge chấm lượt đó | "Model rẻ đủ tốt cho lượt này không?" → nhãn huấn luyện/đánh giá router; không thấy hiệu ứng cộng dồn |
| Session (đắt, trung thực) | User simulator + chính sách route đầy đủ, judge session. Không thể "phát lại" log với model khác vì các lượt user sau phụ thuộc câu trả lời trước — cần simulator để sinh phản ứng user mới | Pass rate theo bucket độ dài × số lần đổi model; chi phí/session thành công; p95 latency |
| Độ bền router | Perturb lượt user (gõ không dấu, thêm mơ hồ, chêm chủ đề lạc) và xem quyết định route | Tỉ lệ quyết định bị lật; "switch thrash" (số lần đổi qua lại trong 3 lượt liên tiếp) |
| Coherence qua ranh giới đổi | Check code: ràng buộc/giá/cam kết trước lượt đổi có được giữ sau lượt đổi | Retention sau switch so với retention khi không switch |
Hiểu nhầm thường gặp
"Router đúng ở 95% request → sản phẩm tốt ở 95% hội thoại." Nếu một session 12 lượt cần mọi lượt quan trọng được route đủ tốt, và lỗi độc lập, xác suất cả session không gặp lượt route sai ≈ 0.9512 ≈ 54%. Độ chính xác cấp request phải rất cao mới giữ được chất lượng cấp session — hoặc session phải chịu được một lượt kém. Chỉ đo được điều đó bằng judge session.Team đề xuất luật: "từ lượt 8 trở đi luôn dùng model mạnh". Thiết kế eval để quyết định có nên ship luật này (tối đa 5 bước, nêu metric và cách phân tầng).
Xem lời giải
① Dữ liệu: lấy mẫu session từ production có ≥ 8 lượt, cộng session simulator với persona dài dòng/hay đổi ý để đủ mẫu bucket 10+. ② Chạy hai nhánh (router hiện tại vs có luật) trên cùng persona/goal/seed bằng simulator — không phát lại log vì user sẽ phản ứng khác. ③ Judge session (đã hiệu chỉnh, kiểm tra TPR ở bucket dài) → pass rate theo bucket 5–9, 10–14, 15+ kèm CI. ④ Chi phí/session thành công và p95 TTFT ở lượt 8 (lượt đổi → cache miss). ⑤ Tiêu chí ship ghi trước: vd. pass rate bucket 10+ tăng ≥ 8 điểm với CI không chạm 0, chi phí/session thành công tăng ≤ 20%. Thêm check coherence ở ranh giới lượt 7→8.
Trong Lab 4, đặt context window model rẻ = 6, số lượt 20, xác suất khó 15%. So sánh per_turn và sticky. Cờ "truncation" xuất hiện ở đâu, và điều đó gợi ý thêm feature gì cho router?
Xem lời giải
Với sticky (thường chốt model rẻ vì lượt đầu dễ), mọi lượt sau lượt 6 đều bị cờ truncation — model rẻ không còn thấy các lượt đầu. per_turn cũng bị ở các lượt dễ sau lượt 6. Gợi ý: router cần feature độ dài context hiện tại (và "có ràng buộc nào ở ngoài cửa sổ không"), không chỉ độ khó của tin nhắn cuối — hoặc hệ thống cần trip sheet/tóm tắt để model rẻ vẫn nhận được ràng buộc cũ.