Kiểm tra độ thân thiện của website với AI Agent: Cơ chế hoạt động của Vercel Is Agentic

hecigo17 min read
AI AgentContent NegotiationWeb ArchitectureHTTPMiddleware

hecigo.com được Is Agentic chấm 73/100. Tám mục fail hoặc chỉ đạt một nửa. Vá hết trong một buổi, quét lại được 79/100, và thứ có tác động lớn nhất hoá ra không nằm trong một dòng code nào của chúng tôi.

Con số 79 đó nói ít hơn vẻ ngoài của nó. Riêng bề mặt "public website" đi từ 59% lên 92%, tức 8 trên 16 check thành 15 trên 17. Tổng chỉ nhích 6 điểm vì bộ quét mở thêm hai bề mặt mới để chấm sau khi đọc trang mới của chúng tôi. Vì sao lại thế là chỗ đáng đọc hơn cả con số.

Bài này gồm hai phần: cơ chế chấm điểm hoạt động ra sao, và tám lỗi thật cùng cách vá, kèm số đo trước sau. Phần thứ hai đáng đọc hơn phần thứ nhất.

Is Agentic: 127 check trên bốn tầng

Đây không phải sản phẩm của riêng Vercel. Theo chính bài công bố của Ora, is-agentic.com là bản mở rộng của bộ xếp hạng do Ora xây, làm chung với Vercel. Ora quét hơn 16.000 domain, và mỗi phiên kiểm tra trên is-agentic.com đều do agent của Ora chạy, không phải một crawler tĩnh đọc HTML.

Bộ chấm gồm 127 check trên bốn tầng: discovery, access, usability và payments. Bốn tầng này là trục phạm vi, khác với trục trọng số ở mục sau. Một site không bán hàng vẫn quét đủ bốn tầng, chỉ là tầng payments không kích hoạt check nào.

Quá trình quét đi qua ba việc:

  1. Khám phá. Agent đọc robots.txt, sitemap.xml, llms.txt, và các endpoint khai báo giao thức như MCP Server Card hay OpenAPI spec.
  2. Truy xuất. Gửi yêu cầu Markdown qua header HTTP, kiểm khả năng render phía máy chủ, và kiểm tính toàn vẹn của mã trạng thái HTTP. Đây là chỗ phần lớn site rụng điểm.
  3. Thực thi tác vụ. Agent ghi lại một hành trình thật khi cố tương tác với các thành phần điều khiển trên trang, và chỉ đúng chỗ phát sinh nghẽn.

Hệ thống tính điểm tách rõ yêu cầu bắt buộc khỏi tính năng mở rộng, nên site không có nhu cầu thương mại hay API không bị trừ điểm oan:

Nhóm kiểm traMục tiêu kỹ thuậtTác động khi lỗi
EssentialHTML render phía máy chủ, mã HTTP chuẩn, cấu trúc thẻ ngữ nghĩa, lỗi phục hồi được, thành phần điều khiển dùng đượcChiếm phần lớn trọng số điểm
RecommendedEndpoint API công khai, luồng OAuth, MCP server, GraphQL, cổng tài liệu, bề mặt thương mạiChỉ kích hoạt khi bằng chứng quét cho thấy site có thứ đó
Emerging formatsllms-full.txt, giao thức A2A, x402Cộng điểm thưởng có giới hạn, vắng mặt không bao giờ làm giảm điểm

Điểm đáng chú ý: Recommended kích hoạt theo bằng chứng. Site không có API thì không bị hỏi về API. Nhưng nếu agent tìm thấy dấu vết một cổng tài liệu mà không tìm được tài liệu, mục đó fail.

📖

Kiến Trúc Nền Tảng Open API: Định Hướng Kỹ Thuật, Tuân Thủ và Vai Trò Của Lớp Middleware

Giao diện lập trình ứng dụng mở (Open API) không đơn thuần là việc mở một cổng HTTP endpoint ra Internet. Khi doanh nghiệp mở rộng kết nối với đối...

Ba chuẩn kết nối: llms.txt, Markdown negotiation và MCP

Theo phân tích hạ tầng agent của Vercel, lưu lượng từ agent lập trình và agent tương tác tự động đang chiếm tỉ trọng ngày càng lớn. Ba giao thức đang thành mặc định:

1. File chỉ dẫn /llms.txt

Đặc tả ở llmstxt.org quy định cấu trúc rất chặt và rất ngắn: một H1 tên site, một blockquote tóm tắt, các mục H2 chứa danh sách link dạng [tên](url): ghi chú, và mục ## Optional cuối cùng dành cho thứ agent bỏ qua được khi cần ngữ cảnh ngắn. Chỉ H1 là bắt buộc.

Phần đắt giá nhất không nằm trong đặc tả: mục nói khi nào nên dùng bạn. Danh sách dịch vụ thì agent nào cũng suy ra được từ trang chủ. Câu "đừng gọi chúng tôi cho việc X" mới là thứ nó không tự biết.

Đi kèm là /llms-full.txt, bản nối toàn bộ nội dung site thành một tài liệu. Với hecigo.com nó nặng 158 KB và thay được 18 lần gọi riêng lẻ bằng đúng 1 lần. Với agent trả tiền theo token thì đó là phép đánh đổi rõ ràng: nạp thừa một ít nội dung để bỏ hẳn 17 vòng mạng.

2. Markdown content negotiation

Khi client gửi Accept: text/markdown, server trả bản Markdown sạch trên cùng URL canonical. Số đo thật trên hecigo.com sau khi bật:

URLHTMLMarkdownGiảm
Trang chủ215.970 bytes4.355 bytes98%
Một bài blog kỹ thuật115.241 bytes8.439 bytes93%
Một bài dài hơn204.351 bytes12.790 bytes94%

Trang chủ giảm nhiều nhất vì nó chứa Three.js, sơ đồ SVG inline và nhiều lớp bọc layout. Con số 98% đó không phải mức chung, nó là mức của một trang chủ nặng. Bài blog, vốn đã chủ yếu là văn bản, giảm quanh 93%.

3. Khai báo Model Context Protocol

Để agent thực thi được hành động chứ không chỉ đọc, site cung cấp công cụ cần khai báo manifest rõ ràng. Điểm cần cẩn thận không nằm ở việc dựng server mà ở việc nuôi nó: một MCP server chết còn tệ hơn không có, vì agent đã học được rằng endpoint đó tồn tại.

hecigo hiện không publish MCP server nào, và /llms.txt nói thẳng điều đó thay vì để agent tự đi dò. Khai một endpoint không tồn tại tốn nhiều uy tín hơn là thiếu nó.

Cái bẫy q-value trong content negotiation

Đây là chỗ bản đầu của bài này viết sai, nên sửa ngay tại đây.

Cách viết trực giác là kiểm chuỗi:

// SAI. Đừng dùng.
const isMarkdown = acceptHeader.includes('text/markdown')

Nó hỏng với client gửi Accept: text/html, text/markdown;q=0.1, nghĩa là "cho tôi HTML, Markdown cũng chấp nhận được nhưng tôi không thích". Phép kiểm chuỗi trả về true và ép Markdown vào một client muốn HTML.

Header Acceptdanh sách ưu tiên có thứ tự, không phải một chuỗi. Theo hướng dẫn parse Accept của acceptmarkdown.com, phép chọn đúng cần ba luật: sắp theo q giảm dần, phá hoà bằng độ cụ thể (text/markdown thắng text/*, text/* thắng ký tự đại diện), và tôn trọng q=0 nghĩa là đừng gửi thứ này cho tôi.

type Entry = { type: string; subtype: string; q: number }
 
function parseAccept(header: string): Entry[] {
  return header.split(',').flatMap((raw) => {
    const [range, ...params] = raw.split(';')
    const [type, subtype] = range.trim().toLowerCase().split('/')
    if (!type || !subtype) return []
    const q = params
      .map((p) => p.trim().toLowerCase())
      .filter((p) => p.startsWith('q='))
      .map((p) => Number.parseFloat(p.slice(2)))
      .find(Number.isFinite) ?? 1
    return [{ type, subtype, q }]
  })
}
 
// Độ cụ thể: khớp đúng 3, text/* 2, ký tự đại diện 1, không khớp 0.
function specificity(e: Entry, mime: string): number {
  const [type, subtype] = mime.split('/')
  if (e.type === '*' && e.subtype === '*') return 1
  if (e.type !== type) return 0
  if (e.subtype === '*') return 2
  return e.subtype === subtype ? 3 : 0
}
 
// Điểm của một định dạng là q của entry khớp CỤ THỂ NHẤT, không phải q cao nhất
// trong các entry khớp. Đúng chỗ này mới làm header thật của Chrome ra HTML.
function scoreFor(entries: Entry[], mime: string): number {
  let bestSpec = 0
  let bestQ = 0
  for (const e of entries) {
    const s = specificity(e, mime)
    if (s === 0) continue
    if (s > bestSpec || (s === bestSpec && e.q > bestQ)) {
      bestSpec = s
      bestQ = e.q
    }
  }
  return bestQ
}
 
/** Trả về định dạng nên phục vụ, hoặc null để trả 406. available[0] là mặc định. */
export function negotiate(header: string | null, available: string[]): string | null {
  if (header === null) return available[0] // không có Accept nghĩa là không ràng buộc
  const entries = parseAccept(header)
  if (entries.length === 0) return null
 
  // Header chỉ toàn q=0 là danh sách loại trừ, không phải lời từ chối tất cả:
  // "text/markdown;q=0" nghĩa là "gì cũng được trừ Markdown".
  if (entries.every((e) => e.q === 0)) {
    return available.find((m) => !entries.some((e) => specificity(e, m) > 0)) ?? null
  }
 
  let chosen: string | null = null
  let top = 0
  for (const mime of available) {
    const s = scoreFor(entries, mime)
    if (s > top) {
      chosen = mime // so sánh lớn hơn hẳn, để thứ tự available làm phép phá hoà
      top = s
    }
  }
  return top > 0 ? chosen : null
}

Bộ test tối thiểu để biết mình chưa hỏng, lấy từ chính bảng test vector của acceptmarkdown.com:

AcceptServer cóPhải trả
text/markdownmd, htmlmarkdown
text/markdown, text/html;q=0.8md, htmlmarkdown
text/htmlmd, htmlhtml
text/markdown;q=0, text/htmlmd, htmlhtml
text/markdown;q=0chỉ md406
không có Acceptmd, htmlhtml
ký tự đại diệnmd, htmlhtml

Dòng cuối là dòng hay bị bỏ sót. Header thật của Chrome kết thúc bằng một ký tự đại diện mang q=0.8, nên nếu tính điểm bằng q cao nhất thay vì q của entry cụ thể nhất, trình duyệt sẽ nhận Markdown.

Phần nối vào Next.js middleware ngắn hơn nhiều so với phần parser, và có một chỗ dễ sai:

export function middleware(request: NextRequest) {
  const chosen = negotiate(request.headers.get('accept'), ['text/html', 'text/markdown'])
 
  if (chosen === null) {
    return new NextResponse('406 Not Acceptable\n\ntext/html\ntext/markdown\n', {
      status: 406,
      headers: { Vary: 'Accept', 'Cache-Control': 'no-store' },
    })
  }
 
  const response =
    chosen === 'text/markdown'
      ? NextResponse.rewrite(new URL(`${request.nextUrl.pathname}.md`, request.url))
      : NextResponse.next()
 
  // Vary đặt trên MỌI nhánh, kể cả nhánh HTML. Thiếu nó thì bản nào tới CDN
  // trước sẽ được phục vụ cho tất cả những ai tới sau.
  response.headers.set('Vary', 'Accept, Accept-Encoding')
  return response
}

Đừng set('Content-Type', ...) trên một response rewrite. Content-Type do đích quyết định, ở đây là chính file .md tĩnh. Ép nó ở tầng middleware là cách tạo ra một header đúng trên máy mình và sai trên CDN.

Tám lỗi của hecigo.com và cách vá

Đây là báo cáo thật, không phải ví dụ. Điểm gốc 73/100.

MụcTrạng tháiNguyên nhânCách vá
404 thân thiện agentMột nửaTrả đúng 404 nhưng thân trang rỗng nghĩaThêm bản đồ phục hồi dạng Markdown ngay trong thân 404
Markdown negotiationFailAccept: text/markdown trả về text/html, Vary thiếu AcceptEdge function làm negotiation, đặt Vary: Accept, Accept-Encoding
Tài nguyên cho lập trình viênFailHai n8n node có thật trên npm nhưng không có trang nào gom lạiTrang /developers với URL cố định, có tên thương hiệu trong tiêu đề và H1
JSON-LDFailTrang chủ không có khối structured data nàoĐồ thị Organization và WebSite nối bằng @id
Hướng dẫn cho agentFailKhông có file nào nói khi nào nên dùng hecigoMục "when to use" trong llms.txt và một file riêng
Organization schemaFailHệ quả của mục JSON-LDcontactPointaddress kiểu PostalAddress
MetadataMột nửaThiếu thẻ canonicalĐặt canonical theo từng trang
Trang neo niềm tinMột nửaCó about và contact, thiếu privacyBa trang thật, mỗi trang trên 500 ký tự nội dung

Hai chỗ đáng nói kỹ hơn vì chúng là bẫy chứ không phải việc thiếu.

Canonical không được đặt ở layout gốc. Trong Next.js App Router, metadata được kế thừa xuống mọi trang con. Khai alternates.canonical một lần ở app/layout.tsx trông rất gọn, và kết quả là mọi trang trên site đều trỏ canonical về trang chủ. Phải khai theo từng trang.

Ba trang tĩnh giờ có một nguồn chân lý duy nhất. Trang privacy và trang developers render thẳng từ file Markdown trong content/pages/, và chính file đó được xuất thành bản .md mà agent đọc. Trước đó chúng tôi định viết hai bản riêng, và đó là cách một site bắt đầu nói hai điều khác nhau với người và với máy.

Đo lại sau khi vá: 18 đường dẫn đều trả Markdown đúng định dạng, Accept: application/pdf trả 406 kèm danh sách định dạng có sẵn theo khuyến nghị của RFC 9110, và bản .md gọi trực tiếp cho ra md5 giống hệt bản negotiation trên URL canonical.

Quét lại: 79/100, và một bất ngờ

Cả tám mục trên biến khỏi danh sách lỗi. Bằng chứng bộ quét tự ghi cho hai mục nặng nhất:

  • 404: "Nonexistent paths return HTTP 404 with markdown guidance for agents - the strongest 404 contract"
  • Negotiation: "Canonical URL serves text/markdown and text/html via Accept negotiation with Vary: Accept"

Bề mặt "public website" đi từ 59% lên 92%. Nhưng tổng chỉ từ 73 lên 79, vì bảng điểm đổi hình:

TrướcSau
Essential5 / 7 check, 62,9 điểm6 / 9 check, 59,3 điểm
Recommended3 / 9 check, 8,9 điểm12 / 17 check, 14,5 điểm
Bonus7 tín hiệu, +1,615 tín hiệu, +3,7

Số check áp dụng nhảy từ 16 lên 26. Bộ quét kích hoạt thêm hai bề mặt: APIMCP, cả hai đều chấm thấp. Điểm Essential vì thế giảm dù số check pass tăng, do OpenAPI specJSON error responses là check Essential và cả hai fail.

Nguyên nhân gần như chắc chắn là trang /developers chúng tôi vừa dựng. Nó nêu tên API docs, OpenAPI, webhook và MCP server, dù là để nói rằng hecigo không có thứ nào trong số đó. Bộ quét đọc thấy các khái niệm ấy, kết luận rằng những bề mặt đó áp dụng cho site này, rồi mở chúng ra để chấm.

Nói thật về việc không có API lại tạo ra một bề mặt API để bị trừ điểm. Đó không phải lỗi của bộ quét: nó chấm theo bằng chứng, và bằng chứng là do chúng tôi cung cấp. Bài học dùng được cho bất kỳ ai sắp viết trang tài liệu: liệt kê thứ mình không có cũng là một tuyên bố, và máy sẽ xử lý nó như mọi tuyên bố khác.

Thứ không nằm trong code

Sau khi mọi thứ đã xanh, một lần kiểm cuối vào robots.txt cho ra 1.925 bytes. File chúng tôi sinh ra chỉ có 89 bytes.

CDN đứng trước site đang chèn thêm một khối quản lý sẵn vào đầu file, đặt Disallow: / cho ClaudeBot, GPTBot, CCBot, Google-Extended, Bytespider, Amazonbot, Applebot-Extended và meta-externalagent, kèm một tín hiệu nội dung từ chối dùng cho huấn luyện.

Khối User-Agent: * với Allow: / do site sinh ra nằm phía dưới. Nó không cứu được gì: theo luật robots.txt, crawler dùng group khớp cụ thể nhất với chính nó. ClaudeBot đọc group tên ClaudeBot và dừng ở đó.

Nghĩa là toàn bộ lớp content negotiation vừa dựng vô hình với đúng nhóm agent nó nhắm tới. Và hai câu đang publish trên /llms.txt cùng /developers nói rằng không crawler AI nào bị chặn đã thành lời nói sai, đúng vào lúc chúng được viết ra.

Ba điều rút ra, và điều thứ ba là điều đắt nhất:

  • Bộ quét không bắt được lỗi này. Điểm số vẫn lên. Mọi check kỹ thuật vẫn xanh.
  • Không có test nào trong repo bắt được nó. Setting nằm ở dashboard của CDN, không có dòng code nào phản ánh nó. Build xanh, test pass, site sai.
  • Nó có thể quay lại bất cứ lúc nào mà không ai được báo, nếu nhà cung cấp đổi mặc định. Phép kiểm rẻ nhất là curl -s https://domain/robots.txt | wc -c và so với kích thước file mình biết mình đã sinh ra.

Đây đúng là loại lỗi mà lớp middleware sinh ra để xử lý: hai hệ thống đều hoạt động đúng theo cách riêng của chúng, và cái sai chỉ tồn tại ở chỗ tiếp giáp.

Điểm số sạch không đồng nghĩa agent chạy được việc

Đạt điểm cao trên một bộ quét mới giải quyết phần đọc dữ liệu. Trong hệ thống doanh nghiệp, chỗ hỏng nằm ở lúc agent phải ghi, qua CRM, ERP hay phần mềm quản lý đơn hàng.

Ba điểm nghẽn thường gặp:

  • Thiếu khoá chống trùng. Agent gặp sự cố mạng và gửi lại một thao tác ghi. Không có idempotency-key, đơn hàng thành hai.
  • Không có trần vòng lặp. Agent kẹt trong suy luận vô tận vì đầu ra API không nhất quán, đốt tài nguyên và chi phí token cho tới khi ai đó nhìn thấy hoá đơn.
  • Dữ liệu lệch ngầm. Frontend báo thành công, hệ thống kế toán bên dưới nhận sai định dạng, và chỉ lộ ra lúc đối soát cuối kỳ.

Chuẩn hoá bề mặt website cho agent là bước đầu tiên và là bước rẻ nhất. Phần còn lại là kiểm soát tầng giữa: ghi log trạng thái, ràng buộc quyền hạn, và một đường đối soát chạy theo lịch.

🚀

Thấy hữu ích? Theo dõi hecigo trên Zalo OA để nhận bài viết kỹ thuật mới sớm nhất, không spam, chỉ nội dung thực tế. Hoặc liên hệ trực tiếp nếu bạn cần hỗ trợ triển khai.

📖

Đọc tiếp: Middleware: phần việc n8n, OpenClaw và mọi nền tảng tự động hóa không làm hộ bạn

Nối được API là phần dễ. Phần khó lộ ra sau vài tuần chạy thật: sự kiện gửi lại hai lần, webhook rơi mất một giao dịch, hóa đơn bị hủy nhưng hệ...

Nguồn tham khảo

Related Articles