Code · Những thứ đang làm, đang hỏng, đang sửa — kiến trúc, vài dòng hay ho, và những con bug.

Tôi đã bảo nó đừng bịa. Nó vẫn bịa.

Mười một giờ đêm, một con 14B quantized chạy local, một system prompt viết hoa hết cỡ — và nó vẫn bịa. Hoá ra "cấm bịa" không phải một câu lệnh, mà là một lời cầu nguyện gửi vào phân phối xác suất.

Chuyện thật từ những đêm chạy LLM open source ở local, và bài học rằng “cấm bịa” không phải một câu lệnh — nó là một lời cầu nguyện.


Những đêm sau ngày dài

Ban ngày tôi thuộc về công ty. Ticket, meeting, những deadline đuổi nhau, những dòng code phải chạy được trước khi mặt trời lặn. Hết tám tiếng, đôi khi mười, tôi rời công việc với cái đầu đã cạn.

Khoảng mười giờ đêm — khi nhà đã yên và điện thoại thôi rung — tôi mở một cái máy khác. Máy của riêng tôi. Đó là khoảng thời gian tôi giữ lại cho mình, để nghịch những thứ chưa ai trả tiền cho tôi làm: mổ xẻ mấy con LLM, dựng RAG, xem ruột gan của những cỗ máy này thật sự vận hành ra sao.

Có người tan làm thì đi tập gym để xả. Tôi thì ngồi với GPU. Mệt thì vẫn mệt, nhưng là kiểu mệt mình chọn — vì tò mò, chứ không vì ai bắt. Và cái đêm tôi sắp kể đã dạy tôi một bài học mà cả ngày ở công ty không bao giờ dạy nổi.


Bối cảnh

Đêm đó tôi đang dựng một hệ RAG nhỏ chạy hoàn toàn local — không gọi API nào hết, vì dữ nằm hết trong máy. Một con 14B quantized nạp qua Ollama, ngồi gọn trong VRAM; pgvector lo phần retrieval, model lo phần đọc context rồi trả lời.

System prompt tôi viết, thề có trời, thuộc loại nghiêm khắc nhất tôi biết:

Bạn CHỈ được trả lời dựa trên thông tin trong phần CONTEXT bên dưới. Tuyệt đối KHÔNG dùng kiến thức bên ngoài. Nếu CONTEXT không chứa câu trả lời, bạn PHẢI trả lời chính xác: “Tôi không tìm thấy thông tin này trong tài liệu.” KHÔNG suy đoán. KHÔNG bịa.

Viết hoa, in đậm tinh thần, gần như quát vào mặt nó. Tôi tự tin lắm.

Rồi tôi hỏi một câu mà tôi biết chắc tài liệu không có: một con số cụ thể về hạn mức của một chính sách nội bộ.

Nó trả lời. Trôi chảy. Tự tin. Kèm một con số đẹp đẽ, tròn trĩnh, đơn vị đàng hoàng.

Con số đó không tồn tại trong context. Cũng không tồn tại trong thực tế. Nó tự nặn ra.


Phản ứng đầu tiên (và sai lầm đầu tiên)

Tôi làm đúng cái việc mọi developer sẽ làm: đổ lỗi cho prompt của mình.

“Chắc tại mình viết chưa đủ rõ.” Thế là tôi thêm. Tôi bồi. Ba câu cảnh báo nữa, mấy lớp delimiter ### bọc quanh context, rồi cả câu “Nếu bạn bịa, điều đó là vi phạm nghiêm trọng.”

Buồn cười nhỉ — nửa đêm, đầu óc rã rời, mà tôi ngồi đe doạ một ma trận trọng số. Như thể nó biết sợ.

Chạy lại. Vẫn con số đó. Có khi còn tự tin hơn.

Đó là lúc tôi dừng lại, dụi mắt, và nhận ra: vấn đề không nằm ở chỗ tôi ra lệnh chưa đủ mạnh. Vấn đề là tôi đang hiểu sai bản chất việc nó làm.


Tại sao nó không “nghe lời”

Tôi tưởng mình đang ra lệnh. Thật ra tôi chỉ đang đưa thêm vài câu chữ vào đầu một chuỗi token, rồi để cơ chế dự đoán tự quyết.

Câu “không được bịa”, với model, không phải một công tắc luân lý. Nó chỉ là thêm vài token vào input. Những token đó có ảnh hưởng đến phân phối xác suất đầu ra — có — nhưng không ghi đè được cơ chế nền tảng. Đến lượt sinh token tiếp theo, model làm đúng việc nó luôn làm: chọn chuỗi nghe có vẻ hợp lý nhất.

Mà một câu trả lời có số liệu cụ thể thì luôn nghe hợp lý hơn câu “tôi không biết”. Trong dữ liệu huấn luyện của cả nhân loại, câu trả lời thẳng thắn áp đảo lời thú nhận không biết. Model học từ một thế giới nơi người ta hiếm khi viết “tôi không rõ” — nên với nó, im lặng hay từ chối là trạng thái ít xác suất. Nó bị kéo về phía trả lời, theo đúng nghĩa đen của trọng lực thống kê.

Tối hôm đó có ba thứ chống lại tôi, và không thứ nào nằm ở prompt:

Một — xung đột giữa context và kiến thức tham số. Model đã “nuốt” hàng tỷ token lúc train; kiến thức đó nằm sẵn trong trọng số, gọi là parametric knowledge. Khi tôi nhét context vào, tôi đang yêu cầu nó ưu tiên cái mới đọc hơn cái đã thuộc lòng — nhưng không có cơ chế cứng nào ép buộc điều đó. Hai nguồn giằng co trong cùng một lớp attention, và với model nhỏ, cái đã-thuộc-lòng thường thắng. Nó không phân biệt được “điều vừa đọc trong context” với “điều vốn tin từ trước” — tất cả, với nó, chỉ là token cùng nằm trong cửa sổ ngữ cảnh.

Hai — khả năng tuân thủ chỉ dẫn của model nhỏ vốn yếu. Đây là chỗ frontier model bỏ xa hàng local 14B. Những con như GPT hay Claude được căn chỉnh (alignment) cực nặng bằng RLHF và vô số kỹ thuật khác để thật sự bám lệnh(hoặc ít nhất là bám lệnh hơn, dù không chắc 100%), nhất là lệnh kiểu “chỉ dùng context, không thì từ chối”. Một con 14B open source fine-tune sơ sài thì cái khả năng “biết kiềm chế” ấy mỏng hơn nhiều. Nó hiểu tôi muốn gì. Nó chỉ không đủ kỷ luật để làm theo, khi xác suất kéo nó đi hướng khác.

Ba — và cái này tôi quên khuấy mất: tôi đang chạy bản quantized. Để nhét vừa VRAM, tôi đã nén trọng số xuống 4-bit. Quantization tiết kiệm bộ nhớ thật, nhưng nó bào mòn đúng phần tinh tế nhất — khả năng suy luận, khả năng giữ vững chỉ dẫn dưới áp lực. Cái kỷ luật vốn đã mỏng, qua lượng tử hoá lại càng mỏng thêm. Tôi đã tự tay làm cho người lính của mình vừa thiếu kỷ luật vừa chuếnh choáng, rồi trách nó không đứng nghiêm.


Khoảnh khắc tôi thấy rõ vấn đề

Tôi bật log, in ra toàn bộ context thực sự được nhồi vào prompt. Và tôi thấy retrieval của mình trả về những đoạn gần đúng nhưng không trúng — nói về đúng chính sách đó, nhưng không có con số.

Đây mới là chỗ hiểm. Model đọc được những đoạn nói xung quanh câu trả lời. Nó thấy cái khuôn của một câu trả lời — “chính sách này có hạn mức là ___” — và với một cỗ máy điền-vào-chỗ-trống, khoảng trống ấy là một lời mời không thể cưỡng lại. Nó điền. Vì điền chính là việc nó sinh ra để làm.

Tôi đã đưa nó một câu gần hoàn chỉnh rồi bảo “đừng hoàn thành câu này”. Chẳng khác nào đưa người ta câu “Quê hương là chùm khế…” rồi cấm nghĩ tới chữ “ngọt”. Não người không hoạt động kiểu đó. Model cũng vậy.

Bịa, trong ca này, không phải nó cố tình dối. Nó chỉ đang nội suy trên một khoảng trống mà retrieval tồi của tôi tạo ra.


Tôi đã sửa thế nào (và sửa được tới đâu)

Bài học rút ra: không thể prompt cho hết bịa — phải thiết kế hệ thống sao cho cái bịa không còn chỗ chui ra.

Tôi làm mấy việc sau, xếp theo tác động từ mạnh đến yếu.

Đầu tiên, tôi sửa retrieval trước khi sửa model. Phần lớn ca “bịa trong RAG” thật ra là ca “context sai hoặc thiếu”. Tôi siết lại chunking, thêm reranking, và quan trọng nhất — đặt một ngưỡng điểm tương đồng: nếu không đoạn nào vượt ngưỡng, hệ thống tự trả về “không tìm thấy” mà không cần gọi model. Cách rẻ nhất để model đừng bịa là đừng cho nó cơ hội mở miệng khi không có dữ liệu.

Thứ hai — và đây mới là chỗ tạo khác biệt thật sự — tôi đổi sang model có số tham số lớn hơn hẳn. Phải thành thật: mọi thứ kể trên, từ siết retrieval đến chỉnh prompt, đều chỉ là giảm thiểu. Tình hình chỉ thực sự chuyển biến khi tôi bỏ con 14B và lên một con lớn hơn nhiều bậc.

Khác biệt không hề tinh tế. Cùng một context, cùng một câu hỏi gài bẫy, cùng một system prompt — con nhỏ điền bừa con số, con lớn dừng lại và nói “tôi không tìm thấy thông tin này trong tài liệu”. Không phải vì tôi prompt khéo hơn, mà vì ở quy mô tham số lớn hơn, khả năng kiềm chế — biết khi nào nên im lặng — mạnh hơn về chất, không chỉ về lượng.

Đây là điều khó chịu tôi phải chấp nhận: rất nhiều thứ ta gọi là “kỹ thuật prompt” hay “tối ưu RAG” thật ra chỉ đang vá víu cho giới hạn năng lực của một model quá nhỏ. Có những bức tường không prompt qua được — chỉ có thể đổi sang một model đủ lớn để bức tường ấy biến mất. Khả năng bám lệnh “chỉ-dùng-context-không-thì-từ-chối” là một năng lực xuất hiện theo quy mô, không phải thứ ép ra được từ một con 14B bằng cách quát to hơn.

Thứ ba, tôi tách câu hỏi “có trả lời được không” khỏi câu hỏi “trả lời là gì”. Một lượt gọi model chỉ để phán: context này có đủ thông tin trả lời câu hỏi không — Có hay Không? Nếu Không thì dừng. Nếu Có mới sang lượt sinh câu trả lời. Bắt nó quyết một việc nhỏ mỗi lần, nó làm tốt hơn hẳn so với bắt nó vừa trả lời vừa tự kiểm duyệt cùng lúc.

Cuối cùng tôi mới động đến prompt — và lần này tôi hạ giọng thay vì quát to hơn. Bỏ những câu đe doạ vô nghĩa, thêm vài ví dụ (few-shot) cho nó thấy một lần từ chối trông như thế nào. Một ví dụ cụ thể “khi context trống thì output là câu này” nặng ký hơn mười câu mệnh lệnh viết hoa. Model học từ mẫu giỏi hơn học từ luật.

Kết quả: nó vẫn bịa. Ít hơn nhiều, nhưng không về 0. Và tôi cũng thôi kỳ vọng nó về không.


Điều tôi mang theo sau đêm đó

Cái sai lớn nhất của tôi không nằm ở kỹ thuật, mà ở kỳ vọng. Tôi đã đối xử với model như một nhân viên có thể ra lệnh, trong khi nó là một hiện tượng thống kê cần được thiết kế xung quanh.

“Cấm bịa” không phải một cầu dao. Nó là một lời cầu nguyện ta gửi vào prompt, hy vọng phân phối xác suất nghe thấy. Đôi khi nó nghe. Đôi khi không. Một hệ thống production tử tế là hệ thống không phụ thuộc vào chuyện nó có nghe hay không — mà dựng sẵn lưới đỡ cho lúc nó phớt lờ.

Con model local ấy không phản bội tôi. Nó làm đúng bản chất của nó đến từng token, trong giới hạn mà số tham số cho phép. Kẻ ảo tưởng trong căn phòng tối hôm đó, hoá ra, là tôi — kẻ tin rằng chỉ cần viết chữ KHÔNG đủ to thì một con 14B sẽ học được cách im lặng. Nó không đủ lớn để im lặng. Đó không phải lỗi của nó, cũng chẳng phải lỗi của prompt. Đó là giới hạn của quy mô.

Nó không học được. Nhưng tôi thì có. Và đó là lý do tôi vẫn ngồi đây, đêm này qua đêm khác, sau mỗi ngày dài — không phải để bắt cỗ máy phải hoàn hảo, mà để hiểu nó đủ sâu, đến mức không còn bị nó lừa nữa.


落款
chưa đóng

Con dấu đọc

Đọc tới cuối rồi ngồi lại một nhịp — con dấu sẽ tự đóng. Không điểm, không streak; chỉ một vết mực cho riêng bạn.