Claude Code 长连接 ECONNRESET 与断流排查
凌晨两点,终端里最后一行输出卡在Reading file...,紧接着蹦出红字API Error: Connection lost mid‑response。再敲一次回车,这次变成Connection dropped (ECONNRESET)。整个下午跑了十几轮都挺顺,偏偏赶着交活儿的时候断在半路,那种感觉做过命令行工具调试的人都懂。
Claude Code 这类跑在终端里的 AI 编程助手,本质上是把「模型推理 + 本地文件操作 + 工具调用」串成一条长连接在跑。它跟普通网页聊天最大的差别在于:一次响应的持续时间可能长达几分钟,中间还要反复往返。时间一长,链路上任何一个环节偷懒——空闲超时、连接表清理、MTU 撞墙、DNS 双栈抖动、客户端读流卡住——都会把这条长连接从中间掐断。ECONNRESET 和 Connection lost mid‑response 就是这条链路上最常见的两种「半路阵亡」形态,看起来像同一个毛病,实际动手排查的路径不完全一样。
这篇内容面向的是已经装好 Claude Code、日常在终端里用它干活,但被这两条报错反复打断节奏的人。下面我会把这两条报错的底层机制拆开讲清楚,然后按「从最便宜的检查开始」的顺序,给出一套可以照着做的排查流程,包括客户端参数怎么调、链路问题怎么定位、运行时环境有哪些坑,最后附一份速查表和一套可以复用的本地探测脚本。文中所有具体数值都是我在实际环境里测出来的参考值,不同版本和不同网络环境下以你自己的实测为准。
1. 先把这两条报错翻译成人话
动手改配置之前,得先弄清楚这两行红字分别在说什么。很多人一看到 Connection 就条件反射去查网络,结果折腾半天发现是输出太长被客户端自己放弃了。这两条报错的成因分布差别挺大,先分清类型能省掉一大半无效操作。
1.1 mid‑response 到底断在了哪一层
Claude Code 跟服务端之间走的是流式响应:模型不是生成完一整段再一起发回来,而是一个 chunk 一个 chunk 往外吐。你在终端里看到文字一行行滚出来,就是这个流在持续供数据。
这个模式有个天然弱点——连接的存活时间被拉长到了几十秒甚至几分钟。普通的短请求发出去几百毫秒就拿到了完整响应,连接立刻可以回收;流式响应不行,它必须一直挂着,直到模型说完最后一个 token,客户端收到结束标记,连接才算完成使命。
Connection lost mid‑response这个提示,字面意思就是「响应还没结束,连接没了」。它通常出现在三种位置:
- 流刚开始几秒就断:多半是连接建立阶段就出了问题,比如握手完成后对端立刻重置,或者 DNS 解析到的地址实际不可达。
- 稳定跑了一段时间,突然某一刻断:这是最典型的空闲超时特征,中间设备认为这条连接「长时间没动静」把它回收了。注意这里说的「没动静」不一定真的没数据——有些设备只看请求方向,你这边一直在收数据,但请求方向早就发完了,它照样判定为空闲。
- 断在输出特别长的回答末尾:这往往不是网络问题,而是输出 token 触顶或者客户端读超时。
区分这三种情况最快的办法是记录时间戳。断的时候看一眼从发出请求到报错过了多少秒,这个数字基本就把答案写在脸上了。60 秒、120 秒、300 秒这种整数值,九成是某个环节的定时器在作祟;完全不规则的随机时长,才更可能是链路质量问题。
1.2 ECONNRESET 是「对方主动关门」
Connection dropped (ECONNRESET)比上一条更明确。ECONNRESET 是 TCP 层的 RST 包,含义是连接被强制重置,不是正常关闭,也不是超时。
谁发的 RST?可能是对端服务器,也可能是路径上的任何一台中间设备。常见的触发场景有这么几类:
| 触发源 | 典型特征 | 判断依据 |
|---|---|---|
| 服务端主动重置 | 与请求内容相关,重试同样内容仍失败 | 换内容重试能过 |
| 中间设备会话表清理 | 与时长相关,固定秒数后必断 | 时间点高度一致 |
| MTU 不匹配导致分片丢弃 | 大响应必挂,小响应正常 | 响应体积是分水岭 |
| 本地连接复用冲突 | 多发于并发调用场景 | 串行跑就正常 |
| 系统休眠 / 终端挂起 | 笔记本合盖、切换网络后出现 | 与操作时序强相关 |
这两种报错经常交替出现,其实是同一个根因的不同表现:连接状态在某一端已经不存在了,另一端还在往上写数据。一端发数据过去,另一端回一个 RST 说「我不认识这个连接」,客户端就报 ECONNRESET;如果客户端读流的时候发现流被静默切断、没有收到结束标记,报的就是 mid‑response。
注意:不要看到 ECONNRESET 就默认是「网络不好」。在我处理过的案例里,真正因为链路丢包引起的比例反而不高,更多是超时参数不匹配和 MTU 这两个原因。
2. 排查顺序:从最便宜的动作开始
排查这类问题最容易犯的错误是「想到什么试什么」。改了一堆参数、换了几个网络环境,最后问题好了也不知道是哪个动作起的作用,下次再犯还得重来一遍。我习惯按「成本从低到高」排一个固定顺序,每一步都留下可观测的证据。
2.1 第一步:给故障定性,是必现还是偶发
先做一件事——连续跑十次同样的操作,记录成功次数和失败次数。
- 十次全挂:基本可以确定是配置或环境问题,跟网络抖动无关。这种最好查,因为可以稳定复现。
- 十次挂两三次:典型的链路质量问题,或者是某个阈值刚好卡在边界上。
- 只在特定任务上挂:比如只要让它读大文件、或者让它一次性重构整个模块就必挂,那问题在输出长度或上下文体积,不在网络。
这一步看着朴素,但它的价值在于把搜索空间砍掉一大半。我见过不少人上来就改超时参数,结果他的问题其实是上下文塞太满导致首字节延迟极长,改超时反而掩盖了真实原因。
顺便记一下失败时的任务特征:输出长不长、有没有大量工具调用、是不是在读大文件。这三项信息对上表,能直接指向对应章节。
2.2 第二步:抓日志,看清断在第几秒
Claude Code 支持把详细日志落盘,排查阶段建议直接开最详细的级别。常见的做法是通过环境变量打开调试输出,具体变量名随版本会有调整,可以在官方文档里搜 debug 相关条目确认当前版本用的是哪个。
日志里重点看三样东西:
- 请求发出的时间戳和报错的时间戳,两者相减就是这条连接的存活时长。这个数字是后面所有判断的基准。
- 最后一个成功接收的 chunk 的内容。如果它正好卡在一个语义完整的位置,说明是外部原因切断;如果卡在半句话中间,说明切断非常突然。
- 有没有出现重试记录。Claude Code 内部对流式中断有一定程度的自动重试,如果日志里显示重试了但每次都断在同一个位置,那基本可以排除偶发抖动。
把这条时间线画出来之后,问题基本就定性了。我自己的经验是:存活时长稳定在某个整数附近(60s / 120s / 300s),指向中间设备超时;存活时长与输出长度正相关,指向输出 token 或客户端读超时;存活时长完全随机,才去查链路质量。
2.3 第三步:用最小化请求做对照实验
定完性之后,做一次对照:让 Claude Code 回答一个极短的问题(比如「回复 OK 两个字」),连跑二十次。
- 短请求稳如老狗、长请求频繁断:问题九成在输出时长相关的环节,跳到第 3 章。
- 短请求也断:问题在连接建立环节,重点查第 4 章的 DNS 和双栈。
- 短请求偶发断:链路本身不稳,第 5 章的运行时检查也要过一遍。
这个对照实验的价值在于,它把一个模糊的「网络问题」拆成了两个性质完全不同的方向。方向错了,后面所有操作都是白费力气。
3. 客户端参数调整:见效最快的一段
如果前面的定性指向「输出时长」相关,那客户端侧的参数就是性价比最高的着手点。这一章讲的都是不改网络、不动机器、只调配置就能生效的手段。
3.1 把超时阈值调到与任务时长匹配
Claude Code 内部对 API 请求设有超时限制,默认值对普通问答够用,但对「读三个文件然后重构」这种重活儿来说经常不够。表现就是:任务跑到一半,客户端自己把连接放弃了,报 mid‑response。
调整方式是通过环境变量把超时时间拉长。常见的做法是在 shell 配置文件里导出类似API_TIMEOUT_MS这样的变量,单位是毫秒。具体数值上,我的建议是先按你实测的最长成功任务时长乘以 2来设。比如你观察到正常任务最长跑 90 秒完成,那就设到 180000 毫秒。
这里有个反直觉的点:超时不要设得太大。有人直接怼到半小时,结果连接其实早就死了,客户端还在傻等,中间那些本来可以重试的机会全被浪费掉了。合理的做法是设成「略大于正常任务上限」,让它在真正卡死的时候能快速失败、快速重试。
提示:调完参数后一定要重新开一个终端会话验证。环境变量是在进程启动时读取的,在当前会话里 export 完就重启 Claude Code,别指望它热加载。
3.2 控制单次输出长度,把长任务拆开
输出 token 触顶也会以「连接中断」的形式表现出来,因为流在达到上限时被服务端主动终止,客户端没有收到正常的结束标记,就判定为 mid‑response。这个坑特别隐蔽,很多人压根没往这个方向想。
两个思路:
一是显式降低单次输出上限。通过类似CLAUDE_CODE_MAX_OUTPUT_TOKENS的环境变量限制模型单次最多吐多少 token。设小一点,比如 4096,好处是触发上限时是「正常结束」而不是「异常断开」,客户端能正确处理。
二是让任务本身分步走。与其让它一口气重构十个文件,不如先让它列计划、再逐个文件改。这不只是规避报错,实际效果也更好——分步任务的成功率明显高于一次性大改,出问题时也更容易定位到是哪一步挂的。
同理,思考 token 也要控制。开启扩展思考模式后,模型在正式输出前会先「想」很长一段,这段时间里流上是没有可见输出的。如果这个思考阶段超过了中间设备的空闲超时阈值,连接就会被掐掉——而你看到的现象是「刚开始就断了」,很容易误判成连接建不起来。遇到这种情况,把思考预算调低是个直接的解法。
3.3 压缩上下文,减少首字节延迟
上下文塞得越满,首字节延迟(TTFT)越长。这个延迟期里,请求已经发出去了,但响应还没开始回,属于典型的「请求方向静默」状态——正好是某些中间设备判定空闲的依据。
所以定期清理上下文是有实际收益的。会话里跑完一个大任务之后,用内置的压缩命令把历史记录压一压,或者干脆开个新会话。我自己养成的习惯是:每完成一个独立的功能块就清一次,不图省事一直挂在一个会话里。
另外一个容易忽略的点是MCP 工具的数量。每挂一个 MCP server,就会往上下文里塞一批工具定义,这些定义在每一轮请求里都要重新发一遍。挂十几个 server 的情况下,光是工具描述就占掉好几千 token,请求体积膨胀,首包时间自然被拉长。排查期间建议先把非必要的 MCP server 停掉,等定位清楚再逐个加回来。
3.4 关掉不必要的自动行为
有些失败是「重试风暴」导致的。流断了之后客户端立刻重试,重试又断,几轮下来连接数暴涨,反而把本地或中间设备的会话表打满,形成恶性循环。
如果日志里能看到密集的重试记录,临时把自动重试关掉或者降低重试次数,先把问题稳定复现出来,比让它自己在那儿疯狂撞墙更有价值。等根因定位了再打开。
4. 链路与中间设备:那些不出现在报错里的坑
当客户端参数已经调到合理范围,问题还在,就该把注意力转到链路上了。这一层的麻烦在于它不会告诉你它在干什么,你只能通过现象反推。
4.1 空闲超时:最像「玄学」的一个原因
先说个常识:从你的机器到服务端之间,通常要经过企业网关、安全设备、负载均衡等好几层。这些设备为了节省资源,普遍会为每条连接维护一个会话表项,并设置一个空闲回收时间。常见值有 60 秒、120 秒、300 秒,也有设成 900 秒的。
问题在于,「空闲」的判定标准各家不一样:
- 有的看双向都没有数据才算空闲;
- 有的只看请求方向,你请求发完了、只剩响应在往回传,它照样算你空闲;
- 还有的看连接总时长,不管有没有数据,到点就断。
第二种是最坑的,因为流式响应天生就是「请求发完、响应慢慢来」的模式,正好踩在判定逻辑上。表现出来就是:每次跑到固定秒数必断,换个任务、换个时间点都一样。
验证方法很直接:找一个能持续输出长内容的提示词,让它一直说下去,同时用秒表计时。如果每次断的秒数高度一致(误差在一两秒内),基本可以锁定是某一层的空闲超时。这时候要么联系网络管理方调整策略,要么在客户端侧把「单次连接时长」压到这个阈值以下——也就是前面说的拆分任务。
4.2 MTU 与分片:大响应必挂的元凶
MTU 问题有个非常鲜明的特征:小请求正常,大响应必挂。而且挂得很干脆,往往在响应传了几十 KB 之后突然 ECONNRESET。
原理不复杂。你的网络路径上如果存在某种封装(隧道、专线、虚拟化网络),实际可用 MTU 会比标准值 1500 小。如果路径上没有正确做 MSS 协商,大包会被分片或者直接丢弃,表现出来就是「传到一半没了」。
判断方法是用带「禁止分片」标志的探测包去试不同包长,找到不丢包的最大值。Linux 下大致是这样:
# 从 1472 字节开始往下试,找到不发分片也能通的包长 ping -M do -s 1472 -c 3 <目标地址> ping -M do -s 1400 -c 3 <目标地址> ping -M do -s 1300 -c 3 <目标地址>-s的值加上 28 字节的 IP/ICMP 头就是实际包长。如果 1472 不通、1400 通,说明路径 MTU 大约在 1428 左右。macOS 下对应的写法是ping -D -s,Windows 下是ping -f -l。
找到上限之后,可以在本机把网卡的 MTU 调低到安全值。这只是个缓解手段,根本解法还是让网络管理方把 MSS clamping 配上。
注意:调 MTU 属于网络层操作,动之前先确认自己有没有权限,以及在用的其他服务会不会受影响。稳妥起见先在一个临时环境验证。
4.3 DNS 解析与双栈优先级的隐形干扰
DNS 这块的问题不会直接报成连接中断,但会以「首字节超时」的形式间接导致 mid‑response。
典型场景:你的机器同时有 IPv4 和 IPv6 地址,系统默认优先走 IPv6,但那条 v6 路径实际不通或者极慢。客户端连上去之后一直在等,等到超时才 fallback 回 v4,这时候留给响应的时间窗已经被吃掉一大半了。
排查方法是把域名解析结果和实际连通性分别测一遍:
# 看解析出了哪些地址,v4/v6 都有吗 dig +short <目标域名> A dig +short <目标域名> AAAA # 分别测连通性和延迟 curl -4 -o /dev/null -s -w "v4 connect: %{time_connect}s\n" <目标地址> curl -6 -o /dev/null -s -w "v6 connect: %{time_connect}s\n" <目标地址>如果 v6 的 connect 时间明显长于 v4,甚至直接超时,那基本就找到原因了。处理办法是在系统层面调整地址优先级,或者在客户端所在的环境里优先走 v4。另外 DNS 缓存过期后重新解析也可能导致正在进行的连接被判定为「来源变化」而重置,排查期间可以留意一下这个因素。
4.4 网络切换时的连接残留
笔记本从有线换无线、从办公室走到会议室、休眠唤醒——这些操作会让底层网络接口换掉,但已经建立的 TCP 连接在短时间内仍然「看起来是活的」。等客户端下一次往这条连接上写数据时,才会收到 RST。
这类问题的特征是与操作时序强相关:换个网络环境之后的第一条请求必挂,之后恢复正常。处理起来很简单,养成切换网络后重开终端会话的习惯就行,不用深究。
5. 运行时环境:被忽略的第三类原因
前面两块都查完还没解决,就该看运行时了。这一层的问题更隐蔽,因为报错信息完全不会指向它。
5.1 Node 运行时版本与 HTTP 客户端行为
Claude Code 跑在 Node 运行时上,底层的 HTTP 客户端实现(比如 undici)在不同大版本之间的行为差异不小,尤其是在连接保活、超时默认值、流式响应的错误处理这几块。老版本上能稳定跑的场景,换到新版本反而出问题,反过来也成立。
排查建议:
- 先记录当前版本(
node -v),然后换一个 LTS 版本做对照。这是最快排除版本因素的方法。 - 如果换版本后问题消失,说明是运行时行为差异,可以考虑固定版本。
- 如果两个版本表现一致,说明跟运行时无关,回到前面的章节继续查。
另外,Node 的默认连接池大小和 keep‑alive 时长也会影响高并发场景下的表现。如果你同时开了好几个 Claude Code 会话,连接数可能超过默认池上限,后来的请求排队等待,等待时间又被算进了总超时里。
5.2 系统层面的挂起与资源耗尽
几个容易被忽视的系统因素:
终端或进程被挂起。macOS 的 App Nap、Linux 的 cgroup 冻结、Windows 的现代待机,都会在后台把进程「冻」住。进程被冻住的时候,连接还在,但没人读数据,服务端或中间设备那边看到的就是一个卡死的连接,超时后直接清掉。你切回来看到的第一个报错就是 mid‑response。
文件描述符耗尽。长时间运行、反复重试的场景下,如果连接没有被正确回收,fd 会越积越多。用ulimit -n看一下上限,用lsof -p <pid> | wc -l看实际占用。占用接近上限的时候,新建连接会失败并报出各种奇怪的错误。
磁盘写满。这个听着离谱但真遇到过——日志写入失败导致进程异常,表现跟网络中断一模一样。排查时顺手df -h看一眼,几秒钟的事。
系统时间跳变。如果机器时间被 NTP 大幅校正,基于时间戳的超时判断会瞬间错乱,正在进行的连接可能被立即判定为超时。容器环境里这个问题更常见。
5.3 一个容易被误判的现象:终端渲染卡顿
还有一种情况,看起来像网络断了,其实网络好得很——终端渲染跟不上。超长输出、大量转义字符、复杂的 ANSI 颜色,都会让终端在渲染上卡住,表现为「输出停在那里不动了」。
区分方法很简单:断的时候看进程是否还在跑(ps或top),以及日志文件是否还在增长。如果日志还在写、进程 CPU 还在动,那纯粹是渲染问题,换个终端或者关掉语法高亮就行。
6. 常见问题速查表
把前面几章的经验整理成一张表,出问题的时候直接对号入座,比从头读一遍快得多。
| 现象特征 | 最可能的根因 | 优先动作 |
|---|---|---|
| 每次固定秒数后断(60/120/300) | 中间设备空闲超时 | 拆分任务,压缩单次连接时长 |
| 大响应必挂,小响应正常 | 路径 MTU 不匹配 | 探测 MTU,调低网卡 MTU |
| 输出到很长突然断,无结束标记 | 输出 token 触顶 | 降低单次输出上限,分步执行 |
| 刚发出请求几秒就断 | 连接建立失败 / DNS 问题 | 分别测 v4/v6 连通性 |
| 思考阶段一到就断 | 思考 token 过多导致长时间静默 | 降低思考预算 |
| 切换网络后第一条必挂 | 连接残留 | 重开会话 |
| 切回终端后看到 mid‑response | 进程被系统挂起 | 关掉节能/挂起策略 |
| 并发多开会话时频繁断 | 连接池或 fd 耗尽 | 减少并发,检查 ulimit |
| 日志还在写但界面不动 | 终端渲染瓶颈 | 换终端,关高亮 |
| 与内容相关,同样内容必挂 | 服务端主动重置 | 换表述重试,排除内容因素 |
再补几条不容易想到的:
重试要加退避。断了立刻重试,很可能再次撞上同一个限流或同一层超时。加个 2 秒、4 秒、8 秒的递增等待,成功率会明显高。
别在排查期间同时改两个变量。一次只动一个,验证有效再动下一个。否则问题好了你也不知道该保留哪个配置。
留一份「基线配置」。把能稳定跑通的参数组合记下来,出问题时先回滚到基线,再逐个往外试。
7. 一套可复用的本地探测脚本
链路问题最难的地方是「看不见」。我的做法是在本地起一个可控的流式服务端,用它来代替真实场景做对照实验——这样就能把「网络问题」和「客户端问题」彻底分离开。
先写一个最小的流式服务端,每隔一秒推一条消息,总共推 N 条:
# sse_probe_server.py import time from http.server import BaseHTTPRequestHandler, HTTPServer TOTAL = 300 # 总共推多少条 INTERVAL = 1.0 # 每条间隔多少秒 class Handler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.send_header("Cache-Control", "no-cache") self.end_headers() try: for i in range(TOTAL): payload = f"data: tick {i} at {time.time():.2f}\n\n" self.wfile.write(payload.encode()) self.wfile.flush() time.sleep(INTERVAL) self.wfile.write(b"data: [DONE]\n\n") self.wfile.flush() except (BrokenPipeError, ConnectionResetError) as e: print(f"客户端断开: {e}") if __name__ == "__main__": HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()然后写一个记录时间戳的客户端:
# 记录每一秒是否收到数据,看连接在第几秒被掐断 start=$(date +%s) curl -N -s http://127.0.0.1:8765/ | while IFS= read -r line; do now=$(date +%s) echo "[$((now - start))s] $line" done echo "总时长: $(( $(date +%s) - start ))s"这套东西的用法是分三轮跑:
第一轮,客户端和服务端都在本机,中间不经过任何网络设备。如果这轮也断,说明问题在客户端或运行时,跟网络完全无关,直接回头看第 5 章。
第二轮,把服务端放到局域网内另一台机器上。这时候会经过交换机和本地的网关设备,能暴露出 MTU 和局域网策略的问题。
第三轮,如果你有条件把服务端放到跟真实使用场景路径相近的位置,效果最好。这一轮的结果如果能复现出固定的断连秒数,那这个秒数就是链路上某个设备的空闲超时阈值。
我自己用这套方法定位过好几次「玄学断连」,最典型的一次是第二轮稳定在 62 秒断、第一轮完全正常,最后确认是局域网某台设备 60 秒空闲回收,加了 2 秒的缓冲。这种东西靠猜是永远猜不出来的,必须让它自己现形。
还有个进阶玩法:把INTERVAL调到 0.5 秒,也就是在 60 秒窗口里持续有数据流动。如果这时候不断了,那就实锤是「请求方向静默」被判为空闲——这个结论会直接改变你的应对策略,从「换网络」变成「让客户端定期发心跳」或者「缩短单次任务」。
这套脚本的好处是完全可控、没有外部依赖、跑起来没有任何副作用,而且能反复跑。排查这类问题时,能稳定复现比任何经验都值钱。
最后说个我自己踩过的坑。有段时间我以为是网络问题,折腾了整整两天,换环境、抓包、调参数全试了一遍,最后发现是笔记本的节能策略把终端进程在后台冻住了——合盖去开个会,回来一敲键盘就是 mid‑response。现在我的习惯是:遇到任何「莫名其妙断了」的情况,先用uptime和进程状态确认这台机器在这一小时里是不是真的醒着。这个动作只花五秒钟,但能省掉两天。