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

hecigo35 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. Bốn vòng vá thì lên 100/100.

VòngĐiểmViệc đã làm
Gốc73
179Tám mục: negotiation, 404, JSON-LD, canonical, trang neo niềm tin
282Tắt khối managed robots.txt của CDN. Không có dòng code nào
393/openapi.json, lỗi trả JSON, và vá content-type sai trên đường dẫn .md
4100Đảo mặc định của thân lỗi sang JSON, sau khi một mục Essential fail trở lại

Vòng bốn là vòng đáng đọc nhất, vì thứ bị chấm trượt là một phép kiểm mà chính bài này, ở bản đăng ngày 24/08, đã lấy làm ví dụ về việc làm đúng. Phần sửa nằm ở mục cuối và không được viết đè lên chỗ sai.

Con số 79 ở vòng một 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à các 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. Bản đầu của trang /developers nói thẳng điều đó, và phần cuối bài sẽ cho thấy vì sao chính câu nói thẳng ấy lại là thứ tốn điểm. Dù sao thì khai một endpoint không tồn tại vẫn 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.

Vá xong: 82/100

Cách vá không có dòng code nào: tắt khối managed robots.txt trong dashboard của CDN, để file 89 bytes do site sinh ra đi thẳng ra ngoài. Kiểm bằng đúng phép đo rẻ nhất ở trên:

$ curl -s https://hecigo.com/robots.txt | wc -c
89

Quét lại: 82/100. Ba điểm cho một setting, và ba điểm đó không phản ánh đúng giá trị của việc sửa. Trước khi tắt, toàn bộ lớp content negotiation dựng ở phần trên vô hình với ClaudeBot và GPTBot; bộ quét vẫn chấm nó xanh vì bộ quét không tự nhận là ClaudeBot. Điểm số đo cái site có thể làm, không đo cái nhóm agent thật được phép thấy.

Nói cách khác: 73 → 79 là sáu điểm cho một buổi viết code, còn 79 → 82 là ba điểm cho một cú click. Xếp theo tác động thật lên agent thì thứ tự ngược lại.

Hai check Essential, và ba cách phản ứng

Ở mốc 82, hai mục Essential vẫn fail, cả hai đều là hệ quả của bề mặt API mà chính trang /developers đã mở ra: OpenAPI specJSON error responses. Báo cáo lúc đó xếp bề mặt API ở mức "Needs work", 3 trên 8 check.

Có ba đường đi, và hai trong số đó sai:

  • Gỡ mọi nhắc đến API khỏi /developers để bộ quét đừng kích hoạt bề mặt đó. Điểm lên ngay. Đây là chỉnh mình cho vừa thước đo, và nó xoá mất một thông tin có ích thật cho người đọc.
  • Dựng một API giả chỉ để có cái mà khai. Tệ hơn: một endpoint chết còn tốn uy tín hơn là không có, đúng như phần MCP ở trên đã nói.
  • Mô tả đúng thứ đang chạy. Site này một bề mặt máy đọc thật: llms.txt, blog-index.json, sitemap.xml, và negotiation trên mọi URL canonical. Chúng có thật, chúng trả lời thật, và chúng chưa từng được mô tả hình thức.

Đường thứ ba là đường duy nhất không phải nói dối, nên /openapi.json giờ tồn tại. Nó khai đúng những gì có: toàn GET, không API key, không đường ghi, security: []. Nó được sinh ra từ cùng cái manifest mà edge function dùng để negotiation, nên một bài bị gỡ không thể còn sót lại trong đó.

Và lỗi giờ trả về đúng định dạng người gọi hỏi:

$ curl -s -H "Accept: application/json" https://hecigo.com/khong-co-trang-nay/
{
  "type": "https://hecigo.com/developers/#http-404",
  "title": "Not Found",
  "status": 404,
  "instance": "/khong-co-trang-nay/",
  "links": { "agentIndex": "https://hecigo.com/llms.txt" }
}

Chỗ đáng nói không phải cái body, mà là mã trạng thái được chọn theo bằng chứng. Đường dẫn có thật nhưng không có bản JSON thì trả 406 kèm danh sách hai định dạng nó thật sự có. Đường dẫn không tồn tại thì trả 404. Gộp hai cái làm một sẽ dạy agent rằng một đường chết là có thật và đáng thử lại bằng định dạng khác, tức là đúng loại tín hiệu sai mà cả bài này đang chống.

Một cái bẫy nữa nằm ở chỗ ai được nhận JSON. Điều kiện là header Accept phải gọi tên JSON; ký tự đại diện */* mà mọi trình duyệt đều gửi thì không tính. Bỏ luật đó đi thì một người gõ nhầm URL sẽ nhận về một cục JSON thay vì trang 404. Đây chính xác là cái bẫy q-value ở phần trên, mặc một bộ đồ khác.

Đoạn trên là chỗ bài này viết sai, và nó đứng nguyên ở đây vì đó là điều chúng tôi tin ngày 24/08. Mục tiêu của nó đúng, phép kiểm thì sai, và hai ngày sau bộ quét chấm trượt đúng chỗ đó. Vòng bốn mổ nó ra.

Chỗ tiếp giáp thứ ba: 404 đúng, content-type sai

Sau khi mọi mục trên đã xanh, một lần gõ thử vào đường dẫn .md bịa ra:

$ curl -sI https://hecigo.com/blog/khong-ton-tai.md
HTTP/2 404
content-type: text/markdown; charset=utf-8

Mã trạng thái đúng. Content-type đúng thứ agent đang hỏi. Body là trang 404 HTML đầy đủ, 58.838 byte, mở đầu bằng <!DOCTYPE html>.

Header nói Markdown, body là HTML. Kiểu sai này tệ hơn một lỗi thẳng thừng: agent tin header, nạp <!DOCTYPE html> vào chỗ nó chờ Markdown, và thứ rút ra được từ đó là rác. Một curl -I không bao giờ thấy, vì curl -I chỉ đọc header.

Lỗi xảy ra với mọi đường dẫn .md không tồn tại, và không phụ thuộc header Accept: thử lại với */*, application/jsontext/markdown cho ra ba kết quả giống hệt nhau. Trong khi đó /khong-ton-tai.txt trả về 404 text/html, tức là đúng. Chỉ đuôi .md sai.

Nguyên nhân nằm ở một dòng cấu hình của edge function, kèm nguyên một comment giải thích vì sao dòng đó tồn tại:

excludedPath: [
  '/_next/*',
  '/*.md',    // loại ra để rewrite trong handler không chạy lại chính function này
  '/*.txt',
]

Hai hệ thống, mỗi bên đều đúng phần của mình:

  • Edge function đúng khi tránh xa file tĩnh. Một file .md đã là một biểu diễn cụ thể, không còn gì để negotiation. Loại nó khỏi phạm vi chạy là quyết định đúng.
  • Netlify đúng khi suy content-type từ đuôi đường dẫn. Mọi web server đều làm thế.

Cái sai chỉ tồn tại ở chỗ hai luật gặp nhau. Một đường dẫn .md không tồn tại thì không có file nào để suy kiểu, nhưng cái đuôi thì vẫn còn đó, nên Netlify vẫn dán nhãn text/markdown lên trang 404 HTML. Không bên nào sai. Chỗ tiếp giáp sai.

Chỗ đáng ngại khi vá

Cách vá nghe đơn giản: bỏ /*.md khỏi danh sách loại trừ, rồi cho hàm quyết định một nhánh riêng cho .md. Gọi origin, 404 thì trả về Markdown 404 do mình sinh ra, 200 thì trả file nguyên vẹn.

Nhưng cái comment trong đoạn cấu hình trên là một cảnh báo thật, và nó đáng kiểm trước khi đụng vào. Edge function này đứng trước mọi trang của site. Sai ở đây không hỏng riêng Markdown, nó hỏng cả site.

Câu hỏi phải trả lời: context.rewrite() có chạy lại edge function không? Tài liệu của Netlify trả lời thẳng - có. Một rewrite mở ra chuỗi request mới và chạy lại mọi edge function khớp đường dẫn mới. Muốn lấy file tĩnh mà không chạy lại thì phải dùng context.next().

Nghĩa là bỏ /*.md ra thì rewrite thật sự chạy lại function này. Điều đó nghe như đúng thứ cái comment đang cảnh báo, nhưng nó không thành vòng lặp, vì lần chạy bên trong rơi vào nhánh .md mới, và nhánh đó gọi next() chứ không gọi rewrite():

/about/  (Accept: text/markdown)
  -> nhánh markdown, rewrite sang /about.md
     -> chạy lại function, rơi vào nhánh .md
        -> next(), lấy file tĩnh, dừng

Chuỗi sâu đúng hai tầng rồi kết thúc. Giá phải trả là một lượt gọi edge function thêm cho mỗi request Markdown đã negotiation, và đó là cái giá chấp nhận được.

Chỗ này đáng nói không phải vì kết luận, mà vì kết luận đúng trùng với thứ trực giác báo là nguy hiểm. Cái comment cũ nói "loại .md ra để khỏi chạy lại". Đọc xong dễ kết luận rằng chạy lại là hỏng, nên đừng đụng. Thực tế là chạy lại không sao, miễn nhánh mới không rewrite tiếp. Nếu tin comment mà bỏ qua, lỗi content-type còn nguyên.

Test bắt được nó, sau khi được sửa để bắt

Bộ test cũ có sẵn một ca cho đường dẫn .md, và nó pass suốt thời gian lỗi đang chạy trên production:

test('a .md request is never renegotiated', () => {
  assert.equal(ask('/index.md', 'text/markdown').action, 'passthrough')
})

Nó khẳng định đúng thứ đang xảy ra, và đúng thứ đang sai. Test không sai về mặt logic - nó chỉ đang canh một quyết định thay vì canh một kết quả.

Ca mới canh cái quan trọng, là body:

const response = await request('/blog/khong-ton-tai.md', 'text/markdown')
assert.equal(response.status, 404)
assert.equal(response.headers.get('content-type'), 'text/markdown; charset=utf-8')
// Chỉ khẳng định trên header là đúng cái đã để lọt lần đầu.
assert.equal(body.includes('<!DOCTYPE'), false)
assert.equal(body.startsWith('# 404 Not found'), true)

Cùng với đó, bộ giả lập origin trong test tích hợp được sửa để rewrite chạy lại handler, đúng như Netlify làm, kèm trần độ sâu. Trước đó nó gọi thẳng vào file, tức là mô hình hoá một hành vi mà production không có. Giờ nếu ai đó làm nhánh Markdown rewrite vào chính nó, thứ đổ vỡ là một test đỏ chứ không phải mọi trang trên hecigo.com.

Phép kiểm cuối là tắt nhánh vá đi rồi chạy lại: 9 ca đỏ. Một bộ test chưa từng thấy đỏ thì chưa biết nó có canh gì không.

Kết quả sau khi vá, cùng một đường dẫn, 1.034 byte thay cho 58.838:

$ curl -s -H "Accept: text/markdown" https://hecigo.com/blog/khong-ton-tai.md
# 404 Not found
 
There is no page at `/blog/khong-ton-tai.md` on hecigo.com.

Ba lần, cùng một mẫu: khối managed robots.txt của CDN chồng lên file site tự sinh, trang /developers mở ra một bề mặt API để bị chấm, và giờ là cái đuôi .md gặp một đường dẫn không tồn tại. Không lần nào có một hệ thống hỏng. Cả ba lần, mỗi bên đều làm đúng việc của nó, và cái sai nằm ở khoảng giữa - đúng chỗ không ai sở hữu, nên cũng không ai test.

Quét lại: 93/100

Sau khi cả ba thứ trên lên production, bộ quét chấm 93/100, và đổi nhãn từ "Ready with a few material gaps" sang "Strong technical baseline".

Mười một điểm cho ba thay đổi. Nhưng chỗ đáng nói là thứ đã không phải làm để có chúng.

Ở mốc 82, danh sách "fix these gaps first" của báo cáo có năm mục, xếp theo thứ tự:

#MụcXử lý
01OpenAPI spec publishedMô tả đúng bề mặt đang chạy
02JSON error responsesTrả lỗi JSON cho client gọi tên JSON
03Developer resource discoverabilityKhai /openapi.json trong llms.txt/developers
04CLI tool availableKhông làm
05MCP server / manifestKhông làm

Ba mục đầu vá được mà không phải nói dối câu nào, vì site này thật sự có một bề mặt máy đọc: llms.txt, blog-index.json, sitemap.xml, và negotiation trên mọi URL canonical. Chúng chỉ chưa từng được mô tả hình thức. Viết OpenAPI cho chúng là mô tả, không phải phát minh.

Hai mục cuối thì không. Dựng một CLI hay một MCP server chỉ để bộ quét thấy là dựng thứ không ai dùng, rồi phải nuôi nó. Một endpoint chết tốn uy tín hơn là thiếu nó, đúng như phần MCP đầu bài đã nói, và nguyên tắc đó không đổi chỉ vì lần này nó đứng giữa mình và một con số tròn hơn.

Nói cách khác, và đây là kết luận của bản đăng ngày 24/08: 93 là trần của việc trung thực, không phải trần của bộ quét. Bảy điểm còn lại nằm ở hai thứ hecigo không có và không giả vờ có.

Nửa đầu câu đó đúng tới hôm nay. Nửa sau sai, và mục tiếp theo là chỗ nó vỡ.

Vòng bốn: cái bẫy ký tự đại diện, và 100/100

Hai ngày sau, quét lại. Mục Essential JSON error responses fail, với bằng chứng: "API does not return JSON error responses (or no API detected)".

Phản xạ đầu tiên là kết luận bộ quét chấm sai, vì phần trên bài này vừa cho thấy lỗi JSON đang chạy. Suýt thì dừng ở đó. Chỉ khi curl cả hai chiều mới thấy chỉ một chiều đúng:

$ curl -sI -H "Accept: application/json" https://hecigo.com/khong-co
HTTP/2 404
content-type: application/json          # đúng
 
$ curl -sI https://hecigo.com/khong-co   # curl mặc định gửi Accept: */*
HTTP/2 404
content-type: text/html                  # đây là chỗ trượt
 
$ curl -sI -H "Accept: application/pdf" https://hecigo.com/
HTTP/2 406
content-type: text/plain                 # cũng không phải JSON

Bộ quét chấm đúng. Nó gửi header mặc định, nhận về HTML, và một agent lạ không có cách nào đoán ra nó phải hỏi bằng header nào để được trả lời tử tế.

Luật đúng, phép kiểm sai

Quay lại đúng đoạn ở trên:

Điều kiện là header Accept phải gọi tên JSON; ký tự đại diện */* mà mọi trình duyệt đều gửi thì không tính.

Mục tiêu của câu đó vẫn đúng: người gõ nhầm URL phải nhận trang 404 dựng sẵn, không phải một cục JSON. Phép kiểm thì sai, và sai ở đúng một chỗ.

*/* không phải dấu hiệu của trình duyệt. Header thật của Chrome là:

text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8

Trình duyệt gọi tên text/html ngay từ đầu, còn */*;q=0.8 chỉ là cái đuôi nói "còn lại thì cũng nhận". Thứ gửi */* trần, hoặc không gửi Accept gì cả, là curl, fetch, requests, và mọi thư viện HTTP khác.

Nghĩa là luật cũ đang dùng dấu hiệu của máy để nhận diện người. Nó vẫn bảo vệ được người đọc, đúng như mục tiêu, nhưng cái giá là mọi client tự động bị đẩy chung vào nhánh dành cho người.

Phép kiểm đúng nằm ở chiều ngược lại: hỏi xem client có gọi tên text/html hay không. Đó là thứ duy nhất dây thật sự nói ra được.

Gửi gì404 trả gì
Không gửi, hoặc */*JSON
Accept: application/jsonJSON
Accept: text/markdownBản đồ phục hồi Markdown
Accept: text/html, tức là trình duyệtTrang 404 dựng sẵn, y nguyên

Người đọc được bảo vệ đúng như trước. Chỉ có mặc định là đảo.

Cùng một bộ chọn, thêm một danh sách

Phần cài đặt ngắn hơn phần lập luận, vì bộ chọn q-value ở đầu bài đã có sẵn và đã có test. Chọn định dạng cho thân lỗi chỉ là gọi lại chính hàm negotiate đó với một danh sách khác:

// Thứ tự ưu tiên của SERVER cho thân lỗi. Khác với danh sách dùng cho một
// response thành công, và khác một cách có chủ ý.
const ERROR_REPRESENTATIONS = ['application/json', 'text/html', 'text/markdown']
 
export function errorFormat(accept, available = ERROR_REPRESENTATIONS) {
  return negotiate(accept, available) ?? 'application/json'
}

Hai chi tiết đáng nói:

  • Danh sách cho thành công và danh sách cho lỗi khác nhau. Thành công vẫn là ['text/html', 'text/markdown'], HTML đứng trước, vì thứ người ta chủ động yêu cầu phải tới ở dạng đọc được. Lỗi thì không ai yêu cầu cả, nên mặc định là dạng phân tích được.
  • ?? 'application/json' ở cuối xử lý client gửi một kiểu chưa nghe bao giờ. Nhánh đó trước kia trả text/plain: đọc được, không phân tích được.

Vì dùng lại đúng bộ chọn cũ nên q-value vẫn có tiếng nói ở nhánh lỗi. Accept: text/html;q=0.9, application/json ra JSON, Accept: application/json;q=0.5, text/html ra HTML, và application/json;q=0 vẫn là lời từ chối chứ không phải lời yêu cầu.

Ba thứ đi kèm

406 giờ cũng là JSON, kể cả khi Accept là kiểu server chưa từng nghe. Thân lỗi thêm ba trường, theo đúng chữ trong khuyến nghị của chính bộ quét ("error codes, messages, and resolution hints"):

{
  "code": "not_found",
  "resolution": "Fetch https://hecigo.com/llms.txt for the link index or https://hecigo.com/sitemap.xml for every URL...",
  "documentation_url": "https://hecigo.com/developers/#errors"
}

code là thứ đáng rẽ nhánh theo. titledetail là chữ viết cho người đọc log, và chữ thì sửa lúc nào cũng được.

Mọi phản hồi lỗi mang Link, kể cả trang 404 HTML. Đây là cách giữ nguyên trang cho người mà vẫn nói được với máy:

link: <https://hecigo.com/openapi.json>; rel="service-desc"; type="application/vnd.oai.openapi+json",
      <https://hecigo.com/llms.txt>; rel="service-doc"; type="text/plain",
      <https://hecigo.com/developers/#errors>; rel="help"; type="text/html"

Một client không phân tích nổi trang HTML vẫn đọc được header, và từ đó lần ra tài liệu mô tả đúng cái lỗi nó vừa nhận.

Bốn đuôi .md, .txt, .json, .xml rời excludedPath. Đây là cùng một lỗi tiếp giáp với mục .md ở phần trên, chỉ rộng hơn: bốn đuôi đó là đúng bốn đuôi mà /openapi.json khai là endpoint, mà trượt của chúng lại đi ra dưới dạng trang HTML. Ảnh và font vẫn nằm ngoài, vì không gì phân tích một thân lỗi mà nó đang chờ một tấm ảnh, và cho chúng vào là đặt edge function trước mọi request của site.

/openapi.json cũng phải sửa. Bản cũ tự khai 404text/html cho 7 trên 8 operation: tài liệu đang nói site không trả JSON, đúng lúc bộ quét đi hỏi site có trả JSON không.

100/100, với sáu mục vẫn chưa đạt

Vòng 3Vòng 4
Essential72,7 / 80, 10 trên 11 check80 / 80, 11 trên 11
Recommended15,9 / 20, 15 trên 21 check15,9 / 20, 15 trên 21
Bonus18 tín hiệu, +418 tín hiệu, +4
Tổng93100

Recommended không nhích một điểm nào. Ba mục vẫn fail, ba mục vẫn đạt một phần. Con số 100 là 99,9 làm tròn lên: 80 cộng 15,9 cộng 4.

Đó là chi tiết đáng nhớ hơn cả con số. Một bảng điểm tròn 100 vẫn đang liệt kê sáu mục chưa đạt, vì đây là ba quỹ điểm cộng lại chứ không phải một danh sách phải tick cho hết. Essential nặng 80 trên 100, nên đầy quỹ Essential gần như đã đủ tròn số, kể cả khi Recommended còn hở 4 điểm. Đọc một điểm số mà không đọc bảng phân rã bên dưới là bỏ mất đúng phần nói được điều gì.

Câu ở vòng ba sai chỗ nào

Vòng ba kết luận rằng bảy điểm còn lại nằm ở một CLI và một MCP server hecigo không có.

Phần nguyên tắc vẫn đứng: hecigo vẫn không có CLI, vẫn không có MCP server, hai mục đó vẫn đang fail, và vẫn không có ý định dựng chúng chỉ để lấy điểm. Phần định vị thì sai. Bảy điểm kia không nằm ở đó. Chúng nằm ở 7,3 điểm Essential đang thiếu, và Essential thiếu vì một phép kiểm sai trong đúng đoạn code mà bài này đã lấy làm ví dụ về việc làm đúng.

Cái sai không phải con số. Là việc tuyên bố mình đã biết bảy điểm còn lại nằm ở đâu trong khi chưa quét lại để kiểm. Câu đúng ở thời điểm đó lẽ ra là: còn 7 điểm, chưa biết nằm ở đâu, và hai mục kia thì cố ý không làm. Câu đó vẫn sẽ đúng tới hôm nay.

Xếp lại cả bốn vòng theo tác động thật lên agent thay vì theo điểm số thì thứ tự gần như đảo ngược:

  • Vòng 2 đáng giá nhất, và chỉ được 3 điểm. Trước khi tắt khối robots.txt, toàn bộ lớp negotiation vô hình với ClaudeBot và GPTBot. Bộ quét vẫn chấm xanh, vì bộ quét không tự nhận là ClaudeBot.
  • Lỗi .md ở vòng 3 không được điểm nào. Nó nằm ngoài mọi check. Nhưng nó là lỗi đẩy nguyên một trang HTML 58.838 byte vào cửa sổ ngữ cảnh của một agent đang chờ Markdown.
  • Vòng 4 được nhiều điểm nhất, và lẽ ra không cần tồn tại. Bảy điểm cho việc sửa một phép kiểm mà vòng 3 đã viết ra, đã giải thích, và đã tin là đúng.

Điểm số đo cái site có thể làm. Nó không đo cái agent thật được phép thấy, cũng không đo cái nó nhận về khi đã thấy, và như vòng bốn cho thấy, nó cũng không đứng yên chỉ vì lần quét trước đã xanh.

Đ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