上周三晚上我在一个三十多万行的老仓库里让 Claude Code 做一次跨模块的接口重命名。前面两千行改得挺顺到第三批文件时终端突然甩出一行API Error: Connection lost mid-response。我以为是小概率抖动回车重试这次换了张脸Connection dropped (ECONNRESET)。同一份任务前一次断在输出的后半段后一次连头都没起来。折腾到凌晨一点我把这两种报错彻底拆开才明白它们根本不是同一类故障套同一个重试就能解决的念头是错的。这篇文章就把我这一路踩下来的东西完整摊开Connection lost mid-response和ECONNRESET在 Claude Code 这类流式对话客户端里分别意味着什么、为什么会挑长回答下手、怎么用几条命令在十分钟内定位到具体是哪一层出了问题、以及客户端侧有哪些真正管用的改法。读者对象是每天用命令行 AI 工具处理真实代码库的人也包括自己写脚本调流式接口的开发者。不需要你是网络专家但我会把每个判断背后的原理说清楚因为下次报错换个马甲你得能自己认出来。1. 两种报错不是一回事先拆清楚再动手很多人看到这两行报错就条件反射地加大超时、换网络、清缓存三件套刷一遍有时候刚好赶上抖动过去了就以为修好了。实际上这两种错误的成因分布差别很大混着治只会让你在错误的路上越走越远。先花五分钟把概念理清楚后面所有排查动作才有指向性。1.1 Connection lost mid-response 丢的是流不是连接Claude Code 这类工具跟模型服务通信走的是流式响应服务端不会憋着一口气把整段回答生成完再发给你而是边生成边往外推事件。HTTP 层面这是一条持续输出的响应体逻辑层面则是一串结构化事件——消息开始、内容块开始、若干个增量片段、内容块结束、消息结束。mid-response这个词非常精确地告诉你客户端已经收到了至少一部分事件连接在响应体还没写完的状态下被切断了。关键点在于这条链路上的传输通道本身是通的。如果是域名解析失败、握手失败、端口不可达报错会是另一个样子通常在请求发出后一两秒内就返回。而mid-response意味着握手完成了、请求发出去了、服务端开始回数据了然后在你正读到一半的时候数据流停了。停下来之后客户端有两种反应要么读到一个干净的 EOF对端正常关闭了写方向要么卡住直到自己的读取超时触发。这个细节很重要因为它把嫌疑范围缩小到了三类一是空闲超时也就是你的客户端和服务端之间有一段链路认为这条连接多久没有数据往返就该回收而模型在思考或者生成大段代码时事件之间的间隔可能超过这个阈值二是中间设备的状态表被清理比如出口网关上的会话表项老化、内容分发节点的空闲连接回收三是响应体太大或太慢导致某一跳的缓冲区被打满设备选择丢弃。这三类现象在客户端看起来完全一样但验证方法完全不同后面第二章会给出具体的区分手段。1.2 ECONNRESET 是被按了急刹车方向决定一切ECONNRESET是操作系统层面抛上来的错误含义是 TCP 连接收到了 RST 报文或者你往一个已经被对端关闭的套接字上写数据。前者是被强行断开后者是对着一个已经关门的房子喊话。这两种情况在处理思路上有本质区别。RST 是谁发的决定了问题归属。如果是服务端或中间设备发出的 RST说明那一段逻辑认为这条连接不应该继续存在了——可能是会话超时、可能是并发限流、可能是设备重启或者规则变更。如果是你本机发出的 RST通常是本地进程主动放弃客户端自己的超时逻辑触发后调用关闭、或者进程被信号终止、或者上层框架判定请求已经失败而强行清理连接池。我遇到过一次特别典型的客户端配了连接复用一条连接用完之后放回池子里几分钟后中间设备那边早就把这条会话回收了但本地池子还认为它是活的。下一次发请求复用这条连接数据刚写出去就撞上 RST报错就是ECONNRESET而且时间点毫无规律看起来像随机抽风。这种僵尸连接复用是命令行工具里最常见的一类后面 3.1 会讲怎么处理。还有个容易混淆的场景你在终端里按了 CtrlC 中断上一次生成然后马上重新发起请求这时候也经常看到ECONNRESET。那不是故障是你自己上次的取消动作留下的尾巴客户端清理连接的动作和被中断的流赶在了一起。判断是不是这种情况看一眼报错出现的时间点——如果它紧跟在一次人为中断之后基本可以直接排除。1.3 为什么长回答和大上下文更容易触发短问答几乎从不出问题一旦让它重构整个目录就翻车这不是巧合。原因藏在三个地方的叠加效应里。第一是时间维度。生成八千个 token按每秒四十个 token 估算也要两百秒加上排队和首 token 延迟单条请求在网上的存活时间轻松超过三分钟。而链路上任何一个环节的空闲超时或者会话老化阈值可能就设在六十秒、一百二十秒或三百秒。请求活得越久撞上这些阈值的概率越高。这解释了一个反直觉的现象网络质量差的时候反而未必断得最狠因为差网络会快速失败让你重试而看起来挺好、只是偶尔卡一下的网络连接活得很久正好卡在超时边缘反复出问题。第二是数据量维度。大上下文意味着请求体本身可能几万甚至几十万 token上行数据量大长回答意味着下行数据量大。数据量大就会暴露最大传输单元相关的问题。路径上如果存在某一段的 MTU 比链路的最小值更小而同时又挡掉了必要的分片反馈报文就会出现小包能过、大包丢失的诡异现象。表现出来就是握手成功、短请求成功、大请求在传了一段之后卡死或者被断。这类问题在家庭宽带拨号、某些云主机网络、以及叠加了额外封装层的链路上并不罕见。第三是事件间隔维度。流式响应不是匀速的。模型在开始输出前有一段思考时间在生成大段结构化内容时也可能出现停顿。有些客户端或者中间设备会把多久没收到任何字节当作判断连接是否还活着的依据而不是这条请求是否还在处理中。停顿一旦超过阈值连接就被判死。这解释了为什么有时候断在回答的中段——恰好是模型从一段代码切到另一段解释、中间停了几秒的位置。2. 分层排查从域名到流尾逐层缩小范围排查网络问题最忌讳的就是我觉得应该是某某原因然后直接改配置。正确的做法是从下往上每层用一条命令给出是或否的结论把范围一步步压缩。我习惯把整个过程控制在十分钟内因为超过这个时间人就会开始烦躁然后开始瞎改。2.1 第一层域名解析和路由路径是否一致先确认你连的是同一个地方。听起来很傻但我真见过因为解析结果在多个地址之间来回跳导致每次请求落到不同的节点其中一个节点就是有问题的情况。# 连续解析十次看返回的地址是否固定 for i in $(seq 1 10); do dig short api.example-service.com | head -n 3 sleep 1 done如果十次结果里出现了两个以上不同的地址说明存在多线路调度。这时候不要急着下结论说某个节点坏了但要记下每次失败时可能对应的地址后面抓包时能对上号。另外留意是否优先走了 IPv6。命令行工具通常遵循系统的地址选择策略如果 IPv6 路径质量不行而 IPv4 正常就会出现时好时坏。快速验证方法是强制走 IPv4 试一次curl -4 -sS -o /dev/null -w %{http_code} %{time_total}\n https://api.example-service.com/ curl -6 -sS -o /dev/null -w %{http_code} %{time_total}\n https://api.example-service.com/两条命令的耗时差出好几倍那基本可以确定协议族选择是影响因素之一。接下来看路径质量用 mtr 跑个几十秒重点看丢包是不是从某一跳开始突然出现并持续到终点mtr -rwc 50 api.example-service.com判断标准要看连续性如果某一跳有丢包但它后面的所有跳都正常那通常只是那台设备不响应探测包不是真丢包如果从某一跳开始往后每一跳都有稳定比例的丢包那一段就是问题所在。这个区分能帮你避免冤枉无辜的中间设备。2.2 第二层握手是否可复现MTU 是不是瓶颈握手环节要关注两件事能不能成以及成得多快。用 openssl 直接摸一把 TLSopenssl s_client -connect api.example-service.com:443 -servername api.example-service.com -brief /dev/null正常情况一两百毫秒内完成输出的协议版本和加密套件都清清楚楚。如果这里就卡住或者报错那问题根本不在流式响应而在更底层先解决握手再说。同时留意证书链是否完整——有些环境缺少中间证书某些客户端能容错、某些不能表现就是同一个地址浏览器能开命令行工具报错。握手过了接下来测 MTU。这一步很多人跳过但它是小请求正常、大请求失败的经典元凶。原理是这样路径上如果有一段的最大传输单元比你的发送端认为的更小而中间设备又把数据包太大的反馈报文挡掉了发送端就会一直用大包发一直丢一直重传直到超时或者被断。表现就是上传大上下文时卡死。# Linux1472 28 字节头部 1500 ping -M do -s 1472 -c 3 api.example-service.com # 如果失败就往下调每次减 8 ping -M do -s 1464 -c 3 api.example-service.com一路往下试找到不丢包的最大值。如果这个值明显小于 1500比如 1400 出头说明路径上确实存在封装开销。这种情况下客户端侧没法直接改 MTU那需要改系统路由影响面大但你可以通过减少单次请求体积来规避——把一次五十个文件的重构拆成五批每批十个。这也是我后来养成的工作习惯不管有没有网络问题大批量任务都拆着跑出问题好定位重跑成本也低。2.3 第三层用最小化 curl 复现流式中断到这一步我们要把客户端软件的锅排除掉。用最原始的 curl 直接打流式接口观察它是不是也在固定时间点断开。这一步的价值在于如果 curl 也断说明问题在客户端之外如果 curl 跑十分钟都不带喘的那问题就在 Claude Code 的参数配置或者它的运行环境里。curl -N -sS --http1.1 \ -H Content-Type: application/json \ -H Authorization: Bearer $API_KEY \ -d {model:your-model,max_tokens:8000,stream:true,messages:[{role:user,content:写一段八百行的示例代码}]} \ https://api.example-service.com/v1/messages \ | while IFS read -r line; do printf %s %s\n $(date %H:%M:%S) $line done-N是关键它关掉 curl 的输出缓冲让每个事件实时落到终端。前面加时间戳是为了看清两件事断在第几秒、断之前最后一条事件是什么。这两个信息极有价值。如果每次都在差不多的秒数断开比如总是六十几秒、总是正好一百二十秒那几乎可以断定是某个空闲超时在起作用属于阈值型故障。如果断得毫无规律有时三十秒有时五分钟那更可能是资源竞争或者中间设备的状态表问题。如果断之前的最后一条事件恰好是某种特定的内容比如一大段代码块结束那可能是数据量触发的缓冲区问题。顺便说一句--http1.1是我故意加的。HTTP/2 在流式场景下有自己的连接复用和流量控制机制出问题时更难判断。排查阶段退回到 HTTP/1.1行为更接近直觉定位完再切回去对比能省很多脑子。2.4 第四层抓包看清 RST 是从哪个方向来的前面三步做完你大概已经知道是阈值型还是随机型了。要拿到确凿证据就得看包。抓包不需要很久二十秒足够重点是在失败发生的那一刻把现场留下来。sudo tcpdump -i any -n -s 0 \ host 目标地址 and tcp port 443 \ -w /tmp/session.pcap抓到之后用 tshark 或者 Wireshark 打开过滤tcp.flags.reset 1看 RST 的方向。源地址是服务端或中间地址、目的地址是你说明是外部断的你本地无法控制只能靠调整请求方式或者更换出口来规避源地址是你自己说明是本地进程主动断开重点查客户端的超时配置和上层逻辑。同时看一眼连接的状态指标这个比抓包更快ss -ti dst 目标地址输出里有几个字段值得盯rtt是往返延迟突然飙高说明路径拥塞retrans是重传计数持续增长说明有丢包cwnd是拥塞窗口如果一直很小说明传输被压制。还有一种情况是send-q一直不为零并且不断增长那是本机发不出去通常是本地网络栈或者出口带宽被打满了——同时开五个大请求跑重构的时候特别容易撞上这个。3. 客户端侧的五个改法按见效速度排序抓完包你就知道哪些能改、哪些改不了。下面这五条是我按改动成本低、见效快排的顺序从最简单的开始试。3.1 先处理僵尸连接复用这是最高频的一类前面提过连接池里躺着的旧连接在中间设备那边早就没了本地还以为活着。表现就是请求发出去立刻报ECONNRESET很快不拖泥带水。这种情况加大超时完全没用因为根本不是超时问题。处理思路有两个方向。第一个是缩短复用窗口让连接不要活那么久。如果你自己写脚本调接口在 Node.js 里可以这样import { Agent, setGlobalDispatcher } from undici; setGlobalDispatcher(new Agent({ keepAliveTimeout: 10_000, // 空闲 10 秒就关不留僵尸 keepAliveMaxTimeout: 30_000, // 上限防止被中间设备先回收 connections: 4, pipelining: 0, headersTimeout: 120_000, // 等响应头的上限 bodyTimeout: 900_000, // 等响应体的上限必须覆盖最长生成时间 }));keepAliveTimeout设为十秒的道理是绝大多数中间设备的空闲回收阈值都远大于十秒你主动先关就不会撞上我以为活着其实已经死了的状态。代价是每次请求多一次握手几十毫秒的事换来的是可预测性这笔账很划算。第二个方向是干脆不复用。命令行工具通常没有关掉连接池的开关但你可以在系统层面观察连接行为如果确认是复用导致那就走第一个方向或者用脚本包一层每次请求独立进程。我现在的做法是把长任务跑在脚本里脚本自己管理连接生命周期命令行只负责发起这样可控性高很多。3.2 把超时参数调到响应长度之上别凭感觉设超时这件事最怕拍脑袋。设个三十秒觉得差不多了结果模型生成两千行代码要四分钟每次都在两分半断掉你还以为是网络问题。正确的算法是从业务需求倒推。先估最大响应时长假设你要的最大输出是八千 token服务端生成速度按保守的每秒三十 token 算纯生成时间约二百六十七秒再算上排队等待、首 token 延迟、网络传输给一倍余量bodyTimeout至少设到六百秒。如果你的场景是更大规模的代码生成就继续往上加。这里有个容易踩的坑超时参数往往不止一个。连接超时、响应头超时、响应体超时、空闲超时四个概念完全不同。连接超时管的是握手多久算失败通常十秒就够响应头超时管的是请求发出后多久必须看到响应头大上下文场景下这个值要放大因为服务端要先处理完输入才吐第一个字节响应体超时管的是整个响应体传输允许多久这是最容易被设小的那个空闲超时管的是两个数据块之间最多隔多久这个值和模型思考停顿有关。Claude Code 这类工具的超时配置通常通过环境变量暴露具体名称随版本变化用--help或者查对应版本的文档确认最靠谱。我自己的习惯是先看有没有相关的环境变量能覆盖能改就改改不了就去它的配置文件里找实在不行在启动脚本外面包一层用系统级的方式兜底。不要凭记忆里的变量名瞎设设错了不生效你还以为改过了白折腾。3.3 重试要分类型无脑重试只会浪费额度ECONNRESET出现在请求发出的第一秒多半是连接复用问题重试大概率能成功而且成本很低还没生成任何内容。这种可以自动重试带上指数退避第一次等一秒第二次等三秒第三次等十秒三次还不行就停下来报错。Connection lost mid-response出现在回答的中后段情况完全不同。这时候服务端已经生成并计费了大量 token你重试就是从零开始再来一遍既费额度又费时间而且如果根因是长响应必然触发某个超时你重试一百次也还是断在同一个位置。这种情形下正确的做法不是重试而是降低单次任务规模把一个大重构拆成几个小重构每批控制在能在两三分钟内完成的量级。我自己总结的判断规则写在下面这张表里贴在脚本注释中省得每次都要重新想一遍。报错与时机最可能原因是否自动重试处理动作请求发出即刻 ECONNRESET僵尸连接复用是最多三次缩短 keepAlive或每次新建连接首字节前长时间无响应响应头超时、大上下文处理慢是一次放大响应头超时拆分输入固定秒数后 mid-response空闲超时或会话老化否缩短单次输出或调大空闲阈值随机时点 mid-response缓冲区打满、路径丢包最多一次减小单次请求体积检查 MTU中后段 ECONNRESET会话被回收、并发限流否降低并发拆批处理3.4 流式落盘让中断变成可续而不是重来这条是我觉得收益最大的一条因为它把故障降级成了麻烦。既然长回答必然有断的风险那就让断掉不损失已生成的内容。思路很简单客户端每收到一个增量事件就把文本追加写到一个临时文件同时记录已完成的段落边界。断了之后你看一眼文件已经生成的部分还在人工判断一下从哪里接上把剩下的部分作为新请求发出去。虽然还是要动手但省掉了重新等待前面几千 token 的时间也让费用不至于白花。自己写脚本的话大概长这样import json, httpx def stream_to_file(prompt, path): with httpx.stream(POST, URL, headersH, jsonbody, timeouthttpx.Timeout(connect10, read600, write60, pool10)) as r: with open(path, a, encodingutf-8) as f: for line in r.iter_lines(): if not line.startswith(data:): continue payload line[5:].strip() if payload [DONE]: break try: evt json.loads(payload) except json.JSONDecodeError: continue # 别让小概率的坏行炸掉整个流 delta evt.get(delta, {}).get(text) if delta: f.write(delta) f.flush() # 关键立刻落盘进程被干掉也不丢注意flush()那一行很多人写了写文件但没 flush进程一崩缓冲区全丢等于白写。另外json.loads外面必须包 try流式传输中偶发的截断行是正常现象不能让它把整个循环打断。用 Claude Code 本身的时候这个思路的变体是善用会话续写。它一般支持在同一个会话里继续对话断掉之后不要开新会话重来而是在原会话里说一句从上一次中断的地方继续已经完成的是前三个文件。这样上下文还在不用重新喂一遍代码库成本低得多。3.5 网络环境做减法别在脏环境里猜排查到后面如果还是没有结论就做一个最朴素的对照实验把环境变量清零。同时开着的东西越少变量越少。具体做三件事。第一确认没有并行的重负载。我见过同时跑三个大请求、还开着同步盘上传、后台在拉依赖包的情况出口带宽被打满send-q一直堆积表现出来的就是随机断开。排查期间只留一个请求其他全停。第二关掉可能干预连接的本地程序。某些终端复用工具、命令行录制工具、本地流量分析工具会在应用层做缓冲或者改写对流式响应这种长连接特别不友好。我之前遇到过一次断开是某个终端插件在每次滚动时刷新缓冲区导致的关掉就没事了查了两小时。第三换一个网络做对照。用手机热点跑一遍同样的长任务如果不断问题在你原来的出口路径上如果照样断问题在客户端配置或者服务端行为上。这一步能一刀切掉一半的可能性非常值。顺带提醒一个容易被忽略的点某些时候看起来是网络问题其实是本地资源问题。CPU 被打满、内存吃紧导致交换、磁盘写满导致日志写不进去都可能让客户端进程卡住卡的时间长了就撞上超时。排查时顺手top看一眼比什么都快。4. 常见问题速查与几个反直觉的坑这一节把我实际遇到过的情况整理成可以直接对着查的形式。前面讲过的不重复重点放在那些照着常规思路查会走偏的地方。4.1 现象与验证方法对照表现象描述优先验证验证命令或方法结论指向短问答正常长生成必断空闲超时阈值curl 加时间戳看断在第几秒阈值型需缩短单次输出或调大阈值上传大上下文时卡住无响应MTU 与分片ping -M do -s递减试探路径 MTU 小于预期需减小请求体积报错随机出现无固定时长连接复用或丢包ss -ti看 retrans 计数复用窗口过长或路径质量差重启终端后第一次请求必成功僵尸连接观察是否只有复用连接失败连接池问题缩短 keepAlive只在特定时间段出问题出口拥塞不同时段跑同一任务对比带宽竞争错峰或降低并发换网络后立即恢复正常出口路径设备热点与宽带各跑一遍原出口设备状态表或规则问题报错总在代码块结束处单事件体积过大抓包看最大包长缓冲区或 MTU拆分输出4.2 三个反直觉的坑坑一以为加大超时能解决一切。我一开始就是这么干的把能设的超时全设到半小时结果报错从固定时间断开变成了更长时间后断开问题还在只是延迟了。原因是超时不是根因只是根因表现出来的一种形式。真正起作用的是让单次请求的生命周期短于链路上最短的那个阈值也就是缩短,而不是延长。这个思路转过来之后问题当场解决一半。坑二忽略客户端的日志级别。默认日志只给你一行错误摘要真正的细节比如断开时最后收到的事件类型、连接建了多久、是否复用了连接全在调试级别日志里。花两分钟把日志级别调到调试很多时候答案直接摆在那儿不用抓包。这个投入产出比极高但大部分人懒得做。坑三把重试成功当成问题已解决。有时候重试一次就过了于是你以为修好了。实际上根因还在只是这次运气好没撞上。真正的验证方法是让同一个长任务连续成功跑五次中间不中断。能过五次再宣布修好只过一次不算。这个标准听起来苛刻但能帮你避开大量假修复。5. 我现在固定用的两个小工具最后分享两个我常驻在本地的小东西不复杂但每次出问题能省我一小时。5.1 一个三十行的连通性体检脚本放在本地随便哪个目录出问题时先跑它三十秒给出结论避免我上来就瞎猜。#!/usr/bin/env bash set -u HOSTapi.example-service.com echo 解析 dig short $HOST | head -n 5 echo 握手耗时 curl -4 -sS -o /dev/null -w connect%{time_connect}s tls%{time_appconnect}s total%{time_total}s\n \ https://$HOST/ || echo 握手失败 echo 三次取首字节 for i in 1 2 3; do curl -4 -sS -o /dev/null -w 第${i}次 ttfb%{time_starttransfer}s\n https://$HOST/ done echo 重传与丢包 netstat -s 2/dev/null | grep -iE retrans|reset | head -n 5 echo 大包通路 for sz in 1472 1444 1400 1300; do if ping -M do -s $sz -c 1 -W 2 $HOST /dev/null 21; then echo MTU 可用上限$((sz 28)); break fi done三个数字最关键握手耗时如果超过一秒路径本身就有问题三次取首字节的时间如果波动很大说明链路质量不稳MTU 如果低于 1400基本可以确认存在封装层长请求要主动减量。5.2 一个分批执行的调度壳子既然长任务必然会撞超时那就别让它变长。我写了个简单的壳子把一个文件列表切成若干批每批单独起一个进程失败了只重跑那一批#!/usr/bin/env bash set -u BATCH8 i0 for f in $(cat filelist.txt); do batch_dir/tmp/batch_$((i / BATCH)) mkdir -p $batch_dir echo $f $batch_dir/input.txt i$((i 1)) done for d in /tmp/batch_*; do echo 处理 $d run-your-tool --input $d/input.txt || echo !! $d 失败可单独重跑 done这个壳子没有任何技术含量但它把一次大任务变成了若干次小任务每次都在阈值内完成失败也只影响一小块。用了这套之后我遇到mid-response的频率从每天几次降到一周一次那一次还是因为我忘了分批直接跑了个大目录。最后说个我自己的体会。这两类报错看着吓人实际上它们是非常守规矩的错误——报错文本里已经把性质告诉你了mid-response说的是流断了ECONNRESET说的是被重置了剩下的就是顺着这两条线索往上找。真正麻烦的从来不是错误本身而是我们习惯性地用一个万能的重试去糊住所有问题糊到某一天糊不住才发现自己根本不知道发生了什么。把链路上每一层的判断方法握在手里这些问题就都变成十分钟内能给出结论的常规操作了。