ARTICLE DETAIL

资讯详情

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

AI Agent中Accept头内容协商Markdown的实践指南

AI Agent中Accept头内容协商Markdown的实践指南 如果你正在写 AI Agent 的调用端或者维护一个把大模型响应转发给前端的代理服务那么 Accept 标头是一个值得先弄清楚的东西。它的作用很简单告诉服务器你希望拿到什么媒体类型。当对端支持text/markdown时你可以在请求里加上Accept: text/markdown让 AI 代理服务直接返回 Markdown 内容而不是把 Markdown 塞在 JSON 字段里。我先说结论这个做法最大的价值不是“少解析一层 JSON”而是让整条链路在内容格式上有明确的协议约束。客户端知道自己会拿到 Markdown代理层知道自己该按 Markdown 处理前端也能直接交给 Markdown 渲染器。适合读者包括对接大模型 API 的开发者、做 AI Agent 网关的人、写前端聊天框或文档生成工具的人。下面按实际落地顺序拆一遍先讲原理再给请求写法再说代理层改造最后聊渲染和排错。1. 先搞清楚 Accept 标头在 AI 代理交互里到底改变什么1.1 Accept 是内容协商不是强制开关HTTP 里有一个内容协商机制。客户端在请求头里写Accept告诉服务器自己能接受哪些媒体类型服务器根据自身能力从可用的表示形式里挑一个返回。这里最容易踩的坑是把Accept当成“强制要求”。实际上服务器有三种处理方式支持text/markdown就把响应体变成 Markdown 原文不支持但愿意忽略这个标头继续返回默认的 JSON不支持且选择严格协商返回406 Not Acceptable。所以写代码时不能默认“加了 Accept 就一定好用”。你仍然要判断状态码和Content-Type再决定怎么解析。Accept和Content-Type千万别记混。Accept是请求头描述客户端希望收到什么Content-Type既可以出现在请求头表示请求体格式也可以出现在响应头表示实际返回格式。客户端请求大模型接口时通常在Content-Type: application/json里提交prompt和参数同时用Accept声明期望返回格式。这两者并不冲突属于两个维度。还有一个细节是质量因子q。例如Accept: text/markdown; q1.0, application/json; q0.8意思是优先要 Markdown退而求其次也可以接受 JSON。服务器如果能返回 Markdown 就会返回不能返回时才可能返回 JSON。这个写法比裸写一个text/markdown更稳适合你不确定服务端能力的时候。1.2 AI Agent 场景里 Markdown 比纯文本和 JSON 更好用在哪里AI Agent 对接大模型时最常见的返回格式是 JSON 包装。比如 OpenAI 兼容接口会返回{ choices: [ { message: { content: python\nprint(hello)\n } } ] }这里content字段的内容本身已经是 Markdown 了。所以严格来说很多大模型接口不是不支持 Markdown而是默认把它包在 JSON 里。你要是直接请求Accept: text/markdown服务端不一定认。那为什么还要专门用 Accept 协商 Markdown因为有一类 AI 代理服务不属于标准大模型接口。比如你自己搭的 Agent 网关、企业内部模型网关、或者一个把外部模型包装成文档生成服务的中间层。这类服务比较灵活可以根据客户端偏好选择内部处理逻辑。如果客户端声明要 Markdown代理可以直接把模型输出整理成 Markdown 文档返回而不是先返回 JSON 再让前端二次提取。Markdown 的实际优势在内容类型复杂时尤其明显代码块和行内代码能保留标题、列表、粗体有明确结构表格在 GFM 方言下可读性很好引用、链接、清单不容易丢。纯文本做不到这些解析器要自己发明一套约定。JSON 虽然结构稳定但把 Markdown 字符串嵌在字段里前端仍然要取出来再渲染中间层也要多做一步“提取 content”的操作。不过也别神化 Markdown。如果 Agent 要和外部工具系统交互需要严格的参数结构那么 JSON Schema 反而更合适。这时候你强行让服务端返回text/markdown反而会把工具调用的结构化信息压平后续解析成本更高。我的建议是面向人的内容用 Markdown面向机器的结构化输出用 JSON。2. 从零到一让 AI 代理返回 Markdown 的请求写法2.1 先用 curl 验证目标端点是否支持 text/markdown不要一上来就写完整客户端。先拿curl打一个最小请求确认这个 AI 代理服务到底认不认Accept: text/markdown。假设接口是POST https://your-ai-agent.example.com/v1/generate一个带 Accept 头的请求长这样curl -X POST https://your-ai-agent.example.com/v1/generate \ -H Authorization: Bearer YOUR_API_KEY \ -H Accept: text/markdown \ -H Content-Type: application/json \ -d { prompt: 写一段关于 HTTP 内容协商的 Markdown 说明包含一个代码块。, stream: false }看响应时重点看三个地方HTTP 状态码200大概率支持或忽略406服务端明确拒绝不支持text/markdown400参数有问题和 Accept 无关。响应头Content-Typetext/markdown; charsetutf-8真正的 Markdown 响应application/json服务端忽略了 Accept返回 JSON。响应体直接是# 标题这样的 Markdown还是{content: # 标题}这样的 JSON 包装。我一般会先跑这一条看到结果后再决定客户端怎么写。刚才这个例子里的stream: false也很关键因为流式响应会改变传输格式不能和普通响应混在一起判断。2.2 Python 客户端写法与降级策略如果服务端支持那么 Python 请求很直接。用httpx举例import httpx def generate_markdown(prompt: str, api_key: str) - str: headers { Authorization: fBearer {api_key}, Accept: text/markdown, Content-Type: application/json, } payload { prompt: prompt, stream: False, } resp httpx.post( https://your-ai-agent.example.com/v1/generate, headersheaders, jsonpayload, timeout120, ) if resp.status_code 406: # 服务端不接受 text/markdown降级成 JSON 请求 headers[Accept] application/json resp httpx.post( https://your-ai-agent.example.com/v1/generate, headersheaders, jsonpayload, timeout120, ) resp.raise_for_status() return resp.json()[content] resp.raise_for_status() content_type resp.headers.get(content-type, ) if text/markdown in content_type: return resp.text # 服务端忽略 Accept仍然返回 JSON 包装 data resp.json() return data.get(content) or data.get(text) or data[choices][0][message][content]这里有几个判断点优先看状态码再看Content-Type最后看响应体如果返回 JSON不要假设字段一定叫content要看具体服务文档超时时间要根据任务复杂度设置不能所有请求都用 5 秒超时API Key 只放在请求头里不要写进 URL 或日志。降级策略不是可选项而是必备项。因为你无法控制 AI 代理服务升级后是否还支持text/markdown。要是哪天它去掉了这个 media type客户端至少要能从 JSON 响应里把 Markdown 取出来。2.3 流式场景下 Accept 怎么搭配 SSE大模型接口很多默认走流式输出。此时客户端要的不是一次性返回而是按事件流接收内容。常见的组合是Accept: text/event-stream在流式接口里Accept: text/markdown和Accept: text/event-stream有一个取舍问题。有些服务支持在 SSE 的事件数据里传 Markdown 片段有些则仍然包装成 JSON 事件。举个例子SSE 返回值可能长这样event: message data: {type: text, content: ## HTTP 内容协商} event: message data: {type: text, content: \n\nAccept 标头用于告诉服务端客户端希望接收的媒体类型。}这里的data是 JSON但content字段是 Markdown。客户端要做的是解析 SSE 事件再把每段content拼接起来。如果服务端直接返回纯文本片段没有 JSON 包装那就更简单data: # 标题 data: 这是正文第一段。处理时要注意不要用普通resp.text一次性读取要逐行读data:行每个事件不一定是一段完整 Markdown可能把代码块切在中间前端渲染时不能每个事件都单独渲染成p否则 Markdown 层级会乱。Python 里可以用httpx的流式响应配合sseclient-py这类库解析后把文本传给 Markdown 渲染器。核心代码如下import httpx import json with httpx.stream( POST, url, headersheaders, json{prompt: prompt, stream: True}, timeout300, ) as r: for line in r.iter_lines(): if not line or not line.startswith(data:): continue data line[5:].strip() if data [DONE]: break try: payload json.loads(data) print(payload[content], end) except json.JSONDecodeError: print(data, end)流式输出和 Markdown 的冲突点在于“边界不完整”。服务端可能把一个三行代码块分成多个事件发送客户端如果每收到一块就调用一次渲染器代码块会闪烁、缩进会错乱。更好的做法是把所有累积文本维护在缓冲区每次渲染整个缓冲区内容而不是只渲染增量。3. 在代理层统一把响应转换成 Markdown 给客户端3.1 反向代理里改写 Accept 头的适用场景如果你的上游模型服务只支持 JSON 返回但你的客户端又希望拿到 Markdown可以在中间加一层反向代理或 API 网关。这里“代理”是工程上的反向代理不是用来绕网络限制的工具。Nginx 配置里可以改写转发给上游的Accept头location /v1/generate { proxy_set_header Accept text/markdown; proxy_set_header Authorization $http_authorization; proxy_pass http://upstream_ai_service; proxy_http_version 1.1; proxy_set_header Connection ; }使用场景要满足两个条件上游服务确实支持通过Accept协商返回 Markdown客户端不需要知道上游细节只和代理层交互。但这里有一个很大的坑不是所有上游都支持text/markdown。你强行把Accept改成text/markdown上游如果不认识可能返回 406也可能假装不认识继续返回 JSON。所以代理层要先做一次联通性验证确认上游对Accept: text/markdown的响应行为再决定要不要统一改写。更常见的情况是上游返回 JSON代理层解析后把content字段提取出来再以 Markdown 形式返回给客户端。这种做法不依赖上游是否支持内容协商原理是代理层自己做格式转换。3.2 用 FastAPI 做一个轻量 AI Agent 网关一个很轻的 Agent 网关可以这样接收客户端请求读取客户端传入的Accept头然后请求上游模型接口。如果客户端要 Markdown就从上游 JSON 响应里提取文本返回text/markdown如果客户端要 JSON就把完整响应原样返回。FastAPI 示例from fastapi import FastAPI, Request, Response import httpx app FastAPI() UPSTREAM_URL https://your-ai-agent.example.com/v1/generate API_KEY YOUR_API_KEY app.api_route(/v1/generate, methods[POST]) async def generate(request: Request): accept request.headers.get(accept, ) payload await request.json() headers { Authorization: fBearer {API_KEY}, Content-Type: application/json, } async with httpx.AsyncClient(timeout120) as client: upstream_resp await client.post( UPSTREAM_URL, headersheaders, jsonpayload, ) if upstream_resp.status_code ! 200: return Response( upstream_resp.text, status_codeupstream_resp.status_code, media_typeupstream_resp.headers.get(content-type, text/plain), ) data upstream_resp.json() markdown data.get(content) or data.get(text) or if text/markdown in accept: return Response(contentmarkdown, media_typetext/markdown; charsetutf-8) return Response(contentupstream_resp.text, media_typeapplication/json)这个网关解决了两个问题客户端可以只关心自己的格式偏好上游接口变化时只改网关一层不用改所有客户端。代码里要注意不要把 API Key 暴露给下游客户端所以网关要重新注入 Authorization 头不要把下游返回的全部响应头透传给客户端至少要过滤敏感字段和content-length。3.3 代理层的缓存与 Vary 头当你开始用缓存加速时Accept 的问题就变敏感了。同一个 URL客户端 A 要application/json客户端 B 要text/markdown。如果缓存只按 URL 和查询参数做 key那么两个客户端可能拿到对方格式的缓存。解决办法是加响应头Vary: Accept这样 HTTP 缓存会依据Accept的值区分缓存条目。使用 Nginxproxy_cache_key时也要把$http_accept加进去proxy_cache_key $scheme$request_method$host$request_uri$http_accept;如果上游返回内容里带有Vary: AcceptNginx 默认也会缓存多个版本但仍建议显式配置缓存 key避免不同Accept值之间互相污染。另一个容易被忽略的点是不要在代理层改写Accept时忘了同步处理缓存 key。比如你强制给上游发Accept: text/markdown但客户端实际上要 JSON此时缓存 key 如果只按客户端的 Accept 算就会出现缓存回填错位。更稳妥的做法是代理层内部统一用“请求上游时用的 Accept”作为缓存 key而不是客户端原始 Accept。4. Markdown 内容从代理到前端渲染的常见坑4.1 SSE 流式 Markdown 要怎么给前端AI 代理如果走 SSE 返回 Markdown前端一般会同时遇到两个问题Markdown 片段不完整以及代码块在滚动中反复重渲染。假设后端把 Markdown 分成下面几段推送data: ## 示例 data: 第一行代码 data: python data: print(hello) data: 如果前端每收到一个data就立刻渲染一次那么“python”这个事件到达时页面上会出现一个短暂的孤立代码围栏然后下一帧又变正常。虽然最终结果没问题但闪烁很影响体验。解决办法是维护一个完整文本缓冲区let buffer ; function onSSEData(chunk) { buffer chunk; renderMarkdown(buffer); // 渲染完整缓冲区而不是 chunk }每次重新渲染完整缓冲区在 Markdown 内容量不大时是可行的。如果单次生成内容很长每次都全量渲染会造成性能压力那就需要做“分段稳定渲染”或“延迟渲染”。更稳妥的方案是SSE 流只负责把 Markdown 文本累积起来用requestAnimationFrame或节流函数把渲染频率控制在一定范围内。比如每 100ms 渲染一次用户既能感觉到流式输出又不会因为每个 token 都重绘而卡顿。4.2 渲染安全Markdown 里的 HTML 不能无脑信任Markdown 原生支持内联 HTML。AI 模型在生成过程中有可能输出script、iframe、img onerror...这类内容。如果代理把模型输出原样转成text/markdown给前端前端又直接使用innerHTML渲染安全风险就出现了。比如模型可能生成正常文本 img srcx onerroralert(document.cookie)很多 Markdown 渲染器默认会保留 HTML 标签。如果不做过滤onerror会被浏览器执行。不要赌模型不会输出恶意内容即使模型本身没有恶意训练数据里也可能学到这种模式。前端渲染时兜底方案使用markdown-it时设置html: false让 HTML 标签以文本形式显示如果必须允许部分 HTML用DOMPurify清洗后再插入 DOM不要把dangerouslySetInnerHTML直接用于模型输出。一个比较稳妥的渲染组合import MarkdownIt from markdown-it; import DOMPurify from dompurify; const md new MarkdownIt({ html: false, linkify: true }); function renderMarkdown(text) { const rawHtml md.render(text); const safeHtml DOMPurify.sanitize(rawHtml); document.getElementById(output).innerHTML safeHtml; }这里的思路是双保险先不让 Markdown 解析器生成原始 HTML再用 DOMPurify 清洗可能残留的危险内容。如果项目对安全要求更高连javascript:链接、data:图片都要在配置里显式处理。4.3 表格、代码块、LaTeX 数学公式的兼容性差异Markdown 不是只有一个标准。同样一段内容在 GitHub、Typora、Obsidian、VitePress 里渲染结果可能不同。AI 代理返回 Markdown 时尤其要注意这些方言差异表格GFM 支持管道表格但标准 CommonMark 不支持代码块反引号围栏多数支持缩进代码块有时表现不一致数学公式$...$和$$...$$需要 KaTeX 或 MathJax 额外处理脚注不是所有渲染器都支持任务列表- [ ]和- [x]也需要扩展支持。如果你的前端只装了基础 Markdown 渲染器模型返回一张表格时可能出来一片乱码。因为表格语法没有被解析器识别时会原样以文本形式展示看起来很糟。建议在项目一开始就确定 Markdown 方言面向通用文档选用 GFM 风格并确保渲染器支持表格、删除线、任务列表面向技术内容需要支持代码高亮和行号面向学术内容需要支持 LaTeX 数学公式。后端代理层也可以做一步归一化。比如模型输出里用了$$公式但前端不确定支持那就在代理层把公式转换成图片 URL或者转成行内代码。这个取决于业务需求不是必须。5. Accept 标头和 Markdown 内容协商的排查清单5.1 返回 406 不是函数 bug先看服务端支持列表遇到406 Not Acceptable先不要改代码先确认服务端到底支持哪些媒体类型。最简单的方法是看接口文档或者找 OpenAPI 定义里produces/content字段。如果文档没有可以发一个带Accept: */*的请求看默认返回的Content-Type是什么。也可以看响应头里的Accept-Post或Allow有些服务会暴露可用的媒体类型。还有一种方式是故意发一个明显不存在的媒体类型curl -X POST https://your-ai-agent.example.com/v1/generate \ -H Accept: application/x-nonsense \ -H Content-Type: application/json \ -d {prompt: test}如果服务端真的执行严格内容协商大概率会返回 406。如果它返回 200说明服务端不太在意 Accept 头。这种情况下你加Accept: text/markdown也不会得到 Markdown只能手动从 JSON 里提取。不要看到 406 就认为是代理层配置问题。先逐层 curl直接请求上游、请求代理层、请求 Nginx看哪一层开始返回 406。这样才能定位是服务端不支持还是中间层把 Accept 头改错了。5.2 代理层悄悄改写 Content-Type / Accept多层代理时最常见的现象是客户端请求头里明明写了Accept: text/markdown但上游收到的已经不是这个值。可能是某层网关故意覆盖也可能 Nginx 配置里没有把 Accept 传给上游。排查时从上往下看客户端发出的请求头用curl -v确认Nginx 或 API 网关的日志看请求头是否被改写上游服务的访问日志看实际收到的 Accept 是什么如果中间有自定义网关检查是否只透传了白名单头部。Nginx 默认会转发Accept头但如果配置里写了proxy_set_header Accept application/json;那么所有请求的 Accept 都会被强制覆盖。这可能是历史原因留下的也可能是你为了统一上游行为故意设置的。另外还要检查响应方向。代理层返回给客户端时Content-Type要匹配实际内容。如果你返回的响应体是 Markdown但Content-Type还是application/json前端会走 JSON 解析逻辑导致解析失败。反过来也一样。5.3 内容对但格式错乱编码、换行、转义“返回结果看着不对”不一定和 Accept 有关。常见问题集中在三个地方第一换行符。Markdown 的段落和列表依赖换行。如果模型输出的 JSON 字符串里是\n你从 JSON 里取出来时没正确转成真实换行符前端拿到的可能是带\n字面量的文本。解决方法是统一在代理层做一次字符串转换把字面量\\n替换成\n再检查是不是\r\n。第二字符编码。Content-Type里没有charsetutf-8时客户端可能按平台默认编码解析中文容易乱码。响应头最好统一返回Content-Type: text/markdown; charsetutf-8第三Markdown 转义。模型输出的标题里可能包含#列表项里可能包含|这些符号会被 Markdown 渲染器当成语法。如果模型内容里包含需要原样展示的字符应该在输出时进行转义。但这个属于内容后处理不能简单一刀切。我的建议是先在界面里看实际渲染效果再决定哪些字符需要转义。排查格式错乱时最直接的方法是看原始响应体。先用curl把响应保存到文件然后用十六进制查看关键位置确认换行是0x0A还是0x0D 0x0A确认中文字节是否正常。很多时候问题不在渲染器而在数据从上游到下游的传输过程中已经变了。我个人更建议先把单条请求跑稳再考虑批量任务和代理层改造。Accept 标头能帮你在格式上做一次明确表达但它不是银弹。服务端支持与否、是否需要 JSON 结构化结果、前端渲染器是否匹配 Markdown 方言这些都要一起评估。真正落地时最该盯住的不是“请求头写没写对”而是从上游模型输出到前端展示这一段格式和语义有没有被正确保住。
返回列表