ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Accept头协商Markdown:让大模型直接读取干净网页

用Accept头协商Markdown:让大模型直接读取干净网页 如果你正在做 RAG、Agent 工具或任何需要“让大模型读懂网页”的应用你一定遇到过这样的尴尬好不容易抓到一个 URL结果拿回来的是几百 KB 的 HTML里面三分之二都是导航栏、广告脚本和无关推荐。为了把正文喂给 LLM你不得不再写一套清洗规则、调用 html2text 之类的库做转换还要处理各种标签残留。这件事本身不难但非常烦而且每次抓不同网站都要重新调一遍。这篇文章想聊一个不太起眼、但理论上很优雅的解法利用 HTTP 的 Accept 请求头做内容协商让服务端在收到请求时直接根据“对方是谁”来返回不同的内容表示。浏览器访问时拿到正常渲染的 HTMLLLM 客户端访问时拿到干干净净的 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答案不一定是要再造一个?formatmd参数而是可以回到 HTTP 协议本身用 Accept 请求头表达你的偏好。2. Accept 请求头与 HTTP 内容协商机制Accept 请求头是 HTTP/1.1 内容协商机制的一部分定义在 RFC 7231 中。简单说客户端在发起请求时可以通过 Accept 告诉服务端我能接受哪些媒体类型以及我对这些类型的偏好程度。一个常见的请求头长这样Accept: text/html, application/xhtmlxml, application/xml;q0.9, */*;q0.8这里每个媒体类型后面可以带一个q值表示权重范围是 0 到 1默认是 1。浏览器通常发给服务器的就是这个样子意思是我最想要 HTML如果服务器给不了XML 也可以接受实在不行你给我什么我都认。服务端收到后会根据自己的能力和客户端偏好做匹配从客户端能接受的列表里挑一个最合适的媒体类型返回并且在响应的Content-Type里标明最终返回了什么。这套机制最常见的应用是“同一 URL不同响应格式”。过去 API 设计里经常用.json后缀、?formatjson参数来实现但更符合 HTTP 语义的做法其实是让客户端设置 Accept 头。比如 GitHub 的 API 就允许你在 Accept 里传application/vnd.githubjson来控制响应结构。对于 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 里加一个*/*;q0.1之类的兜底项。后面我们会写一个带降级策略的示例避免把客户端直接挡在门外。3. 服务端在 FastAPI 中根据 Accept 返回不同格式理解了机制以后我们先写一个最小的服务端示例。这里选用 FastAPI是因为它在处理请求头、依赖注入和响应类型上非常简洁很适合用来演示内容协商的逻辑。先明确我们要实现的行为浏览器访问/docs/rag-guide返回渲染好的 HTML。LLM 客户端设置Accept: text/markdown访问同一个 URL返回 Markdown。二者使用同一个 URL不需要?formatmd参数。为了演示方便我们先不考虑数据库而是用两个变量模拟同一个文档的不同表示形式。实际项目中更常见的做法是只存一份 Markdown 源文件HTML 由模板或前端渲染生成。# main.py from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, PlainTextResponse app FastAPI(titleContent Negotiation Demo) MARKDOWN_SOURCE # RAG 入门指南 RAGRetrieval Augmented Generation是一种把检索结果注入到大模型上下文中的技术架构。 ## 核心组件 - 文档解析 - 向量化 - 检索排序 - 生成回答 ## 为什么需要 RAG 因为大模型的知识有截止日期而业务数据通常需要实时更新。 HTML_TEMPLATE !DOCTYPE html html head meta charsetutf-8 titleRAG 入门指南/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 h1RAG 入门指南/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( contentMARKDOWN_SOURCE, media_typetext/markdown; charsetutf-8, ) return HTMLResponse(contentHTML_TEMPLATE)这个代码虽然能跑但有两个明显的问题第一client_prefers_markdown函数只做了字符串包含判断没有解析 q 值。如果客户端的 Accept 头是text/html;q0.8, text/markdown;q0.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(keylambda item: item[1], reverseTrue) 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(titleContent 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( contentMARKDOWN_SOURCE, media_typetext/markdown; charsetutf-8, ) return HTMLResponse(contentHTML_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 里的正文区域提取出来再调用markdownify或html2text转成 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_styleATX, strip[script, style, nav, footer, aside], ) except Exception: return Response( contenthtml_body, status_coderesponse.status_code, headersdict(response.headers), media_typetext/html, ) return Response( contentmd_text, status_coderesponse.status_code, headers{ content-type: text/markdown; charsetutf-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这个中间件做了几件关键的事它先检查请求头中是否包含text/markdown如果不包含就原样放行不影响普通浏览器用户。它只在后端确实返回 HTML 时才做转换。如果后端返回的是 JSON、图片或 PDF不会去破坏原始内容。转换时通过strip参数主动去掉了nav、footer、aside、script、style这些对 LLM 没有意义、反而会污染上下文的区块。转换失败时降级返回原始 HTML而不是让用户看到一个 500 页面保证可用性。在响应头里加了调试标记方便你确认内容协商是否生效。在 FastAPI 中使用这个中间件# main.py from fastapi import FastAPI from middleware_markdown import MarkdownNegotiationMiddleware app FastAPI(titleLegacy 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 precodeprint(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-text或turndown把 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; charsetutf-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;q0.9, text/html;q0.2, */*;q0.1, } with httpx.Client(timeout30, follow_redirectsTrue) as client: resp client.get(url, headersheaders) if resp.status_code 406: raise ValueError(f服务端不支持 Markdown 返回: {url}) resp.raise_for_status() content_type resp.headers.get(content-type, ) # 如果服务端最终没有返回 markdown但我们设置了 q0.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;q0.9表示没有 Markdown 时纯文本也可以text/html;q0.2表示最后实在不行HTML 也可以看但优先级很低*/*;q0.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: devexample.com) \ https://docs.example.com/page/rag-guide | head -n 20如果服务端支持协商你会看到响应头里有content-type: text/markdown; charsetutf-8正文以 Markdown 语法开始。如果不支持你会拿到 HTML这时就需要在本地做一次转换了。7. RAG 管道中的 Markdown 处理实践Markdown 内容拿到手之后真正决定 RAG 效果的其实是“你怎么把 Markdown 变成检索友好的分块”。这里有一个很容易犯的错误直接把 Markdown 当纯文本按照固定长度硬切。这样做虽然能跑但会把列表项、代码块、表格这些有语义关联的内容拦腰截断检索时很容易丢上下文。举一个例子一篇文档里有这样的 Markdown## 安装方式 ### 使用 pip bash pip install rag-toolkit使用 Dockerdocker 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 管道的完整流程可以概括为通过 Accept 请求头拿到干净的 Markdown。对 Markdown 做结构解析识别标题、代码块、表格、列表。结构化切块记录每个块所属的标题层级路径。对每个块做向量化向量内容可以包含标题路径信息。检索时结合标题路径和语义相似度做排序。这套流程比“HTML 清洗 固定长度切块”在检索精度上通常有明显提升尤其适合文档站、Wiki、技术博客这类按标题组织内容的来源。8. 服务端返回头的设计与缓存策略在生产环境暴露一个支持内容协商的接口时响应头不能随手一写至少要处理好三个问题。第一个问题Content-Type 必须准确。返回 Markdown 时应该设置Content-Type: text/markdown; charsetutf-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: AcceptMarkdown 内容出现乱码响应头里缺少 charsetutf-8客户端按默认编码解析查看响应 Content-Type确认编码声明所有文本响应统一加; charsetutf-8Markdown 里混入了大量导航和广告文字转换中间件没有剔除 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 作为“内容中台”的价值所在。这种架构特别适合以下场景团队内部知识库或 Wiki需要被 AI 助手检索。技术文档站点本身由 Markdown 驱动比如 VitePress、Docusaurus 一类。面向 Agent 生态的公开 API 描述页面。需要批量把网页内容纳入 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 stylecolor: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的内容源如果没有你要的也许不仅是一个清洗函数更应该是一个规范内容的来源。这种意识上的转变比多写几行代码更重要。
返回列表