1. 事故现场:告警、拓扑与 413 的误导
那天下午两点,监控面板突然被红色刷屏,LiteLLM 网关到日志链路的所有写入指标都在往下掉。我们这套系统本身不复杂:法兰克福机房部署 LiteLLM 代理,统一转发 OpenAI、AWS Bedrock 等上游大模型请求;同时通过回调把完整请求和响应体回传到另一个区域机房的 VictoriaLogs,做审计和成本分析。接收日志的是一台 RISC-V 架构服务器,单机部署,数据量不算大,本来一直跑得很安静。直到某次业务方接了一个超大上下文任务,单次请求带上多轮历史、工具调用结果和文档片段,LiteLLM 侧的 Payload 规模直接冲到 40 万 Token 级。从那一刻开始,日志写入就开始大面积失败:超时、连接重置、传输到一半被服务端掐断。
这篇文章就把这次排查的完整链路写出来。从 TCP 层抓包到 VictoriaLogs 的接收端配置,再到 RISC-V 平台的性能短板,最后用 Gzip 压缩把问题按住。整个过程中最有价值的不是某个单独参数,而是一条排查思路:在跨国长肥网络里传超大日志,瓶颈往往不在带宽,而在"必须跨链路的字节数"和"接收端消费报文的速度"。
1.1 先看清楚这套架构的数据流
LiteLLM 网关这层,本质是个 LLM 网关,负责鉴权、限流、负载均衡、失败重试,以及对上游请求的归一化。它的 callback 机制非常灵活,我们当时选它就是看中这一点:可以在 success callback 里拿到完整的请求 messages 和响应内容,再把这些原文结构化成 JSON,通过 HTTP POST 推给日志库。
日志库用的是 VictoriaLogs,跑在一台 RISC-V 架构服务器上。为什么选 RISC-V?这里不多展开,核心是这批次服务器是性能验证平台,CPU 指令集和 x86 有两套生态,Go runtime 对 RISC-V 的支持已经比较成熟,VictoriaLogs 作为单机日志库能直接在 riscv64 上跑起来。两个机房之间走的是企业专线,物理距离横跨大半个地球,RTT 稳定在 150ms 左右,带宽 1Gbps。单独看数字都还行,但"高带宽 × 高延迟"组合在一起,恰恰是最折磨人的网络形态,专业叫法是 Long Fat Network,长肥网络。
小报文日志在这条链路上完全没问题,几百 KB 的请求回传,一次 RTT 内就能把数据推完。问题只出现在超大 payload 上。
1.2 两种"大报文"失败不能混为一谈
排查过程中,最先被翻出来的错误是这条:
unexpected status 413 payload too large: upstream provider rejected the request这很容易让人误判成日志链路的问题。实际上它发生在业务链路上:LiteLLM 把请求转发给上游模型 Provider 时,大模型服务商对单请求体大小有硬限制,超了就会返回 413 Payload Too Large。LiteLLM 会把上游的 413 原样透传回来,业务侧看到的就是这个错误。这个和日志落库是两条完全不同的链路,先分开,别混在一起查。
真正让我们头疼的是另一类错误,集中在日志回传链路:
| 错误类型 | 出现位置 | 说明 |
|---|---|---|
context deadline exceeded | LiteLLM 回调客户端 | 发送日志时 HTTP 请求超过客户端超时 |
connection reset by peer | LiteLLM 回调客户端 | TCP 连接被接收端 RST 中断 |
unexpected EOF/transfer closed | 发送端 | 报文传了一段,连接被对端关闭,剩余数据丢失 |
也就是说,业务侧虽然偶发 413,但真正的日志断流和 413 没有因果关系。我们在排查记录里专门建了两个任务:一个去和上游服务商确认单请求大小上限,另一个处理日志链路断流。
1.3 40 万 Token 到底等于多少字节
对 Token 没有直觉的人,很难理解为什么日志会写到超时。先算一笔账:业界一般按 1 Token 约 4 字节估算,40 万 Token 的纯文本大约 1.6MB。但我们的日志不是纯文本,要把请求 messages、响应 completion、tool_calls、usage 统计、时间戳、metadata 全部序列化成 JSON。JSON 里字符串转义、键名重复、换行符和 Unicode 编码都会放大体积。实测下来,一个 40 万 Token 的请求,请求体和响应体合起来序列化后,常见是 5MB 到 8MB。如果请求里还带图片或文档的 base64,那直接就奔着 10MB 以上去了。
后面所有的排查和优化,都以单条日志 8.2MB 这个实测数据为基准。
2. 长肥网络下的 TCP 行为:抓包看到的不是丢包,是窗口饿死
2.1 长肥网络的 BDP 直觉
长肥网络这个名词听起来文绉绉,其实就是一个非常具体的特征:带宽大、延迟也大。衡量一条链路能"装下"多少在途数据,有个物理概念叫带宽时延积(BDP),公式是:
BDP = 带宽 × RTT我们这条专线 1Gbps,RTT 150ms,算下来 BDP 大约是 18.75MB。什么意思?这条链路像一条非常宽但非常长的高架桥,理想状态下桥上应该能同时跑 18.75MB 的车。可 TCP 的发送窗口如果小于这个值,就相当于每个红绿灯周期只放几辆车进桥,桥上大部分路段是空的。带宽再高,实际吞吐也上不去。
传统 TCP 栈的默认 buffer 非常小,系统默认的tcp_wmem通常只有几十 KB 起步,就算有窗口缩放(Window Scale),如果应用没有显式设置大缓冲区,依然容易变成"管道空转"。对于 8.2MB 的单条报文,这已经是一个需要认真对待的量级了。
2.2 tcpdump 抓包的关键证据
我直接在 LiteLLM 所在主机上抓包,命令很简单:
tcpdump -i eth0 -s 0 -w /tmp/litellm_vlogs.pcap 'host <victorialogs-ip> and port 9428'9428 是 VictoriaLogs 的默认 HTTP 服务端口。抓完一次失败样本后,用 Wireshark 打开,重点看三个东西:TCP 序号走势、窗口大小、RST 标志位。
结果很有意思。8.2MB 的报文按 1460 字节的 MSS 拆分,大约要拆成 5700 个 TCP 分段。从头 3000 多个分段传输都比较顺畅,没有严重的丢包重传。问题出在传输中后段:接收端通告窗口开始逐步变小,从 2MB 一路跌到 0,出现 Zero Window,然后紧接着是 RST,连接被直接重置。客户端这边收到 RST 后尝试重连重发,然后又重复一遍"传一半、窗口饿死、RST"的循环,直到客户端自己的超时触发,报context deadline exceeded。
这个现象说明网络本身不是瓶颈,丢包率并不高。真正的瓶颈在接收端:内核缓冲区里的数据没有被应用层及时读走,缓冲区满了,窗口就收缩到零;等了一段时间应用还是没消费完,HTTP 服务端判断这个连接已经"读不下去"了,直接 RST。换句话说,这是一次应用层消费能力不足引发的传输层断流,不是广域网丢包。
2.3 调大 socket buffer 为什么治标不治本
定位到窗口问题后,第一反应肯定是调大系统缓冲区。我们当时做了两组调整:
# 临时调大内核缓冲上限 sysctl -w net.core.wmem_max=33554432 sysctl -w net.core.rmem_max=33554432 sysctl -w net.ipv4.tcp_wmem='4096 65536 33554432' sysctl -w net.ipv4.tcp_rmem='4096 87380 33554432'调完之后确实有效果,5MB 以下的报文能稳定写入了,8.2MB 的报文从"必失败"变成了"看运气失败"。这说明内核 buffer 的限制占一部分原因,但不是全部。因为即使我们给了 32MB 的缓冲区,接收端应用如果消费速度跟不上,窗口照样会收缩。缓冲区只是把问题往后推,并没有解决"VictoriaLogs 处理超大 JSON 慢"这个核心矛盾。
从这一刻开始,排查方向从"网络层"转到了"接收端应用层"。既然 RISC-V 服务器上的 VictoriaLogs 消费大报文吃力,那就得看看它到底卡在哪。
3. 接收端 VictoriaLogs 的硬门槛:读超时、解压开关与平台性能
3.1 从反代到直连,逐层排除
如果日志链路前面挂了 Nginx 或 Caddy 之类的反向代理,第一个怀疑对象是client_max_body_size。Nginx 默认限制只有 1MB,超过直接返回 413。这是非常常见的"大报文落库失败"原因,一定要先排除。
我们当时确实在 VictoriaLogs 前面挂了一个 Nginx 做 TLS 终止和简单路由,查看配置发现client_max_body_size被设成了 2m。不过把 2m 改成 128m 之后,问题依旧。为了彻底排除反代干扰,我直接用 curl 绕开 Nginx,把 8.2MB 的日志 POST 到 VictoriaLogs 的 9428 端口实测:
curl -v -X POST 'http://<victorialogs-ip>:9428/insert/elasticsearch/_bulk' \ -H 'Content-Type: application/x-ndjson' \ --data-binary @large_payload.txt直连仍然复现超时和 RST。这说明问题不在 Nginx,而是 VictoriaLogs 自己处理不过来。接着又用 1MB、2MB、5MB、8MB 四档报文做梯度测试,结果是一个很典型的"软极限"曲线:1MB 完全没问题;2MB 偶发超时;5MB 成功率降到一半;8MB 基本失败。这不是配置里写死了某个体积上限,而是处理时长超过了服务端的读超时阈值。
VictoriaLogs 底层是 Go 写的 HTTP server,接收 body 时有超时控制。8.2MB 的 JSON NDJSON 报文到达后,服务端要分配内存、做 gzip 解压(如果有)、解析 JSON、建立索引、刷盘。这个过程如果耗时过长,超过了 HTTP 层的读超时,服务端就会主动断开连接。发送端看到的 RST 就是这么来的。
3.2 RISC-V 平台在日志接收链路里的软肋
到这里,RISC-V 平台的性能短板开始浮出水面。我们用同一份 8.2MB 报文,在 x86 机器和 RISC-V 机器上分别做直连压力测试,差异非常明显:
| 平台 | 单条 8.2MB 报文入库耗时 | 5MB 报文成功率 |
|---|---|---|
| x86 服务器 | 约 1.1s | 接近 100% |
| RISC-V 服务器 | 约 6.8s | 约 50% |
| RISC-V 服务器(CPU 负载 60% 以上时) | 约 11s+ | 基本失败 |
RISC-V 平台的单核性能和内存带宽目前和主流 x86 还是有不小差距,尤其是 VictoriaLogs 这种对 CPU 敏感的日志处理任务。JSON 解析本身是纯内存操作,指令集架构的差异在这里被放大了。更要命的是,日志服务器上还跑了其他采集任务,CPU 负载一上来,VictoriaLogs 读 body 的速度更慢,超时断流就变成大概率事件。
其实到这一步,问题的本质已经清楚了:不是网络连不通,也不是配置写错,而是单次传输的 Payload 太大,加上接收端消费慢,在长肥网络上把应用层超时逼到了极限。
3.3 VictoriaLogs 对 gzip 请求体的原生支持
VictoriaLogs 的插入 API 原生支持带Content-Encoding: gzip的请求体。也就是说,我们可以在发送端先把日志压缩,再 POST 过去,服务端会自动解压后处理。这个支持非常关键,等于直接把优化点放在"减少必须跨网的字节数"上。
官方文档对于 bulk 接口的说明是请求体可以是纯文本 NDJSON,也可以是 gzip 压缩格式。也就是说,只要 HTTP 头里带上Content-Encoding: gzip,服务端就会先解压再进入解析流程。最开始我们直接用 gzip 压缩后的文件做了一次直连测试:
gzip -k large_payload.jsonl curl -X POST 'http://<victorialogs-ip>:9428/insert/elasticsearch/_bulk' \ -H 'Content-Type: application/x-ndjson' \ -H 'Content-Encoding: gzip' \ --data-binary @large_payload.jsonl.gz单个 8.2MB 的日志在 gzip -6 压缩下变成了大约 870KB,传输体积直接少了一个数量级。跨网络的传输时间从几秒压缩到几百毫秒,接收端压力骤减。虽然 RISC-V 上还要做一次解压,但解压 870KB 和解析 8.2MB JSON 的开销完全不是一个量级。
不过手动 curl 验证和落地到 LiteLLM 回调链路还差一步:得在发送端把 gzip 压缩变成自动行为。
4. Gzip 优化实战:在发送端把带宽需求打下去
4.1 压缩收益先算账
做任何优化之前,先量化收益,别凭感觉。我们当时拿了三种场景对比:原始 8.2MB、gzip -1(最快压缩)、gzip -6(默认平衡档),结果如下:
| 压缩档位 | 压缩后体积 | 跨链路传输耗时(实测) | CPU 压缩耗时(RISC-V) |
|---|---|---|---|
| 不压缩 | 8.2MB | 平均 7.2s,常超时 | 0 |
| gzip -1 | 1.31MB | 约 1.4s | 约 0.9s |
| gzip -6 | 0.87MB | 约 0.9s | 约 2.6s |
gzip -1 和 gzip -6 在压缩率上差距只有 34%,但压缩耗时差了接近三倍。对于日志回传场景,接收端是弱性能的 RISC-V 平台,发送端也是要靠 CPU 做其他转发工作的 LiteLLM 网关,选 gzip -1 是性价比最高的选择。体积从 8.2MB 降到 1.31MB,已经足够让连接稳定写完,没必要为了多压那 400KB 去消耗大量 CPU。
这个账想明白之后,我们定了策略:小日志不压缩,超过 1MB 的 payload 才走 gzip;压缩级别固定为 1;如果网络条件后续继续变差,再考虑升级到 6。
4.2 LiteLLM 侧接入 gzip 回调
LiteLLM 的 callback 体系里,可以在success_callback注册自定义函数。我们在回调里拿到完整的请求和响应对象后,先序列化成 JSON 字符串,紧接着做体积判断和压缩,最后通过 HTTP 发送。核心逻辑大概长这样:
import gzip import json import http.client import zlib def flush_log_to_vlogs(log_bytes: bytes, endpoint: str, timeout: float = 30.0): # 超过 1MB 的日志做 gzip 压缩,低于阈值直接明文发送 should_compress = len(log_bytes) > 1_000_000 headers = { "Content-Type": "application/x-ndjson", "Connection": "keep-alive", } if should_compress: payload = gzip.compress(log_bytes, compresslevel=1) headers["Content-Encoding"] = "gzip" else: payload = log_bytes # 用 http.client 自持连接池,避免每次新建 TCP 连接吃满 RTT conn = http.client.HTTPConnection(endpoint, timeout=timeout) conn.request("POST", "/insert/elasticsearch/_bulk", body=payload, headers=headers) resp = conn.getresponse() resp.read() conn.close()几个关键选择值得说清楚:
- 压缩放在序列化之后,是因为 gzip 对字节序列操作,不关心你上层是 JSON 还是 NDJSON。
Connection: keep-alive在长肥网络下非常重要。跨国新建一个 TCP 连接至少要一个 RTT 的握手时间,150ms 在这种场景下很奢侈。连接池复用能把握手开销摊薄。- 超时时间单独调大。日志不是业务请求,可以容忍慢,但不能因为慢就无限等。我们设为 30s,超过就异步重试。
gzip 压缩的副作用是发送端 CPU 上升。实测在 LiteLLM 所在 x86 机器上,gzip -1 压缩 8.2MB JSON 大约耗时 0.3s,完全在可接受范围内。RISC-V 接收端解压同样大小的数据大约 0.4s,对比解析原始 8.2MB JSON 的 6.8s,还是快得多的。
4.3 压缩级别与数据特征的三角权衡
Gzip 优化里最容易踩的坑就是把压缩级别拉到 9。压缩级别每高一档,CPU 耗时几乎是线性增长,但体积下降越来越平缓,典型的收益递减。在日志场景里,数据组成常常不是纯文本:40 万 Token 的响应里会有大段 base64 编码的图片或文档,base64 本身就是已经编码过的数据,随机性高,gzip 对它的压缩率很差。如果日志里大部分是 base64,那么压缩级别再高也榨不出多少空间,反而白白消耗 CPU。
所以一个实用的经验是:先做一次数据特征抽样,看看 JSON 里的主要成分。如果以自然语言文本为主,gzip -1 到 -6 都能有 5 到 10 倍的压缩率;如果混杂大量 base64,压缩率会掉到 2 到 3 倍,这时不如放弃极致压缩,把精力放在字段裁剪和采样上。
另外,gzip 压缩不建议一条日志压一次。如果日志是几百条小报文各自独立 gzip,每个 gzip 流都有固定的头部和尾部开销,压缩率和 CPU 开销都很不划算。更好的做法是在 LiteLLM 回调里攒批,等积攒到 2MB 到 5MB 的原始数据量后,一次性压缩、一次性发送。
4.4 别忽视 gzip 的缺点
既然标题里带了 Gzip,就必须把它不好的一面也说清楚。gzip 不是银弹,至少有三个缺点在这类场景里要盯住。
第一是 CPU 开销。压缩和解压都不是免费的。发送端压缩是额外工作,接收端解压也是额外工作。RISC-V 平台上的解压速度不如 x86,如果日志流量极大,解压本身也可能成为新瓶颈。我们的策略是只在报文超过阈值时才压缩,低于 1MB 的报文直接明文发送,避免为了省几毫秒网络时间多烧 CPU。
第二是"压缩炸弹"风险。服务端解压时会把整个内容展开到内存,一个很小的 gzip 文件解压后可能膨胀非常多倍。对于 VictoriaLogs 这类日志接收端,虽然有内存保护机制,但客户端还是应该设置单条压缩后报文的大小上限,超过就截断或拒发。别把几十 MB 的压缩包直接扔给服务端。
第三是中间链路兼容性。如果你的日志链路中间还有别的代理,比如 Nginx、负载均衡或者日志采集器,它们可能会对 body 二次处理。有的代理会自动解压再重新压缩,有的则会直接透传,这会导致Content-Length和实际 body 不匹配,出现411 Length Required或者unexpected EOF。上线前一定要做一次端到端验证,最好抓一下服务端收到的 body 是什么编码。
5. 上线验证、回归压测与后续演进
5.1 压测数据对比
Gzip 逻辑上线后,我们做了一轮为期 12 小时的回归测试。对比指标围绕三个:单条大报文入库成功率、P95 延迟、接收端 CPU 占用。
| 指标 | 优化前 | 优化后(gzip -1 + 阈值压缩) |
|---|---|---|
| 8.2MB 单条日志成功率 | 约 30% | 99.9% |
| P95 入库延迟 | 超过 12s,经常超时 | 约 1.3s |
| VictoriaLogs CPU 平均负载 | 68% | 55% |
| 网络入向流量(同批日志) | 8.2MB/条 | 1.31MB/条 |
接收端 CPU 不升反降,这个结果一开始有点反直觉。细想就明白了:RISC-V 平台最重的开销是解析 8.2MB JSON 字符串,而压缩后解压只需要处理 1.31MB 数据,虽然解压消耗一定 CPU,但相比省下的 JSON 解析和内存分配开销,整体负担是下降的。对弱 CPU 平台来说,减少内存分配往往比减少算力消耗更重要。
5.2 几个容易反复的坑
第一坑:Content-Length 对不上。用现成 HTTP 库发送时,如果手动设置 headers 又写了错误的 Content-Length,服务端会一直等 body。建议不要手算,让 HTTP client 根据字节流自动设置。
第二坑:NDJSON 批量里的单条损坏。Elasticsearch bulk 格式是以行为单位的,如果一条日志里混入了换行符没有转义,整个批次解析会错乱。我们当时就遇到过响应文本里自带\n,序列化时没有转义,导致发送端能发出去、接收端解析失败但不报错。解决方法是发送前强制做一次 JSON 格式校验,确保每条记录是合法 JSON 行。
第三坑:连接被中间设备静默回收。跨国专线中间的防火墙或负载均衡设备,会对长时间空闲的 TCP 连接发 RST。即使连接池 keep-alive,如果日志频率不高,连接空闲几分钟就可能被回收。需要在客户端池子里加空闲检测和自动重建,别让一次偶发的 RST 变成整批日志重试。
第四坑:重试风暴。回调链路加了失败重试之后,如果 VLogs 短暂不可用,多条日志同时进入指数退避重试,恢复后瞬间涌入大量压缩报文,又可能把 RISC-V 平台打满。重试必须加抖动和限流,这个坑我们踩过一次,最后靠一个简单的信号量控制并发重试解决。
5.3 后续还能怎么改
Gzip -1 是成本最低的一刀,但不是终点。如果后面日志量继续涨,有几个方向值得探索。
一个是用 Zstandard 替换 gzip。zstd 在相同压缩率下通常比 gzip 快很多,如果 VictoriaLogs 的接收端版本支持对应压缩格式,跨链路的 CPU 开销会更低。不过要确认服务端版本支持,不能只看客户端。
另一个是流式压缩。目前实现里还是先把完整日志字符串存在内存里,再整体压缩。40 万 Token 的响应如果变成 200 万 Token,单条日志可能到 40MB,这时候内存就不够优雅了。改用流式 gzip,一边读取回调数据一边压缩写入请求体,能显著降低峰值内存。
还有一个更实用的做法是字段裁剪。我们审计日志真正必需的字段,其实只有请求摘要、响应摘要、Token 用量和关键元数据。最开始"全量落库"是为了方便排查,但全量 JSON 里大量字段是重复键名和无效 null。上线后我们做了一个白名单模式,只保留真正要查询的字段,日志体积直接降到原来的四分之一。字段裁剪和 gzip 叠加之后,40 万 Token 的完整审计从 8.2MB 降到了 1MB 以内,RISC-V 平台跑得非常轻松。
6. 收尾:一点个人经验
这次攻坚结束后,我们定了一条规矩:凡是跨长肥链路的日志回传,先回答"有多少字节必须过网"这个问题。很多表面上的网络故障,最后都会收敛到应用层没有控制好单次传输的量级。调 TCP 窗口、改内核参数、扩大 buffer,这些操作在长肥网络下当然有意义,但它们解决的是"管道能不能装满"的问题;而当接收端在弱 CPU 平台上消费不动时,最有效的办法永远是让管子里少走一些字节。
Gzip 不是所有场景的银弹,但在这个组合里,它是投入产出比最高的第一刀。压缩级别、数据特征、CPU 开销三个变量要一起权衡,别无脑上 -9。另外就是任何跨链路的传输优化,上线前一定要从发送端一路抓到接收端,确认中间没有任何环节悄悄改动了你的 Content-Encoding 和 Content-Length。
最后分享一个小技巧:在调试这类大报文问题时,可以先在发送端用curl -w观察时间分布,输出里time_starttransfer和time_total的差值能帮你判断是"传输中花的时间"还是"服务端处理花的时间"。我们当时就是靠这个差值,很快锁定了问题不在广域网丢包,而在接收端应用消费能力。希望这套排查链路,能帮你下次少熬几个夜。