先交代一下背景。我们内部有一套面向跨境的LLM代理网关统一用LiteLLM把不同模型提供商的接口收拢成一个入口。业务方为了做长上下文分析一次请求的Token量经常顶到40万级别这意味着LiteLLM拿到的原始请求和响应日志单条就能到好几MB。这些日志需要跨洋送到一台RISC-V架构的边缘节点落进VictoriaLogs做集中存储。问题就在这条链路上冒了出来超大Payload、跨国长肥网络的高延迟、TCP窗口限制再加上RISC-V节点本身不算富裕的CPU和IO超时、断流、丢日志轮番上演一天能逼疯三个值班的同学。这篇文章就是把当时排查和改造的全过程摊开来讲。适合正在用LiteLLM做网关、想往轻量日志系统里灌大报文的团队参考也适合在边缘设备上鼓捣VictoriaLogs的朋友避坑。我会先把链路和账算清楚再讲Gzip压缩怎么落地最后把踩过的坑整理成速查表。1. 事故现场大报文在跨国链路上反复超时1.1 链路拓扑和数据流先画一下整个请求的走向。客户端调用LiteLLM网关时LiteLLM会同时做两件事一是把请求转发给上游的模型服务商二是把请求和响应的上下文写入我们的日志系统。由于模型调用本身走的是海外节点日志落库也要送到另一台海外边缘服务器也就是说日志数据跨的是同一条国际链路只是目标端口不同。我们的日志节点硬件是RISC-V架构的64位开发板跑着一个单二进制VictoriaLogs。VictoriaLogs本身很轻官方推荐用静态二进制直接启动无需额外依赖。它提供兼容Elasticsearch和Loki的写入API我们用的就是/insert/elasticsearch/_bulk这类批量接口。正常情况下这个组合很稳但一旦单条请求体超过几MB问题就像定时炸弹一样爆发。链路可以简单归纳为LiteLLM日志回调 → 国际网络链路长肥网络 → RISC-V边缘节点 → VictoriaLogs → 落盘1.2 现象时间线和错误信息故障的爆发不是突然全部挂掉而是一点点恶化的。最初只是偶发超时LiteLLM的日志回调里出现Request timed out几秒后自动重试又成功了所以业务无感知。随着长上下文请求变多开始出现connection reset by peer、EOF。再到后期VictoriaLogs侧出现大量写入失败部分日志批次直接丢弃。如果打开LiteLLM的debug日志会看到类似这样的堆栈httpx.ReadTimeout: timed out ... openai.APITimeoutError: Request timed out.而RISC-V节点上抓到的TCP连接状态几乎全是TIME_WAIT和少量SYN_SENT堆积。VictoriaLogs的日志里偶尔会出现读body超时、http: request body too large之类但更多时候是连接被提前断开服务端根本来不及报错。这个阶段很容易误判第一反应是VictoriaLogs配置问题或者RISC-V节点性能太差。但把超时时间一调再调问题依旧。后来才明白根子根本不在“服务处理得慢”而是“数据在链路上根本运不完”。1.3 为什么长肥网络是头号嫌疑人“长肥网络”这个词网络工程师不陌生英文叫Long-Fat Network指带宽高、时延也高的链路。典型特征就是带宽时延积BDP非常大。跨太平洋的专线RTT通常在150毫秒到300毫秒之间带宽可能有几百Mbps。这种情况下TCP的拥塞窗口和接收窗口如果不够大发送端永远跑不满带宽。你可以这么理解TCP是水管窗口大小就是水龙头能开的最大开度RTT是水管长度。水管又长又粗但水龙头开度小水流量就上不去。对1-2MB的小报文水龙头慢慢开也能凑合对3-5MB的报文传输时间会肉眼可见地变长一旦中途丢包触发拥塞控制整个管道就会剧烈抖动表现为连接频繁reset和超时。“能通但很慢且不稳定”这就是长肥网络下大报文的真实处境。2. 从字节数到TCP窗口把账算清楚2.1 40万Token级Payload到底有多大动手优化之前先得把字节数算明白。很多人一听40万Token以为是40万个字符实际完全不是一回事。Token是模型分词后的最小单位一个英文Token大约是4个字符一个中文Token大约是1.5到2个字。拿英文例子算40万Token × 4字符 160万字符按单字节ASCII算这就是1.6MB但日志里还有JSON结构、角色标记、时间戳、请求ID等额外字段这部分通常会再膨胀50%到100%如果上下文里有中文情况更糟。JSON序列化时非ASCII字符有可能被转成\uXXXX的形式一个UTF-8编码的汉字原本占3字节转义后变成6个ASCII字符体积直接翻倍。再如果请求里还带了Base64编码的多模态输入Base64本身就会让二进制数据膨胀33%。所以一个看起来“只有”40万Token的请求序列化成JSON日志后Payload轻松突破4MB甚至5MB。如果LiteLLM在回调里还附带了一些元信息6MB也不是没可能。2.2 带宽时延积与TCP窗口的计算为了让大家理解为什么4MB报文在长肥网络上会超时我列一组实际测试中的数据。假设国际链路RTT为250毫秒客户端在TCP连接建立后初始拥塞窗口可能只有10个MSSMSS通常是1460字节也就是大约14.6KB。随着每个RTT翻倍要传到1MB窗口大概需要7个RTT也就是接近2秒。好不容易把窗口涨到4MB中途一次丢包就会让窗口减半之前的努力基本白费。按照理论吞吐公式理论吞吐上限 TCP窗口 / RTT如果TCP窗口被限制在64KB常见内核默认值之一RTT为0.25秒那么理论吞吐只有256KB/s也就是约2Mbps。一个5MB的Payload在这个链路上需要约20秒才能传完。如果接收方的HTTP服务器超时设置在10秒以内那必然触发读超时。即使把TCP窗口调到8MB理论吞吐上限能到32MB/s但这时又会撞上链路的实际带宽上限而且只要丢包率高于0.1%拥塞控制算法会不断拉低发送速率。跨国公网链路的丢包率通常在0.1%到1%之间所以实测吞吐往往连理论值的20%都不到。这也是为什么单纯调大超时根本不管用——数据量太大传输时间太长连接中途被运营商或中间设备清理掉重试又引发新的超时形成恶性循环。2.3 RISC-V侧的解码与落盘压力网络传输只是上半场真正落库还得看RISC-V节点的“消化能力”。我们的边缘节点用的是某款支持RV64GC的板子CPU主频不高内存也不大存储走的是eMMC或NVMe转接卡。VictoriaLogs本身对硬件要求不高但有一个前提数据能及时进入它的解析管线。当Payload达到4MB时VictoriaLogs要完成几件工作从HTTP body读入、按内容类型解析、建立索引、刷盘。如果数据是流式到达还好但如果整包到达4MB的数据就会瞬时占满节点内存的很大一部分触发内存分配和GC。再加上RISC-V平台上的Go运行时如果没针对具体微架构做优化解析这种大JSON的时间会比x86慢不少。更要命的是如果网络传输不完整客户端断了服务端解析到一半发现EOF前面所有的CPU时间全部浪费。网络断流一次服务端就白算一次。所以解决问题的关键不是让服务端更快而是让网络上传输的数据量先降下来。3. 排查实录从LiteLLM日志到tcpdump抓包3.1 第一轮错误码画像排查这类问题我习惯先把错误码分门别类。ReadTimeout请求发出后服务端在预期时间内没有返回任何数据。ConnectionReset / ConnectionAborted中间链路把TCP连接强制断开。EOF对端在没有正常发送body结束标记的情况下关闭连接常见于反代或服务端主动断开。413 Payload Too Large某个中间层限制body大小直接拒绝请求。request body too largeVictoriaLogs或前置web server主动拒绝。第一轮排查时我把LiteLLM回调日志里出现的错误都按类统计了一下。ReadTimeout占六成ConnectionReset占三成剩下的是零星的EOF和413。这说明问题主要出在“数据运输”环节而不是服务端明确拒绝。3.2 第二轮网络实测靠日志猜不如直接测。我分别在LiteLLM所在节点和RISC-V节点上做了三组测试。先ping测一下RTT和丢包ping -c 100 -i 0.2 RISC_V_NODE_IP结果RTT均值在230毫秒左右丢包率不到0.5%基础链路还能用。然后用iperf3压一下带宽iperf3 -c RISC_V_NODE_IP -P 4 -t 30实测单流TCP吞吐非常惨只有大概30Mbps到50Mbps之间明显没有跑满我们的专线带宽。多流加起来能到200Mbps以上说明瓶颈不是带宽本身而是单条TCP流的窗口和拥塞控制。接着我直接用curl向VictoriaLogs发了一个4MB的测试body并限制超时时间为15秒curl -X POST http://RISC_V_NODE_IP:9428/insert/elasticsearch/_bulk \ -H Content-Type: application/json \ --data-binary large_payload.json \ --max-time 15 -v十五秒内没传完又试了几次偶尔能传完但耗时超过20秒。这就对齐了之前算的账4MB数据在约250ms RTT、40Mbps有效吞吐的链路上理论最短时间接近0.8秒但拥塞控制导致实际到达时间远远大于理论值。3.3 第三轮抓包与内核参数验证最后一步抓包确认。在RISC-V节点上执行tcpdump -i eth0 -s 0 -w /tmp/over_mtu.pcap tcp port 9428抓下来的包用Wireshark打开能看到明显的现象TCP Window经常缩到很小甚至出现TCP ZeroWindow说明发送端数据堆积、本地缓冲区满了或者接收端处理不过来。同时出现了不少TCP Fast Retransmission和Dup ACK说明链路确实有轻微丢包。到这里根因就明确了大Payload4MB以上长肥网络的RTT高、单流吞吐低TCP窗口与缓冲区没有针对BDP做调优丢包引发的拥塞控制进一步拖慢传输速率最终导致客户端HTTP超时、服务端断开连接。调整策略自然就清晰了第一压缩Payload减少数据量第二调优TCP缓冲区让窗口配得上BDP第三在RISC-V侧做好解压和异常兜底。其中收益最大的就是压缩这也是标题里“Gzip优化”的核心。4. Gzip压缩改造从3MB到300KB4.1 为什么选Gzip兼容性与压缩比压缩算法其实有得选。zstd压缩比更高、速度也快但它在老版本的OpenSSL、HTTP库、嵌入式环境里未必默认支持。LZ4速度极快但压缩比较低适合内网不适合跨国链路。最后选Gzip主要是三点考虑Gzip是HTTP标准中定义最完善的压缩格式Content-Encoding: gzip几乎被所有服务端和中间件识别。Go、Python、Node.js等主流运行时都内置了gzip库RISC-V的Linux内核也支持透明解压。对文本型JSON日志gzip的压缩比通常在10倍到15倍之间已经能满足需求。看一组实测数据。一个5MB的JSON日志文件gzip默认级别压缩到约380KB压缩比约13:1。如果同一份数据用LZ4压缩压缩后约1.6MB用zstd最高级别压缩到约320KB但CPU耗时和兼容性成本都更高。跨国链路上传输时间才是稀缺资源所以380KB的gzip方案性价比最高。4.2 LiteLLM侧的改法回调压缩异步投递LiteLLM本身支持自定义回调。我们写了一个Python回调在请求完成后异步拿到request和response的Payload先用gzip压缩再通过打包好的HTTP请求发往VictoriaLogs。核心思路是压缩工作放在LiteLLM所在的高性能x86节点上完成RISC-V节点只负责解压、落库不做高开销的压缩。如果发送失败还需要有一套重试和本地缓冲的机制防止日志丢失。下面是一个简化版的回调片段做了三件事序列化、gzip压缩、发送到VictoriaLogs。import gzip import json import httpx class GzipLogCallback: def __init__(self, victorialogs_url: str): self.victorialogs_url victorialogs_url async def log_event(self, kwargs, response_objNone, start_timeNone, end_timeNone, print_verboseFalse): row { request_id: kwargs.get(litellm_request_id), model: kwargs.get(model), messages: kwargs.get(messages), response: response_obj } body json.dumps(row, ensure_asciiFalse).encode(utf-8) compressed gzip.compress(body, compresslevel6) headers { Content-Type: application/json, Content-Encoding: gzip, ACCOUNT_ID: logs } async with httpx.AsyncClient(timeout30) as client: resp await client.post( self.victorialogs_url, contentcompressed, headersheaders ) if resp.status_code ! 200: raise RuntimeError(flog ingest failed: {resp.status_code})需要注意的是Content-Encoding: gzip头必须写对。这样接收端如果支持自动解压就能直接处理如果不支持我们会在RISC-V侧做一个解压代理来解决。4.3 RISC-V侧解压VictoriaLogs接入代理VictoriaLogs默认是否会自动解压Content-Encoding: gzip的请求体实测结论是部分接口支持但不要完全指望它。我们的做法更稳妥在RISC-V节点上跑一个轻量反向代理专门负责接收gzip请求体、解压后再转发给本地VictoriaLogs的HTTP端口。选型上直接用Go写一层是最省事的。Go对RISC-V有很好的交叉编译支持而且标准库的compress/gzip性能不差占用内存也小。核心代码逻辑不复杂package main import ( compress/gzip io log net/http net/http/httputil net/url ) func deGzipMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get(Content-Encoding) gzip { gzReader, err : gzip.NewReader(r.Body) if err ! nil { http.Error(w, invalid gzip body, http.StatusBadRequest) return } defer gzReader.Close() r.Body gzReader r.Header.Del(Content-Encoding) } next.ServeHTTP(w, r) }) } func main() { target, _ : url.Parse(http://127.0.0.1:9428) proxy : httputil.NewSingleHostReverseProxy(target) http.Handle(/, deGzipMiddleware(proxy)) log.Fatal(http.ListenAndServe(:9428, nil)) }这个代理监听在另一个端口上比如9420LiteLLM回调的写入地址从9428改成9420即可。解压后的数据会原封不动转发给VictoriaLogs不会改变原有接口语义。如果不想常驻一个Go进程也可以直接用Shell管道做离线导入适合非实时、批量补偿的场景gzip -dc /tmp/logs_20250101.gz | curl -X POST \ http://127.0.0.1:9428/insert/elasticsearch/_bulk \ -H Content-Type: application/json \ --data-binary -4.4 压缩级别选择与实测结果压缩级别是另一个需要抠的细节。compresslevel9压缩率最高但CPU开销也最大。我们最初在LiteLLM侧用compresslevel9压缩一个4MB的JSON耗时约300毫秒压缩后330KB换到compresslevel6耗时降到150毫秒压缩后380KB。在跨国链路上这个差距也就多传几十毫秒而CPU省下的时间很可观。后来我们干脆对不同的请求做分流普通日志用compresslevel6超大Payload再用compresslevel9。因为超大Payload本身占比不高多花一点CPU换取更小的传输体积是划算的。改造后的效果非常直观。原来4MB的Payload压到400KB左右在同样的网络条件下传输时间从20秒以上降到2秒左右超时和断流大幅减少。这个优化的收益不是线性的而是跨过了一个质变点——因为传输时间低于HTTP超时阈值后整个链路就稳定了不再触发重试风暴。5. 避坑手册我在这次攻坚中记住的六件事5.1 413 Payload Too Large中途我们还收到过unexpected status 413 payload too large: upstream provider rejected the request。这个错误有两种场景一种是LiteLLM转发给上游模型服务商时上游拒绝过大的请求体另一种是前置nginx拦截了发送给VictoriaLogs的压缩包。如果是前者最简单的做法是压缩后分片传输但很多模型服务商不接受分片后的对话上下文。更实际的做法是设置LiteLLM的max_payload_size把超大请求直接拦截并提示业务方降级。如果是后者修改nginx配置即可client_max_body_size 20m;记得改完reloadnginx -s reload5.2 gzip format violated压缩方案上线后RISC-V侧日志中出现了cuda gzip: stdin: invalid compressed>xxd -l 4 compressed_file.gz如果不是基本可以判定发送端在压缩时出了问题。常见原因有两个一个是发送端用C#的GZipStream生成数据时如果代码里没加Fzip注释某些解析器会拿不到正确的文件名头另一个原因更常见——数据在传输过程中被截断长度不够解压到一半就报错。我们的实际诱因是网络抖动导致包被切断而后台重试时用了同一个已经损坏的压缩文件。解决方法是写一个校验函数在解压前对原始gzip流做crc32校验。gzip -t compressed_file.gz echo OK || echo CORRUPTED只有校验通过才进入后续落库流程。5.3 RISC-V交叉编译VictoriaLogsVictoriaLogs官方发布的二进制不一定有RISC-V版。好在它是Go项目交叉编译很顺畅。我在x86构建机上执行GOOSlinux GOARCHriscv64 CGO_ENABLED0 go build -tags vectorindex ./app/victoria-logs需要注意两点一是CGO_ENABLED0必须设置否则Go会尝试用cgo交叉编译会失败二是构建产物的启动参数和x86版保持一致默认数据目录victoria-logs-data、HTTP监听端口9428即可。如果你用的开发板是RISC-V 64位且内核较老建议静态编译。静态二进制在目标机上跑起来更省心不需要安装一堆依赖库。5.4 超时参数与重试风暴压缩虽然解决了大问题但网络抖动仍在。我们保守地把LiteLLM侧写入VictoriaLogs的HTTP超时设为30秒重试次数设为3次并在重试之间增加退避延迟。retry( reraiseTrue, stopstop_after_attempt(3), waitwait_exponential(multiplier1, min2, max10) )不退避的后果是灾难性的上游某个请求超时所有日志回调同时重试一瞬间把网络打满RISC-V节点跟着被压垮。经验之谈是宁可丢一小部分日志也不能让日志重试拖垮核心链路。5.5 日志幂等与去重网络重试带来的另一个问题是日志重复写入。LiteLLM回调重试时同一份日志可能被连续发两次。虽然VictoriaLogs不会因此崩溃但日志量翻倍会浪费存储和流量。我们引入了request_id作为去重键在VictoriaLogs的导入配置中启用基于该字段的幂等去重。如果不支持按字段去重也可以在回调侧维护一个最近500个请求ID的滑动窗口重复的直接丢弃。这个不算复杂但对日志质量的帮助很直观。5.6 写监控盯住“断流数”而不是“超时数”最后是监控。我们最初只告警超时次数结果超时阈值一调大告警就全消失了但断流还在。后来我把RISC-V节点上connection reset、EOF、read timeout等指标单独拿出来做了一个“异常断流数”面板。只要这个指标持续上升就说明大Payload又开始出现不管超时阈值是多少都能发现。6. 优化后的最终效果与一点心得体会压测和上线后的数据简单整理一下。指标优化前优化后单条Payload大小4MB-5MB350KB-450KB跨国传输耗时20秒以上1-3秒HTTP超时告警每半小时数次基本消除VictoriaLogs入参成功率约70%超过99.9%日志重复率高重试导致低去重后压缩和TCP调优是组合拳。RISC-V节点上我还顺手调整了内核参数让TCP缓冲区匹配带宽时延积net.core.rmem_max 16777216 net.core.wmem_max 16777216 net.ipv4.tcp_rmem 4096 87380 16777216 net.ipv4.tcp_wmem 4096 65536 16777216这一组参数并不是越大越好但针对250ms左右RTT的链路16MB窗口可以明显提升单流吞吐。我自己最大的体会是这类“跨国链路超大Payload”的问题看起来像网络问题实际是压缩、超时配置、TCP参数、服务端接收能力四层问题叠加的结果。哪个环节都差一点叠加起来就是断流。压缩是投入产出比最高的一环但对技术细节的尊重也很关键——光会调gzip级别不够还得懂为什么接受端会报invalid compressed data为什么413可能来自上游而不是自己的服务。这一套方案上线后日志写入稳定了很多VictoriaLogs也没有再出现过大规模断流。如果你也在跨国环境里给LiteLLM配日志落库或者打算把大Payload送到RISC-V这类边缘节点希望这篇排障记录能帮你少踩几个坑。