Skip to content

feat(gateway): 支持 gzip 压缩的请求体 - #474

Merged
caigee-cmd merged 1 commit into
caigee-cmd:mainfrom
NaimioChan:feat/gzip-request-bodies
Nov 8, 2026
Merged

caigee-cmd merged 1 commit into
caigee-cmd:mainfrom
NaimioChan:feat/gzip-request-bodies

Conversation

@NaimioChan

Copy link
Copy Markdown
Contributor

长对话的请求体高度冗余:同样的 system prompt、同样的工具 schema、同样的缩进在每条消息里重复。实测 60 轮历史用普通 gzip 能压 100-300 倍。

为什么值得做

当调用方经由带固定响应预算的反向代理访问网关时,这个体积是致命的。Cloudflare 的 120 秒 Proxy Read Timeout 非 Enterprise 不可调,而上传发生在服务端能开始工作之前:

在丢包严重的家宽链路上实测,同一个 1.7MB 请求体:

body 传输耗时 结果
明文 1.7MB 133s 524(超出预算)
gzip 5.4KB 0.4s 正常完成

这跟上游快慢无关——body 没到齐,服务端连请求都还没拿到,任何服务端流式输出都救不回来。

做法

POST /v1/chat/completions、/v1/messages、/v1/responses 三个入口接受 Content-Encoding: gzip(x-gzip 作为旧别名一并接受)。

解码严格由调用方选择:不带这个头的请求读取方式完全不变,不压缩的客户端零感知。所以这个改动对现有用户是纯增量。

两个独立的上限

  • 线路上限限制从 socket 读多少压缩字节;
  • 解压上限限制 JSON decoder 实际会碰的解压后字节数。

没有第二个上限,一个小体积的 gzip body 可以无限膨胀(zip bomb)。

错误语义

网关不认识的编码返回 415 并指明编码名,而不是把 zstd 之类的字节丢给 JSON 解析器、最后报一个让人摸不着头脑的语法错误。

验证

request_body_test.go 覆盖 8 个场景:gzip 解码、明文路径不变、未知编码 415、损坏 gzip 400、解压上限、identity 透明、x-gzip、以及 Anthropic 和 Responses 两个端点。

端到端实测(真实模型,40 轮对话,122KB body → 883 字节):

code=200  sent=883B  total=2.6s
content: 'COMPRESSED_OK'
prompt_tokens: 16900   ← 与服务端解压后的完整上下文一致

明文与 gzip 两条路径产出的 prompt_tokens 完全相同,确认服务端完整还原了请求。

go build ./... && go test ./internal/gateway/ 全绿。

说明

服务端这半边是前提,客户端也需要压缩请求体才能拿到收益。这个 PR 只做服务端解码;不改的客户端行为完全不变。

Huffman/gzip 是 stdlib 里的,没有新增依赖。


本 PR 由 AI 辅助生成与测试,提交前已人工审阅。

@caigee-cmd

Copy link
Copy Markdown
Owner

感谢,gzip 这条路和测试都看过了。不带 Content-Encoding 的请求保持原样、未知编码返回 415、压缩字节和解压字节分开限流,这些都对。

合之前还想再确认两点:

  1. 解压上限现在是 io.LimitReader。读到上限会静默停住,JSON 解析器接着看到半截对象,调用方拿到的是语法错误,而不是「body 太大」。压缩侧的 MaxBytesReader 超限会给出明确错误;解压侧希望同样直接失败,而不是截断后交给 decoder。
  2. /v1/messages 的解码失败走了 writeRequestBodyError → writeErr,415 和损坏 gzip 的 400 都会变成 OpenAI 的错误形状。这个入口原来的失败是 writeAnthropicError,希望这里也保持 Anthropic 的格式。

另外这个分支已经落后 main(#476 合进去之后),合之前需要 rebase 一下。#473 也改了 internal/gateway/openai.go,两个一起看的话,后合的那个会碰到同一处。

A long chat request is mostly redundant: the same system prompt, the same tool
schemas, and the same indentation repeated across every message. A 60-turn
history compresses ~100-300x with plain gzip.

That matters when the caller reaches the gateway through a proxy with a fixed
response budget. Cloudflare's 120s Proxy Read Timeout is not configurable below
Enterprise, and the upload happens before the origin can answer: measured on a
lossy uplink, a 1.7MB body took 133s to arrive (over budget) while its 5.4KB
gzip form took 0.4s. No amount of server-side streaming can recover a request
whose body never arrived.

Decoding is strictly opt-in on the caller. Without a Content-Encoding header the
body is read exactly as before, so clients that never compress see no behaviour
change. x-gzip is accepted as the legacy alias.

Two limits are enforced independently: the wire limit bounds how many compressed
bytes are read off the socket, and the inflated limit bounds the decompressed
bytes the JSON decoder will touch. Without the second, a small gzip body could
expand without limit.

An unrecognised encoding answers 415 naming the encoding, rather than surfacing
as a JSON syntax error.
@caigee-cmd
caigee-cmd force-pushed the feat/gzip-request-bodies branch from 5e82c46 to 2d59b8d Compare November 8, 2026 06:57
@caigee-cmd
caigee-cmd merged commit e602929 into caigee-cmd:main Nov 8, 2026
6 checks passed
caigee-cmd added a commit that referenced this pull request Nov 11, 2026
…rtbeat

fix(gateway): 补修 #473/#474 合入后仍在的问题
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants