HTTP 内容协商实战:用 Accept 标头给 AI 代理返回 Markdown

HTTP 内容协商实战:用 Accept 标头给 AI 代理返回 Markdown 最近在把一批 API 文档接入本地模型做智能问答时我遇到一个很现实的痛点直接抓回来的页面是 HTML甚至还有一堆 JS 动态渲染后的标签噪音交给大模型解析既浪费 token回答又经常不稳定。后来我尝试让服务端在内容协商阶段根据Accept标头直接返回text/markdownAI 代理拿到的上下文变得干净很多调用链路的可调试性也明显提升。这篇文章就把这套思路完整展开从Accept标头的原理开始到用 FastAPI 实现一个支持 Markdown 内容协商的服务端再到用 httpx 写一个 AI 代理客户端最后把拉回来的 Markdown 内容作为上下文接入本地大模型。文章会包含完整可运行的代码、curl 验证命令和常见问题排查清单适合正在做 AI 代理、知识库问答、API 文档增强的开发者参考。1. 背景AI 代理为什么需要 Markdown1.1 AI 代理获取内容的常见方式AI 代理AI Agent通常不是一个单一的模型调用而是一个“感知-规划-行动”的循环。代理在执行任务时需要先从外部系统获取信息比如读取业务系统的 API 文档获取商品详情页、新闻页面拉取内部知识库文章调用监控平台返回的数据指标查看 GitHub 项目 README 或在线文档。获取内容最常见的两种方式是直接调用数据接口拿到 JSON或者用爬虫/抓取工具拿到 HTML 页面。JSON 数据适合结构化查询但可读性差模型很难直接理解它的业务语义HTML 页面结构完整但包含大量标签、脚本、样式对模型来说属于高噪音文本。1.2 HTML 与大模型解析的矛盾HTML 本身并不是给 AI 模型阅读的格式。它包含大量对渲染有用、但对语义理解无帮助的内容html langzh-CN body div classheader nav a href/home首页/a a href/docs文档/a /nav /div article h1获取商品列表/h1 p请求方法codeGET/code/p precode classlanguage-bashcurl -X GET /api/products/code/pre /article scriptconsole.log(tracking...)/script /body /html如果直接把这段 HTML 塞给大模型模型要花大量 token 去忽略class、nav、script等元素还可能被页面上的广告、推荐位、统计脚本干扰。更麻烦的是不同系统的 HTML 结构差异很大代理程序很难用一套解析规则稳定提取正文。在真实项目中我曾见过把整个 HTML 原文写入 prompt 后模型把导航栏里的“登录/注册”误解为接口说明的情况。这个问题的根源不是模型能力不足而是内容格式对模型不友好。1.3 Markdown 是 AI 友好的结构化文本Markdown 用极简的符号表达文档结构天然适合大模型阅读标题层级清晰模型能理解章节关系代码块使用围栏语法不会被当成普通文本表格语法规整用于接口参数说明非常合适没有嵌套标签token 开销远小于 HTML与大多数 RAG 切分工具的兼容性很好。例如同样一段内容Markdown 表示如下# 获取商品列表 请求方法GET /api/products | 参数 | 类型 | 说明 | | --- | --- | --- | | page | int | 页码 | | size | int | 每页数量 |这段文本几乎没有冗余模型可以直接理解接口的语义。对于一个 AI 代理来说拿到这样的内容后无论是做摘要、回答问题还是生成调用参数效果都会明显好于 HTML 原文。这也解释了为什么越来越多的文档服务开始直接把 Markdown 作为响应格式暴露给 AI 代理调用。1.4 什么是 Accept 标头与内容协商HTTP 协议里的Accept请求头用于告知服务端客户端希望接收哪种媒体类型Media Type。服务端根据这个标头返回最合适的内容格式这个过程称为内容协商Content Negotiation。GET /docs/products HTTP/1.1 Host: api.example.com Accept: text/markdown上面的请求表示如果服务端支持我希望拿到 Markdown 格式而不是 HTML 或 JSON。如果把文档接口设计成“人用浏览器访问时返回 HTMLAI 代理携带Accept: text/markdown访问时返回 Markdown”那么同一个 URL 就能同时服务人和机器不需要额外维护两套接口。这就是本文要解决的核心问题之一用Accept标头协商出 AI 代理最想要的 Markdown 内容。2. 核心概念Accept 标头与内容协商2.1 Accept 标头的基本语法Accept标头的值是一个或多个媒体类型用逗号分隔每个类型后面可以附带权重参数q。q取值范围是 0 到 1默认是 1表示优先级。Accept: text/markdown, text/html;q0.9, */*;q0.8这段标头的含义是优先想要text/markdown如果服务端不支持退而求其次接受text/html再不行任意格式都行。q0则表示“明确不接受”。例如Accept: text/html;q0, text/markdown含义是不接受 HTML只接受 Markdown。不过在生产环境中直接写“只要 Markdown”挺容易导致 406 错误因为很多网关或老服务并不认识text/markdown。2.2 常见的媒体类型在内容协商中经常出现的媒体类型包括媒体类型用途适合的场景text/htmlHTML 页面浏览器直接访问application/jsonJSON 数据程序接口调用text/markdownMarkdown 文本AI 代理、文档系统、知识库text/plain纯文本简单日志、提示语application/xmlXML 数据传统系统对接text/event-streamSSE 流式响应流式输出、增量更新text/markdown在 RFC 7763 中有定义不过一些老系统仍然使用text/x-markdown这种非标准写法。服务端做协商时最好两者都兼容。2.3 服务端如何选择响应格式服务端拿到Accept标头后通常做三步处理解析标头得到媒体类型列表和对应 q 值根据 q 值从高到低排序依次判断服务端是否支持该类型返回第一个能支持的类型。如果所有类型都不支持服务端可以返回 406 Not Acceptable也可以按产品策略回退到默认格式。在 AI 代理场景中我更推荐回退到 Markdown 或 JSON而不是直接报 406因为代理程序通常不具备友好的错误处理能力。2.4 为什么用 Accept 而不是 URL 参数有些项目习惯用?formatmarkdown来指定返回格式这在简单的内部系统中可行但存在几个问题URL 会被缓存系统视为不同资源导致缓存碎片化对调用方来说URL 语义不够清晰很难表达“优先 MarkdownHTML 兜底”这种灵活策略如果多个代理服务共享同一个 URL调用方必须知道每个 URL 的格式约定。Accept标头是 HTTP 标准的能力配合Vary: Accept响应头可以让 CDN 或浏览器正确区分不同格式的缓存副本。从工程角度看这是更规范的方案。3. 环境准备与项目结构3.1 运行环境本文示例使用 Python 实现技术栈如下Python 3.10 或更高版本FastAPI 作为 Web 服务端uvicorn 作为 ASGI 服务器httpx 作为客户端请求库markdown 库用于把 Markdown 渲染成 HTML可选本地部署的 Ollama用于演示 AI 代理调用大模型。版本不需要完全固定建议使用 pip 安装最新稳定版即可。如果你的项目是 Java 或 Node.js 技术栈本文的核心协商逻辑同样适用只是代码写法不同。3.2 安装依赖创建项目目录后先准备一个requirements.txtfastapi uvicorn[standard] httpx markdown在项目根目录执行安装pip install -r requirements.txt如果网络环境下载较慢可以使用国内 pip 镜像这里不再展开。安装完成后可以用下面的命令检查 FastAPI 是否可用python -c import fastapi; print(fastapi.__version__)3.3 项目目录设计本文准备一个简单但完整的最小工程目录结构如下accept-markdown-demo/ ├── requirements.txt ├── server.py # FastAPI 服务端 ├── markdown_utils.py # Markdown 内容与渲染工具 └── ai_agent_client.py # 模拟 AI 代理客户端服务端负责根据Accept标头返回 Markdown 或 HTML客户端负责携带Accept标头拉取内容并把内容发送给本地大模型。为了演示方便文档内容先写死在markdown_utils.py里实际项目中可以换成数据库或文件存储。4. 服务端实现按 Accept 返回 Markdown4.1 Markdown 内容与渲染工具先准备一个工具模块用来管理文档内容和 Markdown 渲染。文件路径accept-markdown-demo/markdown_utils.py# 文件路径accept-markdown-demo/markdown_utils.py import markdown as md DOCS { 1: # 商品服务 API 文档 ## 1. 获取商品列表 请求方法GET /api/products | 参数 | 类型 | 说明 | | --- | --- | --- | | page | int | 页码 | | size | int | 每页数量 | 响应示例 json { items: [], total: 0 }, 2: # 用户服务 API 文档1. 用户登录请求方法POST /api/auth/login请求体{ username: string, password: string }, }def get_doc(doc_id: str) - str: 根据文档 ID 返回 Markdown 原始文本 return DOCS.get(doc_id, # 文档不存在\n\n请检查文档 ID 是否正确。\n)def render_html(markdown_text: str) - str: 把 Markdown 渲染为 HTML return md.markdown( markdown_text, extensions[tables, fenced_code], )这里用 tables 扩展支持 Markdown 表格用 fenced_code 扩展支持围栏代码块。get_doc 方法在真实项目中应该替换为数据库查询或文件读取现在的写法只是为了演示核心逻辑。 ### 4.2 解析 Accept 标头 服务端的关键是解析 Accept 标头。FastAPI 可以直接从 Request 对象中获取原始标头然后由我们自己解析。文件路径accept-markdown-demo/server.py python # 文件路径accept-markdown-demo/server.py from typing import List, Tuple from fastapi import FastAPI, Request, Response from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from markdown_utils import get_doc, render_html app FastAPI(titleAccept Markdown Demo) def parse_accept(accept_header: str) - List[Tuple[str, float]]: 解析 Accept 请求头。 返回 [(媒体类型, q值), ...] 的列表q 值默认是 1.0。 if not accept_header or not accept_header.strip(): return [(*/*, 1.0)] result [] for item in accept_header.split(,): parts [p.strip() for p in item.split(;)] media_type parts[0].lower() q 1.0 for param in parts[1:]: if param.lower().startswith(q): try: q float(param.split(, 1)[1]) except ValueError: q 0.0 result.append((media_type, q)) return result这个解析函数没有依赖第三方库逻辑也比较直观。它会把Accept: text/markdown, text/html;q0.9解析成[ (text/markdown, 1.0), (text/html, 0.9), ]4.3 内容协商函数解析完成后服务端需要根据 q 值优先级选择最终返回的媒体类型。这里需要注意q0表示不接受该类型应该跳过。def negotiate(accept_header: str) - str: 根据 Accept 标头选择响应媒体类型 candidates parse_accept(accept_header) # 按 q 值降序排列 candidates.sort(keylambda x: x[1], reverseTrue) for media_type, q in candidates: if q 0: continue if media_type in (text/markdown, text/x-markdown): return text/markdown; charsetutf-8 if media_type text/html: return text/html; charsetutf-8 if media_type in (application/json, application/*): return application/json; charsetutf-8 # 默认回退到 Markdown return text/markdown; charsetutf-8一个值得留意的点如果客户端传的是Accept: */*解析结果里只有( */*, 1.0)我们的协商函数无法直接把它匹配到具体的 Markdown 分支最后会走默认回退逻辑返回 Markdown。这符合预期因为对 AI 代理来说默认给 Markdown 比默认给 HTML 更合适。4.4 文档接口实现接下来实现文档接口。核心思路是根据Accept标头协商出的类型返回不同的响应格式。app.get(/docs/{doc_id}) async def get_doc_endpoint(doc_id: str, request: Request): accept_header request.headers.get(accept, */*) content_type negotiate(accept_header) markdown_text get_doc(doc_id) headers { Vary: Accept, Cache-Control: public, max-age300, } if content_type.startswith(text/html): return HTMLResponse( contentrender_html(markdown_text), headersheaders, ) if content_type.startswith(application/json): return JSONResponse( {doc_id: doc_id, content: markdown_text}, headersheaders, ) return Response( contentmarkdown_text, media_typecontent_type, headersheaders, )这里有几个关键点使用request.headers.get(accept, */*)获取原始标头返回 Markdown 时不能直接返回str否则 FastAPI 可能尝试按 JSON 处理要使用Response并指定media_type加上Vary: Accept头CDN 和浏览器会按不同Accept值缓存不同版本避免缓存串格式文档内容本身变化少缓存 5 分钟可以减少重复渲染压力。4.5 运行与 curl 验证启动服务cd accept-markdown-demo uvicorn server:app --host 0.0.0.0 --port 8000打开另一个终端分别用三种不同的Accept标头访问同一个接口curl -s -H Accept: text/markdown http://localhost:8000/docs/1返回内容应该是 Markdown 原文响应头中Content-Type是text/markdown; charsetutf-8。curl -s -H Accept: text/html http://localhost:8000/docs/1返回的是渲染后的 HTML 页面。curl -s -H Accept: application/json http://localhost:8000/docs/1返回的是 JSON 结构里面包含doc_id和content字段。你也可以不带Accept头访问curl -s http://localhost:8000/docs/1由于默认回退策略服务端依然会返回 Markdown。这在实际调试中非常方便即使客户端没有显式设置Accept文档也能保持 AI 友好。5. 客户端实现AI 代理请求与本地模型联调5.1 用 httpx 请求 Markdown服务端准备就绪后下面编写客户端。文件路径accept-markdown-demo/ai_agent_client.py# 文件路径accept-markdown-demo/ai_agent_client.py import httpx BASE_URL http://localhost:8000 def fetch_markdown(doc_id: str 1) - str: 模拟 AI 代理携带 Accept 标头获取 Markdown 内容 headers { Accept: text/markdown;q1.0, text/html;q0.9, */*;q0.8, User-Agent: ai-agent/1.0, } with httpx.Client(base_urlBASE_URL, timeout30) as client: resp client.get(f/docs/{doc_id}, headersheaders) resp.raise_for_status() print(HTTP 状态码:, resp.status_code) print(Content-Type:, resp.headers.get(content-type)) return resp.text注意Accept的优先级写法text/markdown权重最高text/html其次*/*兜底。这样即使遇到不认识text/markdown的老服务也能退回到 HTML。5.2 把 Markdown 作为上下文接入本地模型AI 代理拿到 Markdown 内容后把它作为上下文发给大模型。这里以 Ollama 本地模型为例通过 HTTP 接口直接调用。如果你的环境使用 OpenAI 兼容接口只需要替换请求地址和 payload 结构。def ask_local_model(markdown_text: str, question: str, model: str qwen2.5) - str: 把 Markdown 内容发送给本地模型让模型基于文档内容回答 url http://localhost:11434/api/chat payload { model: model, messages: [ { role: system, content: 你是 API 文档助手。请严格根据文档内容回答用户问题不要编造接口。, }, { role: user, content: f以下是一份 Markdown 格式的文档\n\n{markdown_text}\n\n问题{question}, }, ], stream: False, } with httpx.Client(timeout120) as client: resp client.post(url, jsonpayload) resp.raise_for_status() data resp.json() return data[message][content]这里把 Markdown 文档直接嵌入到用户消息中。模型看到的上下文是清晰的标题、表格、代码块而不是混乱的 HTML 标签。5.3 完整运行效果在ai_agent_client.py末尾追加主入口if __name__ __main__: doc fetch_markdown(1) print( * 40) print(doc) print( * 40) question 获取商品列表的 HTTP 方法是什么 answer ask_local_model(doc, question) print(问题:, question) print(AI 回答:, answer)执行脚本python ai_agent_client.py预期输出包括三个部分客户端请求时打印的 HTTP 状态码和Content-Type从服务端拿到的 Markdown 原文本地模型基于文档内容给出的回答。如果 Ollama 服务没有启动脚本会在ask_local_model阶段抛出连接异常。可以先启动 Ollama 并拉取对应模型再运行脚本。不同模型的输出结果会有差异但问题答案应该指向文档中的GET方法。6. 进阶场景SSE 流式输出 Markdown6.1 为什么需要 SSE普通的 HTTP 请求是一次性返回完整响应。当文档很长或者服务端需要“边生成边返回”时一次性返回会让客户端等待很久。AI 代理场景中经常出现两种需求服务端在生成文本时希望逐步把结果推给客户端文档过大时代理希望先拿到前半部分提前开始处理。SSEServer-Sent Events是一种基于 HTTP 的流式响应协议服务端把多条消息按text/event-stream格式推送给客户端。相比 WebSocketSSE 实现简单且天然基于 HTTP非常适合给 AI 代理输出 Markdown 内容。6.2 服务端流式接口在server.py中添加一个流式接口app.get(/stream/{doc_id}) async def stream_doc_endpoint(doc_id: str, request: Request): markdown_text get_doc(doc_id) async def chunk_generator(text: str, max_chars: int 80): current for line in text.split(\n): if current and len(current) len(line) 1 max_chars: yield fdata: {current}\n\n current line else: current f{current}\n{line} if current else line if current: yield fdata: {current}\n\n return StreamingResponse( chunk_generator(markdown_text), media_typetext/event-stream; charsetutf-8, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, }, )这段代码按行拼接 Markdown 文本当超过max_chars时输出一条 SSE 消息。X-Accel-Buffering: no是一个在 Nginx 场景下常用的响应头用于关闭代理缓冲保证内容实时下推。如果你的服务没有经过 Nginx保留它也不会产生影响。6.3 客户端消费 SSE 流客户端可以使用 httpx 的stream方法逐行读取def stream_markdown(doc_id: str 1): headers { Accept: text/event-stream, text/markdown;q0.9, } with httpx.stream( GET, fhttp://localhost:8000/stream/{doc_id}, headersheaders, timeout30, ) as resp: for line in resp.iter_lines(): if line.startswith(data:): print(line[5:].strip())运行后文档内容会分块打印。对于 AI 代理来说你可以把这个流式接口作为一个“文档预处理器”边接收边做切分或摘要不需要等全部内容到齐。需要注意SSE 只是传输层协议它并不限制消息内容必须是 Markdown。因此你完全可以把它扩展为“AI 代理请求生成一篇 Markdown 报告服务端通过 SSE 把 Markdown 逐段推给客户端”。这是目前很多 AI 应用中常见的流式渲染方案。7. 常见问题与排查思路7.1 高频问题对照表问题现象常见原因解决思路客户端收到 HTML 而不是 Markdown请求没有携带Accept或服务端没有按Accept协商打印请求头和服务端协商结果确认服务端是否走到 Markdown 分支返回 406 Not Acceptable服务端严格校验所有媒体类型无法匹配text/markdown在服务端增加默认回退策略比如回退到 Markdown 或 JSON响应Content-Type是application/jsonFastAPI 返回普通str时默认走 JSON 逻辑使用Response(content..., media_typetext/markdown)浏览器访问时显示纯文本服务端返回了 Markdown浏览器没有对应渲染插件浏览器请求默认带text/html服务端应优先返回 HTMLSSE 中文乱码响应头未指定charsetutf-8media_type写成text/event-stream; charsetutf-8流式内容不实时显示前置网关或 Nginx 做了缓冲添加X-Accel-Buffering: no并检查网关配置模型回答引用了文档之外的内容上下文拼接不完整或 Markdown 被截断打印实际收到的 Markdown 内容检查长度与拼接位置本地模型响应很慢模型本身较大或未使用 GPU先使用小模型验证链路再切到生产模型7.2 分步排查流程如果你接入 AI 代理后拿到的内容格式不对最有效的排查方式是依次确认下面三个环节。第一步确认客户端发出的请求头。用 curl 模拟请求打印完整请求头curl -v -H Accept: text/markdown http://localhost:8000/docs/1这一步可以确认Accept标头是否真正发送以及是否被中间代理改写。第二步确认服务端返回的响应头。重点关注Content-Type是不是text/markdown; charsetutf-8。如果返回的是text/html说明协商逻辑没有命中 Markdown 分支。第三步确认大模型实际收到的文本。在把 Markdown 内容发送给模型之前先把它打印出来或者写入日志。很多问题其实是出在“以为发送了 Markdown实际发送的是 Markdown 被渲染后的 HTML”或者“上下文被截断”。只要这三条链路对齐AI 代理拿到的内容质量基本就稳定了。8. 最佳实践与工程建议8.1 内容协商策略服务端协商时不要把逻辑写得太死。建议按下面顺序处理优先支持text/markdown和text/x-markdown其次支持text/html方便浏览器直接访问再次支持application/json方便普通程序调试最后必须有默认回退值建议回退到 Markdown 或 JSON。同时服务端返回时要加上Vary: Accept响应头。这个头的作用是告诉缓存系统同一个 URL 的响应内容会随Accept标头变化。如果少了它CDN 可能把第一次请求返回的 HTML 缓存下来后续 AI 代理带着Accept: text/markdown请求时仍然拿到 HTML。8.2 安全与权限控制给 AI 代理提供 Markdown 内容不等于把接口完全公开。对于内部文档、业务数据、知识库内容仍要做身份认证和权限校验。安全方面需要关注几个点不要让接口通过任意doc_id遍历出全部内部文档需要做归属校验如果 Markdown 内容包含用户生成内容渲染 HTML 时要防止 XSS。markdown.markdown默认不会过滤危险 HTML生产环境建议用bleach对渲染结果做白名单过滤日志中不要打印完整文档内容也不要打印Authorization等敏感请求头避免日志泄露业务数据对 AI 代理的调用频率做好限流防止文档接口被批量抓取。8.3 缓存与性能优化文档型接口的特点是读多写少。可以引入两层缓存内容缓存把 Markdown 原文缓存到内存或 Redis减少数据库查询渲染缓存把 Markdown 渲染成 HTML 的结果缓存起来避免每次请求都执行渲染。如果文档更新