server: streaming responses advertise Keep-Alive but the connection is closed after the stream, causing ReadError/RemoteProtocolError on poo

当 llama-server 对 stream: true 的流式响应仍然返回 Keep-Alive 头,但写完最后一个 chunk 后立即关闭 TCP 连接时,使用连接池的 HTTP 客户端(如 Python httpx、Go net/http、.NET HttpClient)可能复用这条“即将关

快速结论:当 llama-server 对 stream: true 的流式响应仍然返回 Keep-Alive 头,但写完最后一个 chunk 后立即关闭 TCP 连接时,使用连接池的 HTTP 客户端(如 Python httpx、Go net/http、.NET HttpClient)可能复用这条“即将关闭”的连接,随后收到 ReadError 或 RemoteProtocolError。优先排查服务端流式响应与连接关闭行为是否一致。

适用环境:Issue 中确认的环境包括:llama.cpp 的 llama-server(涉及 commit 272700b、build 11347/5fc4f3c8c、b11977、b12182,cpp-httplib 0.58.0/0.59.0);Linux(Ubuntu 24.04、x86_64、CUDA)以及 macOS arm64、Metal;复现的客户端包括 Python httpx、Python http.client、Go net/http(经 llama-swap v261 代理)、.NET HttpClient。Python、CUDA、PyTorch 的具体版本号在 Issue 中未给出。

最快修复方案:暂无确认的一步修复方案。Issue 中提出的“在 sink.done() 之后返回 true”以及“对流式响应发送 Connection: close”两种改法均未经测试验证;客户端侧已验证可降低错误的临时办法是禁用对 llama-server 的连接复用,例如 httpx 设置 httpx.Limits(max_keepalive_connections=0),或对每个请求发送 Connection: close。

注意事项:该问题具有间歇性,复现比例较低(Issue 中报告约 0.3–2.5% 的请求受影响,部分测试轮次为 0),因此单次测试“没报错”不能证明已修复。禁用连接复用会增加建连开销,只宜作为客户端侧临时规避,不能修正服务端头部与连接行为不一致的根因。

问题场景

用户在运行 llama.cpp 的 llama-server 时,通过 OpenAI 兼容接口 /v1/chat/completions 发起 "stream": true 的流式请求。服务端在响应头中声明 Keep-Alive: timeout=5, max=100,并完整发送了 chunked 结束块 0\r\n\r\n,但随后马上关闭连接。

使用连接池的客户端(如 FastAPI 代理中的 httpx.AsyncClient、llama-swap 的 Go net/http transport、.NET HttpClient)会把这条连接放回池中,并在关闭到达之前写入下一个请求,导致该请求丢失:llama-server 日志中没有任何对应记录,请求也从未到达处理槽位。非流式请求则表现为正常保持约 5 秒连接。

报错原文

server: streaming responses advertise Keep-Alive but the connection is closed after the stream, causing ReadError/RemoteProtocolError on pooled HTTP clients

httpx.ReadError
httpx.RemoteProtocolError

HttpIOException: The response ended prematurely (ResponseEnded)
RemoteDisconnected: Remote end closed connection without response
proxy error: EOF

原因分析

根据 Issue 中对代码的阅读,问题出在 tools/server/server-http.cpp 的 chunked content provider:在 !has_next 分支中先调用 sink.done(),然后仍然 return has_next;(即返回 false)。

cpp-httplib 把 content provider 返回 false 视为写入被取消(Error::Canceled),于是把该请求当作失败处理并关闭连接——即使结束块已经写出、响应头也已声明 Keep-Alive。这解释了为什么流式响应总是提前断开,而非流式响应正常。

第二个独立复现中,llama-server 侧先进入 TIME-WAIT,说明是服务端先关闭了连接。所谓“偶发失败”本质上是客户端把关闭通告到达之前的那段时间窗口里的复用请求发到了已死连接上;Go 不会对复用连接上的 POST 自动重试,因此表现为 502。

环境排查

  • 确认 llama-server 构建版本:Issue 涉及 commit 272700b、build 11347(5fc4f3c8c)、b11977、b12182,当前 master 上该代码路径仍存在。
  • 确认捆绑的 cpp-httplib 版本:0.58.0 与 0.59.0 均观察到该行为。
  • 确认操作系统与后端:Linux(Ubuntu 24.04、x86_64、CUDA)或 macOS arm64、Metal。
  • 确认客户端是否启用了连接池/keep-alive(httpx 的默认连接限制、Go 默认 transport、.NET HttpClient 默认复用)。
  • 确认请求是否为 stream: true;非流式请求不应出现该现象。
  • 检查客户端与 llama-server 之间是否有代理层(如 llama-swap),代理自身会池化上游连接,从而放大该问题。

解决步骤

  1. 先用 Issue 提供的裸 socket 最小复现确认现象:发送一个 stream: true 的 chat completions 请求,读取到 0\r\n\r\n 后继续等待,若立即收到空读(连接关闭),则符合本 Issue 描述。
  2. 对照发送同样请求但 "stream": false 的情况;若非流式连接能保持约 5 秒,说明只有流式路径提前关闭。
  3. 客户端侧临时规避:对 llama-server 的 httpx 客户端设置 httpx.Limits(max_keepalive_connections=0) 关闭连接复用;Issue 报告该设置下 788 次请求 0 错误。
  4. 或者对发往 llama-server 的每个请求显式发送 Connection: close,使客户端不将该连接放回池中(第三个复现中给出的客户端侧办法)。
  5. 服务端侧可优先尝试 Issue 建议的改法(未经测试验证):在 !has_next 分支调用 sink.done() 后返回 true,让 cpp-httplib 正常完成响应,使连接行为与响应头声明一致。
  6. 另一种服务端侧可选思路(同样未经测试):若本意是不保持连接,则对流式响应发送 Connection: close,并从响应头中移除 Keep-Alive。
  7. 任一服务端改动实施后,重新用裸 socket 复现脚本确认:写完结束块后连接应保持,或响应头明确声明关闭。

验证方法

使用裸 socket 脚本检查:流式响应头中若仍有 Keep-Alive,则发完请求后连接应保持打开(与 timeout=5 声明一致);若改为 Connection: close,则响应头不应再出现 Keep-Alive,且客户端不应复用该连接。

在连接池客户端侧,可在流式请求结束后 15–30 ms 内立即发送下一个请求进行压测,观察是否仍出现 ReadError、RemoteProtocolError、RemoteDisconnected 或代理 502;由于问题间歇出现,需要多轮较大样本量(Issue 中单轮为数百次请求)才有参考意义。同时确认 llama-server 日志中不再出现“请求无记录即失败”的情况。

参考来源

ggml-org/llama.cpp #29680

GamsGo AI

AI 工具推荐

想把多个 AI 模型放在一个入口?

GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。

了解 GamsGo AI

推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。

这个方案解决了吗?

celebrityanime
celebrityanime
文章: 35203

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注