에이전트를 위한 마크다운 콘텐츠 협상

에이전트가 페이지를 요청하면 HTML을 받습니다. 그리고 대부분을 버립니다. 내비게이션, 푸터, 쿠키 배너, 인라인 스크립트, class 속성, SVG 아이콘. 남는 것은 본문 몇 단락입니다.

에이전트가 원했던 그 버전은 이미 여러분에게 있습니다. 그걸 실제로 제공하는 곳이 거의 없을 뿐입니다.

마크업이 치르게 하는 비용

보내는 것과 살아남는 것을 비교해 보세요.

curl -s https://example.com/about | wc -c
curl -s -H "Accept: text/markdown" https://example.com/about | wc -c

두 숫자가 같다면 모든 클라이언트에 같은 표현 하나만 내보내고 있고, 걸러내는 일은 에이전트가 직접 하고 있다는 뜻입니다. 그 작업은 공짜가 아닙니다. <head> 안의 모든 것, 감싸는 <div> 하나하나, 모든 data- 속성, 모든 분석 스크립트가 잡음으로 판정되기 전에 먼저 컨텍스트 창으로 들어갑니다. 실제 내용이 제목 하나와 여덟 단락뿐인 페이지에서도 바이트의 대부분은 비계입니다.

마크다운 쌍둥이는 제목 단계, 링크, 목록, 표, 코드 블록을 그대로 지킵니다. 잃는 것은 내비게이션과 스타일과 스크립트인데, 어차피 인용될 일이 없던 것들입니다.

Accept 협상은 어떻게 동작하나

클라이언트가 자기가 다룰 수 있는 형식을 밝힙니다.

GET /about HTTP/1.1
Host: example.com
Accept: text/markdown

서버는 같은 URL에 대해 표현을 고릅니다.

HTTP/1.1 200 OK
Content-Type: text/markdown; charset=utf-8
Vary: Accept

브라우저는 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8을 보내고 HTML을 받습니다. text/markdown을 요청한 클라이언트는 마크다운을 받습니다. 정규 URL은 그대로이므로 링크할 주소도 공유할 페이지도 여전히 하나입니다.

Vary: Accept, 또는 브라우저가 텍스트 파일을 내려받게 되는 경위

Vary는 선택 사항이 아니고, 하필 다들 건너뛰는 부분입니다.

여러분과 독자 사이의 모든 캐시(CDN, 리버스 프록시, 브라우저 자체 저장소)는 응답을 URL로 색인합니다. 한 URL에서 서로 다른 본문을 내보내면서 그 사실을 알리지 않으면, 가장 먼저 캐시된 응답이 모두에게 전달됩니다. 에이전트가 Accept: text/markdown을 붙여 /about을 요청하고, CDN이 마크다운을 저장하고, 그다음에 /about을 연 사람은 별표로 뒤덮인 화면이나 내려받기 창을 보게 됩니다.

Vary: Accept

이 한 줄이 캐시에게 Accept 헤더를 키에 포함하라고 알려 주고, 그래야 HTML과 마크다운이 따로 저장됩니다. 두 응답 모두에 붙이세요. 마크다운 쪽에만 붙이면 안 됩니다.

실제 비용도 있습니다. Accept 헤더는 클라이언트마다 편차가 커서, 그걸 기준으로 나누면 캐시가 잘게 쪼개집니다. 아래 대안을 지지하는 정직한 근거입니다.

.md 쌍둥이

더 단순한 선택지는 URL을 하나 더 두는 것입니다. /about은 HTML을, /about.md는 같은 내용을 마크다운으로 제공합니다. 협상도 없고 Vary도 없고 캐시에서 어긋날 여지도 없습니다. 평범한 URL이므로 링크하고, 북마크하고, 사이트맵에 넣고, 어떤 클라이언트로든 가져올 수 있습니다. 쓸 만한 Accept 헤더를 아예 보내지 않는 클라이언트라도 마찬가지입니다.

그다음에는 그것이 있다는 사실을 알려야 합니다. HTML에서는 이렇게 씁니다.

<link rel="alternate" type="text/markdown" href="https://example.com/about.md">

본문은 읽지 않고 HEAD만 던지는 쪽을 위해 응답 헤더에도 씁니다.

Link: <https://example.com/about.md>; rel="alternate"; type="text/markdown"

둘 다 해도 각각 한 줄이고, 한 곳만 들여다보는 클라이언트까지 챙길 수 있습니다.

직접 확인하기

# 1. 같은 URL이 다르게 응답하는가
curl -s -H "Accept: text/markdown" https://example.com/about | head -20

# 2. HTML 응답이 쌍둥이의 존재를 인정하는가
curl -sI https://example.com/about | grep -i "vary\|link"

# 3. .md URL이 단독으로 동작하는가
curl -sI https://example.com/about.md | grep -i "content-type\|vary"

흔한 실패를 잡아내는 것은 두 번째 명령입니다. 흠잡을 데 없는 .md 쌍둥이를 갖추고도 아무도 찾지 못하는 사이트가 있습니다. 그것을 가리키는 헤더도 태그도 없기 때문입니다.

우리가 하는 방식

이 사이트의 모든 페이지에는 마크다운 쌍둥이가 있고, 그 전부가 packages/web/src/lib/content-negotiation.js 안에 들어 있습니다. 아흔 줄 남짓입니다.

고정 매핑표가 정적 경로와 그 쌍둥이를 나열합니다. / → /index.md, /about → /about.md, 각 도구 페이지 같은 식입니다. 식별자나 슬러그가 들어가는 경로(/r/<uuid>의 보고서, 가이드, 이 글 같은 블로그 게시물)는 패턴으로 판별해 <경로>.md를 받습니다.

요청 판별은 일부러 투박합니다. Accept 헤더에 text/markdown이라는 문자열이 들어 있으면 마크다운을 보냅니다. q 값에 따른 우선순위 계산은 하지 않습니다. 브라우저는 그 문자열을 보내는 일이 없어서 실제로는 두 경우가 깔끔하게 갈리지만, text/markdown을 낮은 우선순위로 나열한 클라이언트도 마크다운을 받게 됩니다.

모든 마크다운 응답에는 다음이 붙습니다.

Content-Type: text/markdown; charset=utf-8
Content-Signal: search=yes, ai-input=yes, ai-train=no
Vary: Accept
X-Markdown-Tokens: 4213
Cache-Control: public, max-age=3600

Content-Signal은 가져갈 수 있는지만이 아니라 그 내용으로 무엇을 해도 되는지를 밝힙니다. X-Markdown-Tokens는 크기 표시로, 지금은 본문의 문자 수가 들어갑니다. 실제 토큰 수가 아닙니다.

쌍둥이가 있는 페이지의 HTML 응답에는 Vary: Accept와 Link 헤더가 붙고, <head>에는 같은 매핑표에서 가져온 rel="alternate" 태그가 들어갑니다.

마지막 대목에서 우리가 한 번 당했습니다. 템플릿이 참조한 것은 그 매핑표였는데, 패턴으로 판별하는 라우트는 표에 없었습니다. 그래서 모든 분석 보고서가 마크다운 쌍둥이를 제공하고 Link 헤더로 알리면서도, 정작 HTML에서는 한마디도 하지 않았습니다. 쌍둥이는 존재했고, 마크업을 읽는 쪽에서는 내내 보이지 않았습니다.

표준이 아니라 관례입니다

Accept: text/markdown은 평범한 HTTP 콘텐츠 협상이고, 그쪽은 표준이 맞습니다. 그것을 마크다운에 쓰는 것은 llms.txt 제안에서 자라난 관례이며, 그렇게 요청할 의무를 진 크롤러는 없습니다. 정직하게 말하면 확실히 통하는 쪽은 .md 쌍둥이입니다. 여느 URL과 다를 바 없어서 무엇이든 가져갈 수 있으니까요. 협상은 헤더 하나의 비용으로, 실제로 물어보는 클라이언트에게서 값을 합니다. 둘 다 해가 되지는 않고, 어느 쪽이 쓰이는지는 공지가 아니라 로그를 읽으면 알 수 있습니다.

마크다운 협상 테스트하기 · llms.txt 생성하기