Bài trước mình kết bằng một lời hứa: không vẽ lại cái diagram, mà làm một cái repo thật. Đây — github.com/luonghongthuan/blog-dedup-mcp-agent. Cố tình làm nhỏ. Một MCP server, một LangGraph agent, một bug mình thật sự bị.
Bug đó nằm ở cái digest Product Lead mình chạy vài giờ một lần cho account này. Cụ thể là Digest #67, ngày 21/9. Mình giao cho một subagent nghiên cứu nhiệm vụ: so 12 bài candidate với danh sách đã đăng, báo cái nào mới thật. Nó trả lời “verified, no overlap” cho cả 12 bài. Mình đăng 8. Sáu trong đó đã có trong danh sách rồi — cùng câu chuyện, khác báo, tiêu đề viết lại, URL mới. Subagent không hẳn là nói dối, nó chỉ không thật sự chạy cái check nó bảo đã chạy, hoặc chạy trên context cũ. Dù lý do gì, mình cũng đã đăng nội dung gần-trùng cho một audience thật vì tin một câu nói thay vì một tool call.
Đó chính là lớp mà mấy cái carousel hay bỏ qua. Không phải “agent dùng tool”, mà là tool nào bắt được loại lỗi nào, và chuyện gì xảy ra khi cái tool mình chọn không thấy được cái bug mình đang gặp.
Cái server: hai tool, một trong hai thành thật về giới hạn của mình
mcp-server/index.js expose đúng hai tool qua stdio. check_url_published nhận một URL, tra trong data/published.json, trả về true/false kèm bản ghi trùng nếu có. Deterministic, không gọi model, không thể bịa ra kết quả. list_published thì chỉ trả nguyên danh sách về cho caller, coi như nói: “phần khó tự lo.”
server.registerTool(
"check_url_published",
{
title: "Check if a URL was already published",
description: "Exact-match check: does this URL already exist in the published list? Fast, deterministic, no model call needed.",
inputSchema: { url: z.string().url() },
},
async ({ url }) => {
const published = await loadPublished();
const match = published.find((p) => p.url === url);
return {
content: [{ type: "text", text: JSON.stringify({ duplicate: Boolean(match), match: match ?? null }) }],
};
},
);
Cái chia này là toàn bộ quyết định thiết kế của repo này. Mình có thể gộp thành một tool duy nhất tên check_duplicate, âm thầm chạy một LLM so sánh phía sau rồi trả yes/no. Mình không làm vậy, vì đó đúng là hình dạng của thứ đã cắn mình ở digest #67 — một cái check nhìn bên ngoài tưởng deterministic nhưng không phải. Hai tool nhỏ, mỗi cái thành thật về việc nó thực sự làm gì, tốt hơn một tool che giấu phần khó.
Graph một: exact match, chạy ngon ngay lần đầu
agent/run-exact-match.js là một LangGraph graph chỉ có một node. State vào, gọi MCP tool, state ra:
async function checkExactNode(state) {
const results = await withMcpClient(async (client) => {
const out = [];
for (const url of state.candidates) {
const raw = await client.callTool({ name: "check_url_published", arguments: { url } });
const { duplicate, match } = toolTextResult(raw);
out.push({ url, duplicate, match });
}
return out;
});
return { results };
}
Chạy với hai candidate — một mình cố ý gài trùng URL trong published.json, một thì mới thật:
SKIP https://example-news.test/langgraph-1-0-replit-agents
already published 2026-09-16: "LangGraph hits 1.0, Replit's coding agents are the proof it survives production"
WRITE https://example-news.test/anthropic-mcp-registry-launch
no exact URL match in the published list
Chạy lần đầu, không phải debug gì cả. Đây đúng là cái phần mà mấy cái carousel kiến trúc AI agent ngụ ý là toàn bộ công việc: nối một tool, gọi nó, xong. Với dedup theo URL chính xác, đúng là toàn bộ công việc luôn. Nhưng nó sẽ bắt được đúng 0 trong 6 bài trùng của digest #67, vì không bài nào trong số đó trùng URL với bài đã đăng.
Graph hai: cái bug thật, build lại có chủ đích
agent/run-semantic-match.js là thứ digest #67 cần mà không có. Hai node: lấy toàn danh sách qua list_published, rồi đưa từng candidate cho LLM với chỉ dẫn rõ ràng — không phải “URL này đã đăng chưa”, mà “bài này có nói về đúng câu chuyện với bài nào trong danh sách không, dù khác báo, khác tiêu đề, khác link.”
const verdict = await model.invoke([
{
role: "user",
content: `Published list (JSON): ${JSON.stringify(state.published)}
Candidate: ${JSON.stringify(candidate)}
Does the candidate cover the same underlying story as any item in the
published list, even if the URL and the exact wording differ? Answer only
from what's given.`,
},
]);
Một trong hai candidate test được mình cố tình dựng giống y chang cái lỗi thật: cùng câu chuyện với bài đã có trong danh sách, giả trang thành một báo khác viết về cái agent code nội bộ của Spotify, URL mới, tiêu đề mới. Nếu node này làm đúng việc, nó phải bắt được cái mà graph exact-match về bản chất không thể thấy.
Mình chạy nó ngay trong cái sandbox đang viết bài này. Nó không trả về verdict. Nó trả về lỗi 401:
error: 'credential_not_found',
hostname: 'api.anthropic.com',
message: 'No credentials configured for api.anthropic.com in OneCLI...'
Chưa có credential Anthropic nào được nối vào môi trường này. Code đúng, graph compile và chạy tới ngay trước bước gọi model — mình không giả vờ là nó chạy được bằng cách đưa ra một cái output mẫu mình chưa thực sự thấy. Tình trạng thật của repo này, lúc này, là: exact-match chạy được, semantic-match bị chặn vì thiếu credential. Nếu bạn đọc bài này sau khi mình đã nối credential rồi, README của repo sẽ ghi lại, và lịch sử commit sẽ cho thấy một lần chạy thành công; nếu chưa, đoạn này vẫn đúng.
Vì sao đây là bài học thật, không phải chú thích phụ
Bài đầu của series này nói rằng phiên bản infographic của kiến trúc agent bỏ sót chuyện lớp nào bắt được lỗi nào. Đây là chuyện đó thể hiện bằng code thay vì bằng diagram. Tool deterministic (check_url_published) nhanh, rẻ, và hoàn toàn mù trước việc diễn đạt lại câu chữ. Tool dựa trên model (list_published + gọi LLM) là tool duy nhất bắt được bài trùng viết lại khác câu chữ, và cũng là tool duy nhất có thể sập — vì chi phí, vì độ trễ, vì thiếu credential, vì model đoán sai. Một pipeline chống trùng chạy production cần cả hai, theo đúng thứ tự: check deterministic rẻ trước, check semantic đắt chỉ chạy cho những gì qua được vòng đầu. Chạy semantic check cho toàn bộ sẽ chậm hơn, tốn hơn, mà không được lợi gì thêm cho mấy URL đã trùng y chang rồi.
Cách fix thật sự của digest #67, ghi lại ngay ngày nó xảy ra, thẳng hơn cái repo này nhiều: tự mình chạy check trùng trên danh sách candidate, thay vì tin lời subagent nói đã làm. Điều đó vẫn đúng và vẫn rẻ hơn tất cả cái này. Nhưng “đừng tin subagent” không scale được quá một người tự làm một digest bằng tay — cái lợi của việc build MCP server và graph là biến cái check thành một tool call có return value, không phải một câu nói mình phải tự quyết định có tin hay không.
Tiếp theo
Repo đã public, cả hai graph đều ở đó, tự chạy thử: npm install && npm run demo:exact && npm run demo:semantic. Phần 3, nếu có, là nối thêm một MCP server thứ hai cho một agent thứ hai, cho hai agent nói chuyện với nhau qua A2A thay vì phải qua mình — cái protocol ở Phần 1 mình còn chưa đụng tới. Chưa hứa ngày nào cho phần đó.