Context window là gì? Cho chatbot quán Mộc nhớ cuộc trò chuyện
Context window là gì, vì sao chatbot quên điều khách vừa nói, và cách giữ lịch sử hội thoại bằng TypeScript mà không gửi lại cả buổi sáng trong mỗi lần gọi.
Context window là gì? Đó là toàn bộ phần chữ mà mô hình đọc được trong một lần gọi, gồm system prompt, mọi tin nhắn bạn gửi kèm và cả câu trả lời nó đang viết. Mô hình không nhớ gì ngoài phần đó. Bạn có muốn chatbot nhớ được mã đơn khách vừa nói không? Cùng mình sửa nhé!
Vì sao chatbot quên điều khách vừa nói?
Ở bài trước, chatbot của quán Mộc (một quán cà phê nhỏ bán online, quán và dữ liệu trong series là minh họa) đã có vòng lặp agent, nên nó tự làm được một yêu cầu cần nhiều bước. Nhưng bài đó kết thúc bằng một cuộc trò chuyện hỏng. Khách hỏi "Đơn 1041 của em giao chưa ạ?", chatbot tra và trả lời đúng. Khách nhắn tiếp "Vậy thêm cho em một bạc xỉu nữa nhé", và chatbot hỏi lại mã đơn.
Nguyên nhân nằm ở dòng đầu tiên của hàm chayAgent trong bài trước:
const messages: Anthropic.MessageParam[] = [{ role: "user", content: cauHoi }];Mỗi tin nhắn của khách tạo ra một mảng messages mới chỉ có đúng tin nhắn đó. Trong khi đó API của mô hình không lưu lại gì giữa hai lần gọi. Tài liệu của Anthropic mô tả phần đầu vào của mỗi lượt là "toàn bộ lịch sử hội thoại trước đó cộng với tin nhắn hiện tại của người dùng", và người phải gửi phần lịch sử ấy là code của bạn. Bạn không gửi thì với mô hình, tin nhắn thứ hai là câu đầu tiên của một khách lạ.
Vậy "bộ nhớ" của một chatbot thật ra là một mảng do bạn giữ. Bài này làm ba việc với mảng đó. Mình giữ nó lại giữa các tin nhắn, đo xem nó lớn nhanh thế nào, rồi cắt bớt mà không làm chatbot quên điều quan trọng.
Dự án gồm những file nào?
Thư mục của bài này tiếp tục từ thư mục của bài trước. Bốn file giữ nguyên, bạn chép lại từ bài về vòng lặp agent: cong-cu.ts (ba công cụ xem_don, tim_don_theo_sdt, them_mon), goi-llm.ts (gọi Claude thật khi có key, gọi mô hình giả lập khi không có), prompts.ts (system prompt SYSTEM_V4) và thong-tin-quan.ts (bảng giá, giờ mở cửa).
05-bo-nho-hoi-thoai/
├── package.json
└── src/
├── cong-cu.ts # giữ nguyên
├── goi-llm.ts # giữ nguyên
├── prompts.ts # giữ nguyên
├── thong-tin-quan.ts # giữ nguyên
├── don-hang.ts # thêm hàm datLaiDonHang
├── offline.ts # mô hình giả lập, giờ đọc cả lịch sử
├── agent.ts # vòng lặp, giờ nhận và trả về lịch sử
├── phien.ts # MỚI: một cuộc trò chuyện với một khách
├── cat-lich-su.ts # MỚI: hai cách cắt lịch sử và hàm kiểm tra
├── ghi-nho.ts # MỚI: nhớ mã đơn khách đang nói tới
├── hai-tin-nhan.ts # chạy thử 1
├── buoi-sang.ts # chạy thử 2
├── cat-sai.ts # chạy thử 3
└── quen-don.ts # chạy thử 4Bạn cần Node.js 22.18 trở lên để chạy thẳng file .ts. Các kết quả trong bài chạy ở chế độ offline, tức là không có ANTHROPIC_API_KEY, nên câu trả lời đến từ một kịch bản viết tay trong offline.ts chứ không phải từ Claude. Kịch bản đó đủ để thấy code quanh mô hình đúng hay sai, còn lời văn của mô hình thật sẽ khác.
Giữ lịch sử hội thoại bằng code thế nào?
Việc đầu tiên là sửa chayAgent để nó nhận lịch sử cũ và trả về lịch sử mới. Hàm nối tin nhắn của khách vào sau lịch sử, chạy vòng lặp như cũ, rồi trả lại cả mảng. Mình thêm một mảng cacLanGoi ghi lại mỗi lần gọi mô hình đã gửi đi bao nhiêu tin nhắn và bao nhiêu ký tự, để lát nữa đo.
import type Anthropic from "@anthropic-ai/sdk";
import { chayCongCu } from "./cong-cu.ts";
import { goiMoHinh, layChu } from "./goi-llm.ts";
import { SYSTEM_V4 } from "./prompts.ts";
export type TinNhan = Anthropic.MessageParam;
export type LanGoi = { soTinNhan: number; kyTu: number; inputTokens: number };
export type KetQuaAgent = { traLoi: string; lichSu: TinNhan[]; nhatKy: string[]; cacLanGoi: LanGoi[] };
// Vòng lặp agent của bài trước, khác một chỗ: nó nhận lịch sử cuộc trò chuyện và trả về lịch sử mới.
export async function chayAgent(lichSu: TinNhan[], cauHoi: string, system = SYSTEM_V4, maxLanGoi = 6): Promise<KetQuaAgent> {
const messages: TinNhan[] = [...lichSu, { role: "user", content: cauHoi }];
const nhatKy: string[] = [];
const cacLanGoi: LanGoi[] = [];
for (let lan = 1; lan <= maxLanGoi; lan++) {
const phanHoi = await goiMoHinh(system, messages);
// Ghi lại mỗi lần gọi đã gửi đi bao nhiêu: số tin nhắn, số ký tự, và số token nếu gọi API thật.
cacLanGoi.push({ soTinNhan: messages.length, kyTu: JSON.stringify(messages).length, inputTokens: phanHoi.inputTokens });
messages.push({ role: "assistant", content: phanHoi.content });
if (phanHoi.stop_reason !== "tool_use") return { traLoi: layChu(phanHoi.content), lichSu: messages, nhatKy, cacLanGoi };
const ketQua: Anthropic.ToolResultBlockParam[] = [];
for (const block of phanHoi.content) {
if (block.type !== "tool_use") continue;
const kq = chayCongCu(block.name, block.input);
nhatKy.push(`${block.name}(${JSON.stringify(block.input)}) → ${kq.noiDung}${kq.loi ? " (is_error)" : ""}`);
ketQua.push({ type: "tool_result", tool_use_id: block.id, content: kq.noiDung, is_error: kq.loi });
}
messages.push({ role: "user", content: ketQua });
}
const xinLoi = "Dạ yêu cầu này Mộc chưa xử lý xong, nhân viên sẽ kiểm tra và nhắn lại bạn ngay ạ.";
messages.push({ role: "assistant", content: xinLoi });
return { traLoi: xinLoi, lichSu: messages, nhatKy: [...nhatKy, `dừng: đã gọi mô hình ${maxLanGoi} lần`], cacLanGoi };
}Có một chi tiết dễ bỏ sót ở cuối hàm. Khi chạm giới hạn 6 lần gọi, mình vẫn thêm câu xin lỗi vào lịch sử với vai assistant. Nếu không thêm, lịch sử sẽ kết thúc bằng một tin nhắn vai user, và tin nhắn kế tiếp của khách sẽ tạo ra hai tin user đứng liền nhau.
Việc thứ hai là có một nơi giữ mảng đó. Mình gọi nó là phiên, tức một cuộc trò chuyện với một khách. Hàm taoPhien trả về một đối tượng có hàm nhan để nhận tin nhắn mới. Nó nhận hai tùy chọn mà phần sau của bài mới dùng tới, là chonLichSu để quyết định gửi phần nào của lịch sử và soanSystem để soạn system prompt.
import { chayAgent, type KetQuaAgent, type TinNhan } from "./agent.ts";
import { SYSTEM_V4 } from "./prompts.ts";
export type TuyChonPhien = {
// Chọn phần lịch sử sẽ gửi cho mô hình. Mặc định: gửi tất cả.
chonLichSu?: (lichSu: TinNhan[]) => TinNhan[];
// Soạn system prompt cho lần gọi này từ toàn bộ lịch sử. Mặc định: prompt cố định của quán.
soanSystem?: (lichSu: TinNhan[]) => string;
};
// Một phiên là một cuộc trò chuyện với một khách. Phiên giữ toàn bộ lịch sử trong bộ nhớ của tiến trình.
export function taoPhien({ chonLichSu = (lichSu) => lichSu, soanSystem = () => SYSTEM_V4 }: TuyChonPhien = {}) {
let lichSu: TinNhan[] = [];
return {
async nhan(tinNhan: string): Promise<KetQuaAgent> {
const gui = chonLichSu(lichSu);
const kq = await chayAgent(gui, tinNhan, soanSystem(lichSu));
// Phần mới của lượt này là những tin nhắn nằm sau phần đã gửi; nối nó vào lịch sử ĐẦY ĐỦ.
lichSu = [...lichSu, ...kq.lichSu.slice(gui.length)];
return kq;
},
get lichSu() {
return lichSu;
},
};
}Phiên luôn giữ lịch sử đầy đủ, kể cả khi chỉ gửi đi một phần. Đó là lý do có dòng kq.lichSu.slice(gui.length), nó lấy đúng những tin nhắn mới sinh ra trong lượt này rồi nối vào bản đầy đủ. Giữ bản đầy đủ có ích về sau, vì bạn có thể đổi cách cắt mà không mất dữ liệu.
Phiên trong bài nằm trong bộ nhớ của tiến trình, nên tắt chương trình là mất. Một chatbot thật sẽ lưu mảng này vào database theo mã khách hoặc mã cuộc trò chuyện, nhưng cách dùng không đổi.
Mô hình giả lập cũng phải sửa, vì kịch bản cũ chỉ nhìn tin nhắn đầu tiên của mảng. File offline.ts của bài trước giữ nguyên phần đầu (các hàm goiCongCu, traLoi, monTrongCau, muonThem, tenCongCu) và phần xử lý kết quả công cụ ở cuối. Bạn thêm hai hàm dưới đây, rồi thay phần "lượt đầu" của moHinhOffline bằng đoạn mới. Nếu bạn chạy với key thật thì bỏ qua bước này.
// Câu hỏi của lượt hiện tại: tin nhắn bằng chữ gần nhất của khách.
function cauHoiHienTai(messages: Anthropic.MessageParam[]): { cau: string; viTri: number } {
const viTri = messages.findLastIndex((tin) => tin.role === "user" && typeof tin.content === "string");
return { cau: String(messages[viTri].content).toLowerCase(), viTri };
}
// Bắt chước việc mô hình thật đọc lại phần trước của cuộc trò chuyện: tìm mã đơn trong thẻ <ghi_nho>
// của system prompt, rồi tới các tin nhắn và kết quả công cụ đứng trước câu hỏi, từ gần tới xa.
function maDonDaNhac(system: string, messages: Anthropic.MessageParam[], truocViTri: number): string | undefined {
const ghiNho = /<ghi_nho>[\s\S]*?đơn (\d{4})/.exec(system)?.[1];
if (ghiNho) return ghiNho;
for (let i = truocViTri - 1; i >= 0; i--) {
const ma = /\b(\d{4})\b/.exec(JSON.stringify(messages[i].content))?.[1];
if (ma) return ma;
}
return undefined;
}
export function moHinhOffline(system: string, messages: Anthropic.MessageParam[]): PhanHoi {
const { cau: cauHoi, viTri } = cauHoiHienTai(messages); // bài trước: messages[0]
const cuoi = messages.at(-1)!;
// Lượt mới: tin nhắn cuối là câu hỏi của khách.
if (typeof cuoi.content === "string") {
const ma = cauHoi.match(/\b\d{4}\b/)?.[0];
if (ma && muonThem(cauHoi)) return goiCongCu("them_mon", { ma_don: ma, mon: monTrongCau(cauHoi)!, so_luong: 1 });
if (ma) return goiCongCu("xem_don", { ma_don: ma });
const sdt = cauHoi.match(/\b0\d{9}\b/)?.[0];
if (sdt) return goiCongCu("tim_don_theo_sdt", { so_dien_thoai: sdt });
if (muonThem(cauHoi)) {
// MỚI: khách không nói mã đơn thì tìm trong phần trước của cuộc trò chuyện.
const maCu = maDonDaNhac(system, messages, viTri);
if (maCu) return goiCongCu("them_mon", { ma_don: maCu, mon: monTrongCau(cauHoi)!, so_luong: 1 });
return traLoi("Dạ bạn cho Mộc xin mã đơn hoặc số điện thoại đặt hàng để Mộc thêm món nhé!");
}
if (/giờ|mở cửa/.test(cauHoi)) {
return traLoi(`Dạ, ${(system.split("\n").find((d) => /mở cửa từ/.test(d)) ?? "").replace("Mộc mở", "quán mở")}`);
}
// MỚI: hai loại câu hỏi nữa, để buổi trò chuyện dài ở phần sau có nội dung.
if (/ship|phí/.test(cauHoi)) return traLoi(`Dạ, ${system.split("\n").find((d) => /bán kính/.test(d)) ?? ""}`);
const mon = monTrongCau(cauHoi);
if (mon) return traLoi(`Dạ ${mon} bên Mộc giá ${MENU[mon].toLocaleString("vi-VN")}đ ạ.`);
return traLoi("Dạ Mộc chưa có thông tin này ạ.");
}
// Giữa lượt: phần xử lý kết quả công cụ giữ nguyên như bài trước.
// ...
}Giờ chạy lại đúng hai tin nhắn đã hỏng ở bài trước, lần này qua một phiên:
// Hai tin nhắn liên tiếp của cùng một khách, lần này đi qua một phiên có giữ lịch sử.
import { taoPhien } from "./phien.ts";
const phien = taoPhien();
for (const tinNhan of ["Đơn 1041 của em giao chưa ạ?", "Vậy thêm cho em một bạc xỉu nữa nhé"]) {
const kq = await phien.nhan(tinNhan);
console.log(`Khách: ${tinNhan}`);
for (const dong of kq.nhatKy) console.log(` ${dong}`);
console.log(`Mộc: ${kq.traLoi}`);
console.log(` (lịch sử giờ có ${phien.lichSu.length} tin nhắn)\n`);
}Khách: Đơn 1041 của em giao chưa ạ?
xem_don({"ma_don":"1041"}) → {"ma_don":"1041","mon":["cà phê sữa","bánh mì trứng"],"trang_thai":"đang pha chế","dat_luc":"hôm nay 8:12"}
Mộc: Dạ đơn 1041 (cà phê sữa, bánh mì trứng) bạn đặt hôm nay 8:12 hiện đang pha chế ạ.
(lịch sử giờ có 4 tin nhắn)
Khách: Vậy thêm cho em một bạc xỉu nữa nhé
them_mon({"ma_don":"1041","mon":"bạc xỉu","so_luong":1}) → {"ma_don":"1041","mon":["cà phê sữa","bánh mì trứng","bạc xỉu"],"trang_thai":"đang pha chế"}
Mộc: Dạ Mộc đã thêm vào đơn 1041. Đơn giờ gồm: cà phê sữa, bánh mì trứng, bạc xỉu, vẫn đang pha chế ạ.
(lịch sử giờ có 8 tin nhắn)Tin nhắn thứ hai giờ gọi them_mon với đúng mã 1041. Bạn cũng nên nhìn hai dòng trong ngoặc. Khách mới nhắn hai tin mà lịch sử đã có 8 tin nhắn, vì mỗi lần gọi công cụ thêm vào hai tin, một tin assistant chứa khối tool_use và một tin user chứa khối tool_result.
Lịch sử dài ra thì tốn bao nhiêu?
Giữ toàn bộ lịch sử là cách đơn giản nhất, và với một cuộc trò chuyện ngắn thì mình thấy không cần làm gì hơn. Vấn đề chỉ hiện ra khi khách nói chuyện lâu. File buoi-sang.ts giả lập một khách nhắn 30 tin trong buổi sáng, lặp lại sáu câu hỏi quen thuộc, và in ra lần gọi mô hình cuối của mỗi tin đã gửi đi bao nhiêu.
// Một khách nhắn 30 tin trong buổi sáng. Sau mỗi tin, in ra lần gọi mô hình cuối cùng đã gửi đi bao nhiêu.
import { taoPhien } from "./phien.ts";
const CAU_HOI = [
"Đơn 1041 của em giao chưa ạ?",
"Quán mở cửa tới mấy giờ ạ?",
"Cà phê muối bao nhiêu tiền vậy?",
"Phí ship thế nào ạ?",
"Bánh mì trứng giá sao ạ?",
"Bạc xỉu bao nhiêu ạ?",
];
const phien = taoPhien();
let tongKyTu = 0;
let tongLanGoi = 0;
console.log("tin thứ | tin nhắn gửi đi | ký tự gửi đi | cộng dồn ký tự");
for (let i = 1; i <= 30; i++) {
const kq = await phien.nhan(CAU_HOI[(i - 1) % CAU_HOI.length]);
tongLanGoi += kq.cacLanGoi.length;
for (const lan of kq.cacLanGoi) tongKyTu += lan.kyTu;
const cuoi = kq.cacLanGoi.at(-1)!;
if (i === 1 || i % 5 === 0) {
console.log(`${String(i).padStart(7)} | ${String(cuoi.soTinNhan).padStart(15)} | ${String(cuoi.kyTu).padStart(12)} | ${String(tongKyTu).padStart(14)}`);
}
}
console.log(`\n30 tin của khách, ${tongLanGoi} lần gọi mô hình, lịch sử cuối cùng có ${phien.lichSu.length} tin nhắn.`);tin thứ | tin nhắn gửi đi | ký tự gửi đi | cộng dồn ký tự
1 | 3 | 415 | 473
5 | 11 | 1105 | 3876
10 | 23 | 2259 | 14614
15 | 35 | 3458 | 32256
20 | 47 | 4639 | 56757
25 | 59 | 5787 | 88135
30 | 69 | 6624 | 119650
30 tin của khách, 35 lần gọi mô hình, lịch sử cuối cùng có 70 tin nhắn.Cột thứ ba tăng đều. Tin thứ 10 gửi đi 2.259 ký tự, tin thứ 20 gửi 4.639, tin thứ 30 gửi 6.624. Cột cuối mới là cột đáng lo, vì nó tăng nhanh hơn nhiều. Mười tin đầu cộng lại gửi khoảng 14.600 ký tự, còn cả ba mươi tin gửi gần 120.000 ký tự, tức gấp tám lần cho số tin nhắn gấp ba. Lý do là mỗi lần gọi đều gửi lại mọi thứ đã nói từ đầu.
Mình đo bằng ký tự vì chế độ offline không có token thật. Mô hình tính tiền và tính giới hạn theo token, một đơn vị nhỏ hơn từ. Khi chạy với key, mỗi phản hồi của API có sẵn usage.input_tokens, và mảng cacLanGoi trong agent.ts đã ghi lại con số đó. Bạn cũng có thể đếm trước khi gửi bằng client.messages.countTokens, theo tài liệu về đếm token. Số token sẽ khác số ký tự, nhưng hình dạng của bảng thì giống hệt.
Vậy bao giờ thì chạm giới hạn? Theo tài liệu về context window, các mô hình Claude hiện tại có context window 1 triệu token, và nếu đầu vào vượt quá thì API trả lỗi 400 với thông báo "prompt is too long". Một buổi sáng trò chuyện về cà phê còn cách con số đó rất xa. Theo mình, với chatbot chăm sóc khách hàng bạn sẽ gặp hóa đơn và độ trễ trước khi gặp giới hạn. Mỗi token gửi đi đều tính tiền, và phần lớn số token của tin thứ 30 là những câu hỏi về giờ mở cửa mà không ai cần đọc lại.
Cắt bớt lịch sử sao cho không hỏng?
Ý đầu tiên ai cũng nghĩ tới là chỉ giữ vài tin nhắn cuối. Mình viết đúng như vậy trong cat-lich-su.ts, kèm cách cắt thứ hai và một hàm kiểm tra.
import type Anthropic from "@anthropic-ai/sdk";
import type { TinNhan } from "./agent.ts";
// Cách cắt ngây thơ: giữ N tin nhắn cuối, không quan tâm chúng là gì.
export function catTheoSoTinNhan(lichSu: TinNhan[], giu: number): TinNhan[] {
return lichSu.slice(-giu);
}
// Một lượt bắt đầu bằng tin nhắn của khách (vai user, nội dung là chữ) và kéo dài tới trước tin nhắn kế tiếp của khách.
const laDauLuot = (tin: TinNhan) => tin.role === "user" && typeof tin.content === "string";
// Cách cắt đúng: giữ N lượt cuối, luôn cắt ở ranh giới lượt nên không bao giờ chẻ đôi một cặp tool_use / tool_result.
export function catTheoLuot(lichSu: TinNhan[], giuLuot: number): TinNhan[] {
const dauLuot = lichSu.flatMap((tin, i) => (laDauLuot(tin) ? [i] : []));
if (dauLuot.length <= giuLuot) return lichSu;
return lichSu.slice(dauLuot[dauLuot.length - giuLuot]);
}
// Kiểm tra hai quy tắc của Messages API mà một lịch sử bị cắt sai hay vi phạm. Trả về lỗi đầu tiên, hoặc null.
// Hàm này do mình viết để bắt lỗi khi chạy offline; API thật trả về lỗi 400 với lời văn của riêng nó.
export function kiemTraLichSu(messages: TinNhan[]): string | null {
if (messages[0]?.role !== "user") return `tin nhắn đầu tiên có vai "${messages[0]?.role}", phải là "user"`;
for (let i = 0; i < messages.length; i++) {
const noiDung = messages[i].content;
if (typeof noiDung === "string") continue;
for (const block of noiDung) {
if (block.type !== "tool_result") continue;
const truoc = i > 0 ? messages[i - 1].content : [];
const coCap = typeof truoc !== "string" && truoc.some((b) => b.type === "tool_use" && (b as Anthropic.ToolUseBlock).id === block.tool_use_id);
if (!coCap) return `tin nhắn ${i} chứa tool_result của ${block.tool_use_id}, nhưng tin nhắn ngay trước không có tool_use đó`;
}
}
return null;
}Hàm kiemTraLichSu tồn tại vì API có quy tắc về thứ tự. Tài liệu về xử lý tool call viết rằng các khối kết quả công cụ phải đứng ngay sau khối gọi công cụ tương ứng trong lịch sử, và không được chen tin nhắn nào vào giữa. Mô hình giả lập không kiểm tra điều này, nên mình tự kiểm tra để thấy lỗi mà không cần key.
File cat-sai.ts tạo một cuộc trò chuyện ba lượt, in ra lịch sử, rồi thử cắt theo số tin nhắn với ba con số khác nhau:
// Cắt lịch sử theo số tin nhắn rồi kiểm tra xem phần còn lại có gửi được cho API không.
import type { TinNhan } from "./agent.ts";
import { catTheoLuot, catTheoSoTinNhan, kiemTraLichSu } from "./cat-lich-su.ts";
import { taoPhien } from "./phien.ts";
const phien = taoPhien();
for (const tin of ["Em đặt sáng nay, sđt 0901000001", "Quán mở cửa tới mấy giờ ạ?", "Phí ship thế nào ạ?"]) await phien.nhan(tin);
const moTa = (tin: TinNhan) => (typeof tin.content === "string" ? `"${tin.content}"` : tin.content.map((b) => b.type).join(" + "));
console.log(`Lịch sử đầy đủ (${phien.lichSu.length} tin nhắn):`);
phien.lichSu.forEach((tin, i) => console.log(` ${i} ${tin.role.padEnd(9)} ${moTa(tin)}`));
for (const giu of [8, 7, 6]) {
const cat = catTheoSoTinNhan(phien.lichSu, giu);
console.log(`\nGiữ ${giu} tin nhắn cuối → ${kiemTraLichSu(cat) ?? "hợp lệ"}`);
}
const theoLuot = catTheoLuot(phien.lichSu, 2);
console.log(`\nGiữ 2 lượt cuối (${theoLuot.length} tin nhắn) → ${kiemTraLichSu(theoLuot) ?? "hợp lệ"}`);Lịch sử đầy đủ (10 tin nhắn):
0 user "Em đặt sáng nay, sđt 0901000001"
1 assistant tool_use
2 user tool_result
3 assistant tool_use
4 user tool_result
5 assistant text
6 user "Quán mở cửa tới mấy giờ ạ?"
7 assistant text
8 user "Phí ship thế nào ạ?"
9 assistant text
Giữ 8 tin nhắn cuối → tin nhắn 0 chứa tool_result của toolu_offline_1, nhưng tin nhắn ngay trước không có tool_use đó
Giữ 7 tin nhắn cuối → tin nhắn đầu tiên có vai "assistant", phải là "user"
Giữ 6 tin nhắn cuối → tin nhắn 0 chứa tool_result của toolu_offline_2, nhưng tin nhắn ngay trước không có tool_use đó
Giữ 2 lượt cuối (4 tin nhắn) → hợp lệCả ba con số 8, 7 và 6 đều cho ra một lịch sử không gửi được. Nhìn vào danh sách phía trên bạn sẽ thấy vì sao. Lượt đầu tiên của khách chiếm sáu tin nhắn, từ số 0 tới số 5, vì agent gọi hai công cụ. Cắt ở bất kỳ đâu bên trong sáu tin đó đều để lại một tool_result mất tool_use, hoặc một lịch sử mở đầu bằng vai assistant. Điều khó chịu là cách cắt này vẫn chạy đúng với những cuộc trò chuyện không gọi công cụ, nên bạn sẽ không thấy lỗi khi thử vài câu hỏi về giờ mở cửa.
Hàm catTheoLuot tránh chuyện đó bằng cách chỉ cắt ở chỗ một lượt bắt đầu, tức ở một tin nhắn bằng chữ của khách. Dòng cuối của kết quả cho thấy giữ hai lượt cuối thì còn bốn tin nhắn và hợp lệ.
Cắt rồi thì chatbot quên mất mã đơn, làm sao?
Cắt theo lượt cho ra lịch sử hợp lệ, nhưng hợp lệ chưa có nghĩa là đủ. Khách hay nói mã đơn ở đầu buổi, hỏi vài chuyện khác, rồi mới quay lại đơn đó. File quen-don.ts chạy đúng kịch bản ấy ba lần, mỗi lần một cách chọn lịch sử, và in ra chuyện xảy ra ở tin nhắn thứ năm.
Trước hết don-hang.ts cần thêm một hàm để mỗi lần chạy bắt đầu từ cùng dữ liệu. Bạn đổi tên mảng DON_HANG cũ thành DON_BAN_DAU và thêm mấy dòng sau, các hàm còn lại giữ nguyên:
const DON_BAN_DAU: Don[] = [
{ maDon: "1041", soDienThoai: "0901000001", mon: ["cà phê sữa", "bánh mì trứng"], trangThai: "đang pha chế", datLuc: "hôm nay 8:12" },
{ maDon: "1042", soDienThoai: "0901000002", mon: ["bạc xỉu"], trangThai: "đang giao", datLuc: "hôm nay 9:40" },
];
let DON_HANG: Don[] = structuredClone(DON_BAN_DAU);
// Trả dữ liệu về như lúc đầu, để các lần chạy thử không ảnh hưởng nhau.
export function datLaiDonHang(): void {
DON_HANG = structuredClone(DON_BAN_DAU);
}Cách sửa mình chọn là một ghi nhớ nhỏ do code tự rút ra. File ghi-nho.ts đọc toàn bộ lịch sử, tìm mã đơn gần nhất mà một công cụ đã trả về, rồi thêm một thẻ <ghi_nho> vào cuối system prompt.
import type { TinNhan } from "./agent.ts";
import { SYSTEM_V4 } from "./prompts.ts";
// Đọc toàn bộ lịch sử, rút ra điều cần nhớ lâu: mã đơn gần nhất mà một công cụ đã trả về.
// Code tự rút từ kết quả công cụ, không nhờ mô hình tóm tắt, nên không thể nhớ sai.
export function donDangNoi(lichSu: TinNhan[]): string | null {
let maDon: string | null = null;
for (const tin of lichSu) {
if (typeof tin.content === "string") continue;
for (const block of tin.content) {
if (block.type !== "tool_result" || block.is_error) continue;
const ma = /"ma_don":"(\d{4})"/.exec(String(block.content))?.[1];
if (ma) maDon = ma;
}
}
return maDon;
}
// System prompt của quán, thêm một thẻ <ghi_nho> khi đã biết khách đang nói về đơn nào.
export function systemCoGhiNho(lichSu: TinNhan[]): string {
const ma = donDangNoi(lichSu);
if (!ma) return SYSTEM_V4;
return `${SYSTEM_V4}\n\n<ghi_nho>\nTrong cuộc trò chuyện này khách đang nói về đơn ${ma}.\n</ghi_nho>`;
}Mình lấy mã đơn từ kết quả công cụ chứ không lấy từ chữ khách gõ, vì kết quả công cụ là mã đã được hệ thống xác nhận có tồn tại. Khách gõ nhầm "1014" thì công cụ báo lỗi, và ghi nhớ bỏ qua các kết quả lỗi.
// Khách nói mã đơn ở đầu buổi, hỏi vài câu khác, rồi mới nhờ thêm món.
// Chạy ba lần với ba cách chọn lịch sử để so sánh.
import { catTheoLuot } from "./cat-lich-su.ts";
import { datLaiDonHang } from "./don-hang.ts";
import { systemCoGhiNho } from "./ghi-nho.ts";
import { taoPhien, type TuyChonPhien } from "./phien.ts";
const TIN_NHAN = [
"Đơn 1041 của em giao chưa ạ?",
"Quán mở cửa tới mấy giờ ạ?",
"Phí ship thế nào ạ?",
"Cà phê muối bao nhiêu tiền vậy?",
"Vậy thêm cho em một cà phê muối nhé",
];
const CACH: [string, TuyChonPhien][] = [
["gửi toàn bộ lịch sử", {}],
["chỉ gửi 2 lượt cuối", { chonLichSu: (lichSu) => catTheoLuot(lichSu, 2) }],
["gửi 2 lượt cuối, kèm ghi nhớ mã đơn", { chonLichSu: (lichSu) => catTheoLuot(lichSu, 2), soanSystem: systemCoGhiNho }],
];
for (const [ten, tuyChon] of CACH) {
datLaiDonHang(); // mỗi cách chạy trên dữ liệu đơn hàng như nhau
const phien = taoPhien(tuyChon);
let kq = await phien.nhan(TIN_NHAN[0]);
for (const tin of TIN_NHAN.slice(1)) kq = await phien.nhan(tin);
const dau = kq.cacLanGoi[0];
console.log(`--- ${ten} ---`);
console.log(`Khách (tin thứ 5): ${TIN_NHAN[4]}`);
console.log(` gửi đi ${dau.soTinNhan} tin nhắn, ${dau.kyTu} ký tự`);
for (const dong of kq.nhatKy) console.log(` ${dong}`);
console.log(`Mộc: ${kq.traLoi}\n`);
}--- gửi toàn bộ lịch sử ---
Khách (tin thứ 5): Vậy thêm cho em một cà phê muối nhé
gửi đi 11 tin nhắn, 1116 ký tự
them_mon({"ma_don":"1041","mon":"cà phê muối","so_luong":1}) → {"ma_don":"1041","mon":["cà phê sữa","bánh mì trứng","cà phê muối"],"trang_thai":"đang pha chế"}
Mộc: Dạ Mộc đã thêm vào đơn 1041. Đơn giờ gồm: cà phê sữa, bánh mì trứng, cà phê muối, vẫn đang pha chế ạ.
--- chỉ gửi 2 lượt cuối ---
Khách (tin thứ 5): Vậy thêm cho em một cà phê muối nhé
gửi đi 5 tin nhắn, 405 ký tự
Mộc: Dạ bạn cho Mộc xin mã đơn hoặc số điện thoại đặt hàng để Mộc thêm món nhé!
--- gửi 2 lượt cuối, kèm ghi nhớ mã đơn ---
Khách (tin thứ 5): Vậy thêm cho em một cà phê muối nhé
gửi đi 5 tin nhắn, 405 ký tự
them_mon({"ma_don":"1041","mon":"cà phê muối","so_luong":1}) → {"ma_don":"1041","mon":["cà phê sữa","bánh mì trứng","cà phê muối"],"trang_thai":"đang pha chế"}
Mộc: Dạ Mộc đã thêm vào đơn 1041. Đơn giờ gồm: cà phê sữa, bánh mì trứng, cà phê muối, vẫn đang pha chế ạ.Cách thứ hai gửi ít hơn hẳn, 405 ký tự thay vì 1.116, nhưng chatbot quay lại đúng lỗi của bài trước vì mã đơn đã nằm ngoài hai lượt cuối. Cách thứ ba gửi cùng số tin nhắn đó mà vẫn thêm được món. Con số 405 ký tự ở cách thứ ba chỉ đếm phần messages, còn system prompt dài thêm một thẻ chưa tới 80 ký tự.
Có hai lưu ý để bạn không tin kết quả này quá mức. Thứ nhất, đây là mô hình giả lập, và mình viết nó để đọc thẻ <ghi_nho>. Một mô hình thật gần như chắc chắn cũng dùng thông tin trong system prompt, nhưng bạn phải tự chạy với key rồi mới biết nó diễn đạt và hành xử ra sao. Thứ hai, ghi nhớ này chỉ biết một thứ là mã đơn. Khách dặn "ít đường nhé" ở đầu buổi thì nó vẫn quên.
Tài liệu của quán gửi kèm thế nào cho hợp lý?
Chatbot của Mộc giờ nhớ được cuộc trò chuyện, và mình có một cách cắt lịch sử không làm hỏng các cặp gọi công cụ. Thứ nó biết về quán thì vẫn chỉ là mấy dòng trong system prompt, gồm giờ mở cửa, phí ship và bảng giá năm món.
Quán thật thì có nhiều hơn thế. Giả sử Mộc có một tập chính sách 40 trang về đổi trả, hoàn tiền, dị ứng và khuyến mãi. Với context window 1 triệu token thì 40 trang vẫn đưa vừa vào system prompt. Nhưng bảng số liệu ở trên đã cho thấy cái giá của việc đó, vì mọi thứ trong prompt được gửi lại ở mỗi lần gọi, kể cả khi khách chỉ hỏi giờ mở cửa. Mình muốn chatbot chỉ đọc đúng đoạn chính sách liên quan tới câu khách đang hỏi, và đó là việc của các bài viết sau.
Bạn theo dõi cả series ở Xây AI agent đầu tiên. Nếu đang ôn phỏng vấn, các câu hỏi về AI agent có trong ngân hàng câu hỏi phỏng vấn.