AI bịa API khi viết code: cách kiểm chứng code AI trước khi tin
AI bịa API là khi code trông hợp lý nhưng gọi hàm không tồn tại. Cách bắt: chạy thử, trình biên dịch, kiểu của thư viện và test, trên một bot Telegram.
AI bịa API nghĩa là nó viết code gọi một hàm hoặc tùy chọn không có trong thư viện, nhưng đọc vẫn rất hợp lý. Cách bắt là kiểm chứng bằng máy: trình biên dịch, kiểu của thư viện và test. Bạn đã bao giờ dán code AI vào rồi mới thấy nó không tồn tại chưa? Cùng thử nhé!
AI viết đúng giọng, nhưng đúng chưa?
Ở bài trước, app sổ chi tiêu của mình (một bot Telegram ghi lại khoản chi, viết bằng TypeScript và thư viện grammY) đã có test cho lớp nối với Telegram. Trong lúc làm bài đó có một chuyện: một test gọi hàm processUpdate mà grammY không có, và nó chỉ lộ ra khi chạy. Chuyện này không phải hiếm, và mình muốn nhìn nó kỹ hơn thay vì coi là một lần xui.
Bối cảnh bài này như sau. Spec của app lên bản v1.3 với một yêu cầu mới: khi người dùng sửa một tin nhắn đã gửi, ví dụ từ "cà phê 35k" thành "cà phê 45k", khoản chi tương ứng được cập nhật, thay vì bị bỏ qua như hiện nay. Mình nhờ AI viết tính năng đó. Dưới đây là đoạn code mà một công cụ AI có thể trả về. Mình nói rõ trước: đây là bản mình dựng để minh họa, không trích từ một lần chạy AI cụ thể nào, nhưng cả ba kiểu lỗi trong đó là những kiểu mình đã thấy AI mắc.
// Đoạn code "AI viết" khi được nhờ: "khi người dùng sửa một tin nhắn thì cập nhật khoản chi đã ghi".
// Đây là bản mình dựng để minh họa, không trích từ một lần chạy AI cụ thể. Nó đọc rất hợp lý và có ba chỗ bịa.
import { Bot } from "grammy";
import { parseExpense } from "../src/parse-expense.ts";
import type { Entry } from "../src/so-chi-tieu.ts";
const bot = new Bot("token-gia");
const soTheoNguoi = new Map<number, Entry[]>();
bot.on("edited_message.text", async (ctx) => {
const so = soTheoNguoi.get(ctx.from.id) ?? [];
const khoanMoi = parseExpense(ctx.editedMessage.text ?? "");
so.capNhat(ctx.editedMessage.message_id, khoanMoi);
await ctx.replyWithMarkdown("Đã cập nhật khoản chi");
});
console.log("Đã đăng ký xong handler sửa tin nhắn.");Hai thứ được import từ các bài trước: parseExpense đọc một câu như "cà phê 35k" thành { note, amount }, còn Entry là một khoản chi đã ghi (nội dung, số tiền, thời điểm).
Đọc lướt thì cả đoạn rất ổn. Có ba chỗ đáng ngờ, và mình muốn bạn thử tìm trước khi đọc tiếp:
- Bộ lọc
"edited_message.text"viết với dấu chấm. so.capNhat(...), trong đósolà một mảngEntry[].ctx.replyWithMarkdown(...), hàm của Telegraf.
Cả ba đều trông "đúng gu" của thư viện: tên hàm kiểu capNhat, chữ replyWithMarkdown có thật ở đâu đó, và dấu chấm là cách viết đường dẫn thuộc tính quen thuộc. Đó chính là lý do chúng lọt qua mắt người.
Chạy thử có bắt được không?
Cách đầu tiên ai cũng làm là chạy file. Bạn cài thư viện rồi chạy bằng Node.js 22.18 trở lên (Node chạy thẳng file .ts từ bản này). Mình ghim phiên bản grammY 1.46.0, vì kết quả bên dưới là của đúng phiên bản đó:
npm init -y
npm pkg set type=module
npm install --save-exact grammy@1.46.0
npm install --save-exact --save-dev typescript@7.0.2 @types/node@22Error: Invalid L1 filter 'edited_message.text' given in 'edited_message.text'. Permitted values are: 'message', 'edited_message', 'channel_post', 'edited_channel_post', 'business_connection', ...
at check (node_modules/grammy/out/filter.js:110:15)Mình đã rút gọn dòng báo lỗi (nó liệt kê cả danh sách dài các loại update) và bỏ phần đường dẫn đầy đủ. Lần chạy này bắt được lỗi thứ nhất ngay lúc đăng ký handler. Nhưng hãy nhìn kỹ điều gì chưa xảy ra: hai chỗ bịa còn lại nằm bên trong handler, và handler chỉ chạy khi có người thật sự sửa một tin nhắn. Nếu hôm đó grammY không kiểm tra bộ lọc ngay lúc đăng ký, cả ba lỗi sẽ nằm im trong code cho tới khi một người dùng sửa tin nhắn. Khi đó bot không trả lời, và bạn chỉ biết qua dòng log lúc hai giờ sáng.
Đây là hạn chế cơ bản của việc kiểm chứng bằng cách chạy: JavaScript chỉ phát hiện lỗi ở đường mà chương trình đi qua. Mỗi lần chạy, bạn thấy một lỗi, sửa nó rồi mới đụng tới lỗi sau.
Trình biên dịch TypeScript bắt được bao nhiêu?
Cách thứ hai là để trình biên dịch đọc cả file, không cần chạy. Thêm hai file cấu hình. tsconfig.json cho phần mã chính, và một file riêng cho thư mục ai-viet/ để lỗi của đoạn code mẫu không lẫn vào phần còn lại:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}{
"extends": "./tsconfig.json",
"include": ["ai-viet"]
}ai-viet/bot-sua-tin.ts(11,8): error TS2345: Argument of type '"edited_message.text"' is not assignable to parameter of type 'ComputeFilterQueryList[] | ComputeFilterQueryList'.
ai-viet/bot-sua-tin.ts(12,30): error TS18048: 'ctx.from' is possibly 'undefined'.
ai-viet/bot-sua-tin.ts(13,33): error TS18048: 'ctx.editedMessage' is possibly 'undefined'.
ai-viet/bot-sua-tin.ts(14,6): error TS2339: Property 'capNhat' does not exist on type 'Entry[]'.
ai-viet/bot-sua-tin.ts(14,14): error TS18048: 'ctx.editedMessage' is possibly 'undefined'.
ai-viet/bot-sua-tin.ts(15,13): error TS2339: Property 'replyWithMarkdown' does not exist on type 'FilteredContext<Context, ...Mình lược đoạn cuối của dòng cuối cho gọn, vì thông báo đó kéo dài với các kiểu của grammY. Trình biên dịch báo cả sáu lỗi một lượt, không cần ai sửa từng cái. Ba lỗi thuộc về ba chỗ bịa (dòng 11, 14 và 15). Ba lỗi còn lại, "possibly undefined", là hệ quả: vì bộ lọc sai nên grammY không biết ctx là một tin nhắn được sửa, và coi mọi trường đều có thể thiếu.
Chỗ này có một bài học nhỏ mà mình thấy đáng nhớ. Khi trình biên dịch báo nhiều lỗi, hãy sửa lỗi đầu tiên rồi chạy lại trước khi bắt tay vào cả danh sách, vì một chỗ bịa có thể kéo theo nhiều lỗi hệ quả. Mình sửa bộ lọc thành "edited_message:text" (dấu hai chấm) rồi chạy lại:
ai-viet/bot-sua-tin.ts(14,6): error TS2339: Property 'capNhat' does not exist on type 'Entry[]'.
ai-viet/bot-sua-tin.ts(15,13): error TS2339: Property 'replyWithMarkdown' does not exist on type 'FilteredContext<Context, ...Chỉ còn hai lỗi thật. Ba lỗi "possibly undefined" biến mất, vì giờ grammY biết ctx.from và ctx.editedMessage chắc chắn có mặt.
Nếu AI bịa ngay tên hàm trên đầu một chuỗi gọi (ví dụ một bot.onEditedMessage(...) không có thật), thì tham số ctx của hàm bên trong mất kiểu, và trình biên dịch có thể không báo được các lỗi nằm trong thân hàm. Tức là một lần chạy tsc sạch lỗi không có nghĩa là mọi dòng đều đúng, mà chỉ là mọi dòng mà trình biên dịch hiểu được đều đúng. Mình dùng tsc như một cái lưới thô, không phải bằng chứng cuối cùng.
Hỏi thư viện: nó thật sự có gì?
Hai lỗi còn lại, mình cần biết hàm đúng là gì. Hỏi lại AI "có hàm replyWithMarkdown không?" là cách dễ nhất, và cũng là cách kém tin cậy nhất, vì nó có thể trả lời chắc chắn theo cách cũ. Nguồn đáng tin hơn nằm ngay trong thư mục node_modules: các file kiểu (.d.ts) của thư viện là hợp đồng thật giữa thư viện và code của bạn.
grep -rn "replyWithMarkdown" node_modules/grammy/out/
grep -n "^ reply(" node_modules/grammy/out/context.d.ts(lệnh đầu không in gì: không có hàm nào tên replyWithMarkdown)
483: reply(text: string, other?: Other<"sendMessage", "chat_id" | "text">, signal?: AbortSignal): Promise<Message.TextMessage>;Lệnh đầu không tìm thấy gì, nên replyWithMarkdown không có trong grammY. Lệnh thứ hai cho thấy reply nhận tham số thứ hai other, là các tùy chọn của lời gọi sendMessage của Telegram. Trong các tùy chọn đó có parse_mode để chọn Markdown hay HTML. Vậy cách đúng là ctx.reply(text, { parse_mode: "Markdown" }). Mình không cần Markdown cho dòng "Đã cập nhật", nên chỉ dùng ctx.reply(text).
Cũng nên đối chiếu với tài liệu chính thức. Phần filter queries trong tài liệu của grammY giải thích rằng chuỗi lọc nối loại update và loại nội dung bằng dấu hai chấm, như "message:text" hay "edited_message:text". Còn với chuyện mỗi tin nhắn có một mã nhận dạng riêng, tài liệu Bot API của Telegram ghi rằng message_id là mã duy nhất bên trong một cuộc trò chuyện. Mình cần điều này cho bước sau, và đây là loại thông tin mà kiểu của thư viện không nói được, mình phải đọc tài liệu.
Còn lỗi so.capNhat thì khác: nó không bịa từ thư viện nào, mà bịa một hàm trên chính code của mình. AI không thấy hết code của bạn trong mọi lúc. Một mảng JavaScript không có capNhat, và ở đây không có hàm nào như vậy cả, nên cách sửa là viết logic đó ra: tìm khoản chi theo mã tin nhắn rồi sửa tại chỗ.
Viết lại với API có thật, và test nó
Để tìm lại đúng khoản chi khi tin nhắn bị sửa, mình cần lưu mã tin nhắn đã tạo ra khoản chi đó. Hai thay đổi nhỏ trong src/so-chi-tieu.ts: thêm trường messageId cho Entry, và xuất hàm vnd (định dạng tiền) để dùng chung:
// messageId: id tin nhắn Telegram đã tạo ra khoản chi, để tìm lại khi người dùng sửa tin nhắn đó.
export type Entry = { note: string; amount: number; at: Date; messageId?: number };
export const vnd = (n: number) => `${n.toLocaleString("vi-VN")}đ`;Sau đó là src/bot.ts, toàn bộ file. Phần cũ của các bài trước gồm sổ riêng cho từng người và đổi giây thành mili giây, mình để nguyên và thêm phần mới:
// 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 { parseExpense } from "./parse-expense.ts";
import { handleMessage, vnd, 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 (xem handler bên dưới).
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);
const soKhoanTruoc = so.length;
const traLoi = handleMessage(ctx.message.text, luc, so);
// Nhớ tin nhắn nào đã tạo ra khoản chi vừa ghi, để còn tìm lại khi người dùng sửa tin nhắn đó.
if (so.length > soKhoanTruoc) so[so.length - 1].messageId = ctx.message.message_id;
await ctx.reply(traLoi);
});
// Người dùng sửa một tin nhắn đã gửi (spec v1.3): cập nhật đúng khoản chi do tin nhắn đó tạo ra, không ghi thêm.
// Bộ lọc là "edited_message:text" (dấu hai chấm): tin nhắn được sửa là một loại update riêng, khác "message".
bot.on("edited_message:text", async (ctx) => {
const so = soTheoNguoi.get(ctx.from.id) ?? [];
const khoanChi = so.find((entry) => entry.messageId === ctx.editedMessage.message_id);
if (!khoanChi) return; // sửa một tin nhắn không phải khoản chi, hoặc bot đã khởi động lại và quên sổ
const khoanMoi = parseExpense(ctx.editedMessage.text.trim().normalize("NFC"));
if (!khoanMoi) return void (await ctx.reply("Mình không đọc được khoản chi sau khi sửa, nên giữ nguyên khoản cũ."));
khoanChi.note = khoanMoi.note;
khoanChi.amount = khoanMoi.amount;
await ctx.reply(`Đã sửa: ${khoanMoi.note}, ${vnd(khoanMoi.amount)}`);
});
// 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;
}Hai điểm trong code này mình muốn bạn chú ý, vì mỗi điểm đến từ việc đọc tài liệu chứ không từ trí nhớ của AI. Thứ nhất, việc tìm khoản chi nằm trong sổ của đúng người gửi (soTheoNguoi.get(ctx.from.id)). Vì message_id chỉ duy nhất trong một cuộc trò chuyện, hai người khác nhau có thể có tin nhắn cùng mã, và nếu tìm trong sổ chung thì người này có thể vô tình sửa khoản chi của người kia. Thứ hai, nếu không tìm thấy khoản chi (tin nhắn đó không phải khoản chi, hoặc bot vừa khởi động lại và sổ trong bộ nhớ đã mất) thì bot im lặng thay vì ghi một khoản mới.
Để chạy test với bot này, hàm sua trong file thu-bot.ts của bài trước nhận thêm mã tin nhắn muốn sửa. Đây là phần thay đổi, mặc định là tin nhắn số 1 để các test cũ vẫn chạy:
/** Người dùng `id` SỬA tin nhắn có id `messageId` thành `text` (mặc định tin nhắn đầu tiên bot nhận, có id 1). */
async sua(id: number, text: string, messageId = 1, luc = "2026-12-01T06:30"): Promise<string | null> {
daGui.length = 0;
const update: Update = { update_id: ++updateId, edited_message: { message_id: messageId, date: giay(luc), edit_date: giay(luc), chat: chatCua(id), from: nguoi(id), text } };
await bot.handleUpdate(update);
return daGui.at(-1) ?? null;
},Và các test. Test cũ "sửa một tin nhắn cũ không ghi thêm khoản chi" mô tả hành vi của v1.2, giờ không còn đúng nên mình thay bằng bốn test của v1.3. Mỗi test có một lý do:
test("sửa tin nhắn thì cập nhật đúng khoản chi đó, không ghi thêm", async () => {
const bot = taoBotThu();
await bot.nhan(42, "cà phê 35k"); // tin nhắn số 1
assert.equal(await bot.sua(42, "cà phê 45k", 1), "Đã sửa: cà phê, 45.000đ");
assert.equal(await bot.nhan(42, "/tong"), "Tháng 12/2026: 1 khoản, tổng 45.000đ");
});
test("sửa thành chữ không đọc được thì giữ nguyên khoản cũ", async () => {
const bot = taoBotThu();
await bot.nhan(42, "cà phê 35k");
assert.equal(await bot.sua(42, "cà phê", 1), "Mình không đọc được khoản chi sau khi sửa, nên giữ nguyên khoản cũ.");
assert.equal(await bot.nhan(42, "/tong"), "Tháng 12/2026: 1 khoản, tổng 35.000đ");
});
test("sửa một tin nhắn không phải khoản chi thì bot im lặng", async () => {
const bot = taoBotThu();
await bot.nhan(42, "/tong"); // tin nhắn số 1 là một lệnh, không tạo khoản chi
assert.equal(await bot.sua(42, "/tong tháng này", 1), null);
});
test("sửa tin nhắn của mình không đụng tới sổ của người khác", async () => {
const bot = taoBotThu();
await bot.nhan(42, "cà phê 35k"); // tin nhắn số 1, của người 42
assert.equal(await bot.sua(77, "cà phê 99k", 1), null); // người 77 "sửa" tin nhắn số 1: không có trong sổ của họ
assert.equal(await bot.nhan(42, "/tong"), "Tháng 12/2026: 1 khoản, tổng 35.000đ");
});Chạy trình biên dịch trên thư mục src và toàn bộ test:
(không in gì: không có lỗi, thoát với mã 0)# tests 30
# pass 30
# fail 0Mình lược các dòng chi tiết của từng test và chỉ chép phần tổng kết cuối của Node.js. Ba mươi test xanh, gồm cả bốn test mới.
Những gì cả ba lớp kiểm chứng đều không bắt được
Mình muốn nói rõ giới hạn của cách làm trên, để bạn không yên tâm quá. Chạy thử bắt được lỗi trên đường đã chạy. Trình biên dịch bắt được lỗi về hình dạng của các lời gọi. Test bắt được hành vi mà bạn đã nghĩ ra để kiểm tra. Còn lại là hành vi đúng hình dạng nhưng sai ý nghĩa, và máy không có cách nào biết.
Ví dụ, nếu AI viết bot.on("edited_message:text", ...) rồi tìm khoản chi trong một sổ chung của mọi người thay vì sổ của riêng từng người, cả ba lớp trên đều xanh. Lỗi đó chỉ lộ ra khi hai người dùng bot cùng lúc, và mình chỉ bắt được vì có test "sửa tin nhắn của mình không đụng tới sổ của người khác", một test mình viết vì đã đọc rằng mã tin nhắn chỉ duy nhất trong từng cuộc trò chuyện. Cuối cùng vẫn là con người nghĩ ra các trường hợp cần kiểm tra.
Cách mình dùng AI sau bài này là: khi nó đưa ra một hàm hay tùy chọn mình chưa từng thấy, mình coi đó là một câu hỏi cần trả lời, không phải một câu trả lời. Mình tìm tên đó trong code của thư viện, và nếu không thấy thì không dùng. Đó là một thói quen rẻ, mất chưa tới một phút mỗi lần.
Làm nhiều thứ nhỏ, lịch sử Git đã thành một mớ
Đến đây tính năng sửa tin nhắn chạy được, có test. Nhưng nếu bạn làm toàn bộ việc trên trong một buổi tối và commit từng lần với những cái tên như "fix", "sửa lại", "ok", thì sáng hôm sau không ai nhìn lịch sử Git mà hiểu một commit làm gì, kể cả chính bạn. Một thay đổi gồm đổi spec, sửa mã, đổi test, thêm file kiểm chứng, bị chia ra theo thời gian bạn nhớ ra, không theo ý nghĩa. Cách viết commit và mô tả pull request để người đọc sau hiểu được, kể cả khi nhờ AI viết giúp, là việc của các bài viết sau.
Bạn theo dõi cả hành trình ở 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.