用Accept头协商Markdown:让大模型直接读取干净网页
2026/9/20 16:22:22 网站建设 项目流程

如果你正在做 RAG、Agent 工具或任何需要“让大模型读懂网页”的应用,你一定遇到过这样的尴尬:好不容易抓到一个 URL,结果拿回来的是几百 KB 的 HTML,里面三分之二都是导航栏、广告脚本和无关推荐。为了把正文喂给 LLM,你不得不再写一套清洗规则、调用 html2text 之类的库做转换,还要处理各种标签残留。这件事本身不难,但非常烦,而且每次抓不同网站都要重新调一遍。

这篇文章想聊一个不太起眼、但理论上很优雅的解法:利用 HTTP 的 Accept 请求头做内容协商,让服务端在收到请求时,直接根据“对方是谁”来返回不同的内容表示。浏览器访问时拿到正常渲染的 HTML,LLM 客户端访问时拿到干干净净的 Markdown。同一个 URL,不需要爬虫硬解析,不需要 JS 渲染,不需要额外写转换层,源头就给对格式。

这不是什么新发明。HTTP 内容协商从 HTTP/1.1 时代就存在,只是过去主要用于在 HTML、JSON、XML 之间做切换。如今 LLM 应用大量出现,Markdown 成了模型最友好的文本结构之一,Accept 头这个老机制,正在迎来一个新的使用场景。

读完这篇文章,你会理解 Accept 头的工作原理、为什么 Markdown 比 HTML 更适合 LLM、服务端和客户端分别怎么写,以及生产环境中真正的坑在哪。代码以 Python 生态为主,同时也给出 Node.js 中间件和 Nginx 配置思路,方便你直接迁移到自己项目里。

1. 为什么 LLM 应用需要 Markdown,而不是 HTML

先说结论:HTML 是给人看的,Markdown 是给模型看的。这里不是贬低 HTML,而是两者的设计目标不同,导致它们在 LLM 应用里的表现天差地别。

HTML 的原始设计目标是文档结构化,它用大量标签来描述布局和语义,比如<div><span><nav><footer>。一个典型的博客页面,真正的内容可能只占整个响应体积的 30% 到 50%,其余全是导航、侧边栏、相关文章、埋点脚本和 CSS 类名。这些内容喂给 LLM 的时候,至少带来三个问题:

第一,Token 浪费。你按字符把 HTML 塞进上下文时,模型需要处理的字符量可能比有效信息多好几倍。商用 LLM 按 Token 计费时,这直接变成成本问题。

第二,干扰注意力。HTML 里的导航链接、广告文本、推荐列表,跟你真正想提问的正文混在一起,模型很容易被无关内容带偏。你问“这篇文章讲什么”,它可能先被侧边栏的“热门文章”吸引了。

第三,结构信息不友好。HTML 把标题层级、列表、代码块的语义藏在标签里,模型虽然能读,但需要额外“翻译”一层。而 Markdown 天然用#-、反引号这些极简符号表示结构,模型在预训练阶段已经见过海量 Markdown 语料,它处理这类文本时几乎不需要额外开销。

纯文本更简单,但纯文本会丢失标题层级、列表嵌套、代码块边界这些对理解文档至关重要的结构。JSON 适合做程序化数据交换,但你不会想用 JSON 去承载一篇几千字的文章正文。PDF 更不用提,它本质上是排版格式,Token 效率极低。

Markdown 是这些格式里最好的折中:语法足够简单,Token 消耗低,又恰好保留了 LLM 做摘要、问答、向量化时需要的结构语义。这也是为什么现在很多文档站、博客平台、知识库后端都开始把 Markdown 当作一种“源格式”来维护。

当你意识到 Markdown 的价值后,下一个问题就是:怎么让一个公开 URL 在面对 LLM 客户端时直接返回 Markdown?答案不一定是要再造一个?format=md参数,而是可以回到 HTTP 协议本身,用 Accept 请求头表达你的偏好。

2. Accept 请求头与 HTTP 内容协商机制

Accept 请求头是 HTTP/1.1 内容协商机制的一部分,定义在 RFC 7231 中。简单说,客户端在发起请求时,可以通过 Accept 告诉服务端:我能接受哪些媒体类型,以及我对这些类型的偏好程度。

一个常见的请求头长这样:

Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8

这里每个媒体类型后面可以带一个q值,表示权重,范围是 0 到 1,默认是 1。浏览器通常发给服务器的就是这个样子,意思是:我最想要 HTML,如果服务器给不了,XML 也可以接受,实在不行你给我什么我都认。

服务端收到后,会根据自己的能力和客户端偏好做匹配,从客户端能接受的列表里挑一个最合适的媒体类型返回,并且在响应的Content-Type里标明最终返回了什么。

这套机制最常见的应用是“同一 URL,不同响应格式”。过去 API 设计里经常用.json后缀、?format=json参数来实现,但更符合 HTTP 语义的做法其实是让客户端设置 Accept 头。比如 GitHub 的 API 就允许你在 Accept 里传application/vnd.github+json来控制响应结构。

对于 LLM 应用来说,我们可以复用这套机制,只是把“想要的媒体类型”换成text/markdown。浏览器访问时默认 Accept 里没有text/markdown,所以仍然拿到 HTML;而 LLM 客户端访问时主动带上Accept: text/markdown,服务端就能识别出“这是机器在读取内容”,直接返回 Markdown。

有一个容易混淆的点是:不要把 Accept 头和 User-Agent 混为一谈。User-Agent 也能判断客户端类型,比如很多网站检测到爬虫就返回精简页面,但那是一种非常脆弱且容易被绕过的做法。Accept 头的意义在于它描述的是客户端对内容格式的偏好,而不是客户端身份。即便将来某个 LLM 工具使用了一个看起来像浏览器的 User-Agent,只要它仍然声明自己能接受 Markdown,服务端就可以做出合理的响应。这也是内容协商的初衷:双方在协议层面达成一致,而不是靠猜测对方的身份。

还需要了解一个状态码:406 Not Acceptable。当服务端没有能力返回客户端要求的任何媒体类型时,可以返回这个状态码。不过实际生产中,大多数服务端不会直接返回 406,而是忽略掉客户端不接受的类型,返回一个默认格式,或者干脆在 Accept 里加一个*/*;q=0.1之类的兜底项。后面我们会写一个带降级策略的示例,避免把客户端直接挡在门外。

3. 服务端:在 FastAPI 中根据 Accept 返回不同格式

理解了机制以后,我们先写一个最小的服务端示例。这里选用 FastAPI,是因为它在处理请求头、依赖注入和响应类型上非常简洁,很适合用来演示内容协商的逻辑。

先明确我们要实现的行为:

  • 浏览器访问/docs/rag-guide,返回渲染好的 HTML。
  • LLM 客户端(设置Accept: text/markdown)访问同一个 URL,返回 Markdown。
  • 二者使用同一个 URL,不需要?format=md参数。

为了演示方便,我们先不考虑数据库,而是用两个变量模拟同一个文档的不同表示形式。实际项目中,更常见的做法是只存一份 Markdown 源文件,HTML 由模板或前端渲染生成。

# main.py from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, PlainTextResponse app = FastAPI(title="Content Negotiation Demo") MARKDOWN_SOURCE = """# RAG 入门指南 RAG(Retrieval Augmented Generation)是一种把检索结果注入到大模型上下文中的技术架构。 ## 核心组件 - 文档解析 - 向量化 - 检索排序 - 生成回答 ## 为什么需要 RAG 因为大模型的知识有截止日期,而业务数据通常需要实时更新。 """ HTML_TEMPLATE = """<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>RAG 入门指南</title> <style> body {{ font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }} pre {{ background: #f4f4f4; padding: 16px; border-radius: 8px; }} </style> </head> <body> <h1>RAG 入门指南</h1> <p>这是一篇面向开发者的技术文档,用浏览器访问会看到 HTML 渲染效果。</p> <h2>核心组件</h2> <ul> <li>文档解析</li> <li>向量化</li> <li>检索排序</li> <li>生成回答</li> </ul> <h2>为什么需要 RAG</h2> <p>因为大模型的知识有截止日期,而业务数据通常需要实时更新。</p> </body> </html> """ def client_prefers_markdown(accept_header: str) -> bool: """ 简单判断客户端是否希望获得 Markdown。 生产环境建议解析 q 值,这里先做最小实现。 """ if "text/markdown" in accept_header: return True if "text/plain" in accept_header and "text/html" not in accept_header: return False return False @app.get("/docs/rag-guide") async def get_doc(request: Request): accept_header = request.headers.get("accept", "") if client_prefers_markdown(accept_header): return PlainTextResponse( content=MARKDOWN_SOURCE, media_type="text/markdown; charset=utf-8", ) return HTMLResponse(content=HTML_TEMPLATE)

这个代码虽然能跑,但有两个明显的问题:

第一,client_prefers_markdown函数只做了字符串包含判断,没有解析 q 值。如果客户端的 Accept 头是text/html;q=0.8, text/markdown;q=0.5,说明它更想要 HTML,但我们的函数还是会返回 Markdown,这就违背了客户的真实偏好。

第二,用“是否包含 text/html”来反向推断,逻辑不够严谨。一个客户端可能同时声明接受 HTML 和 Markdown,但权重不同。

更规范的做法是写一个简单的 MIME 权重解析器。下面的函数将 Accept 头解析成可排序的列表,然后按权重选出服务端支持的最佳类型。

# negotiate.py from typing import List, Tuple def parse_accept(header: str) -> List[Tuple[str, float]]: """ 解析 Accept 头,返回 (媒体类型, 权重) 列表,按权重从高到低排序。 """ if not header: return [("*/*", 1.0)] parsed = [] for part in header.split(","): part = part.strip() if not part: continue segments = part.split(";") mime_type = segments[0].strip().lower() q_value = 1.0 for seg in segments[1:]: seg = seg.strip() if seg.startswith("q="): try: q_value = float(seg[2:]) except ValueError: q_value = 0.0 parsed.append((mime_type, q_value)) parsed.sort(key=lambda item: item[1], reverse=True) return parsed def best_match(accept_header: str, supported_types: List[str]) -> str: """ 根据 Accept 头和服务器支持的媒体类型,选出一个最佳匹配类型。 """ if not accept_header: return supported_types[0] accepts = parse_accept(accept_header) for mime_type, q_value in accepts: if q_value == 0: continue if mime_type in supported_types: return mime_type # 处理后一种情况:客户端声明了 */*,但没有显式列出支持类型 if mime_type == "*/*" and supported_types: return supported_types[0] # 处理 text/* 这类通配符 if mime_type.endswith("/*"): prefix = mime_type.split("/")[0] for st in supported_types: if st.startswith(prefix + "/"): return st return supported_types[0]

然后在 FastAPI 路由中调用这个函数:

# main.py(使用 negotiated 版本) from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, PlainTextResponse from negotiate import best_match app = FastAPI(title="Content Negotiation Demo") SUPPORTED_TYPES = ["text/html", "text/markdown"] # ... 省略 MARKDOWN_SOURCE 和 HTML_TEMPLATE 的定义 ... @app.get("/docs/rag-guide") async def get_doc(request: Request): accept_header = request.headers.get("accept", "text/html") chosen = best_match(accept_header, SUPPORTED_TYPES) if chosen == "text/markdown": return PlainTextResponse( content=MARKDOWN_SOURCE, media_type="text/markdown; charset=utf-8", ) return HTMLResponse(content=HTML_TEMPLATE)

这里能看出内容协商的核心逻辑:服务器不依赖请求路径、不依赖参数、不依赖调用方身份,只看客户端在协议层面声明了它想要什么。代码里之所以用supported_types列表做白名单,是因为服务端不能无条件信任客户端的 Accept 头,只能返回自己确实支持的类型。

运行上面的 FastAPI 应用后,你可以先用浏览器访问:

http://127.0.0.1:8000/docs/rag-guide

你会看到正常的 HTML 页面。然后在命令行测试:

curl -H "Accept: text/markdown" http://127.0.0.1:8000/docs/rag-guide

输出就是干净的 Markdown 文本了。注意这里请求头和响应头完全走的是标准 HTTP 语义,没有定制任何私有协议。这套思路可以平移到任何支持自定义响应类型的 Web 框架上。

4. 静态站点与 Nginx:没有后端代码时怎么处理

很多开发者维护的是静态站点,比如用 Hugo、VitePress、MkDocs 生成的文档站。这类站点的特点是:构建过程会生成一堆 HTML 文件,原始 Markdown 可能也在仓库里,但并没有直接通过 Web 暴露出来。

如果你希望让这类站点也支持 Accept 内容协商,有两条路可以走。

第一条路:在构建产物里保留一份 Markdown 文件,然后用 Nginx 根据 Accept 头做内部重定向。但这条路有个坑:静态站点生成器默认不会为每个页面生成对应的.md文件。你需要改构建流程,确保 Markdown 源文件被复制到发布目录,并且路径要和 URL 能对应上。

第二条路更省事:让 Nginx 在检测到 Accept 头包含text/markdown时,反向代理到一个后端的转换服务上,由后端读取 HTML 再转成 Markdown。这种方式适合暂时没有后端、但想快速验证效果的场景,同时也适合 HTML 源文件是唯一可靠内容的场景。

下面是 Nginx 配置的示例:

server { listen 80; server_name docs.example.com; root /var/www/docs; index index.html; # 浏览器访问时,走正常的静态文件 location / { try_files $uri $uri/ =404; } # 当 Accept 包含 text/markdown 时,代理到转换服务 location /md/ { internal; proxy_pass http://127.0.0.1:9000/convert; proxy_set_header Host $host; proxy_set_header X-Original-URI $request_uri; } # 精确匹配:当客户端声明支持 markdown 时,把请求转发到 /md/ location ~* ^/docs/ { if ($http_accept ~* "text/markdown") { rewrite ^/(.*)$ /md/$1 last; } try_files $uri $uri/ =404; } }

这个配置不是最佳实践,因为if在 Nginx location 里有很多历史问题。更稳妥的方式是使用 Nginx 的map指令,根据 Accept 头设置一个变量,然后基于变量做判断。不过这里想说明的核心点是:内容协商不一定非要在应用层实现,反向代理层也可以做。

如果你有独立的转换服务,这个转换服务可以很简单:收到一个 URL 后,先获取对应的 HTML 页面,把 HTML 里的正文区域提取出来,再调用markdownifyhtml2text转成 Markdown。这种方式的缺点是每个请求都要执行一次转换,比较消耗 CPU,但只要加了缓存,性能通常可以接受。

对于构建类站点,还有第三条比较务实的路线:与其在服务端做动态协商,不如直接在文档的源文件层面开放.md访问。比如 MkDocs 生成的站点,你在docs/目录下访问某个路径时,尝试在docs_dir里找到同名 Markdown 文件,在生成的站点中额外暴露一个raw/目录,专门存放 Markdown 源文件。框架层面做不到“同一路径、两种格式”,那就用“另一个路径直接给源文件”,很多内部知识库就是这么干的。

5. 内容协商中间件:为已有站点增加 Markdown 输出

前两节场景里,服务端代码是你自己写的,Markdown 源文件也都在你手里。更常见的现实是:你想给一个已经上线、不可能大改的历史站点增加 Markdown 输出支持。它是一个老博客、一个旧版 CMS,模板里的 HTML 已经定死了,你不可能为了这个需求去动整个渲染链路的代码。

这种情况下,“接收原始响应,在中间层把 HTML 转成 Markdown”是最合适的思路。无论你用的是 Django、Flask 还是纯粹的原生 Python WSGI 应用,都可以用一个统一的中间件把所有响应拦截下来,检测到客户端想要 Markdown 时,就现场把 HTML 转换掉。

我以 Python 的 Starlette 为例写一个可运行的中间件。FastAPI 底层用的就是 Starlette,所以这段代码在 FastAPI 项目中也能直接用。

# middleware_markdown.py from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response import markdownify class MarkdownNegotiationMiddleware(BaseHTTPMiddleware): """ 中间件:当请求 Accept 头包含 text/markdown 时, 把后端返回的 HTML 响应自动转换成 Markdown。 """ SUPPORTED_TYPES = ("text/markdown",) async def dispatch(self, request, call_next): accept = request.headers.get("accept", "") if not self._wants_markdown(accept): return await call_next(request) response = await call_next(request) # 只处理 HTML 响应,其他类型保持不变 content_type = response.headers.get("content-type", "") if "text/html" not in content_type: return response html_body = b"" async for chunk in response.body_iterator: html_body += chunk try: md_text = markdownify.markdownify( html_body.decode("utf-8"), heading_style="ATX", strip=["script", "style", "nav", "footer", "aside"], ) except Exception: return Response( content=html_body, status_code=response.status_code, headers=dict(response.headers), media_type="text/html", ) return Response( content=md_text, status_code=response.status_code, headers={ "content-type": "text/markdown; charset=utf-8", "x-content-negotiated": "html-to-markdown", "x-original-content-type": content_type, }, ) @staticmethod def _wants_markdown(accept_value: str) -> bool: if not accept_value: return False mime_parts = [part.split(";")[0].strip().lower() for part in accept_value.split(",")] return "text/markdown" in mime_parts or "text/*" in mime_parts or "*/*" in mime_parts

这个中间件做了几件关键的事:

  1. 它先检查请求头中是否包含text/markdown,如果不包含,就原样放行,不影响普通浏览器用户。
  2. 它只在后端确实返回 HTML 时才做转换。如果后端返回的是 JSON、图片或 PDF,不会去破坏原始内容。
  3. 转换时通过strip参数主动去掉了<nav><footer><aside><script><style>这些对 LLM 没有意义、反而会污染上下文的区块。
  4. 转换失败时降级返回原始 HTML,而不是让用户看到一个 500 页面,保证可用性。
  5. 在响应头里加了调试标记,方便你确认内容协商是否生效。

在 FastAPI 中使用这个中间件:

# main.py from fastapi import FastAPI from middleware_markdown import MarkdownNegotiationMiddleware app = FastAPI(title="Legacy Site with Markdown Negotiation") app.add_middleware(MarkdownNegotiationMiddleware) @app.get("/page/{page_id}") async def get_page(page_id: str): # 这里模拟一个已经存在的 HTML 页面 return HTMLResponse( content=""" <html> <body> <nav>导航链接</nav> <article> <h1>页面标题</h1> <p>这是正文内容。</p> <pre><code>print("hello")</code></pre> </article> <footer>版权信息</footer> </body> </html> """ )

这个方案的优点是接入成本极低,原有路由完全不用改。缺点是中间件拦截了所有响应,会引入一定性能开销,而且 HTML 转 Markdown 的质量一定程度上取决于源 HTML 的规范程度。如果原始页面里有大量嵌套的<div>而不是语义化的<article>等标签,转换出来的 Markdown 可能不够干净。

所以,如果你有精力,不妨在中间件里加一层基于路径的白名单或黑名单,只对内容类页面做转换。比如/article//docs/前缀下的页面才处理,/api//admin/就直接放行。这样可以显著减少不必要的转换开销,也避免内部管理页面内容被意外暴露给抓取方。

Node.js 生态里也有对应的思路。Express 中间件里可以在res.send之前拦截,用html-to-textturndown把 HTML 转成 Markdown。核心套路一致:请求进来时看 Accept,响应出去时做转换。

// markdownNegotiation.js const turndown = require('turndown'); function markdownNegotiation(req, res, next) { const accept = req.headers.accept || ''; if (!accept.includes('text/markdown')) { return next(); } const originalSend = res.send; res.send = function (body) { const contentType = res.get('Content-Type') || ''; if (!contentType.includes('text/html')) { return originalSend.call(this, body); } const converter = new turndown(); // 移除导航和页面噪音 converter.remove(['nav', 'footer', 'aside', 'script', 'style']); try { const markdown = converter.turndown(body); res.set('Content-Type', 'text/markdown; charset=utf-8'); res.set('X-Content-Negotiated', 'html-to-markdown'); return originalSend.call(this, markdown); } catch (err) { return originalSend.call(this, body); } }; next(); } module.exports = markdownNegotiation;

这类中间件的存在让内容协商不只是一个“新项目自嗨”的玩具,它完全可以给历史项目续命,让旧站点不重写也能接入 LLM 应用生态。

6. 客户端:LLM 工具如何请求 Markdown

服务端能力已经就绪后,客户端的写法就简单多了。你只需要在发起 HTTP 请求时,把 Accept 头设置为text/markdown即可。

用 Pythonhttpx库做一个完整示例。这也是目前很多 LLM 应用框架内部使用的 HTTP 客户端:

import httpx def fetch_as_markdown(url: str) -> str: """ 以 Markdown 为偏好格式获取 URL 内容。 """ headers = { "Accept": "text/markdown, text/plain;q=0.9, text/html;q=0.2, */*;q=0.1", } with httpx.Client(timeout=30, follow_redirects=True) as client: resp = client.get(url, headers=headers) if resp.status_code == 406: raise ValueError(f"服务端不支持 Markdown 返回: {url}") resp.raise_for_status() content_type = resp.headers.get("content-type", "") # 如果服务端最终没有返回 markdown,但我们设置了 q=0.2 的 html 兜底, # 说明站点不支持协商,可以根据业务需求决定是否降级读取 HTML。 if "text/markdown" in content_type: return resp.text # 降级:如果拿到了 HTML,且调用方允许,可在此做一次本地转换 if "text/html" in content_type: import markdownify return markdownify.markdownify(resp.text, strip=["script", "style", "nav"]) # 其他类型(如纯文本)直接返回 return resp.text

注意这里 Accept 头设计了一个合理降级序列:

  • text/markdown是首要选择;
  • text/plain;q=0.9表示没有 Markdown 时纯文本也可以;
  • text/html;q=0.2表示最后实在不行,HTML 也可以看,但优先级很低;
  • */*;q=0.1是兜底,避免某些代理服务器或防火墙因为 Accept 里没有通配符而过滤请求。

这里有一个实际体验层面的小技巧:有些站点虽然声称支持 Markdown,但它们返回的 Content-Type 可能是text/plain,而不是标准的text/markdown。这时候需要靠内容嗅探来识别,比如判断响应开头是否包含#开头或明显是 Markdown 语法,然后再决定是否把它当作 Markdown 处理。不过内容嗅探容易误判,最稳妥的做法还是严格按照 Content-Type 判断,如果服务端不规范,就当作降级场景处理。

在 LangChain 或 LlamaIndex 这类框架里,如果你想加载一个文档站的内容,可以直接用fetch_as_markdown函数封装一个自定义 Loader。这样 RAG 管道的入口不再是“抓 HTML 再清洗”,而是“请求 Markdown,拿到就切块向量化”。切块时甚至可以直接按 Markdown 的二级标题、三级标题来做结构感知切块,比单纯按字符切效果会好不少。

还有一个细节容易被忽略:请求头里最好设置一个合理的 User-Agent,并附上联系方式或项目说明。很多站点会拦截看起来像爬虫的请求。内容协商解决的是“格式偏好”问题,不解决“身份信任”问题。一个有礼貌的客户端应该同时声明 User-Agent,遵守站点的 robots 规则,只在合法授权范围内抓取内容。

在命令行里验证一下:

curl -s -i \ -H "Accept: text/markdown" \ -H "User-Agent: MyLLMAgent/0.1 (contact: dev@example.com)" \ https://docs.example.com/page/rag-guide | head -n 20

如果服务端支持协商,你会看到响应头里有content-type: text/markdown; charset=utf-8,正文以 Markdown 语法开始。如果不支持,你会拿到 HTML,这时就需要在本地做一次转换了。

7. RAG 管道中的 Markdown 处理实践

Markdown 内容拿到手之后,真正决定 RAG 效果的其实是“你怎么把 Markdown 变成检索友好的分块”。这里有一个很容易犯的错误:直接把 Markdown 当纯文本,按照固定长度硬切。这样做虽然能跑,但会把列表项、代码块、表格这些有语义关联的内容拦腰截断,检索时很容易丢上下文。

举一个例子,一篇文档里有这样的 Markdown:

## 安装方式 ### 使用 pip ```bash pip install rag-toolkit

使用 Docker

docker run -p 8000:8000 rag-toolkit:latest
如果按 500 字符硬切,第二刀很可能切在 `pip install` 和它的说明文字之间,甚至切在代码块的中间。后面用户问“怎么用 pip 安装”,检索出来的片段可能只有半截,因为“使用 pip”这个标题已经被切到上一个分块里去了。 更好的策略是感知 Markdown 结构后再切块。具体做法有两种: 第一种,标题感知切块。先识别出 Markdown 里的一级标题、二级标题、三级标题,然后以标题为边界,把一个标题下的内容整体作为一个语义块。如果内容太长,再在该标题内的次级标题或段落边界处继续拆。市面上很多 LangChain 的 MarkdownTextSplitter 本质上就是做这件事。 第二种,先用结构解析器把 Markdown 转成带元数据的对象。你可以把标题层级、列表层级、代码块语言、链接地址都提取出来,作为切块时的辅助信息。比如你可以在切块元数据里记录 `source`、`heading_path`,让检索时能根据标题路径快速定位文档位置。 下面是一个简化的实现思路: ```python # chunk_markdown.py import re from typing import List, Dict def split_markdown_by_headings(markdown_text: str) -> List[Dict[str, str]]: """ 按标题切分 Markdown,返回每个分块的标题路径和正文。 """ lines = markdown_text.splitlines() chunks = [] current_heading = "" current_heading_level = 0 current_lines = [] def flush(): if current_lines: chunks.append({ "heading": current_heading, "content": "\n".join(current_lines).strip(), }) for line in lines: heading_match = re.match(r"^(#{1,6})\s+(.*)", line) if heading_match: flush() level = len(heading_match.group(1)) title = heading_match.group(2).strip() # 简单拼接标题路径:> 号用来标识层级 current_heading = "#" * level + " " + title current_heading_level = level current_lines = [line] else: current_lines.append(line) flush() return chunks

这个函数只是一个起点,实际项目里还需要处理代码块内的#符号不被误判成标题。比如 Markdown 里一段 Python 注释# 注释不能当作标题切分。处理方法是先扫描代码块范围,跳过代码块内的行。

RAG 管道的完整流程可以概括为:

  1. 通过 Accept 请求头拿到干净的 Markdown。
  2. 对 Markdown 做结构解析,识别标题、代码块、表格、列表。
  3. 结构化切块,记录每个块所属的标题层级路径。
  4. 对每个块做向量化,向量内容可以包含标题路径信息。
  5. 检索时结合标题路径和语义相似度做排序。

这套流程比“HTML 清洗 + 固定长度切块”在检索精度上通常有明显提升,尤其适合文档站、Wiki、技术博客这类按标题组织内容的来源。

8. 服务端返回头的设计与缓存策略

在生产环境暴露一个支持内容协商的接口时,响应头不能随手一写,至少要处理好三个问题。

第一个问题:Content-Type 必须准确。返回 Markdown 时,应该设置Content-Type: text/markdown; charset=utf-8。有些开发者偷懒写成text/plain,客户端按理说也能读,但这会丢失格式语义,而且会让一些严格的客户端把它当普通文本处理。text/markdown是 IANA 已经注册的媒体类型,虽然注册文档列出的常见后缀是.md,但在 HTTP 响应里使用完全没问题。

第二个问题:设计 Vary 响应头。这是新手最容易忽略、但也是内容协商最核心的缓存控制点。如果在 Nginx 或 CDN 层开启了缓存,而不同客户端访问同一个 URL 得到的内容不一样,那么缓存就一定不能只按 URL 来区分。Vary: Accept告诉缓存系统:同一 URL 下,Accept 请求头不同的请求要分别缓存。少了这个头,CDN 可能把第一次浏览器访问得到的 HTML 缓存了,之后所有 LLM 请求都会命中 HTML 缓存,内容协商直接失效。

location /docs/ { proxy_pass http://backend; add_header Vary Accept; }

如果是 FastAPI 这类应用,可以在中间件或路由里手动设置 Vary 头:

from starlette.responses import Response response.headers["Vary"] = "Accept"

第三个问题:协商失败时应该怎么处理。有的服务端会直接返回 406,有的会返回默认格式。我的建议是始终在 Accept 头支持的范围内降级,不要主动拒绝。一个请求如果没有显式声明text/markdown,那它的 Accept 头大概率是text/html, ...,我们返回 HTML 就行。如果客户端只声明了text/markdown,服务端确实没有能力返回,那么返回 406 并且附上支持的媒体类型列表是合理的。但如果你做的只是一个文档站,更友好的做法是给客户端的返回里保留一份“支持类型列表”响应体,比如 JSON 里写{"supported_types": ["text/markdown", "text/html"]}

有些大型站点还会用自定义的响应头来标记协商是否发生,例如:

X-Content-Negotiation: markdown X-Source-Format: html X-Converted-By: markdownify/1.1.0

这些头一方面方便调试,另一方面也方便客户端判断内容是否经历过转换。自建 LLM 应用如果发现响应里有X-Converted-By,就可以知道拿到的 Markdown 是转换产物,它的保真度可能不如源生 Markdown,检索时权重可以适当调整。

9. 常见问题与排查思路

内容协商机制本身不复杂,但在真实网络环境里坑不少。下表汇总了我在实践中看到的高频问题,供排查时对照。

问题现象可能原因排查方式解决方案
设置了 Accept: text/markdown 后,服务端仍然返回 HTML客户端发出的请求经过了代理/网关,代理把 Accept 头修改了抓包或用 curl -v 查看实际发送的请求头检查代理服务器配置,确认没有重写 Accept
服务端返回 406 Not Acceptable服务端代码里对不支持的媒体类型直接抛了异常查看服务端日志,确认客户端 Accept 头的完整值服务端做降级处理,支持类型列表里增加兜底项
内容协商偶尔生效、偶尔不生效CDN 缓存没有设置 Vary: Accept连续请求两次并分别修改 Accept,看是否拿到相同缓存在 Nginx/CDN 层配置Vary: Accept
Markdown 内容出现乱码响应头里缺少 charset=utf-8,客户端按默认编码解析查看响应 Content-Type,确认编码声明所有文本响应统一加; charset=utf-8
Markdown 里混入了大量导航和广告文字转换中间件没有剔除 HTML 噪音区块用浏览器打开页面,查看 HTML 结构里导航所在标签在转换时 strip 掉 nav/footer/aside 等标签
中文标题变成一堆十六进制实体HTML 源页面中字符本身被编码为实体检测转换后文本转换前先做 HTML 实体解码
curl 测试没问题,但 Python 客户端拿到的类型不对Python 请求库默认设置了 Accept 头,覆盖了自定义值打印 request.headers 确认最终 Accept 值构造 headers 字典时用Accept覆盖默认值
本站点内容频繁被抓取没有设置访问频率控制查看 Nginx 访问日志,确认客户端 IP 分布加限流,或对明显是爬虫的 User-Agent 做频控

在这些问题里,最隐蔽的就是缓存。很多团队在开发环境测试内容协商一切正常,一上生产就发现 LLM 拿到的永远是 HTML。原因基本都出在 CDN 缓存或者 Nginx 代理缓存没有配置 Vary。排查方法很简单,在请求里加一个随机参数绕开缓存,再对比有没有这个参数时响应是否一致,就可以确认问题是否出在缓存层。

另一个容易被忽视的点是响应体的压缩。很多网关会在 Nginx 层开启 gzip 压缩。如果中间件在应用层读取了响应体,但 Nginx 又对响应做了压缩,可能出现内容经过了两次处理的问题。最稳妥的做法是在做内容协商转换的路径上关闭压缩,或者在中间件读取已经解压过的响应体。

最后,如果你发现内容协商经常被某些 HTTP 库搞乱,可以在客户端显式打印一下最终发出的请求头。Python 的 httpx 和 requests 库在“默认 Accept 头”这件事上行为不同,requests 默认发的是Accept: */*,httpx 默认发的可能是Accept: */*,但某些版本或配置下也会自动附带浏览器风格的 Accept。任何时候都不要假设“我代码里写了 Accept 它就一定原样发出去”,中间件、代理、网关都能改写它。

10. 内容协商的安全边界与适用场景

每次聊让 URL 直接返回 Markdown 的时候,一定会有人提出安全疑虑:你是不是把文档源文件直接暴露了?这个担心很合理。MIME 类型从text/html变成text/markdown之后,内容本质上是一样的,只是格式不同,所以如果这个 URL 本身是公开的,等于是把公开内容换了个格式输出,并没有额外泄露私有信息。

但如果你的站点有一些非公开的草稿、待审核文章、后台数据,绝对不能因为开启了内容协商就自动给所有 Accept: text/markdown 的请求放行。需要先检查这个 URL 对应的内容是否有权限控制,逻辑顺序应该是“先鉴权,再协商”。一个没登录的用户设置 Accept: text/markdown,他应该得到的是 401 或 403,而不是绕过权限的 Markdown 源文件。

第二类风险是:有些站点的 HTML 渲染时会做内容过滤,比如把某些邮箱地址做混淆、把部分数字改成图片,防止爬虫抓取。如果直接返回 Markdown 源文件,等于绕过了这些防护。但这其实是内容策略问题,不是格式问题——只要内容本身公开可访问,除了格式变化,没有额外风险。

从架构角度看,真正需要想清楚的不是“能不能协商”,而是“哪些页面对 LLM 开放”。我的建议是:文档站、博客、帮助中心这类本来就是要被索引和引用的页面,完全可以开放 Markdown 输出;个人后台、在线编辑界面、涉及隐私的交互页面,一律不协商,只返回正常页面或直接要求鉴权。

还有一类比较容易踩坑的情况是动态渲染页面。如果一个页面是前端用 JS 动态加载的,服务端响应里只有空壳 HTML,主体内容是通过 XHR 请求出来的。这种情况下,服务端做 Markdown 协商没有意义,因为 HTML 响应里根本没内容。遇到这类站点,客户端即使拿到了一个 HTML 空壳,也需要知道真正的内容在哪个 XHR 接口里。这类问题属于“前端渲染型站点无法内容协商”的典型限制,现实中经常遇到,解决方式只能靠后端改造,或使用无头浏览器渲染后提取,成本较高。

所以如果你在搭建一个“面向 LLM 消费”的内容平台,与其等别人来请求 HTML 再转 Markdown,不如从一开始就把服务设计成“源格式优先”的架构:页面用 Markdown 存储,HTML 是 Markdown 的渲染结果,Markdown 本身可以通过 Accept 协商返回。这样数据库只需维护一份源文件,浏览器和 LLM 都被当作内容的消费者,只是表达形式不同。这也是 Markdown 作为“内容中台”的价值所在。

这种架构特别适合以下场景:

  1. 团队内部知识库或 Wiki,需要被 AI 助手检索。
  2. 技术文档站点,本身由 Markdown 驱动,比如 VitePress、Docusaurus 一类。
  3. 面向 Agent 生态的公开 API 描述页面。
  4. 需要批量把网页内容纳入 RAG 管道的企业内容系统。

反过来,如果你的站点很小、只是个人博客,用户主要是真人,那么做内容协商的收益有限。但即便如此,把站点生成的 HTML 用 Markdown 格式额外暴露一份也是一个低成本的加分项,至少能让你自己的笔记工具或 AI 助手在需要总结博客时少浪费一些 Token。

11. 最佳实践与工程建议

聊完原理、代码、排错,最后梳理几条工程层面的最佳实践。

第一,创建一套标准的媒体类型常量,不要在多个文件里手写字符串。客户端和服务端最好维护同一份类型约定,后端定义text/markdown时旁边加注释,说明这是什么场景下使用。如果项目比较大,建议把这个变量放在协议层或网络层公共包中,避免拼写错误导致协商静默失败。

第二,明确降级顺序。服务端如果没有能力返回 Markdown,最好返回 HTML 而不是 406。如果担心客户端误解响应格式,可以额外加一个X-Content-Type-Options: nosniff,同时把 Content-Type 设置准确,这样客户端拿到后能立刻发现自己需要降级。

第三,转换质量要可观测。所有自动生成的 Markdown 都应该留一个标记,比如响应头X-Content-Source: converted,或者正文里加一个 HTML 注释性质的元数据块。RAG 管道最好能识别出这些标记,并在向量化时区分“源生 Markdown”和“HTML 转换来的 Markdown”。原因很简单,源生 Markdown 的标题结构与语义是可靠的,转换来的则不一定。

第四,开发时自测端到端流程。不要只跑 curl,因为 curl 与真实 LLM 客户端的行为往往有差异。建议用一段完整的 Python 脚本,模拟一个真实的 RAG 流程:请求 URL 拿 Markdown、切块、调用向量模型、执行一个检索任务。任何一步有问题,都会在实际业务里被放大。

第五,监控和日志。在生产环境里,设置一个独立的指标,统计有多少比例的请求带上了Accept: text/markdown,其中又有多少最终拿到了 Markdown。如果这个数字很低,说明很多请求被代理或 CDN 干扰了,需要在链路上一层一层排查。

第六,注意处理 Accept 头的正则匹配与大小写问题。MIME 类型理论上是大小写不敏感的,但很多代码里用in做子串判断时会把大小写写死。稳妥起见,判断前先转小写。同时,Text/Markdown这种写法虽然少见,但最好统一用小写解析。

第七,面向 LLM 的文档内容,Markdown 里最好避免使用 HTML 标签加 Markdown 混排。一些页面为了排版方便会在 Markdown 里夹带<span style="color:red">之类的内容,这类标签在向量化时会被模型视为噪声。能纯 Markdown 就纯 Markdown,不能做到时至少保证代码示例部分干净。

12. 总结:让内容协商成为 LLM 应用的默认选项

回到标题说的“Serving Markdown to LLMs with Accept headers”。这件事的技术门槛很低,甚至可以说只需要二三十行代码。它的价值不在于“新的算法”或“新的框架”,而在于帮我们重新梳理了一个之前被忽略的共识:HTTP 内容协商是 Web 的通用语言,以前它服务于浏览器和 API 客户端,现在它可以服务于 LLM 客户端。

未来一段时间,随着 RAG、Agent、AI 搜索这类应用的增多,“人类用浏览器,模型用 API 或 Markdown”会成为越来越普遍的分层需求。与其让每个抓取端都从 HTML 里费力提取正文,不如服务端主动提供面向 LLM 友好的内容表达。Accept header 是老工具,Markdown 是老格式,但两者的组合在 AI 时代打开了一个新的切入点。

如果你现在运营着一个文档站或内容型 Web 服务,可以考虑做三件低成本的改造:第一,把你的页面内容以合法授权的形式支持text/markdown返回;第二,在服务端妥善设置Vary: Accept,保证缓存后协商仍然有效;第三,写一个简单的客户端 demo,用几行代码验证 LLM 能从你的站点直接拿到干净内容。这三件事做完,你的服务就从一个“面向浏览器的网站”升级成了“同时面向人类与 AI 的数据源”。

如果你是一名 LLM 应用开发者,下一次需要在 RAG 管道里加载网页文档时,可以停下来想一想:是不是要找那个支持Accept: text/markdown的内容源?如果没有,你要的也许不仅是一个清洗函数,更应该是一个规范内容的来源。这种意识上的转变,比多写几行代码更重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询