·

Trực giác là một chỉ dẫn rất tệ khi tối ưu prompt. Một prompt "trông gọn hơn, sạch hơn" trong playground rất nhiều lần lại cho kết quả tệ hơn khi chạy trên dữ liệu thực; ngược lại, một prompt dài dòng, rườm rà đôi khi lại vượt trội một cách khó hiểu. A/B testing (kiểm thử so sánh hai phiên bản trên traffic thực) loại bỏ cảm tính khỏi quyết định và thay bằng bằng chứng đo được. Bài này trình bày cách thiết kế một thực nghiệm A/B đủ chặt về thống kê cho prompt và context strategy, cách vận hành nó an toàn trên production, và cách đọc kết quả để đưa ra cải tiến bền vững — không phải cải tiến "trông có vẻ tốt hơn" rồi vài tuần sau lại quay đầu.


Lý do cần A/B Testing nghiêm ngặt trong hệ thống LLM

Phần lớn team tối ưu prompt theo kiểu tùy tiện: một kỹ sư thử vài biến thể trong playground, chọn cái "nhìn ổn nhất" bằng cảm nhận cá nhân, rồi deploy thẳng lên production. Cách làm này chứa ba lỗi nghiêm trọng.

Thứ nhất là sample size quá nhỏ. Khi bạn thử 5-10 ví dụ trong playground và thấy phiên bản mới trả lời "hay hơn" 7/10 lần, đó không phải là bằng chứng — đó là nhiễu thống kê. Với cỡ mẫu nhỏ như vậy, khoảng tin cậy (confidence interval) rộng đến mức bạn không thể phân biệt được một cải tiến thật với một sự trùng hợp ngẫu nhiên. LLM có tính ngẫu nhiên (do temperature, do phân phối dữ liệu input đa dạng), nên hai lần chạy cùng một prompt trên hai batch dữ liệu khác nhau có thể cho kết quả chênh nhau 10-15 điểm phần trăm chỉ vì may rủi.

Thứ hai là thiếu baseline (phiên bản đối chứng) chạy song song. Nếu bạn đổi prompt vào thứ Hai và so sánh metric tuần này với tuần trước, bạn đang trộn lẫn hiệu ứng của prompt mới với hàng chục yếu tố khác thay đổi theo thời gian: người dùng khác, loại request khác, model provider có thể đã âm thầm cập nhật, thậm chí ngày trong tuần cũng ảnh hưởng đến độ khó của câu hỏi người dùng gửi lên.

Thứ ba là thiên lệch xác nhận (confirmation bias). Khi bạn là người viết ra prompt mới, bạn có động lực vô thức để đọc kết quả theo hướng ủng hộ nó. Bạn sẽ nhớ những ví dụ mà bản mới thắng, và bỏ qua những ví dụ nó thua. A/B testing với quy trình đo lường được định nghĩa trước (pre-registered) loại bỏ hoàn toàn kiểu thiên lệch này vì bạn cam kết với metric và ngưỡng quyết định trước khi nhìn thấy kết quả.

Chi phí của việc bỏ qua kiểm thử nghiêm ngặt không chỉ là "chọn nhầm prompt". Với hệ thống AI agent chạy ở quy mô lớn, một thay đổi prompt tưởng như vô hại có thể làm tăng 20% số token input mỗi request (vì thêm few-shot examples), âm thầm đội chi phí hạ tầng lên hàng chục triệu đồng mỗi tháng, hoặc làm tăng latency p95 đủ để vi phạm SLA — trong khi metric "chất lượng câu trả lời" mà bạn theo dõi ban đầu lại chỉ nhích lên 1-2%, không đáng để đánh đổi.

Những gì cần test

Không phải mọi thay đổi đều xứng đáng một thực nghiệm A/B chính thức — việc đó tốn thời gian và traffic. Nhưng có một số hạng mục luôn nên đi qua quy trình test có kiểm soát vì rủi ro và tác động tài chính của chúng cao:

  • Viết lại toàn bộ prompt (full rewrite): thay đổi cấu trúc, giọng văn, thứ tự instruction. Rủi ro cao nhất vì tác động lan tới mọi hành vi của model.
  • Số lượng và cách chọn few-shot examples: tăng từ 2 lên 5 ví dụ có thể cải thiện độ chính xác nhưng tăng token cost tuyến tính; chọn ví dụ nào (theo độ khó, theo tính đại diện) cũng ảnh hưởng lớn.
  • Cấu trúc system prompt: đặt ràng buộc ở đầu hay cuối, dùng danh sách gạch đầu dòng hay đoạn văn liền mạch, có ép format JSON hay không.
  • Kích thước context window / số chunk retrieval trong RAG: lấy 3 chunk hay 8 chunk, mỗi chunk 256 hay 1024 token.
  • Model hoặc phiên bản model: chuyển từ một model rẻ hơn sang model đắt hơn (hoặc ngược lại) để xem chất lượng có tương xứng với chi phí không.
  • Tham số decoding: temperature, top_p, max_tokens — những tham số này ảnh hưởng đến độ ổn định của output nhưng thường bị bỏ qua khi tối ưu.
  • Mô tả tool (tool description) trong agent: với AI agent gọi tool, cách viết docstring/description của từng tool ảnh hưởng trực tiếp đến việc model chọn đúng tool hay không.
  • Ràng buộc định dạng output: yêu cầu trả JSON có schema chặt so với văn bản tự do — ảnh hưởng cả độ chính xác lẫn số token phải sinh ra để "giải thích" trước khi trả kết quả.

Mẹo: Trước khi mở một thực nghiệm A/B, tự hỏi: "Nếu kết quả sai theo hướng xấu nhất, tổn thất là gì?" Nếu câu trả lời là "vài request bị câu trả lời hơi dài dòng hơn", một vài chục lượt test tay là đủ. Nếu câu trả lời là "agent có thể approve nhầm một PR chứa lỗi bảo mật" hoặc "chi phí token tăng 30% trên toàn bộ traffic", bắt buộc phải đi qua quy trình A/B testing đầy đủ với sample size được tính trước, không rút ngắn.

Thiết kế một A/B Test có ý nghĩa thống kê

Một thực nghiệm A/B đáng tin cậy luôn đi qua bốn bước theo đúng thứ tự: viết giả thuyết rõ ràng, chọn đúng metric để đo, tính sample size cần thiết trước khi chạy, và kiểm soát các yếu tố gây nhiễu có thể làm sai lệch kết quả. Bỏ qua bất kỳ bước nào trong bốn bước này đều khiến kết quả cuối cùng không đáng tin, dù bạn có thu thập bao nhiêu dữ liệu.

Step 1: Viết giả thuyết (Hypothesis)

Một hypothesis tốt phải có dạng: "Nếu chúng ta thay đổi X, thì Y sẽ cải thiện Z%, vì lý do Z." Câu này buộc bạn phải cụ thể hóa cả nguyên nhân, cả kết quả kỳ vọng, cả độ lớn kỳ vọng — ba thứ mà một câu mơ hồ như "prompt mới sẽ tốt hơn" hoàn toàn thiếu.

Ví dụ cho một agent review pull request:

Hypothesis: Nếu chúng ta thêm trường "confidence" (0-100) vào mỗi comment mà agent sinh ra, tỷ lệ reviewer con người chấp nhận comment mà không cần sửa (acceptance rate) sẽ tăng từ baseline 60% lên tối thiểu 70%, vì reviewer sẽ ưu tiên đọc kỹ các comment có confidence cao và bỏ qua nhanh các comment confidence thấp, giảm thời gian đọc lãng phí.

Về mặt thống kê, mọi hypothesis như vậy luôn đi kèm một cặp: null hypothesis (H0) — giả định không có sự khác biệt thật giữa control và variant, mọi khác biệt quan sát được chỉ là do ngẫu nhiên; và alternative hypothesis (H1) — giả định có sự khác biệt thật. Mục tiêu của thực nghiệm không phải là "chứng minh H1 đúng" mà là thu thập đủ bằng chứng để bác bỏ H0 với độ tin cậy đã định trước. Cách đặt vấn đề này quan trọng vì nó buộc bạn thiết kế thực nghiệm theo hướng "tìm bằng chứng phủ nhận", tránh xu hướng chỉ tìm bằng chứng ủng hộ ý tưởng ban đầu của mình.

Step 2: Định nghĩa Metrics

Mỗi thực nghiệm cần đúng một primary metric — chỉ số quyết định thắng/thua — và một tập guardrail metrics — các chỉ số không được phép xấu đi quá một ngưỡng cho phép, dù primary metric có cải thiện.

Ví dụ với agent review PR ở trên:

  • Primary metric: acceptance rate (tỷ lệ comment được giữ nguyên, không sửa, không xóa).
  • Guardrail metrics: input token trung bình/request (không được tăng quá 15%), latency p95 (không được tăng quá 500ms), tỷ lệ comment bị đánh dấu "sai hoàn toàn" bởi reviewer (không được tăng).

Lý do chỉ chọn một primary metric: nếu bạn theo dõi 5 metric và coi "thắng ở bất kỳ metric nào" là thành công, xác suất bạn tìm được ít nhất một metric "thắng" chỉ do ngẫu nhiên tăng rất nhanh — đây là vấn đề multiple comparisons sẽ nói kỹ ở phần phân tích kết quả. Ngoài ra, cần phân biệt proxy metric (chỉ số đo được ngay, ví dụ độ dài câu trả lời, điểm từ LLM-as-judge) và true north metric (chỉ số phản ánh giá trị thật, ví dụ tỷ lệ PR merge thành công, thời gian giải quyết ticket). Proxy metric nên được kiểm định tương quan với true north metric ít nhất một lần trước khi tin tưởng dùng nó làm primary metric dài hạn.

Step 3: Tính Sample Size cần thiết

Đây là bước bị bỏ qua nhiều nhất trong thực tế, và cũng là bước quan trọng nhất. Sample size quá nhỏ dẫn đến thực nghiệm underpowered — dù variant thực sự tốt hơn, bạn vẫn không phát hiện ra được sự khác biệt có ý nghĩa thống kê (false negative, hay còn gọi là lỗi Type II).

Ba tham số cần chốt trước khi tính sample size:

  • alpha (mức ý nghĩa): xác suất bác bỏ H0 nhầm khi H0 đúng (false positive / Type I error). Chuẩn ngành thường dùng 0.05.
  • power (lũy thừa thống kê): xác suất phát hiện được sự khác biệt thật nếu nó tồn tại. Chuẩn ngành thường dùng 0.8 (80%).
  • minimum detectable effect (MDE — độ khác biệt nhỏ nhất cần phát hiện): mức chênh lệch nhỏ nhất giữa control và variant mà bạn muốn thực nghiệm có khả năng phát hiện ra. MDE nhỏ hơn đòi hỏi sample size lớn hơn rất nhiều.

Với metric là một tỷ lệ (proportion) — ví dụ acceptance rate — công thức ước lượng sample size mỗi nhóm cho two-proportion z-test là:

import math

def sample_size_two_proportions(p1: float, p2: float, alpha: float = 0.05, power: float = 0.8) -> int:
    """
    Estimate the per-variant sample size for a two-proportion z-test.

    p1: baseline rate (control), e.g. current acceptance rate = 0.60
    p2: minimum detectable rate we want to be able to distinguish from p1
    alpha: two-tailed significance level (default 0.05 -> z = 1.96)
    power: statistical power (default 0.80 -> z = 0.84)
    """
    z_alpha = 1.96  # two-tailed test, alpha = 0.05
    z_beta = 0.84   # power = 0.80
    pooled_variance = p1 * (1 - p1) + p2 * (1 - p2)
    effect = (p1 - p2) ** 2
    n = ((z_alpha + z_beta) ** 2) * pooled_variance / effect
    return math.ceil(n)

n_per_variant = sample_size_two_proportions(p1=0.60, p2=0.70)
print(f"Required samples per variant: ~{n_per_variant}")

Với ví dụ này, mỗi nhóm (control và treatment) cần khoảng 353 request có outcome đo được — tổng cộng khoảng 706 request cho toàn thực nghiệm — trước khi có thể tin vào kết quả. Nếu team review PR của bạn chỉ tạo ra 50 PR/ngày, thực nghiệm cần chạy khoảng hai tuần để đạt đủ sample size, không phải hai ngày.

Một điều quan trọng cần ghi nhớ: MDE càng nhỏ, sample size cần thiết tăng theo cấp bậc hai (quadratic). Muốn phát hiện lift 2 điểm phần trăm thay vì 10 điểm phần trăm, sample size cần thiết tăng khoảng 25 lần, không phải 5 lần. Vì vậy, khi chi phí thu thập dữ liệu cao (traffic thấp), hãy chấp nhận chỉ test những thay đổi có kỳ vọng tác động lớn — những thay đổi nhỏ, tinh chỉnh chi tiết nên để dồn lại test theo lô hoặc dùng phương pháp khác như multi-armed bandit.

Step 4: Kiểm soát Confounders

Confounders (yếu tố gây nhiễu) là những biến số thay đổi cùng lúc với biến bạn muốn test, khiến bạn không thể kết luận chắc chắn khác biệt quan sát được là do prompt mới hay do yếu tố khác.

Các confounder phổ biến nhất trong hệ thống LLM:

  • Thời gian (time-based confounds): loại câu hỏi người dùng gửi lên có thể khác nhau giữa sáng và tối, giữa ngày làm việc và cuối tuần. Nếu bạn chạy 100% traffic vào control trong tuần 1 và 100% vào treatment trong tuần 2, bạn đang đo cả sự khác biệt về hành vi người dùng theo thời gian, không chỉ khác biệt về prompt.
  • Đơn vị randomization: nên chia ngẫu nhiên theo user hay theo request? Nếu chia theo request, cùng một user có thể vừa thấy control vừa thấy treatment trong cùng một buổi làm việc — gây nhầm lẫn trải nghiệm và làm outcome của hai request không còn độc lập với nhau về mặt thống kê. Với hầu hết bài toán liên quan đến trải nghiệm người dùng lặp lại, nên randomize theo user_id, cố định variant cho cùng một user trong suốt thời gian thực nghiệm.
  • Hiệu ứng tương tác giữa các thực nghiệm chạy song song: nếu team khác đang chạy một A/B test khác trên cùng pipeline (ví dụ test model version) cùng lúc với test của bạn, hai thực nghiệm có thể tương tác và làm sai lệch cả hai kết quả.
  • Novelty effect: người dùng có thể phản hồi tích cực hơn với bất kỳ thay đổi nào chỉ vì nó mới, và hiệu ứng này giảm dần sau vài ngày. Đừng kết luận sớm khi mới thấy 2-3 ngày dữ liệu đầu.

Mẹo: Luôn ghi log biến "assignment_timestamp" và "variant" cho mọi request ngay từ ngày đầu tiên của thực nghiệm, ngay cả khi bạn chưa có ý định phân tích ngay. Rất nhiều lần bạn sẽ cần quay lại kiểm tra sample ratio mismatch hoặc phân tích theo từng ngày để phát hiện novelty effect — nếu không log từ đầu, bạn sẽ phải chạy lại toàn bộ thực nghiệm.

Triển khai hạ tầng A/B Testing

Hạ tầng A/B testing cho hệ thống LLM cần giải quyết hai bài toán riêng biệt: chia traffic một cách nhất quán và có kiểm soát, và thu thập đủ dữ liệu chất lượng để phân tích sau này — kể cả những metric chỉ có được sau một khoảng thời gian trễ (delayed outcome).

Traffic Splitting đơn giản với Feature Flag

Cách đơn giản và đáng tin cậy nhất để chia traffic là dùng consistent hashing (băm nhất quán) trên user_id kết hợp với tên thực nghiệm, đảm bảo cùng một user luôn nhận đúng một variant trong suốt thời gian thực nghiệm mà không cần lưu trạng thái ở một database riêng:

import hashlib

def get_variant(user_id: str, experiment_key: str, variants: list[str], weights: list[float] | None = None) -> str:
    """
    Deterministically assign a user to a variant using consistent hashing.
    Same user_id + experiment_key always returns the same variant, so a user
    never bounces between control and treatment across sessions.
    """
    if weights is None:
        weights = [1 / len(variants)] * len(variants)

    digest = hashlib.sha256(f"{experiment_key}:{user_id}".encode()).hexdigest()
    bucket = int(digest[:8], 16) / 0xFFFFFFFF  # normalize to [0, 1)

    cumulative = 0.0
    for variant, weight in zip(variants, weights):
        cumulative += weight
        if bucket < cumulative:
            return variant
    return variants[-1]

variant = get_variant(
    user_id="user_48213",
    experiment_key="pr-review-prompt-v3",
    variants=["control", "treatment"],
    weights=[0.5, 0.5],
)

Prompt (và context strategy) của từng variant nên được quản lý qua một prompt registry — không hard-code chuỗi prompt ngay trong code xử lý request, vì như vậy sẽ rất khó rollback nhanh khi phát hiện vấn đề và không có lịch sử thay đổi rõ ràng:

PROMPT_REGISTRY = {
    "pr-review-prompt-v3": {
        "control": {
            "version": "v2.1",
            "template": "prompts/pr_review_v2_1.txt",
            "created_at": "2026-05-02",
            "author": "minh.tran",
            "changelog": "Baseline: list issues by severity, no confidence field.",
        },
        "treatment": {
            "version": "v3.0",
            "template": "prompts/pr_review_v3_0.txt",
            "created_at": "2026-06-18",
            "author": "linh.pham",
            "changelog": "Adds a mandatory 'confidence' field (0-100) per comment.",
        },
    }
}

def load_prompt(experiment_key: str, variant: str) -> str:
    entry = PROMPT_REGISTRY[experiment_key][variant]
    with open(entry["template"], "r", encoding="utf-8") as f:
        return f.read()

Cách tổ chức này cho phép rollback tức thì (đổi weights về [1.0, 0.0]) mà không cần deploy lại code, và giữ được lịch sử đầy đủ ai đã thay đổi prompt nào, khi nào — rất quan trọng khi cần điều tra một regression xảy ra vài tuần sau khi thực nghiệm đã kết thúc.

Thu thập Quality Metrics

Metric trong hệ thống LLM chia thành hai loại cần xử lý khác nhau: metric đo được ngay (input/output token, latency, có tuân thủ format yêu cầu hay không) và metric có độ trễ (delayed metric) — chỉ biết được sau khi có hành động của con người hoặc hệ thống downstream, ví dụ reviewer có chấp nhận comment hay không, ticket có được đóng thành công hay không.

import time
import uuid
import json

def log_experiment_event(event_type: str, **fields) -> None:
    """Append an event to the experiment log stream (Kafka topic, warehouse table, etc.)."""
    event = {
        "event_id": str(uuid.uuid4()),
        "event_type": event_type,
        "timestamp": time.time(),
        **fields,
    }
    print(json.dumps(event))  # replace with your actual event pipeline producer

log_experiment_event(
    "llm_response",
    request_id="req_7f3a",
    experiment_key="pr-review-prompt-v3",
    variant="treatment",
    input_tokens=1840,
    output_tokens=310,
    latency_ms=2140,
)

def log_outcome(request_id: str, accepted: bool, edited: bool) -> None:
    log_experiment_event(
        "review_outcome",
        request_id=request_id,
        accepted=accepted,
        edited_before_accept=edited,
    )

Hai bảng sự kiện này (llm_responsereview_outcome) được join với nhau qua request_id trong warehouse (ví dụ một câu SQL JOIN trên BigQuery/Snowflake) khi đến lúc phân tích. Thiết kế tách rời như vậy giúp hệ thống ghi log không phải chờ outcome mới ghi được, và cũng cho phép outcome đến trễ vài ngày mà không làm mất dữ liệu.

Mẹo: Luôn gắn experiment_keyvariant vào MỌI event liên quan đến request đó, kể cả event log lỗi (error log) và event billing (chi phí). Rất nhiều lần đội ngũ chỉ gắn tag vào log chính, sau đó không thể tính được chi phí token thực tế của từng variant vì log billing tách biệt không có thông tin thực nghiệm.

Phân tích kết quả A/B Test

Statistical Analysis Workflow

Sau khi đã thu thập đủ sample size đã tính trước, quy trình phân tích gồm các bước cố định — không thay đổi thứ tự để tránh thiên lệch trong lúc đọc kết quả:

  1. Tính điểm ước lượng (point estimate) của primary metric cho cả hai variant.
  2. Tính khoảng tin cậy (confidence interval) 95% cho độ khác biệt giữa hai variant.
  3. Tính p-value bằng phép kiểm định phù hợp với loại metric (two-proportion z-test cho tỷ lệ, t-test cho giá trị liên tục như latency hoặc token count).
  4. So sánh statistical significance (p-value < alpha) với practical significance (độ khác biệt có đủ lớn để đáng đánh đổi chi phí triển khai không).
  5. Kiểm tra toàn bộ guardrail metrics — dù primary metric thắng, nếu guardrail xấu đi vượt ngưỡng, không được ship.
from scipy import stats
import numpy as np

def two_proportion_ztest(success_a: int, n_a: int, success_b: int, n_b: int):
    p_a, p_b = success_a / n_a, success_b / n_b
    p_pool = (success_a + success_b) / (n_a + n_b)
    se = np.sqrt(p_pool * (1 - p_pool) * (1 / n_a + 1 / n_b))
    z = (p_b - p_a) / se
    p_value = 2 * (1 - stats.norm.cdf(abs(z)))
    diff = p_b - p_a
    ci_low, ci_high = diff - 1.96 * se, diff + 1.96 * se
    return z, p_value, (ci_low, ci_high)

z, p_value, ci = two_proportion_ztest(success_a=210, n_a=350, success_b=245, n_b=353)
print(f"z = {z:.2f}, p-value = {p_value:.4f}, 95% CI on lift = ({ci[0]:.3f}, {ci[1]:.3f})")

Kết quả ví dụ trên có p-value = 0.0093, nhỏ hơn alpha = 0.05, nên bác bỏ H0 với độ tin cậy đủ mạnh — treatment thực sự tốt hơn control, không phải do ngẫu nhiên. Khoảng tin cậy (0.023, 0.165) nghĩa là chúng ta tin 95% rằng lift thật nằm trong khoảng từ 2.3 đến 16.5 điểm phần trăm; vì cả hai đầu khoảng này đều lớn hơn 0 (không chứa 0), kết luận có sự khác biệt thật là hợp lý. Nếu khoảng tin cậy này bao gồm cả giá trị 0, dù p-value có nhỏ đến đâu, kết quả vẫn không đủ ổn định để ra quyết định — nên chạy thêm dữ liệu.

Một cạm bẫy phổ biến là peeking — nhìn kết quả mỗi ngày và dừng thực nghiệm ngay khi thấy p-value < 0.05 lần đầu tiên. Việc này làm tăng false positive rate thật sự lên rất cao (có thể vượt 30-40% dù cấu hình alpha = 0.05), vì bạn đang thực hiện nhiều lần kiểm định trên cùng dữ liệu đang tích lũy dần. Cách xử lý đúng: cam kết trước sample size cần đạt (đã tính ở bước 3 phần trước), chỉ phân tích và ra quyết định khi đã đạt đủ sample size đó, hoặc dùng phương pháp sequential testing (kiểm định tuần tự) có điều chỉnh ngưỡng alpha theo số lần nhìn kết quả.

Common A/B Test Failure Patterns

Ngoài peeking, có một số mẫu lỗi khác thường gặp khi phân tích A/B test cho hệ thống LLM:

  • Multiple comparisons (kiểm định nhiều lần không điều chỉnh): theo dõi 10 metric và coi bất kỳ metric nào có p < 0.05 là "thắng". Với 10 phép kiểm định độc lập ở alpha 0.05, xác suất có ít nhất một false positive lên tới khoảng 40%. Cách xử lý: chỉ định một primary metric duy nhất, hoặc dùng hiệu chỉnh Bonferroni (chia alpha cho số lượng metric) nếu buộc phải theo dõi nhiều metric.
  • Sample Ratio Mismatch (SRM) — tỷ lệ traffic thực tế đổ vào hai nhóm lệch đáng kể so với tỷ lệ cấu hình (ví dụ cấu hình 50/50 nhưng thực tế 55/45). SRM là dấu hiệu có lỗi trong logic randomization hoặc logging, và bất kỳ kết luận nào rút ra từ dữ liệu có SRM đều không đáng tin — phải sửa lỗi và chạy lại, không được "vẫn phân tích cho có kết quả".
  • Simpson's Paradox: kết quả tổng thể (aggregate) cho thấy treatment thắng, nhưng khi chia theo từng segment (ví dụ theo ngôn ngữ code, theo loại người dùng) thì treatment lại thua ở mọi segment con. Hiện tượng này xảy ra khi tỷ lệ phân bổ giữa các segment không đồng đều giữa hai variant. Luôn kiểm tra kết quả theo từng segment quan trọng trước khi tin vào số liệu tổng.
  • Quan sát không độc lập (non-independence): nếu một user gửi 20 request trong thực nghiệm và mỗi request được tính là một "điểm dữ liệu" độc lập, công thức sample size ở trên sẽ đánh giá sai độ tin cậy thực (vì hành vi của 20 request từ cùng một user có tương quan với nhau). Cách xử lý phổ biến: gộp (aggregate) outcome theo user trước khi phân tích, hoặc dùng mô hình có tính đến cấu trúc phân nhóm (clustered/hierarchical model).

Mẹo: Dựng một dashboard tự động kiểm tra SRM ngay khi thực nghiệm bắt đầu chạy (so sánh số lượng request đổ vào mỗi variant với tỷ lệ kỳ vọng bằng một chi-square test đơn giản). Phát hiện SRM ở ngày 1 giúp bạn dừng và sửa lỗi sớm, thay vì phát hiện ở ngày 14 khi đã tốn hai tuần traffic vô nghĩa.

Context Strategy A/B Testing

A/B testing không chỉ áp dụng cho câu chữ trong prompt — cách bạn chọn, cắt và sắp xếp context đưa vào LLM (đặc biệt trong hệ thống RAG) ảnh hưởng đến chất lượng câu trả lời và chi phí token không kém gì bản thân prompt.

RAG Chunk Size Experiments

Kích thước chunk (chunk size) trong hệ thống RAG là một trade-off kinh điển: chunk nhỏ (ví dụ 256 token) cho retrieval chính xác hơn vì mỗi chunk tập trung vào một ý cụ thể, nhưng dễ mất ngữ cảnh liên quan nằm ở chunk lân cận; chunk lớn (ví dụ 1024 token) giữ được nhiều ngữ cảnh liên tục hơn nhưng làm loãng thông tin quan trọng (dilution) và tăng token cost cho mỗi lần gọi model.

CHUNK_SIZE_VARIANTS = {
    "small": {"chunk_tokens": 256, "overlap_tokens": 32},
    "medium": {"chunk_tokens": 512, "overlap_tokens": 64},
    "large": {"chunk_tokens": 1024, "overlap_tokens": 96},
}

def build_index(documents, chunk_tokens: int, overlap_tokens: int):
    # Split documents into overlapping windows of `chunk_tokens`,
    # embed each chunk, and upsert into a vector store tagged with the variant name.
    ...

def run_chunk_size_experiment(eval_questions: list[dict], variant_name: str) -> list[dict]:
    config = CHUNK_SIZE_VARIANTS[variant_name]
    index = build_index(CORPUS, **config)
    results = []
    for q in eval_questions:
        retrieved = index.search(q["query"], top_k=5)
        answer = generate_answer(q["query"], context=retrieved)
        results.append({
            "question_id": q["id"],
            "variant": variant_name,
            "retrieval_precision": score_precision(retrieved, q["gold_chunks"]),
            "answer_correct": score_answer(answer, q["gold_answer"]),
            "context_tokens": sum(count_tokens(c) for c in retrieved),
        })
    return results

for variant_name in CHUNK_SIZE_VARIANTS:
    run_chunk_size_experiment(EVAL_QUESTIONS, variant_name)

Khác với thay đổi prompt (có thể A/B test trực tiếp trên production traffic vì chỉ cần đổi text gửi lên), thay đổi chunk size đòi hỏi xây lại toàn bộ index — nên thường được test offline trên một eval set cố định trước, sau đó chỉ variant thắng mới được đưa vào online A/B test để xác nhận trên traffic thực. Metric cần theo dõi song song: retrieval precision (chunk lấy về có thực sự chứa câu trả lời không), answer correctness (câu trả lời cuối cùng đúng không — vì precision cao không tự động nghĩa model dùng đúng thông tin), và context_tokens (chi phí — nếu chunk lớn cho answer correctness chỉ nhích 2% nhưng tăng 40% token, không đáng đổi).

Context Ordering Tests

Nghiên cứu về "lost in the middle" (thất lạc thông tin ở giữa) cho thấy các LLM có xu hướng chú ý nhiều hơn đến thông tin nằm ở đầu và cuối context, và dễ "bỏ sót" thông tin quan trọng nằm ở giữa một context dài — dù thông tin đó vẫn nằm trong context window và về lý thuyết model có thể đọc được. Điều này khiến thứ tự sắp xếp các chunk retrieval được trở thành một biến số đáng test riêng, tách biệt với việc chọn chunk nào.

def order_most_relevant_first(chunks_with_scores: list[tuple]) -> list:
    return [c for c, _ in sorted(chunks_with_scores, key=lambda x: x[1], reverse=True)]

def order_most_relevant_last(chunks_with_scores: list[tuple]) -> list:
    # Put the strongest evidence right before the question, closest to
    # where the model generates its answer.
    return list(reversed(order_most_relevant_first(chunks_with_scores)))

def order_original_document(chunks_with_scores: list[tuple], original_index_map: dict) -> list:
    # Preserve the order chunks appeared in the source document, which can
    # help when answers depend on reading a procedure step by step.
    chunks = [c for c, _ in chunks_with_scores]
    return sorted(chunks, key=lambda c: original_index_map[c.id])

ORDERING_VARIANTS = {
    "relevance_first": order_most_relevant_first,
    "relevance_last": order_most_relevant_last,
    "document_order": order_original_document,
}

Ba biến thể trên đại diện cho ba giả thuyết khác nhau: "model đọc kỹ đầu context nhất" (relevance_first), "model nhớ tốt phần vừa đọc xong trước khi sinh câu trả lời" (relevance_last), và "một số câu hỏi cần đọc tuần tự đúng thứ tự gốc" (document_order). Kết quả thực tế phụ thuộc nhiều vào loại model đang dùng và độ dài context trung bình — nên đây là loại thực nghiệm nhất thiết phải test bằng dữ liệu thật của hệ thống bạn, không thể suy ra từ paper hay benchmark chung.

Mẹo: Khi test context ordering, cố định số lượng và nội dung chunk giữa các variant — chỉ thay đổi thứ tự. Nếu bạn vô tình để retrieval trả về chunk khác nhau giữa các lần gọi (do tính bất định của vector search hoặc do re-ranking), bạn sẽ không biết khác biệt kết quả là do thứ tự hay do nội dung chunk khác nhau.

Xây dựng Pipeline A/B Testing Prompt với LangSmith

LangSmith (nền tảng observability và evaluation của LangChain) cung cấp sẵn hạ tầng để chạy các thực nghiệm A/B trên prompt mà không cần tự xây dựng toàn bộ pipeline logging và phân tích từ đầu — đặc biệt hữu ích khi bạn cần chạy đánh giá offline lặp lại nhiều lần trong quá trình phát triển, trước khi đưa lên online A/B test thật.

from langsmith import Client
from langsmith.evaluation import evaluate

client = Client()

dataset = client.create_dataset("pr-review-eval-set-2026-07")
for example in production_samples:
    client.create_example(
        inputs={"diff": example["diff"], "file_context": example["file_context"]},
        outputs={"gold_comments": example["human_reviewer_comments"]},
        dataset_id=dataset.id,
    )

def run_control(inputs: dict) -> dict:
    prompt = load_prompt("pr-review-prompt-v3", "control")
    return {"comments": call_llm(prompt, inputs)}

def run_treatment(inputs: dict) -> dict:
    prompt = load_prompt("pr-review-prompt-v3", "treatment")
    return {"comments": call_llm(prompt, inputs)}

def relevance_judge(run, example) -> dict:
    score = llm_judge_score(run.outputs["comments"], example.outputs["gold_comments"])
    return {"key": "relevance", "score": score}

def token_cost_evaluator(run, example) -> dict:
    return {"key": "input_tokens", "score": run.extra["input_tokens"]}

control_results = evaluate(
    run_control, data=dataset.name, evaluators=[relevance_judge, token_cost_evaluator]
)
treatment_results = evaluate(
    run_treatment, data=dataset.name, evaluators=[relevance_judge, token_cost_evaluator]
)

Điểm mạnh của cách tiếp cận này là dataset cố định (frozen) — cả hai variant chạy trên đúng cùng một tập input, loại bỏ hoàn toàn nguy cơ confound do khác biệt traffic theo thời gian, vốn là rủi ro lớn nhất khi test online. Điểm yếu là dataset offline không phản ánh hoàn toàn phân phối traffic thực và không đo được hành vi thật của người dùng (ví dụ họ có thực sự chấp nhận comment hay không) — vì vậy quy trình chuẩn là: dùng LangSmith để lọc nhanh các biến thể tệ trong giai đoạn phát triển, sau đó chỉ đưa 1-2 biến thể tốt nhất vào online A/B test có đo delayed outcome thật để ra quyết định cuối cùng.

Mẹo: Khi dùng LLM-as-judge làm evaluator, luôn kiểm tra độ đồng thuận (agreement) giữa judge model và đánh giá của con người trên một mẫu nhỏ (30-50 ví dụ) trước khi tin tưởng dùng judge score làm primary metric cho cả thực nghiệm lớn. Một judge model bị lệch (biased) — ví dụ luôn thiên vị câu trả lời dài hơn — sẽ khiến toàn bộ kết luận A/B test sai theo cùng một hướng lệch đó.

Tổng kết

A/B testing biến việc tối ưu prompt và context strategy từ một chuỗi quyết định cảm tính thành một quy trình có bằng chứng, có thể lặp lại và bảo vệ được trước câu hỏi "tại sao lại chọn phương án này". Những điểm cốt lõi cần nhớ:

  • Luôn viết hypothesis cụ thể (nguyên nhân, kết quả kỳ vọng, độ lớn kỳ vọng) trước khi chạy thực nghiệm, kèm một primary metric duy nhất và một tập guardrail metric.
  • Tính sample size cần thiết trước khi bắt đầu, dựa trên baseline hiện tại, MDE mong muốn, alpha và power — không dừng thực nghiệm sớm chỉ vì thấy p-value nhỏ ở giữa đường (peeking).
  • Kiểm soát confounder bằng cách randomize nhất quán theo user, tránh chạy các thực nghiệm chồng chéo, và chờ novelty effect qua đi trước khi kết luận.
  • Xây hạ tầng chia traffic bằng consistent hashing và prompt registry để có thể rollback tức thì, cùng pipeline logging tách rời cho metric ngay lập tức và metric có độ trễ.
  • Khi đọc kết quả, luôn kiểm tra Sample Ratio Mismatch, đa kiểm định không điều chỉnh, và Simpson's Paradox trước khi tin vào số liệu tổng.
  • Context strategy (chunk size, thứ tự sắp xếp chunk) cần được test riêng biệt với prompt text, thường bắt đầu bằng offline evaluation trước khi xác nhận bằng online A/B test.
  • Công cụ như LangSmith giúp tăng tốc vòng lặp offline evaluation, nhưng không thay thế được online A/B test có đo delayed outcome thật khi cần ra quyết định cuối cùng ảnh hưởng đến production.

Mẹo: Lưu lại mọi thực nghiệm A/B đã chạy — kể cả những thực nghiệm "thất bại" (không tìm thấy khác biệt có ý nghĩa) — vào một nhật ký chung của team (experiment log). Biết được "chúng ta đã test ý tưởng này và không thấy tác dụng" quan trọng không kém biết "ý tưởng nào đã hiệu quả", vì nó ngăn đội ngũ lặp lại đúng thực nghiệm vô nghĩa sáu tháng sau.