Debug với AI: đưa đúng ngữ cảnh để tìm đúng nguyên nhân
Debug với AI cần đúng ngữ cảnh: cách tái hiện, kết quả thật, kết quả mong muốn, đúng file liên quan và điều đã loại trừ. Ví dụ với một bot Telegram.
Debug với AI thế nào để nó tìm đúng nguyên nhân? Bạn đưa nó cách tái hiện lỗi, kết quả thật, kết quả mong muốn, đúng những file liên quan và những gì đã loại trừ, rồi đòi nó chỉ ra file và dòng trước khi sửa. Bạn hay dán gì khi nhờ AI sửa lỗi? Cùng mình xem nhé!
Lỗi nào đang chờ sửa?
Series này làm một app sổ chi tiêu từ đầu tới cuối cùng AI. Bạn nhắn "cà phê 35k" là app ghi một khoản chi, nhắn /tong là app trả tổng tháng này. Ở bài trước, mình nối app với Telegram bằng thư viện grammY 1.46.0, và cuối bài lộ ra một lỗi khi có hai người cùng nhắn cho bot.
Đây là script tái hiện lỗi đó. Nó không cần token hay mạng. Nó tạo bot với thông tin bot dựng sẵn (botInfo) để grammY không phải hỏi Telegram, chặn mọi lời gọi API bằng một transformer để ghi lại câu bot định gửi, rồi đưa vào ba tin nhắn giả:
// Hai người khác nhau nhắn cho cùng một bot. Dùng lại cách thử offline: update giả + transformer.
import type { Update, UserFromGetMe } from "grammy/types";
import { taoBot } from "./src/bot.ts";
import type { Entry } from "./src/so-chi-tieu.ts";
const botInfo = {
id: 1, is_bot: true, first_name: "Sổ chi tiêu", username: "so_chi_tieu_bot",
can_join_groups: false, can_read_all_group_messages: false, supports_inline_queries: false,
can_connect_to_business: false, has_main_web_app: false, has_topics_enabled: false,
allows_users_to_create_topics: false, can_manage_bots: false, supports_join_request_queries: false,
} satisfies UserFromGetMe;
const so: Entry[] = []; // giống src/chay-bot.ts: cả bot có đúng một cuốn sổ
const bot = taoBot("token-gia", so, { botInfo });
let traLoi = "";
bot.api.config.use(async (_prev, _method, payload) => {
traLoi = (payload as { text?: string }).text ?? "";
return { ok: true, result: { message_id: 0, date: 0, chat: { id: 0, type: "private", first_name: "" }, text: "" } } as never;
});
const luc = Math.floor(new Date("2026-12-01T06:30+07:00").getTime() / 1000);
let updateId = 0;
function nhan(id: number, ten: string, text: string): Update {
const nguoi = { id, is_bot: false, first_name: ten };
return { update_id: ++updateId, message: { message_id: updateId, date: luc, chat: { id, type: "private", first_name: ten }, from: nguoi, text } };
}
for (const [id, ten, text] of [[42, "Mình", "cà phê 35k"], [77, "Bạn mình", "trà sữa 32k"], [42, "Mình", "/tong"]] as const) {
await bot.handleUpdate(nhan(id, ten, text));
console.log(`${ten} (id ${id}) nhắn "${text}" → ${traLoi}`);
}Mình (id 42) nhắn "cà phê 35k" → Đã ghi: cà phê, 35.000đ
Bạn mình (id 77) nhắn "trà sữa 32k" → Đã ghi: trà sữa, 32.000đ
Mình (id 42) nhắn "/tong" → Tháng 12/2026: 2 khoản, tổng 67.000đDòng cuối sai. Người có id 42 chỉ ghi một ly cà phê 35.000đ, nhưng /tong của họ ra 2 khoản và 67.000đ, vì ly trà sữa của người 77 cũng bị cộng vào. TZ=UTC ở đầu lệnh là để chạy theo giờ UTC giống máy chủ thật, một chuyện từ các bài trước.
Bài trước đã đoán được nguyên nhân, nhưng bài này mình muốn làm lại từ góc nhìn của một người chỉ có lỗi mà chưa biết vì sao. Đó là tình huống bạn gặp hằng ngày khi đi làm.
Nhờ AI "sửa giúp lỗi tổng sai" thì thiếu gì?
Phản xạ đầu tiên của mình khi gặp lỗi là mở khung chat và gõ đại ý "bot tính tổng sai, sửa giúp mình", kèm file mà mình nghĩ có lỗi. Với lỗi này, file đáng nghi nhất là so-chi-tieu.ts, vì nó chứa hàm tính tổng. Đây là file đó, giữ nguyên từ bài thứ ba của series:
// Xử lý một tin nhắn theo SPEC.md (v1.2): ghi khoản chi, hoặc trả lời lệnh.
// Dữ liệu tạm giữ trong bộ nhớ; các tập sau mới lưu vào database.
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)}`;
}Hàm parseExpense đọc số tiền trong tin nhắn và nằm ở file parse-expense.ts, bạn lấy từ bài về mẫu prompt. Nó không liên quan tới lỗi này.
Giờ bạn thử đặt mình vào chỗ của AI. Bạn nhận một câu "tổng sai" và file trên. Bạn không biết tổng sai thế nào, sai nhiều hay ít, sai với một người hay với nhiều người. Bạn đọc tongThang và thấy nó lọc đúng tháng, cộng đúng số tiền. Trong file này thật sự không có gì sai.
Mình không chép câu trả lời của một công cụ AI cụ thể vào đây, vì mỗi công cụ và mỗi lần hỏi cho ra câu khác nhau. Nhưng có một điều chắc chắn: AI chỉ tìm được trong những gì bạn đưa cho nó. Dòng gây lỗi không nằm trong file bạn dán, nên câu trả lời tốt nhất có thể có là "mình không thấy lỗi trong đoạn này". Câu trả lời tệ hơn là một bản sửa cho một lỗi không tồn tại, ví dụ đổi cách tính múi giờ, vì đó là chỗ trông phức tạp nhất trong file.
Các công cụ AI làm việc trực tiếp trong thư mục dự án, như Claude Code hay Cursor, tự đọc được các file khác. Điều đó giúp nó tìm thấy chay-bot.ts. Nhưng nó vẫn không biết "tổng sai" nghĩa là gì nếu bạn không nói kết quả thật và kết quả mong muốn.
Một prompt debug cần những phần nào?
Mình dùng một khung tám phần cho mọi lỗi. Mỗi phần trả lời một câu hỏi mà người debug, dù là AI hay đồng nghiệp, sẽ phải đoán nếu bạn không nói:
- Bối cảnh: app làm gì, chạy bằng gì, phiên bản thư viện nào. Thiếu phần này, AI có thể đề xuất API của một phiên bản khác.
- Lỗi: một câu mô tả triệu chứng.
- Cách tái hiện: lệnh và script, để AI hoặc bạn chạy lại được.
- Kết quả thật: dán nguyên văn output, không tóm tắt.
- Kết quả mong muốn: dòng nào lẽ ra phải ra gì. Đây là phần hay bị bỏ nhất, mà thiếu nó thì "sai" không có nghĩa.
- Code liên quan: những file mà dữ liệu đi qua, không chỉ file bạn nghi.
- Đã loại trừ: những gì bạn đã kiểm tra là đúng, kèm bằng chứng. Phần này giữ AI khỏi đi lại con đường bạn đã đi.
- Yêu cầu: bạn muốn nhận về gì.
Với lỗi sổ chung, prompt của mình như sau. Phần trong ngoặc nhọn là chỗ dán nội dung file, mình không lặp lại ở đây vì bạn đã thấy chúng ở trên và ở phần sau:
Bối cảnh: bot Telegram ghi chi tiêu, TypeScript chạy thẳng bằng Node 22 (không build), grammY 1.46.0.
Spec (SPEC.md v1.2): mỗi người dùng nhắn khoản chi cho bot; /tong trả tổng tháng này của NGƯỜI ĐÓ.
Lỗi: khi có hai người cùng nhắn cho bot, /tong của người này cộng cả khoản chi của người kia.
Tái hiện (không cần token): TZ=UTC node hai-nguoi-dung.ts
<dán nội dung hai-nguoi-dung.ts>
Kết quả thật:
Mình (id 42) nhắn "cà phê 35k" → Đã ghi: cà phê, 35.000đ
Bạn mình (id 77) nhắn "trà sữa 32k" → Đã ghi: trà sữa, 32.000đ
Mình (id 42) nhắn "/tong" → Tháng 12/2026: 2 khoản, tổng 67.000đ
Kết quả mong muốn ở dòng cuối: Tháng 12/2026: 1 khoản, tổng 35.000đ
Code liên quan:
<dán src/bot.ts>
<dán src/chay-bot.ts>
<dán src/so-chi-tieu.ts>
Đã loại trừ:
- parseExpense đọc đúng số tiền: 16 test xanh.
- tongThang tính đúng với một người: 7 test trong so-chi-tieu.test.ts xanh.
Yêu cầu:
1. Chỉ ra nguyên nhân, trích đúng file và dòng. Chưa sửa code.
2. Đề xuất 2 cách sửa. Với mỗi cách: những file nào phải đổi, test nào phải sửa, và nơi nào khác đang gọi
hàm bị đổi chữ ký.Hai dòng trong phần yêu cầu đáng giải thích. Dòng "chưa sửa code" tách việc tìm nguyên nhân khỏi việc sửa, để mình kiểm tra nguyên nhân trước khi nhận một bản sửa dựa trên nó. Đây là cùng ý với mẫu "giải thích kèm cách kiểm chứng" trong 7 mẫu prompt cho lập trình viên, và với việc đòi file và số dòng ở bài trước. Dòng "nơi nào khác đang gọi hàm bị đổi chữ ký" là dòng mình thêm vào sau chuyện xảy ra ở cuối bài này.
Phần tốn công nhất của prompt là script tái hiện, và đó là công sức đáng bỏ ra kể cả khi bạn không hỏi AI. Khi phải viết rõ dòng nào lẽ ra ra gì, nhiều khi chính bạn đã thấy lỗi trước khi kịp gửi.
Nguyên nhân nằm ở file nào?
Lần này mình làm đúng việc mà prompt yêu cầu: lần theo dữ liệu qua từng file trong phần "code liên quan". Câu hỏi là cuốn sổ, tức mảng Entry[], được tạo ở đâu và ai dùng chung nó. Đây là hai file còn lại, ở trạng thái của bài trước:
// Chạy bot thật: BOT_TOKEN=<token từ @BotFather> node src/chay-bot.ts
import { taoBot } from "./bot.ts";
import type { Entry } from "./so-chi-tieu.ts";
const token = process.env.BOT_TOKEN;
if (!token) throw new Error("Thiếu biến môi trường BOT_TOKEN (lấy từ @BotFather trên Telegram).");
const so: Entry[] = []; // tạm giữ trong bộ nhớ: tắt bot là mất
const bot = taoBot(token, so);
await bot.start({ onStart: (me) => console.log(`Bot @${me.username} đang nhận tin nhắn. Nhấn Ctrl+C để dừng.`) });// Nối app sổ chi tiêu với Telegram qua grammY. Mọi logic vẫn nằm trong handleMessage.
import { Bot, type BotConfig, type Context } from "grammy";
import { handleMessage, type Entry } from "./so-chi-tieu.ts";
// donVi chỉ để minh họa lỗi đơn vị thời gian trong bài; code thật luôn dùng "giay".
export function taoBot(token: string, so: Entry[], config?: BotConfig<Context>, donVi: "giay" | "mili-giay" = "giay"): Bot {
const bot = new Bot(token, config);
// "message:text" chỉ khớp tin nhắn MỚI có chữ: không khớp ảnh, sticker hay tin nhắn được sửa.
bot.on("message:text", async (ctx) => {
// Telegram gửi thời điểm theo GIÂY (Unix time), còn Date của JavaScript cần mili giây.
const luc = new Date(donVi === "giay" ? ctx.message.date * 1000 : ctx.message.date);
await ctx.reply(handleMessage(ctx.message.text, luc, so));
});
// catch là một HÀM nhận error handler. Không có nó, lỗi trong handler sẽ làm bot dừng nhận tin.
bot.catch((err) => console.error(`Lỗi khi xử lý update ${err.ctx.update.update_id}:`, err.error));
return bot;
}Nguyên nhân là hai dòng ở hai file. Dòng const so: Entry[] = [] trong chay-bot.ts tạo đúng một mảng khi bot khởi động. Dòng handleMessage(ctx.message.text, luc, so) trong bot.ts truyền chính mảng đó cho mọi tin nhắn, bất kể ai gửi. Hàm xử lý không sai, và thư viện cũng không sai. Lỗi nằm ở chỗ ghép chúng lại, nơi mà không file nào một mình cho thấy được.
Đây là lý do phần "code liên quan" phải theo đường đi của dữ liệu chứ không theo cảm giác. Nếu chỉ dán so-chi-tieu.ts, cả mình lẫn AI đều không có cơ hội nhìn thấy hai dòng này.
Có hai cách sửa, chọn cách nào?
Cách thứ nhất là cho mỗi khoản chi biết nó của ai. Entry có thêm trường userId, handleMessage nhận thêm id người gửi, và tongThang lọc theo id trước khi cộng. Cách này sửa ba chỗ trong so-chi-tieu.ts, đổi chữ ký của handleMessage, và phải sửa cả 7 test đang gọi hàm đó.
Cách thứ hai là cho mỗi người một cuốn sổ riêng. handleMessage giữ nguyên, nó vẫn chỉ làm việc với một cuốn sổ, còn bot.ts chọn đúng cuốn sổ theo người gửi.
Mình chọn cách thứ hai, vì spec từ đầu đã mô tả một cuốn sổ cho một người, và handleMessage là phần có test đầy đủ nhất. Sửa ở lớp nối với Telegram thì phần đã được test không phải động vào.
// Nối app sổ chi tiêu với Telegram qua grammY. Mọi logic vẫn nằm trong handleMessage.
import { Bot, type BotConfig, type Context } from "grammy";
import { handleMessage, type Entry } from "./so-chi-tieu.ts";
// donVi chỉ để minh họa lỗi đơn vị thời gian ở bài trước; code thật luôn dùng "giay".
export function taoBot(token: string, config?: BotConfig<Context>, donVi: "giay" | "mili-giay" = "giay"): Bot {
const bot = new Bot(token, config);
// Mỗi người dùng Telegram một cuốn sổ riêng, theo id của họ. Trước đây cả bot dùng chung một mảng.
const soTheoNguoi = new Map<number, Entry[]>();
// "message:text" chỉ khớp tin nhắn MỚI có chữ: không khớp ảnh, sticker hay tin nhắn được sửa.
bot.on("message:text", async (ctx) => {
const nguoi = ctx.from.id;
let so = soTheoNguoi.get(nguoi);
if (!so) {
so = [];
soTheoNguoi.set(nguoi, so);
}
// Telegram gửi thời điểm theo GIÂY (Unix time), còn Date của JavaScript cần mili giây.
const luc = new Date(donVi === "giay" ? ctx.message.date * 1000 : ctx.message.date);
await ctx.reply(handleMessage(ctx.message.text, luc, so));
});
// catch là một HÀM nhận error handler. Không có nó, lỗi trong handler sẽ làm bot dừng nhận tin.
bot.catch((err) => console.error(`Lỗi khi xử lý update ${err.ctx.update.update_id}:`, err.error));
return bot;
}Mình dùng ctx.from.id, tức id của người gửi, thay vì id của cuộc trò chuyện. Trong chat riêng hai số này bằng nhau. Nếu ai đó thêm bot vào một nhóm, dùng id cuộc trò chuyện sẽ cho cả nhóm chung một cuốn sổ, quay lại đúng lỗi vừa sửa.
Tham số so biến mất khỏi taoBot, vì cuốn sổ giờ do bot tự quản lý. Mình sửa hai nơi gọi hàm mà mình đang mở. Trong chay-bot.ts, dòng tạo bot thành const bot = taoBot(token);. Trong hai-nguoi-dung.ts, mình bỏ dòng tạo mảng so và dòng import Entry, và dòng tạo bot thành const bot = taoBot("token-gia", { botInfo });.
Mình (id 42) nhắn "cà phê 35k" → Đã ghi: cà phê, 35.000đ
Bạn mình (id 77) nhắn "trà sữa 32k" → Đã ghi: trà sữa, 32.000đ
Mình (id 42) nhắn "/tong" → Tháng 12/2026: 1 khoản, tổng 35.000đDòng cuối giờ đúng như phần "kết quả mong muốn" trong prompt. Mình chạy lại cả hai bộ test để chắc rằng không có gì khác bị ảnh hưởng:
# pass 7
# fail 0
# pass 16
# fail 023 test đều xanh. Mình đã định dừng ở đây.
23 test xanh mà vẫn hỏng một file
Trước khi commit, mình chạy lại thu-offline.ts, script thử bot offline của bài trước. Mình chạy nó vì thói quen chứ không vì nghi ngờ gì:
--- thời điểm tính theo GIÂY (đúng) ---
C:\...\05-debug-cung-ai\node_modules\grammy\out\bot.js:224
throw new Error("Bot not initialized! Either call `await bot.init()`, \
^
Error: Bot not initialized! Either call `await bot.init()`, or directly set the `botInfo` option in the `Bot` constructor to specify a known bot info object.
at Bot.handleUpdate (C:\...\05-debug-cung-ai\node_modules\grammy\out\bot.js:224:19)
at chay (file:///C:/.../05-debug-cung-ai/thu-offline.ts:56:15)Mình rút gọn đường dẫn thư mục thành C:\... cho dễ đọc, còn lại là output nguyên văn. Script này vẫn gọi hàm theo chữ ký cũ, taoBot("token-gia", so, { botInfo }, donVi). Với chữ ký mới, mảng so rơi vào vị trí của config, còn { botInfo } rơi vào vị trí của donVi. Bot được tạo mà không có botInfo, nên grammY báo lỗi ở lần nhận update đầu tiên. Đây đúng là lỗi mà bài trước đã gặp và đã đọc mã nguồn grammY để hiểu.
Vì sao không có gì báo trước? Node.js chạy thẳng file .ts bằng cách bỏ đi phần khai báo kiểu, và tài liệu của Node về TypeScript nói rõ nó không kiểm tra kiểu. Lời gọi sai số tham số vẫn chạy, cho tới khi hỏng ở một chỗ xa. Kiểm tra kiểu bằng TypeScript thì bắt được ngay. Mình chạy tsc --noEmit trên hai file src/bot.ts và thu-offline.ts:
thu-offline.ts(45,52): error TS2554: Expected 1-3 arguments, but got 4.Sửa dòng đó thành taoBot("token-gia", { botInfo }, donVi) và bỏ dòng tạo mảng so phía trên là xong. Nhưng chuyện này làm mình nghĩ lại về cách mình dùng AI. Prompt của mình hỏi đúng nguyên nhân và nhận đúng bản sửa. Thứ hỏng là một file nằm ngoài phần "code liên quan", và không công cụ nào bảo vệ nó, vì 23 test chỉ kiểm tra handleMessage và parseExpense. Đó là lý do dòng cuối của phần yêu cầu trong prompt hỏi thêm về "nơi nào khác đang gọi hàm bị đổi chữ ký".
Bản sửa sau không làm hỏng bản sửa trước bằng cách nào?
Bài này sửa được lỗi sổ chung nhờ cho AI, và cho chính mình, đúng những gì cần để thấy nguyên nhân. Nhưng nó cũng cho thấy một chỗ hở lớn hơn trong dự án. Phần nối app với Telegram, tức bot.ts, không có test nào. Lỗi sổ chung nằm ở đó, và lỗi hỏng thu-offline.ts cũng bắt nguồn từ việc đổi chữ ký hàm ở đó. Mỗi lần AI hay mình sửa lớp này, cách duy nhất để biết có hỏng gì không là tự chạy lại từng script và đọc output.
Mình muốn có test cho lớp này, và vì đang làm cùng AI, mình sẽ nhờ AI viết. Nhưng một test do AI viết cũng cần được kiểm tra, vì một test luôn xanh thì không bảo vệ được gì. Đó là việc của các bài viết sau.
Bạn theo dõi cả series ở Làm side project cùng AI. Nếu đang ôn phỏng vấn, các câu hỏi về cách dùng AI khi lập trình có trong ngân hàng câu hỏi phỏng vấn.