以为是模型上下文爆了,结果凶手是 nginx 的 1MB

一次真实故障的完整排查记录:自部署推理引擎 + 代理网关 + Codex 桌面端,"压缩上下文"反复报错 "ran out of room in the model's context window"。引擎清白、两个代理清白、配置清白——最后发现,是 GPU 服务器上 nginx 的 client_max_body_size 默认 1MB,把请求拦在了链路的第三跳。

一、环境与症状

我的环境是一条完全自部署的链路 (svr GPU 服务器, vsvr 为其虚拟IP):

Codex Desktop
   │  /v1/responses(262k 上下文模型)
   ▼
本地 opencodex 代理 (127.0.0.1:10100)
   │  http://vsvr:10100/v1
   ▼
svr 服务器 nginx (vsvr:10100)   ← 凶手在这里,但我最后才看见它
   │  proxy_pass 127.0.0.1:10100
   ▼
svr opencodex 代理 (127.0.0.1:10100)
   │
   ▼
ninfer-serve (127.0.0.1:11436)
   Qwen3.8-27B(v3 制品, fp8 KV, MTP 投机解码)

引擎参数(节选):--max-context 262144 --kv-dtype fp8 --spec mtp --default-max-tokens 262144 --device-state-slots 4 --host-kv-mib 32768。加载后设备侧 KV 容量约 740k token,另有 32GB 主机侧 KV 溢出区。

客户端 catalog 声明:context_window = 262144,auto_compact_token_limit = 235929(约 90% 时触发自动压缩)。

症状:长会话里点"压缩上下文",以及之后的每一个操作:

上下文已压缩
Error running remote compact task: Codex ran out of room in the model's context window.
Start a new thread or clear earlier history before retrying.

当时那个会话的输入大约 23 万 token,窗口是 262144——"上下文爆了"从字面上就不成立。会话历史里有一张 1.1MB 的截图,以及几十段推理内容,序列化后的 HTTP 请求体是 2,407,269 字节(约 2.4MB)。

二、排查过程

1. 先给链路每一层装监控

两台 opencodex 代理都带 /api/logs 环形日志 API。我写了个小 watcher(定时拉取、非 2xx 事件落 jsonl、支持 mark "备注" 在手动复现时打时间戳)。这个工具后面救了我很多次。

2. 手动复现,三方日志对齐

在问题会话上点压缩,然后按 conversationId + 时间窗对齐三层日志:

层 故障时刻的表现
本地 opencodex 400,148ms,错误文案 "The provider rejected this turn because its input exceeds the provider size or context limit…",上游 attempt 400/14ms
svr opencodex 零记录——请求根本没到
ninfer 引擎 零记录;前后的请求全部 200(包括 21 万 prompt 的大请求)

本地代理坚称"上游返回了 400",但 svr 的代理和引擎都查无此请求——说明它被两者之间的某个东西拦下了。引擎到此洗清嫌疑,但"谁拒的"还是悬案。

3. 读源码,发现错误文案是"合成"的

翻 opencodex 源码(context-overflow.ts)看到关键设计:上游返回 413(Request Entity Too Large)时,代理会故意把它转成 response.failed(code: context_length_exceeded) 的终端事件。源码注释写得很直白:裸 413 会被客户端当成可重试的传输错误,把同样的超大 body 重发一遍又一遍;而"上下文超限"是终端事件,会引导客户端走压缩流程。

这解释了为什么报错长得那么"像那么回事":那句 "input exceeds the provider size or context limit" 是代理给 413 写的"翻译",不是模型的判断。

而客户端收到 context_length_exceeded 后,会把该会话标记为"上下文已满"。于是之后任何操作(包括压缩本身)都携带同样的超大 body → 再被 413 → 再标记满 → 会话进入只能重开的死状态。中间有两级"放大器",让一个网络层错误看起来完全像模型层错误。

4. 一系列"排除法"探针

  • 小体积压缩请求(<1MB):本地、svr 两个代理都 200 正常返回摘要;
  • 直连引擎:小 prompt + max_tokens=262144(远超窗口,且当时四路并发正占着 KV)→ 引擎照样 200。这排除了我一度怀疑的"4×262k 申报上限超过 740k 总 KV"的超卖假设——引擎日志全文没有任何一条拒信(从实测看引擎不会按 max_tokens 预扣 KV;不过超卖仍是潜在隐患,见下文);
  • 前缀阶梯:拿问题会话真实的压缩请求(body 约 2.4MB)逐条截断历史——86KB、105KB、251KB、387KB、644KB、818KB、863KB、966KB 各档全部放行(无快速拒绝,进入正常生成),完整版本(约 2.4MB)67ms 内失败;
  • 微调阶梯:固定文本部分在 980KB,只变一张图的大小——0KB 图放行,200KB 图(总计 1.16MB)186ms 内 400。

分界线干净利落地落在 ~1MB。看到这个数字,答案其实已经呼之欲出了。

5. 最后一锤:沿着真实链路打

此前我探测 svr 全部是 ssh 登录后直连 127.0.0.1:10100——绕过了 nginx(nginx 只听 vsvr:10100)。"svr 侧零记录"这个现象,其实早就在提示我链路中间还有一层没被观测。

这次把同样的约 2.4MB 请求体直接打向 svr 对外入口:

HTTP 413
nginx/1.28.3: Request Entity Too Large

凶手现形。翻 /etc/nginx/conf.d/opencodex.conf:那个 vhost 只配了 proxy_set_header 和超时,client_max_body_size 压根没配——nginx 默认就是 1m。约 2.4MB 的请求体,nginx 读完 body 的瞬间就回了 413,后面的 opencodex 和引擎连影子都没见到。

三、完整因果链

请求体约 2.4MB(含 base64 截图)
  → nginx(默认 1MB 上限)413,请求未到达 opencodex/引擎
  → 本地 opencodex 把上游 413 合成为 context_length_exceeded 事件
  → Codex 渲染成 "ran out of room in the model's context window"
  → Codex 把该会话标记为"上下文已满"
  → 之后任何操作(含压缩)都带同样的超大 body → 再被 413 → 死循环

四、修复

/etc/nginx/conf.d/opencodex.conf 里 location 部分增加一行 client_max_body_size 256m; 最终文件内容如下

# /etc/nginx/conf.d/opencodex.conf
upstream ocx_backend {
    server 127.0.0.1:10100;
    keepalive 32;
}

server {
    listen vsvr:10100;
    server_name vsvr;

    location / {
        client_max_body_size 128m;  # 核心:agent 场景请求体随历史无上限增长
        proxy_pass http://ocx_backend;
        proxy_http_version 1.1;

        # 关键:把 Host / Origin 洗成 ocx 认可的白名单值
        proxy_set_header Host "localhost:10100";
        proxy_set_header Origin "http://localhost:10100";
        proxy_set_header Referer "http://localhost:10100/";

        # 真实来源另存,便于排查
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $http_host;

        # 流式响应
        proxy_buffering off;
        proxy_read_timeout 3600s;   原 300s,实测有跑 7 分钟一轮的生成,有断流风险
        proxy_send_timeout 3600s;
    }
}
sudo nginx -t && sudo nginx -s reload

改完回到那个"废"会话点"压缩上下文"——成功了,会话救活,连重开都省了。

为什么要加一层 nginx 呢? 因为 opencodex 只接受来自 localhost 的请求,通过 iptables 做 nat 或 socat 转发都不行,因为 opencodex 不知是看 IP 地址,还看内容,而内容中的真实地址无法被 nat 或 socat 替换掉,所以只能加一层 nginx。

五、踩过的坑(按痛感排序)

  1. 错误信息是合成的,别按字面理解。 "context window 满了"实际可能是 HTTP 层的 413。看到代理报 context 超限,第一步应怀疑传输层:nginx、网关、负载均衡的 body 上限与超时配置。
  2. 探针要沿真实链路打。 我 ssh 到 svr 后直连 127.0.0.1,绕过了 nginx,导致"svr 侧零记录"被误读成"svr 代理清白"。生产流量走对外 IP,探针也必须走同一入口——loopback 上的行为不能代表外部入口。
  3. 图大 ≠ token 多,但图大 = body 大。 1.1MB 的图在模型侧只折算几百到上千个视觉 token(窗口装得下),但在 HTTP body 里实打实是 1.4MB 的 base64。nginx 卡的是字节,不是 token。agent 会话每轮全量重放历史,早期塞进去的图会反复占用每一轮的 body。
  4. nginx 默认值全是"看起来够用"的坑。 client_max_body_size 1m、proxy_read/send_timeout 60s/300s,给人用的 API 没问题,对 agent 流量全部偏小。凡是在 LLM 网关前面挂 nginx,这两项必须显式调大。
  5. 代理的"fail-open"设计会扭曲问题现场。 本地代理没配 contextWindow,它的输入预检直接放行,所以问题确实不在代理自己的门槛;但也正因如此,nginx 的 413 经过代理"翻译"后,连错误文案都指向了错误的方向。
  6. KV 超卖是另一个伏笔。 4 并发 × 262144 的申报上限合计 1.05M,超过 740k 的设备 KV 总量。这次不是它(引擎不按 max_tokens 预扣),但极端并发下可能真的打爆,稳妥做法是把 --default-max-tokens 收敛到 32768 量级。
  7. 先钉死"什么变了"。 我一度以为是代理版本回归;后来澄清 v2/v3 是 ninfer 引擎制品/程序版本(v2 格式模型 + 支持 v2 的旧程序 → v3 制品 + ninfer-serve v3),两台机器的代理都是同一版本。版本变量没钉死,就会把精力花在错误方向上。

六、可复用的排查套路

对"多级代理 + 自部署引擎"的架构,这套流程相当有效:

  1. 每层都有环形日志,配一个小 watcher 记录异常事件,手动复现时打时间戳,事后按时间窗对齐;
  2. 三层对齐:本地代理 / 远端代理 / 引擎,按 conversationId + 时间窗对。"A 层说上游拒绝了,下一层却完全沉默" 是强信号:中间存在你没纳管的一层(nginx、LB、安全组);
  3. 重放真实失败的请求:从会话历史(rollout)重建同尺寸请求,做"前缀截断 / 图片替换"阶梯,找到"过与不过"的精确字节分界线,再去看哪一层恰好有那个分界值;
  4. 探针走生产入口,不要用 loopback 冒充对外入口。

七、补充

由于 codex->opencodex->nginx->opencodex->ninfer-server 这样的多步中转,使得直连情况下本没有的问题会出现。除了内容大小外,还有一个能引起 codex 压缩上下文失败的原因是超时,两个 opencodex 和一个 nginx 都有超时设置,ninfer-server 本身也有(--pending-timeout-ms 3600000)。任何一关超时太小都会导致上下文压缩失败。当多个长任务轮流 prefill 时会没有输出导致长时间等待。opencodex 默认好像只有5分钟,不改就会超时。所以必须在两个 opencodex 的配置文件 ~/.opencodex/config.json 中最顶层加入如下一句

  "stallTimeoutSec": 3600

或者使用命令 ocx config set stallTimeoutSec 3600 添加如上设置,将超时设为一小时,然后 ocx service restart 重启服务生效。

此外,还有另外一个超时设置,那就是关于非流式输出的,非流式时要等模型思考完给出答案后整体一次性输出,但如果这个时间太长超过默认的超时(好像是200s)就会被中断,导致无法输出。要么改流式输出,要么增大如下超时,然后重启 ocx 服务。

ocx config set connectTimeoutMs 1800000

写在最后

真正"上下文窗口爆了"(引擎 KV 在生成时打满)的错误,是引擎自己报的,文案和时机都完全不同。如果请求根本没到达模型,第一嫌疑人应该是网络层,而不是模型。

而这次最讽刺的一点:系统里每个组件的报错都在如实描述自己做的事——nginx 拦了大包,代理把 413 翻译成上下文超限,客户端按翻译行事——每一层单独看都没错,串起来却指错了方向。链路越长,越要确保自己观测到了每一层。

最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容