最近折腾了一个特别磨人的链路问题,前前后后花了两天,最后靠Gzip压缩和一批网络参数调整才彻底解决。先说背景:我们内部用 LiteLLM 做统一的大模型网关,后端接了 AWS Bedrock 几个 provider,出于审计和复现需求,必须把每次 40 万 Token 级的超大请求和响应 Payload 全量写入日志库。日志库选用的是 VictoriaLogs,部署在一台 RISC-V 架构的服务器上。听起来没什么,但真实跑起来才发现,在跨国长肥网络里,一个 5MB 的 JSON 报文从 LiteLLM 传到 RISC-V 上的 VictoriaLogs,各种超时、断流、413 全来了。
这篇就把整个排查过程、根因分析、以及最终的 Gzip 优化方案完整记录下来。如果你也遇到类似“大报文日志写入困难”的处境,或者正在评估 LiteLLM + VictoriaLogs 的组合,这篇应该能帮你少走不少弯路。
1. 项目背景与需求拆解
1.1 为什么是 40 万 Token 级 Payload
我们的业务场景有点特殊:用户会把几十页甚至整本书丢给 LLM 做分析,一次请求可能包含很长的上下文,响应端又是超长生成结果。Token 数一上去,最直观的就是序列化后的 JSON 体积急剧膨胀。40 万 Token 看起来只是个数字,换算一下就很吓人了:一个 Token 按 3-4 字节算,40 万 Token 就是 1.2MB-1.6MB 的纯文本,再加上 JSON 转义、换行、字段名,实际报文常常到 4MB-6MB。
这种规模下,如果把每次请求和响应的原始报文都记录到日志里,单个日志条目就是普通文本日志的上千倍。刚开始很多人劝我别存原文,存个摘要就行,但审计要求不允许。我们需要知道每个问题到底是怎么被 Prompt 拼出来的、模型输出了什么原话、Token 计费是否准确,少一个字都没法复核。所以全量落库是没有商量余地的。
1.2 选型:LiteLLM + VictoriaLogs + RISC-V 是偶然也是必然
LiteLLM 是我们一直在用的网关,它能把 OpenAI 格式的请求转发到 Bedrock、Anthropic 等各家后端,还内置了回调机制,可以在请求完成后把 Trace 数据抛给外部日志系统,非常适合做统一审计入口。
日志库这边,我们一开始考虑过 Elasticsearch 和 Loki,但都不太满意。ES 这种重量级方案在单机和资源受限环境里太笨重;Loki 对压缩和高基数没问题,但查询能力和原始报文的索引不太友好。后来盯上了 VictoriaLogs,它本来就是 VictoriaMetrics 团队推出的日志专用库,单二进制即可运行,内置了高压缩率的索引,而且摄入接口直接用 HTTP JSON,落地非常快。
至于 RISC-V,并不是我刻意选的,而是公司正好有一批 RISC-V 服务器作为信创实验平台,想验证开源软件栈在这个架构下的可用性。VictoriaLogs 官方没有直接给 RISC-V 二进制,需要自己交叉编译,这也是后面折腾的一部分。
1.3 长肥网络给日志写入带来的物理约束
“跨国长肥网络”听起来玄乎,其实就是 Long-Fat Network,指高带宽、高时延的网络链路。我们的 LiteLLM 部署在欧洲云区域,RISC-V 服务器在某东南亚机房,中间跨了半个地球,RTT 差不多 150-200ms,链路带宽虽然不低,但带宽时延积(BDP)非常大。
在这种网络里传一个 5MB 的 JSON,最理想情况也要好几秒,但只要 TCP 窗口、丢包重传、中间代理稍微不给力,时间就会指数级恶化。更麻烦的是,VictoriaLogs 摄入一个大 JSON 本身的处理时间也不短,两端一叠加,15 秒超时根本不够用。后面 90% 的故障都源于此。
2. 故障现象与被浪费的两个小时
2.1 症状一:LiteLLM 回调超时
LiteLLM 的日志回调是通过
success_callback
或自定义 webhook 实现的,默认靠 HTTP POST 把数据发出去。我在回调里设置了一个 15 秒超时,平时小报文毫秒级就完成了,但一旦请求到了 40 万 Token,日志写入就频繁触发
Read timed out
。
更头疼的是,LiteLLM 内部对失败的日志回调有重试机制。一开始我觉得这是优点,结果在跨网络上变成了缺点。每个大报文都重试 2-3 次,不仅日志写不进去,还把 LiteLLM 的 worker 占满了,正常推理请求的响应也跟着变慢。那两天最直观的感受就是:主业务时不时卡一下,但网关本身的 CPU 和内存占用又没异常,直到把日志线程栈打出来,才发现全堵在 HTTP 发送上。
2.2 症状二:VictoriaLogs 摄入接口响应极慢
直接查看 VictoriaLogs 的日志,发现摄入接口不是不可用,而是响应时间从正常的几十毫秒涨到了 5 秒以上。每次大报文进来,服务的 CPU 会瞬间打满,然后连接被客户端断开。
这里有个容易忽略的细节:客户端断开后,VictoriaLogs 服务端可能还在继续解析半截报文体。因为 HTTP body 已经进来了,服务端其实已经把部分数据读进缓冲区,解析任务一旦开始,就很难中途取消。这就导致一个超大日志对象即使被客户端放弃,服务端仍然在消耗 CPU 去解析一个注定废弃的 payload,白白浪费算力。
2.3 症状三:curl 模拟出的 “断流” 现场
为了排除代码问题,我用 curl 直接模拟了一次写入:
curl -X POST http://<riscv-host>:9428/insert/jsonline \
-H "Content-Type: application/json" \
-H "Authorization: Bearer xxx" \
--data-binary @payload_5mb.json \
--max-time 60 -v
结果很有意思:数据在跨境链路里传了一半,卡住了几分钟后,服务端直接返回
Connection reset by peer
。也有的时候客户端报
Empty reply from server
。如果改用 HTTP chunked 分块传输,数据能发完,但服务端超时后仍然后续无响应。
这些现象综合起来几乎可以断定:不是简单的带宽不够,而是链路中某个环节的缓冲区或超时策略扛不住大报文。
3. 根因分析:TCP、序列化与 RISC-V 性能三方叠加
3.1 第一刀:先算算带宽时延积
我习惯遇到网络问题先做数学估算。假设链路 RTT 是 200ms,带宽是 50Mbps,那么带宽时延积 BDP = 50Mbps × 0.2s ÷ 8 ≈ 1.25MB。也就是说,TCP 窗口至少要大于 1.25MB,才能填满整个链路。如果窗口只有 256KB,那么理论上限就是 256KB ÷ 0.2s ≈ 1.28MB/s,换算成带宽只有 10Mbps,远远跑不出实际带宽。
而 5MB 的报文在这个窗口下,光传输就要 4 秒以上,这还只是理想模型。实际加上 TCP 慢启动、丢包重传、中间设备的缓冲限制,20 秒都打不住。我们的 15 秒超时被突破太正常了。
注意:跨国长肥网络的“长肥”不是贬义词,而是对它物理特性的描述。排查这类问题时,第一件事就是算 BDP,不要盲目调大超时。
3.2 第二刀:RISC-V 服务器的 TCP 缓冲区短板
算完理论值,我去 RISC-V 服务器上检查了 TCP 缓冲区配置:
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem
默认值通常是
4096 87380 6291456
这种,看上去最大值有 6MB,但实际生效的第二个值(初始窗口)只有 87KB,慢启动过程特别漫长。加上 RISC-V 平台的网卡驱动和中断处理能力偏弱,大包吞吐很容易成为瓶颈。
我把几个关键参数调大后做了对比实验,确实有效,但还不够。因为最根本的问题不只是网络,还有对端解析 JSON 的能力上限。
3.3 第三刀:VictoriaLogs 解析大 JSON 的成本
VictoriaLogs 虽然很轻量,但它仍然需要解析 JSON 里的字段、识别标签、建立索引。我们的一条日志里有 40 万 Token 的文本,这个文本本身是作为一个字段存进去的,但周围还有其他 metadata 字段、拆出来的统计字段。整条日志解析下来,对象分配和内存占用非常夸张。
在 RISC-V 这种低主频、弱乱序执行能力的 CPU 上,5MB JSON 的解析耗时能从 x86 的几百毫秒放大到几秒。服务端处理得慢,客户端等不起,连接断开,然后服务端还在继续工作,形成了一个非常差的恶性循环。
后来我用
VictoriaLogs
自带的
-http.disableResponseCompression
之类的参数测试过,发现关闭这些边缘功能效果有限,真正的性能瓶颈在 JSON 解析本身,不是 HTTP 层。
3.4 第四刀:中间链路代理的隐藏限制
排查线上问题还发现,我们实际部署时,LiteLLM 到 VictoriaLogs 之间还隔了一层 Nginx 做 TLS 终止和反向代理。Nginx 默认
client_max_body_size
只有 1MB,超过就直接返回 413。
这一度让我们误以为是 VictoriaLogs 拒绝了请求,直到抓包才看到是 Nginx 拦截的。调整 Nginx 配置之后,又遇到
proxy_read_timeout
默认 60 秒的问题,大报文传输加上服务端处理超过 60 秒,Nginx 主动断开,客户端就看到断流。
有时候断流并不是终点,中间代理每个都有自己的“小脾气”,排查时一定要把所有节点都过一遍。
4. Gzip 优化方案的设计与实施
4.1 先做减法:能不全量落库吗?
在动手压缩之前,我先试过减少数据量的方案。比如去掉响应里的 base64 图片字段、把浮点数截断到两位小数、去掉一些无关紧要的 metadata。这么一通操作后,日志体积大概是原来的 70%,但仍然有 3MB 左右,并没有本质改变。
毕竟审计要求摆在那,我们不可能把核心内容和完整输出裁掉。所以方向只剩下两个:要么把传输体积压下来,要么把服务端处理能力提上去。RISC-V 硬件短期内换不了,VictoriaLogs 也不支持 Alter Table 那种预聚合,最现实的就是 Gzip 压缩。
这里要多说一句:Gzip 不是银弹,但对我们这种“原始 JSON 里大量重复字段名和空格”的场景,压缩率非常可观。
4.2 压缩放在哪一层最合适?
一开始我在 VictoriaLogs 前面加了一层 Nginx,用
proxy_set_header Content-Encoding gzip
这种简单的配置,发现数据过不去,因为 VictoriaLogs 收到 gzip 请求体后会自己解压,这样必须在发送端就把数据压缩。
真正合理的方案是:LiteLLM 侧在内存里对 JSON 序列化后的字节做 Gzip 压缩,然后设置 HTTP
Content-Encoding: gzip
,把压缩后的二进制作为请求体发出去。VictoriaLogs 摄入接口原生支持 gzip 请求体,会在解析前自动解压,对业务代码几乎透明。
重点:Gzip 压缩要在发送方做,不要指望中间代理替你压。Nginx 的
gzip on只对响应生效,不会自动压缩上游请求体。
4.3 实测压缩率:从 4.8MB 到 0.45MB
我在 x86 的 LiteLLM 服务器上用不同的压缩级别做了对比测试:
| 压缩级别 | 原始大小 | 压缩后大小 | 压缩耗时 | 解压耗时(RISC-V) |
|---|---|---|---|---|
| gzip -1 | 4.8 MB | 0.96 MB | 45 ms | 180 ms |
| gzip -6 | 4.8 MB | 0.45 MB | 130 ms | 350 ms |
| gzip -9 | 4.8 MB | 0.42 MB | 420 ms | 360 ms |
压缩耗时的数据是在 LiteLLM 所在的高频 x86 机器上测的,所以用
gzip -6
很划算,只花 130ms 就能把体积压缩到十分之一以下。传输时间直接从原来的 20 秒级降到 1 秒以内,VictoriaLogs 的解析时间虽然只降低了一点点,但至少不会再因为等待网络而占用连接。
这里提醒一下,Gzip 的缺点也很明显:消耗 CPU、压缩流不可随机访问、而且一次压缩的数据量不宜过大。如果你需要实时流式写入超长日志,可能要改用 zstd 或按行拆分压缩,后面我会再说。
4.4 核心代码实现:在 LiteLLM 回调里压缩
我们用的 LiteLLM 版本支持自定义回调函数,核心就是写一个接收 kwargs 的函数,把日志内容序列化压缩后发出去。简化后的伪代码如下:
import gzip
import json
import requests
def write_to_victorialogs(llm_payload: dict):
body = json.dumps(llm_payload, ensure_ascii=False).encode("utf-8")
compressed_body = gzip.compress(body, compresslevel=6)
headers = {
"Content-Encoding": "gzip",
"Content-Type": "application/json",
"Authorization": "Bearer <token>",
}
resp = requests.post(
"http://<riscv-host>:9428/insert/jsonline?_stream_fields=message",
data=compressed_body,
headers=headers,
timeout=(10, 120),
)
return resp
两点需要注意:
-
用
data=而不是json=发送压缩后的二进制,如果用json=,requests 会自己再序列化一次,破坏你的压缩流。 -
超时设置要区分连接超时和读取超时,我这里设为
(10, 120),连接等待不能太久,但读取要给服务端足够的解压和入库时间。
4.5 批量写入:把多次小请求合成一个大请求
单条日志压缩后虽然小了,但如果我们每条日志都发一次 HTTP 请求,跨网络的开销还是很大。后来我把回调改成攒批模式,比如每隔 2 秒或者攒够 20 条日志,就合并成一个 JSON Lines 数组,统一压缩发送。
VictoriaLogs 的
/insert/jsonline
接口天然支持多行写入,每行一条日志。批量压缩的效果比单条压缩更好,因为重复的字段名在跨行压缩时会被重复利用,压缩率能进一步提升到 95% 左右。
# 攒批伪代码
buffer = []
def flush_logs():
global buffer
if not buffer:
return
lines = "\n".join(json.dumps(record, ensure_ascii=False) for record in buffer)
payload = gzip.compress(lines.encode("utf-8"), 6)
requests.post("http://<riscv-host>:9428/insert/jsonline",
data=payload,
headers={"Content-Encoding": "gzip"})
buffer = []
注意攒批不能无限大,单次压缩后的 POST body 最好控制在 4MB 以内,否则又会撞上 Nginx 和网络中间设备的体积限制。
4.6 超时参数与代理配置的配套调整
压缩把数据量降下来了,超时和代理限制也得同步调整,否则 413 和 504 还是会冷不丁冒出来。
我的 Nginx 反代配置最终是这样的:
server {
listen 9428;
client_max_body_size 16m;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
location /insert/jsonline {
proxy_pass http://127.0.0.1:9428;
proxy_request_buffering off;
}
}
proxy_request_buffering off
很关键,它让 Nginx 不先把整个请求体缓冲到磁盘再转发,而是边收边转,能少一次磁盘读写,对 5MB 级别的请求体有帮助。
VictoriaLogs 本身也要注意调整 HTTP 并发参数。我没有改它的默认值,但在高并发大报文接入时,可以适当调大
-http.maxConcurrentRequests
,避免瞬时大请求把解析线程池堵死。
5. 常见问题与排查清单实录
5.1 调大了 client_max_body_size 还是报 413
如果你改了 Nginx 的
client_max_body_size
仍然 413,先别怀疑配置没生效。检查两件事:
-
有没有重启 Nginx?配置改完后不会热生效,
nginx -s reload或者nginx -t && systemctl reload nginx。 - 中间是不是还有一层代理?比如云负载均衡、API 网关、CDN,每个都有独立限制。我排查过最夸张的是三个不同层的默认值都是 1MB,改了一层根本没意义。
另外注意
client_max_body_size
的单位,默认是字节,写
16m
表示 16MB。别把
m
漏了。
5.2 gzip 压缩后服务端收到乱码或者空数据
这个问题我踩过一次。最开始我用
requests.post(..., json=body)
发送,结果 requests 会帮忙把 body 再
json.dumps
一次,而且可能重新编码,导致服务端解压直接失败。一定要用
data=
传原始二进制,而且 Content-Encoding 必须和实际压缩算法严格一致。
如果服务端返回
EOFError: Compressed file ended before the end-of-stream marker was reached
,基本就是数据被二次编码或者被截断了。先用本地
gzip -d payload.bin
验证一下压缩文件能正常解压,再谈能不能传输。
5.3 RISC-V 上解压导致 CPU 飙升怎么办
Gzip 压缩是在 x86 端做的,省了网络时间,但 RISC-V 端要解压 0.45MB 数据,耗时 300 多毫秒,再加上 JSON 解析,整体处理时间还是上来了。如果并发一大,RISC-V 的 CPU 照样吃紧。
我的解决方案是:在 RISC-V 服务器前再加一层专门做解压的 Nginx,用
proxy_set_header Content-Encoding ""
配合 Lua 或第三方模块解压。不过这个方案引入额外复杂度,不太适合所有人。更轻量的是改用 zstd 压缩,解压速度比 gzip 快不少,VictoriaLogs 也支持 zstd。如果你没有强烈要求必须 Gzip,我会建议直接上 zstd。
5.4 上游 Provider 返回 unexpected status 413 payload too large
这其实和日志链路无关,而是 LiteLLM 转发到 AWS Bedrock 时遇到的。Bedrock 这类 provider 对单次请求体积有上限,超过就拒绝。日志链路压缩得再完美也解决不了这个。
要处理的是从 Prompt 侧入手:截断过长的上下文、把不必要的历史记录摘要化、启用流式输出减少响应体一次性返回的压力。为了排查是不是这个原因,我抓过 LiteLLM 的请求日志,发现一个 40 万 Token 的请求发到 Bedrock 时,API 直接拒绝。后来在 LiteLLM 配置里把
max_input_tokens
调低,或者做上下文裁剪,才没继续触发。
5.5 调整 TCP 缓冲区后仍断流
调大 RISC-V 服务器的
net.ipv4.tcp_rmem
/
tcp_wmem
后,短期有效,但过了两天又断流。后来发现是跨网络链路上的某个中转设备的会话超时太短,当一个 TCP 连接空闲时间超过它允许的空闲时间,它就会悄悄把连接拆掉。
解决办法是启用 TCP Keepalive,并调小重试间隔:
sysctl -w net.ipv4.tcp_keepalive_time=60
sysctl -w net.ipv4.tcp_keepalive_intvl=10
sysctl -w net.ipv4.tcp_keepalive_probes=3
同时请求端也要设置合理的
read timeout
,不是越大越好,而是要和 Keepalive 的节奏匹配,避免一直傻等。
6. 几条越用越值钱的经验
这个项目上线到现在已经稳定跑了三周,压缩后日志写入成功率接近 100%。但回头来看,有几个细节是光看文档学不到的。
先说说监控。Gzip 压缩后我们最担心的是压缩率突然下降,比如如果某个请求的 Token 全是随机文本或加密内容,Gzip 几乎压不动,日志体积又会飙回去。所以我在 LiteLLM 回调里加了压缩比率的统计,一旦压缩后体积超过原始体积的 30%,就告警。现在这个指标已经成了我们判断日志链路健康度的核心信号之一。
其次是压测一定要从 1MB、3MB、5MB 逐步往上加,不要一上来就测试 40 万 Token 的极限场景。很多网络问题在小报文下根本没症状,到了大报文才暴露。用
curl --data-binary @payload_1mb.json
往上递进,能非常快地定位到是哪个环节先扛不住。
最后是我的偏好问题:如果完全由我选,我会优先考虑 zstd 而不是 gzip。zstd 的解压速度比 gzip 高一个量级,在 RISC-V 这种 CPU 资源宝贵的平台上尤其重要。这次因为标题和团队既有要求限制用了 Gzip,但后续我打算在 VictoriaLogs 的摄入端加一层 zstd 支持,进一步把服务端的 CPU 占用压下去。毕竟日志系统拼到最后,拼的从来不是带宽,而是端到端的处理效率。
本文针对LiteLLM网关向VictoriaLogs写入40万Token级大报文时出现的超时、断流和413错误问题,深入分析TCP带宽时延积、RISC-V服务器TCP缓冲区短板、VictoriaLogs JSON解析开销及中间代理限制等根因,并提出在LiteLLM回调层实施Gzip压缩、批量JSON Lines写入、配套调优Nginx与超时参数的完整方案。实测将4.8MB日志压缩至0.45MB,显著提升写入成功率与端到端效率。
272

被折叠的 条评论
为什么被折叠?



