Phần III · Measure Chương 6

Đánh giá hội thoại nhiều lượt: Session → Turn → Coherence

Chapter 6 — Evaluating Multi-Turn Conversations

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àolấ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 turncoherence/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ó

💬
Turn
one exchange

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.

🧵
Session
full conversation

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ờ).

🧠
Thách thức mới
context over time

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.

Single-turn Mọi thông tin A + B + C trong 1 tin nhắn Đáp án đúng ✓ Multi-turn Lượt 1: A Đoán sớm: X Lượt 2: B Lượt 3: C Vẫn X ✗ bám chặt đoán ban đầu, bỏ qua thông tin mới Laban et al.: năng lực thuần giảm nhẹ — "thiếu ổn định" (đoán sớm, không sửa) mới là phần lớn của mức sụt ~39%.
Anchoring: khi hội thoại nhiều lượt hỏng, nghi phạm số 1 là "chốt đáp án quá sớm".
Ví dụ 6.1 · Thí nghiệm "nhỏ giọt" tự làm với bot đặt vé (số liệu minh hoạ)

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.

  1. 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.
  2. 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.
  3. Đ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.
  4. Đọ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.
  5. 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.)
Bài tập 6.1

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.

① SESSION Cả cuộc hội thoại đạt mục tiêu user? → Pass / Fail cho mọi session bắt đầu ở đây · rẻ nhất chỉ các session Fail ② TURN Lượt nào làm hội thoại đi chệch? (đúng? liên quan? giọng?) ③ COHERENCE & MEMORY nhớ ràng buộc? nhất quán? nhớ tiến độ?
Ba "lăng kính" trên cùng một hội thoại — không loại trừ nhau, nhưng đi từ rộng đến hẹp.
CấpCâu hỏiKhi nào dùngĐầu ra điển hình
SessionCả cuộc hội thoại có đạt mục tiêu user không?Cho mọi session — tín hiệu chínhPass/Fail + 1 câu lý do
TurnLượ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 & memoryCó 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ướcRàng buộc bị rơi, lượt mâu thuẫn, fact memory sai
"Start with session-level evaluation and resist the urge to score every turn."— Shankar & Husain, Chương 6 (trích nguyên văn)

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.

Nhãn cấp turn T1 chào hỏi T2 hỏi ngày T3 hỏi số khách T4 báo giá T5 hỏi thêm T6 cảm ơn Session: FAIL — hết 6 lượt, user vẫn chưa có vé; bot chưa từng giữ chỗ mỗi câu đều lịch sự, đúng thông tin, nhưng mục tiêu không đạt Tổng của các nhãn turn ≠ nhãn session. Chiều ngược lại cũng có: session Pass dù có một lượt vụng về.
Vì sao session là tín hiệu chính: nó bắt được lỗi "không ai sai cả nhưng việc không xong".
Ví dụ 6.2 · Chấm một hội thoại ở cả 3 cấp (transcript bịa)

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.

  1. 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.
  2. 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).
  3. 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.
  4. 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).
  5. 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""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ài tập 6.2

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:

Quên ràng buộc
forgetting stated preferences

User nói "không bay đêm" ở lượt 2; lượt 9 bot đề xuất chuyến 23:40.

Tự mâu thuẫn
contradicting earlier responses

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".

Lạc tiến độ
losing track of the task

Đã 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).

Ví dụ 6.3 · Phân loại ba lỗi "quên" trông giống nhau

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.

  1. 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 prompttruncation. Sửa prompt vô ích — model không thấy thì không thể tuân theo.
  2. 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.
  3. 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.
  4. 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.
Bài tập 6.3

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ạnhCâu hỏiTest mẫu (tự soạn, bot du lịch)
StorageUser 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.
RetrievalSession 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ò".
UpdateUser 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ị.
StalenessFact đã 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.

Session 1 · đặt "Mình ăn chay" → kiểm Storage Session 2 · sửa "Giờ mình ăn cả cá rồi" → kiểm Update Session 3 · kiểm "Gợi ý chỗ ăn ở Hội An?" → Retrieval + Staleness Fail: vẫn chỉ gợi ý quán chay fact cũ không được cập nhật / đã lỗi thời Pass: có món cá, không kéo thêm fact không liên quan vào ngữ cảnh
Đặt → sửa/phủ định → kiểm tra. Cấu trúc giống đánh giá retrieval (Chương 7): đúng thông tin, đúng lúc.
Ví dụ 6.4 · Bộ test memory chạy được, bắt một bug "append thay vì ghi đè"

Độ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:

  1. Storage: sau session 1, store có "ăn chay" → PASS.
  2. 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.
  3. 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ũ.
  4. Staleness: 14 tháng sau, fact ghế ngồi quá hạn 365 ngày không còn được áp dụng → PASS.
  5. 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).
Bài tập 6.4

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?

Ví dụ 6.5a · Kế hoạch "tự đóng vai" một buổi chiều cho bot đặt vé
  1. 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ờ.
  2. 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}.
  3. 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.
  4. 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).
  5. 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.

Ví dụ 6.5b · Rubric session cho bot đặt vé — và chỗ nó vỡ khi hai người chấm

Rubric v1: "Session PASS nếu user đặt được vé mong muốn."

  1. Hai người chấm cùng 40 session; bất đồng ở 9 session (đồng thuận 78%).
  2. Đọ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.
  3. 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.
  4. 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ụ.
Bài tập 6.5

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é.

Lượt hỏng trong hội thoại Gộp ngữ cảnh liên quan thành 1 prompt chạy lại vài lần — lỗi có tái hiện không? vẫn sai hết sai Lỗi single-turn trá hình vd. hỏi thẳng vẫn sai hạn mức hành lý → debug retrieval/grounding (Ch.5, Ch.7) Lỗi multi-turn thật vd. quên "không bay đêm" sau 8 lượt → dataset prefix N-1 lượt, sample nhiều lần
Cô lập trước khi xây evaluator riêng cho multi-turn — tránh over-engineer.
Ví dụ 6.6a · Lỗi single-turn trá hình: hạn mức hành lý

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)

  1. 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ề).
  2. 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.
  3. Đà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).
  4. 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.
Ví dụ 6.6b · Lỗi multi-turn thật: quên "không bay đêm" + dataset prefix N-1

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 ạ.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Trace gốc (Fail ở lượt 8) L1 ★ L2 L3 L4 L5 L6 L7 L8 ✗ cắt trước lượt trả lời hỏng ★ = lượt nêu ràng buộc "không bay đêm" Prefix: L1…L7 + tin nhắn user L8 = 1 test case trong dataset "N-1 lượt" Gọi model k=10 lần nhiệt độ như production giữ 4/10 = retention 40% Lặp lại trên nhiều prefix → so sánh retention giữa các phiên bản (cửa sổ trượt vs ghim ràng buộc…).
Lỗi multi-turn là lỗi xác suất: đo tỉ lệ trên nhiều lần sample, không kết luận từ một lần chạy.

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.
▶ LAB 1 · Soi transcript🎮

Đọ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.

Session:
Loại lỗi:
Chọn lượt hỏng + nhãn rồi bấm Kiểm tra.
Bài tập 6.6

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ả personamụ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.

Persona + Goal tính cách · ngân sách hiểu biết · phong cách Simulator LLM đóng vai user Agent của bạn (hệ thống cần test) user msg reply [DONE] / [GIVE_UP] / hết max_turns Transcript + persona, goal, seed Judge cấp session Pass/Fail theo rubric 6.5
Lưu cả persona/goal/seed cùng transcript để tái hiện và để phân tích pass rate theo persona.
  1. Đa dạng hoá personangân sách, vị trí, tính cách, mức hiểu biết domain.
  2. Đ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ồ.
  3. Đ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.
  4. 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.
Ví dụ 6.7 · Simulator mock chạy được: persona nào làm bot gãy?

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")
  1. Kết quả: "kiệm lời" 20/20, "hay đổi ý" 3/20, "bực bội" 0/20.
  2. Đọ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ớ.
  3. Đọ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.
  4. 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.
▶ LAB 2 · Sân chơi user simulator🎮
Chọn persona / mục tiêu / bot rồi bấm "Sinh hội thoại".
Bài tập 6.7

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.

0% 100% 92% 2–4 lượt 88/96 66% 5–9 lượt 61/92 34% 10+ lượt 22/64 tổng: 68% Pass rate cấp session
Số liệu minh hoạ (từ case study 6.11). Con số tổng 68% không cho thấy bucket 10+ chỉ còn 34% — nơi cần soi truncation hoặc drift.
Ví dụ 6.8 · Khi con số tổng tăng nhưng hệ thống tệ đi

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.

  1. Tách bucket: bucket 2–4 giữ ~92%, bucket 5–9 giữ ~66–67%. Bucket 10+ từ 34% còn 20%.
  2. 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.
  3. 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ù).
  4. 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ài tập 6.8

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.

Trace đang Pass Đà Nẵng · 3 người · 9tr Đổi mục tiêu giữa chừng "thôi đổi sang Phú Quốc nhé" Thêm mơ hồ "đi đâu đó biển biển" → hỏi lại hay đoán? Phản bác agent "không, giá đó sai rồi" → sửa hay cãi? Fail: vẫn Đà Nẵng ✗ vẫn Pass ✓ vẫn Pass ✓
Perturbation = biến đổi tự nhiên; adversarial testing = tìm input tệ nhất để phá. Perturbation dễ bắt đầu hơn.

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.

Trace #17 (đang Pass) — áp perturbation theo thứ tự ngẫu nhiên, cộng dồn + gõ không dấuPass + chêm chủ đề lạcPass + đổi số kháchFail ✗ brittleness(#17) = 3 gãy ở perturbation thứ 3 Lặp cho mọi trace đang Pass → lấy median. Trace sống sót hết mọi perturbation ghi giá trị trần (vd. 7) và báo riêng "% sống sót hết". Đếm thêm perturbation nào hay là "cú cuối" → đó là chỗ cần sửa.
Một cách vận hành hoá brittleness (tự đề xuất). Giữ cố định danh sách perturbation và seed thứ tự giữa các release để so sánh được.
Ví dụ 6.9 · Tính brittleness cho hai phiên bản bot (mock)
  1. 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.
  2. 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.
  3. 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.
  4. 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.
▶ LAB 3 · Phòng thí nghiệm perturbation🎮

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.

Chưa chạy.
Bài tập 6.9

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.

Xây trước: judge cấp session
session-level LLM-as-judge

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.

Sau đó: evaluator cấp turn có chọn lọc
targeted turn-level checks

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ể.

Ví dụ 6.10a · Check ràng buộc bằng code cho failure mode "quên ràng buộc"

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"}}"""
  1. 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í.
  2. 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.
  3. 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.
Ví dụ 6.10b · Hiệu chỉnh judge session với nhãn người (số liệu minh hoạ)

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: FailNgười: Pass
Judge: Fail3610
Judge: Pass450
  1. TPR (bắt được Fail thật): 36/40 = 0.90. TNR (nhận đúng Pass): 50/60 ≈ 0.83.
  2. Đọ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.
  3. Đọ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.
  4. Ướ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.
Bài tập 6.10

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

Case study · Bay Nhé — trợ lý đặt vé máy bay + khách sạn (hoàn toàn hư cấu, số liệu minh hoạ)

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

BucketnPassPass rate (95% CI)
2–4 lượt968892% (84–96%)
5–9 lượt926166% (56–75%)
10+ lượt642234% (24–47%)
Tổng25217168%

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 modeSố sessionGhi chú
Quên ràng buộc (giờ bay, ngân sách, số khách)31tập trung ở bucket 10+
Sai chính sách / giá (hành lý, phí đổi)17rải đều các bucket
Mâu thuẫn giá giữa các lượt12thường sau khi user hỏi đổi tiền tệ
Lạc tiến độ (hỏi lại tên hành khách…)11chỉ ở hội thoại > 8 lượt
Khác10lỗi tool đặt chỗ, user bỏ ngang không rõ lý do

Bước 5 — Cô lập

  1. 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.
  2. 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).
  3. 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)

Bucketv1v2
2–4 lượt92% (88/96)94% (90/96)
5–9 lượt66% (61/92)84% (77/92)
10+ lượt34% (22/64)72% (46/64)
Tổng68%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.

6.13Quy trình tổng hợp & liên hệ các chương

  1. Đị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.
  2. Thu trace từ log production, tự đóng vai, hoặc user simulator.
  3. Phân tầng theo độ dài, báo cáo tỉ lệ thành công từng bucket.
  4. Chẩn đoán session Failanchoring, truncation hay drift?
  5. Cô lậpmulti-turn thật hay single-turn trá hình?
  6. Perturbation testing trên hội thoại đang Pass; theo dõi brittleness.
  7. Evaluator cấp session trước; cấp turn chỉ cho failure mode cụ thể.
ChươngNối với chương 6 ở đâu
Ch.3 Phân tích lỗiOpen 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ácKiểm chứng rubric session bằng độ đồng thuận giữa người chấm.
Ch.5 Evaluator tự độngJudge session, hiệu chỉnh TPR/TNR, khoảng tin cậy cho tỉ lệ.
Ch.7 RAGMemory xuyên session đánh giá như retrieval; nhiều lỗi "single-turn trá hình" là lỗi retrieval.
Ch.8 Tool & agentCheck "tool call có được lượt user trước biện minh?" là evaluator cấp turn điển hình.
Ch.9 CI/CDDataset prefix N-1 và brittleness score làm cổng hồi quy cho mỗi release.
Ch.10 Giao diện reviewUI 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 đủ.

Per-turn routing C C S C C S C C 4 lần đổi model cache lạnh sau mỗi lần đổi Sticky + escalate C C S S S S S S 1 lần đổi, cache ấm nhưng trả giá model mạnh 6 lượt C = model rẻ · S = model mạnh · lượt 3 và 6 là lượt khó Chất lượng (session) mất ngữ cảnh khi đổi model? giọng/định dạng lệch? lượt khó rơi vào model rẻ? Chi phí (session) giá/token × độ dài context cache hit theo từng model chi phí / session thành công Độ trễ (turn) TTFT tăng khi cache miss p95 theo bucket độ dài p95 ngay sau lượt đổi model
Quyết định route ở cấp turn, nhưng hậu quả (chất lượng, chi phí, cache) tích luỹ ở cấp session.

Năm điểm multi-turn riêng cho router

  1. Đơ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 ✗).
  2. Đổ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.
  3. 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.
  4. 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.
  5. Độ 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.
Ví dụ 6.14 · Ba chính sách route trên cùng một session 12 lượt (giá minh hoạ)

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}")
  1. 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.
  2. 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.
  3. 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%).
  4. 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ầngCá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ớiPass rate theo bucket độ dài × số lần đổi model; chi phí/session thành công; p95 latency
Độ bền routerPerturb lượt user (gõ không dấu, thêm mơ hồ, chêm chủ đề lạc) và xem quyết định routeTỉ 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 đổiCheck code: ràng buộc/giá/cam kết trước lượt đổi có được giữ sau lượt đổiRetention 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.
▶ LAB 4 · Mô phỏng router trong một session🎮
Bấm "Chạy session".
Bài tập 6.14a

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.

Bài tập 6.14b

Trong Lab 4, đặt context window model rẻ = 6, số lượt 20, xác suất khó 15%. So sánh per_turnsticky. 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ũ.

Tự kiểm tra

1. Nên xây evaluator ở cấp nào trước tiên, và vì sao?
Cấp session (Pass/Fail cả hội thoại). Nó trả lời trực tiếp "hệ thống có làm được việc user cần không" với chi phí thấp nhất. Cấp turn chỉ để chẩn đoán các session đã Fail.
2. Theo Laban et al., phần lớn mức sụt độ chính xác ở hội thoại nhiều lượt đến từ đâu?
Từ thiếu ổn định: model đoán sớm sau tin nhắn đầu, bám vào đó và không sửa khi có thông tin mới. Năng lực thuần chỉ giảm nhẹ.
3. Bot trả lời sai giờ check-in khách sạn ở lượt 5. Gộp đủ ngữ cảnh thành một prompt, bot vẫn sai. Kết luận gì?
Lỗi single-turn trá hình — thường là retrieval/grounding. Debug bằng kỹ thuật một lượt, không cần dataset hội thoại.
4. System prompt vẫn nguyên trong context, nhưng từ lượt 12 agent bỏ qua quy tắc định dạng. Nguyên nhân và cách sửa?
Instruction drift; nhắc lại chỉ dẫn then chốt định kỳ. Nếu system prompt bị cắt khỏi context thì mới là truncation → sửa kiến trúc.
5. "Prefix N-1 lượt" là gì và vì sao phải sample nhiều lần trên mỗi prefix?
Là hội thoại bị cắt ngay trước câu trả lời hỏng (giữ tin nhắn user cuối). Sample nhiều lần vì lỗi multi-turn mang tính xác suất — thứ cần đo là tỉ lệ giữ/đánh rơi ràng buộc, không phải một lần chạy.
6. Bốn khía cạnh đánh giá memory xuyên session là gì? Bug "append thay vì ghi đè" làm fail khía cạnh nào?
Storage, Retrieval, Update, Staleness. Append thay vì ghi đè làm fail Update (store giữ cả fact cũ lẫn mới, mâu thuẫn nhau).
7. Vì sao pass rate trên user simulator thường cao hơn production, và kiểm tra thế nào?
User ảo quá hợp tác, nhất quán, hội thoại ngắn và bám mục tiêu. Kiểm tra bằng cách so phân phối (số lượt, tỉ lệ đổi ý, bỏ ngang, độ dài tin nhắn) giữa trace ảo và trace thật, rồi chỉnh persona.
8. Pass rate tổng tăng từ 68% lên 82% nhưng bucket 10+ giảm. Chuyện gì có thể đang xảy ra?
Phân phối độ dài thay đổi (nhiều session ngắn/dễ hơn) → con số tổng tăng do trộn dữ liệu, không phải do bot tốt hơn. Luôn báo theo bucket kèm n và CI.
9. Perturbation testing khác adversarial testing thế nào? Agent A có median brittleness 1, B là 5 — agent nào đáng lo?
Perturbation áp biến đổi tự nhiên lên hội thoại đang Pass; adversarial tìm input tệ nhất để phá. Agent A đáng lo hơn: một thay đổi nhỏ đã làm nó Fail.
10. Router chọn đúng model cho 95% request. Vì sao chưa thể kết luận 95% hội thoại tốt?
Quyết định route là cấp turn; session 12 lượt có thể gặp ít nhất một lượt route sai với xác suất cao (≈ 1 − 0.95¹² ≈ 46% nếu độc lập), cộng thêm lỗi do đổi model (mất ngữ cảnh, cache lạnh). Cần judge cấp session trên hội thoại đã được route.