Prompt cho lập trình viên: 7 mẫu viết code mình dùng hằng ngày

7 mẫu prompt cho lập trình viên dùng hằng ngày: hỏi lại trước khi code, tìm ca biên, test đỏ trước, giải thích kèm kiểm chứng, review và refactor giữ hành vi.

Prompt cho lập trình viên khác prompt thông thường ở đâu? Theo mình, một prompt tốt khi viết code luôn nói rõ ba thứ: việc cần làm, việc không được làm, và cách bạn sẽ kiểm tra kết quả. Cùng mình xem 7 mẫu mình dùng hằng ngày, thử trên app sổ chi tiêu nhé!

Code chạy được, nhưng mình có hiểu nó không?

Ở bài trước, mình giao cùng một việc cho Copilot, Cursor và Claude Code: thêm lệnh /tong vào app sổ chi tiêu, một app nhỏ ghi chi tiêu bằng cách nhắn tin kiểu "cà phê 35k". Lệnh /tong cộng các khoản chi của tháng hiện tại theo giờ Việt Nam, kể cả khi máy chủ chạy theo giờ UTC. Code do AI viết, test xanh, và đây là hàm tính tháng nằm ở trung tâm của nó:

src/so-chi-tieu.ts (bản ở bài trước)
const VN_TIME = "Asia/Ho_Chi_Minh";

// Tháng/năm theo giờ Việt Nam, kể cả khi máy chủ chạy theo giờ UTC.
function thangVN(date: Date): string {
  const parts = new Intl.DateTimeFormat("en-GB", { timeZone: VN_TIME, month: "2-digit", year: "numeric" }).formatToParts(date);
  const month = parts.find((p) => p.type === "month")!.value;
  const year = parts.find((p) => p.type === "year")!.value;
  return `${month}/${year}`;
}

Bài trước kết thúc bằng ba câu hỏi mà mình chưa trả lời được. formatToParts trả về cái gì? Vì sao dùng "en-GB" cho một app tiếng Việt? Dấu ! sau find(...) có nguy hiểm không?

Bài này trả lời chúng bằng các mẫu prompt mình dùng thật. Mỗi mẫu đi kèm một bước kiểm tra mà bạn tự làm, vì câu trả lời của AI nghe hợp lý chưa có nghĩa là đúng. Mọi lệnh và output dưới đây là mình chạy thật trên code của app. Cả bảy mẫu cũng có trong file PROMPTS.md của demo để bạn chép về dùng.

Mẫu 1: Bắt AI hỏi lại trước khi viết code

Mẫu 1
Đọc <SPEC.md / mô tả việc cần làm>. Chưa viết code.
Liệt kê những chỗ chưa rõ dưới dạng câu hỏi, mỗi câu kèm 2 cách hiểu có thể có và hậu quả của từng cách.

Đây là mẫu mình dùng nhiều nhất, và nó xuất phát từ bài đầu tiên của series, viết spec trước khi prompt. AI hiếm khi tự dừng lại để hỏi. Nếu yêu cầu mơ hồ, nó sẽ chọn một cách hiểu và viết code thật nhanh theo cách đó, và bạn chỉ phát hiện ra khi đọc tới dòng thứ 80.

Hai chữ "chưa viết code" là phần quan trọng nhất. Thiếu nó, câu trả lời thường là một đoạn code kèm vài giả định nằm lẫn ở cuối. Yêu cầu "hai cách hiểu và hậu quả" thì buộc câu trả lời chỉ ra chỗ nào thật sự cần bạn quyết định.

Bước kiểm tra là tự trả lời từng câu hỏi và ghi câu trả lời vào spec. Ví dụ "/tong tính theo tháng của máy chủ hay của người dùng?" chính là câu đã sinh ra quy tắc "tháng theo giờ Việt Nam" trong spec của app.

Mẫu 2: Tìm ca biên trước khi sửa

Mẫu 2
Với <hàm / lệnh>, liệt kê 10 đầu vào dễ làm sai nhất. Mỗi dòng: đầu vào → kết quả đúng theo <SPEC.md> → vì sao dễ sai.
Chưa sửa code, chưa viết test.

Mình chạy mẫu này với Claude Code cho lệnh /tong, rồi chọn ra vài đầu vào để thử ngay trên code hiện tại. Đây là bước kiểm tra quan trọng: một danh sách ca biên chỉ có giá trị khi bạn thử chúng trên code thật. Script thử của mình gửi lần lượt từng tin nhắn vào hàm handleMessage của app, dùng chung một sổ chi tiêu, vào lúc 6:30 sáng ngày 1/12. Tin nhắn đầu tiên ghi một khoản chi để lệnh /tong có thứ để cộng:

thu-lenh.ts
// Gửi lần lượt từng tin nhắn vào handleMessage, dùng chung một sổ chi tiêu, rồi in câu trả lời.
import { handleMessage, type Entry } from "./src/so-chi-tieu.ts";

const luc = new Date("2026-12-01T06:30+07:00");
const so: Entry[] = [];
for (const tinNhan of ["cà phê 35k", "/tong", "/TONG", " /tong ", "/tong tháng 10", "/tổng", "tong"]) {
  console.log(JSON.stringify(tinNhan), "→", handleMessage(tinNhan, luc, so));
}
TZ=UTC node thu-lenh.ts (trên code của bài trước)
"cà phê 35k" → Đã ghi: cà phê, 35.000đ
"/tong" → Tháng 12/2026: 1 khoản, tổng 35.000đ
"/TONG" → Tháng 12/2026: 1 khoản, tổng 35.000đ
" /tong " → Tháng 12/2026: 1 khoản, tổng 35.000đ
"/tong tháng 10" → Đã ghi: /tong tháng, 10đ
"/tổng" → Mình chưa hiểu. Nhắn kiểu "cà phê 35k" để ghi một khoản chi, hoặc /tong để xem tổng tháng này.
"tong" → Mình chưa hiểu. Nhắn kiểu "cà phê 35k" để ghi một khoản chi, hoặc /tong để xem tổng tháng này.

Bạn nhìn dòng thứ năm. Một người muốn xem tổng tháng 10 nhắn "/tong tháng 10", và app ghi vào sổ một khoản chi 10 đồng với ghi chú "/tong tháng". Code không so khớp được lệnh /tong, nên tin nhắn rơi xuống phần đọc khoản chi, và phần đó thấy số 10 thì coi là số tiền. Không test nào bắt được chuyện này, vì không ai nghĩ tới nó lúc viết spec.

Dòng thứ sáu nhẹ hơn nhưng cũng đáng sửa. Bàn phím tiếng Việt trên điện thoại rất dễ biến "tong" thành "tổng", và người dùng nhận về một câu hướng dẫn khó hiểu.

Mình không sửa code ngay. Trước tiên mình cập nhật spec lên v1.2 với hai quy tắc mới: tin nhắn bắt đầu bằng / là lệnh và không bao giờ được ghi thành khoản chi, còn /tổng có dấu cũng là lệnh tổng.

Mẫu 3: Viết test phải đỏ trước

Mẫu 3
Viết test cho các trường hợp <số ...> trong danh sách trên, vào <file test>. Không sửa code.
Chạy test và dán kết quả: các test mới phải ĐỎ trên code hiện tại. Test nào xanh ngay thì nói rõ.

Một test viết cho lỗi chưa sửa mà lại xanh ngay thì chưa kiểm tra đúng chỗ. Vì vậy mẫu này đòi kết quả đỏ, và đòi nói rõ nếu có test xanh. Hai test mới được thêm vào cuối file src/so-chi-tieu.test.ts. File này dùng trình chạy test có sẵn của Node.js, với hàm vn() đổi một chuỗi giờ Việt Nam thành Date:

src/so-chi-tieu.test.ts (hai test mới)
test("lệnh kèm chữ phía sau không bị ghi thành khoản chi", () => {
  const entries: Entry[] = [];
  const reply = handleMessage("/tong tháng 10", vn("2026-11-05T09:00"), entries);
  assert.equal(reply, "Mình chưa có lệnh /tong. Nhắn /tong để xem tổng tháng này.");
  assert.equal(entries.length, 0);
});

test("/tổng (gõ có dấu) cũng là lệnh tổng", () => {
  const entries: Entry[] = [];
  handleMessage("cà phê 35k", vn("2026-11-03T08:00"), entries);
  assert.equal(handleMessage("/Tổng", vn("2026-11-05T09:00"), entries), "Tháng 11/2026: 1 khoản, tổng 35.000đ");
});

Chạy trên code cũ:

TZ=UTC node src/so-chi-tieu.test.ts (code của bài trước)
ok 5 - tin nhắn không hiểu thì không lưu gì
not ok 6 - lệnh kèm chữ phía sau không bị ghi thành khoản chi
  expected: 'Mình chưa có lệnh /tong. Nhắn /tong để xem tổng tháng này.'
  actual: 'Đã ghi: /tong tháng, 10đ'
not ok 7 - /tổng (gõ có dấu) cũng là lệnh tổng
# pass 5
# fail 2

Hai test mới đỏ, và dòng actual cho thấy đúng lỗi khoản chi 10 đồng. Năm test cũ vẫn xanh. Đây là điểm xuất phát mình muốn trước khi đụng vào code.

Mẫu 4: Sửa code mà không được sửa test

Mẫu 4
Sửa <file code> để mọi test trong <file test> qua. Không sửa test, không thêm thư viện.
Chạy toàn bộ test <kèm điều kiện, ví dụ TZ=UTC> và dán kết quả.

Mẫu này mình đã dùng ở bài trước. Câu "không sửa test" quan trọng hơn bạn nghĩ, vì khi được giao "làm cho test qua", con đường ngắn nhất là sửa test. Bước kiểm tra là xem diff có đụng tới file test không, rồi tự chạy lại test chứ không chỉ tin vào kết quả AI dán ra.

Phần sửa cho hai lỗi trên nằm ở đầu hàm handleMessage. Mình đưa cả file sau khi sửa ở mục mẫu 7, vì nó còn gồm thay đổi từ mẫu 6.

Mẫu 5: Giải thích để mình hiểu, kèm cách tự kiểm chứng

Mẫu 5
Giải thích <hàm> cho một người mới, từng dòng.
Với mỗi khẳng định, đưa kèm một lệnh chạy được (ví dụ node -e "...") để mình tự kiểm chứng.
Chỗ nào bạn không chắc, nói là không chắc.

Đây là mẫu trả lời ba câu hỏi ở đầu bài. Dòng thứ hai là phần mình thấy đáng giá nhất. Một lời giải thích trôi chảy rất dễ khiến bạn gật đầu, còn một lệnh chạy được thì hoặc đúng, hoặc sai. Mình gom các lệnh kiểm chứng vào một file kiem-chung.ts, chạy với TZ=UTC để giống máy chủ, cho thời điểm 6:30 sáng ngày 1/12 giờ Việt Nam (lúc đó ở UTC vẫn là 23:30 ngày 30/11):

kiem-chung.ts
const d = new Date("2026-12-01T06:30+07:00");
const opts = { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" } as const;
console.log("en-GB parts:", JSON.stringify(new Intl.DateTimeFormat("en-GB", opts).formatToParts(d)));
console.log("vi-VN parts:", JSON.stringify(new Intl.DateTimeFormat("vi-VN", opts).formatToParts(d)));
console.log("vi-VN format:", new Intl.DateTimeFormat("vi-VN", opts).format(d), "| en-US format:", new Intl.DateTimeFormat("en-US", opts).format(d));
console.log("không có timeZone, TZ=", process.env.TZ, ":", new Intl.DateTimeFormat("en-GB", { month: "2-digit", year: "numeric" }).format(d));
TZ=UTC node kiem-chung.ts
en-GB parts: [{"type":"month","value":"12"},{"type":"literal","value":"/"},{"type":"year","value":"2026"}]
vi-VN parts: [{"type":"literal","value":"tháng "},{"type":"month","value":"12"},{"type":"literal","value":", "},{"type":"year","value":"2026"}]
vi-VN format: tháng 12, 2026 | en-US format: 12/2026
không có timeZone, TZ= UTC : 11/2026

Dòng đầu trả lời câu hỏi thứ nhất. `formatToParts` không trả về một chuỗi mà trả về một mảng các mảnh có gắn nhãn type. Code tìm mảnh month và mảnh year theo nhãn, nên không phụ thuộc vào thứ tự hay dấu phân cách.

Dòng thứ hai và thứ ba trả lời câu hỏi về "en-GB". Với "vi-VN", kết quả là "tháng 12, 2026", có thêm chữ và dấu phẩy. Thật ra vì code tìm theo nhãn, dùng "vi-VN" cũng vẫn ra đúng "12/2026", nhưng "en-GB" cho định dạng tháng/năm gọn nhất nếu ai đó lỡ dùng format thay cho formatToParts. Đây là chỗ mình phải sửa lại hiểu biết của chính mình sau khi chạy lệnh: lý do của "en-GB" là cho gọn, không phải là bắt buộc.

Dòng cuối cho thấy vì sao timeZone là phần quan trọng nhất của hàm. Bỏ nó đi, trên máy chủ chạy UTC, khoản chi lúc 6:30 sáng ngày 1/12 bị tính vào tháng 11.

Mẫu 6: Review như một senior khó tính

Mẫu 6
Review <file> như một senior khó tính. Liệt kê vấn đề theo mức độ nghiêm trọng.
Mỗi vấn đề: dòng nào, hậu quả cụ thể (đầu vào nào → sai thế nào), cách kiểm chứng. Không sửa code.

"Hậu quả cụ thể" là phần giữ cho review khỏi biến thành danh sách lời khuyên chung chung kiểu "nên thêm xử lý lỗi". Với hàm thangVN, review chỉ ra hai vấn đề đáng tin, vì cả hai đều kiểm chứng được.

Vấn đề thứ nhất là hàm tạo một Intl.DateTimeFormat mới ở mỗi lần gọi, trong khi lệnh /tong gọi thangVN cho từng khoản chi. Mình không tin ngay mà đo bằng một script nhỏ, tính tháng cho 10.000 khoản chi theo hai cách:

do-toc-do.ts
const OPTS = { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" } as const;
const moiLanMoi = (d: Date) => new Intl.DateTimeFormat("en-GB", OPTS).format(d);
const FORMATTER = new Intl.DateTimeFormat("en-GB", OPTS);
const dungLai = (d: Date) => FORMATTER.format(d);

const ngay = Array.from({ length: 10_000 }, (_, i) => new Date(Date.UTC(2026, 10, 1) + i * 3_600_000));
for (const [ten, ham] of [["tạo formatter mỗi lần", moiLanMoi], ["dùng lại một formatter", dungLai]] as const) {
  ham(ngay[0]);
  const batDau = performance.now();
  for (const d of ngay) ham(d);
  console.log(`${ten}: ${(performance.now() - batDau).toFixed(1)} ms cho ${ngay.length.toLocaleString("vi-VN")} khoản`);
}
node do-toc-do.ts (máy của mình, chạy 3 lần cho kết quả gần giống nhau)
tạo formatter mỗi lần: 646.1 ms cho 10.000 khoản
dùng lại một formatter: 10.3 ms cho 10.000 khoản

Chênh nhau hơn 60 lần. Với vài chục khoản chi mỗi tháng thì bạn không cảm nhận được, nhưng khi app có nhiều người dùng và /tong đọc cả năm dữ liệu, đây là khác biệt giữa trả lời ngay và bắt người dùng chờ gần một giây. Con số trên máy bạn sẽ khác, nhưng tỷ lệ thì nên tương tự.

Vấn đề thứ hai là dấu !. Nó bảo TypeScript "tin mình đi, chỗ này không bao giờ là undefined". Nếu một ngày find không tìm thấy mảnh month, chẳng hạn vì ai đó đổi tùy chọn định dạng, chương trình sẽ hỏng với một lỗi khó hiểu là "Cannot read properties of undefined", thay vì một thông báo nói rõ chuyện gì đã xảy ra.

Mẫu 7: Refactor mà không đổi hành vi

Mẫu 7
Sửa đúng vấn đề <số ...> trong review trên, không làm gì khác.
Không đổi hành vi: mọi test cũ phải qua, không sửa test. Dán kết quả test trước và sau khi sửa.

"Không làm gì khác" chặn thói quen tiện tay đổi tên biến, sắp xếp lại import hay viết lại cả file, những thứ làm diff phình to và khó review. Đây là file src/so-chi-tieu.ts sau khi gộp phần sửa lệnh (mẫu 4) và hai vấn đề của review (mẫu 6):

src/so-chi-tieu.ts (spec v1.2)
import { parseExpense } from "./parse-expense.ts";

export type Entry = { note: string; amount: number; at: Date };

const HUONG_DAN = 'Mình chưa hiểu. Nhắn kiểu "cà phê 35k" để ghi một khoản chi, hoặc /tong để xem tổng tháng này.';

// Tạo MỘT lần rồi dùng lại: tạo Intl.DateTimeFormat mới ở mỗi lần gọi chậm hơn hàng chục lần (xem do-toc-do.ts).
// "en-GB" vì nó trả về đúng "12/2026"; "vi-VN" trả về "tháng 12, 2026".
const THANG_VN = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" });

// Tháng/năm theo giờ Việt Nam, kể cả khi máy chủ chạy theo giờ UTC.
function thangVN(date: Date): string {
  const parts = THANG_VN.formatToParts(date);
  const month = parts.find((p) => p.type === "month")?.value;
  const year = parts.find((p) => p.type === "year")?.value;
  if (!month || !year) throw new Error(`Không tách được tháng/năm từ ${date.toISOString()}`);
  return `${month}/${year}`;
}

const vnd = (n: number) => `${n.toLocaleString("vi-VN")}đ`;

export function tongThang(entries: Entry[], now: Date): string {
  const thang = thangVN(now);
  const trongThang = entries.filter((entry) => thangVN(entry.at) === thang);
  if (trongThang.length === 0) return `Tháng ${thang} chưa có khoản chi nào.`;
  const tong = trongThang.reduce((sum, entry) => sum + entry.amount, 0);
  return `Tháng ${thang}: ${trongThang.length} khoản, tổng ${vnd(tong)}`;
}

export function handleMessage(text: string, now: Date, entries: Entry[]): string {
  const message = text.trim().normalize("NFC");
  // Tin nhắn bắt đầu bằng "/" là lệnh, không bao giờ là khoản chi (spec v1.2).
  if (message.startsWith("/")) {
    const lenh = message.toLowerCase();
    if (lenh === "/tong" || lenh === "/tổng") return tongThang(entries, now);
    return `Mình chưa có lệnh ${message.split(/\s/)[0]}. Nhắn /tong để xem tổng tháng này.`;
  }

  const expense = parseExpense(message);
  if (!expense) return HUONG_DAN;
  entries.push({ ...expense, at: now });
  return `Đã ghi: ${expense.note}, ${vnd(expense.amount)}`;
}

normalize("NFC") có mặt vì chữ "ổ" có thể được gõ thành một ký tự hoặc thành chữ "o" cộng hai dấu rời, tùy bàn phím. Hai cách nhìn giống hệt nhau nhưng so sánh bằng === thì khác nhau. Đưa về cùng một dạng trước khi so sánh giúp /tổng được nhận ra dù gõ bằng bàn phím nào.

File src/parse-expense.ts, phần đọc số tiền, không đổi so với bài trước. Chạy lại cả hai bộ test với TZ=UTC:

TZ=UTC node src/so-chi-tieu.test.ts
ok 1 - ghi ba khoản rồi /tong ra đúng tổng tháng
ok 2 - tháng chưa có khoản nào
ok 3 - 23:59 ngày 30/11 giờ VN vẫn thuộc tháng 11
ok 4 - 6:30 sáng 1/12 giờ VN (còn là 30/11 theo UTC) thuộc tháng 12
ok 5 - tin nhắn không hiểu thì không lưu gì
ok 6 - lệnh kèm chữ phía sau không bị ghi thành khoản chi
ok 7 - /tổng (gõ có dấu) cũng là lệnh tổng
# pass 7
# fail 0

Chạy lại script thử ở mẫu 2, hai dòng từng sai giờ đã đúng:

TZ=UTC node thu-lenh.ts (sau khi sửa, chỉ hai dòng đã đổi)
"/tong tháng 10" → Mình chưa có lệnh /tong. Nhắn /tong để xem tổng tháng này.
"/tổng" → Tháng 12/2026: 1 khoản, tổng 35.000đ

Bộ test đọc số tiền parse-expense.test.ts vẫn qua cả 16 test. Năm test cũ của /tong, gồm cả ca 6:30 sáng 1/12 dễ sai nhất, vẫn xanh sau khi đổi cách tạo formatter, nên mình yên tâm là hành vi không đổi.

Bảy mẫu này có điểm gì chung?

Nhìn lại cả bảy, mình thấy chúng không phải những câu thần chú. Mỗi mẫu chỉ nói rõ hơn ba điều mình nêu ở đầu bài: làm gì, không được làm gì, và kết quả sẽ được kiểm tra bằng cách nào. Hướng dẫn prompt engineering của Anthropic cũng nhấn mạnh việc nói rõ ràng, trực tiếp và có tiêu chí thành công, chứ không nhấn mạnh câu chữ đặc biệt nào.

Bước kiểm tra là phần không giao cho AI được. Lỗi "/tong tháng 10" chỉ lộ ra khi mình chạy thử đầu vào trên code thật. Hiểu biết sai của mình về "en-GB" chỉ được sửa khi mình chạy lệnh kiểm chứng. Con số 60 lần chỉ đáng tin vì mình đã tự đo. Đó cũng là tinh thần của bài một workflow dùng AI mà vẫn giữ được tư duy.

Khi cần một thư viện mình chưa từng dùng

App sổ chi tiêu giờ đã đọc lệnh đúng hơn và tính tổng nhanh hơn, nhưng nó vẫn chỉ chạy trong test. Để nhận tin nhắn thật từ điện thoại, app cần nói chuyện với một ứng dụng chat qua một thư viện mà mình chưa từng dùng.

Lúc đó, các mẫu prompt ở trên chưa đủ. AI có thể trả lời rất tự tin về một thư viện dựa trên phiên bản cũ, và bạn không có sẵn test nào để đối chiếu. Cách dùng AI để đọc hiểu một thư viện hay một codebase lạ mà không bị dẫn sai là chủ đề của bài viết tiếp theo. Bạn theo dõi cả series ở Làm side project cùng AI, và nếu đang ôn phỏng vấn, câu hỏi "bạn dùng AI khi lập trình thế nào" có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.