一次「首次聊天卡顿」的排查:当 WLAN IP 变化遇上 MCP
明明页面Agent已经显示“就绪”,为什么第一条消息总要等几十秒?本文记录的,是如何从错误假设里爬出来,最终找到问题解原因和解决方法的真实经历。
一、症状
Coder 是 IntelliJ IDEA 里的 AI 编程助手插件。它在本地启动一个kiloCLI 进程作为后端,通过 HTTP + SSE 跟插件 UI 通信,同时还会拉起一个本地 MCP Server,让 AI 能调用 IDE 里的工具。
有一天,出现一个很诡异的现象:
打开工具窗口后,UI 显示“就绪”,输入框也能打字,但发送第一条消息后,迟迟没有回复。等了快一分钟,AI 才开始回应。神奇的是,这之后的所有对话又完全正常。
更蹊跷的是,这个问题是在我们修复“多工程窗口无法连接 agent server”那个 bug 之后才冒出来的。我隐约觉得,一定是那次改动带来了什么副作用。
二、第一轮排查
2.1 误导性日志
习惯性地先翻日志,最先注意到的是这条异常:
java.io.IOException: unexpected content length header with 204 responsePOST /session/{id}/prompt_async明明返回了 204 No Content,可我们的代码里用了BodyHandlers.ofString()去接响应,碰上 204 又带 Content-Length 头时,直接抛出了 IOException。
我当时一拍大腿:这不就是 sendMessage 失败了吗?消息都没发出去,当然没响应。
我做的修复:把BodyHandlers.ofString()换成BodyHandlers.discarding(),反正 204 本来就没 body。
2.2 事件队列的 hold 机制
继续深挖,我又发现SessionEventQueue里有一个hold标志。一旦hold=true,flushNow()就不会分发任何 SSE 事件。如果在同步历史消息的时候 hold 被置为 true,而用户恰好这时发了消息,SSE 事件就可能被卡住。
我又补了一个修复:在loadMessageHistoryAndSync()里加保护逻辑——要是用户已经发送过消息,就跳过历史同步,不再 hold。
2.3 健康检查超时
再看健康检查checkHealth()。它用的是默认 HTTP 客户端,连接超时 3 秒。每次 kilo 进程还没启动时,都要干等 3 秒才走进“启动进程”的分支,体验很差。
我又顺手优化了一下:专门建了一个FAST_CHECK_CLIENT,连接超时 500ms,请求超时 1s。
2.4 问题没解决
三个地方都改完了,编译、部署、测试——问题依旧。
这时我想到:
“这个问题是由修复 bug「在多个工程页面,后打开的工程中无法连接 agent server」引入的。”
现在又回到那个引入问题的 commit 上去。
三、第二轮排查:锁定 commit
我翻 git log,找到了 commit492e53f:
refactor(startup): 延迟非关键加载,优先解锁用户输入这个 commit 的核心改动,是把 MCP 注册从infraReady()里的同步阻塞调用,移到了一个叫registerMcpAsync()的方法里,改成异步 fire-and-forget。
改动前:
// infraReady() 中mcpManager.register(httpClient);// 同步:PATCH /global/config 完成才返回setState(READY);future.complete(null);// → ready.set(true) 时 MCP 已经注册完毕改动后:
// infraReady() 中setState(READY);future.complete(null);// 立即完成// connectAndSync() 中ready.set(true);// 用户可以立即发送消息impl.registerMcpAsync();// MCP 注册异步执行顺着这个 diff 推导出一个“竞态条件”:ready.set(true)之后,用户马上发送消息,而 PATCH/global/config还在处理,Kilo 服务端得同时应付配置变更和 prompt 请求,所以才卡。
于是给出的修复方案是:让registerMcpAsync()返回CompletableFuture,把ready.set(true)挪到 MCP 注册完成之后。
但同时又想到
- “就算发送消息的时候 MCP 没注册成功,也不应该正常对话,对话里又不一定会用到 MCP。”
- “registerMcpAsync“ 这是个异步方法,它不会阻塞聊天。
- 如果没注册成功,后台打行日志就行,不会该卡住对话。
经过启动日志发现,启动后对话卡住是在MCP注册过程中。当MCP注册成功。对话恢复正常。但是MCP的向kilo服务注册是异步。没有理由会卡住。
四、真正的根因
4.1 插件的 async 根本没阻塞
我重新审视registerMcpAsync():
publicvoidregisterMcpAsync(){CompletableFuture.runAsync(()->{mcpManager.register(httpClient);// 内部全部 sendAsync}).exceptionally(e->{LOG.warn("MCP registration failed: "+e.getMessage());returnnull;});}调用链是CompletableFuture.runAsync()→mcpManager.register()→fetchGlobalConfigAsync()→sendAsync()→ 立刻返回。插件这边没有任何线程被阻塞。
4.2 真正的阻塞点:Kilo 服务端
问题出在 MCP URL 的构建方式上:
// McpManager.javaprivatestaticStringbuildMcpUrl(intport){return"http://"+NetworkUtils.getLocalIp()+":"+port+"/sse";// ^^^^^^^^^^^^^^^^^^^// 返回 WLAN IP,比如 192.168.1.100}// McpHttpServer.java - handleSse()StringmessagesUrl="http://"+NetworkUtils.getLocalIp()+":"+port+"/messages?sessionId="+sessionId;NetworkUtils.getLocalIp()会遍历本机网络接口,返回 site-local IPv4 地址(比如192.168.1.100)。这个地址被写进 MCP 配置,发给了 Kilo。但是我本地用的是 WLAN,每次启动 IP 可能发生变化。MCP server 配置里存的是之前的 IP,插件里的 agent 就连不上了。”
故障的时序:
第一天: 1. 插件启动 MCP Server → 监听 127.0.0.1:59324 2. buildMcpUrl() 拿到 WLAN IP → 192.168.1.100 3. PATCH /global/config → MCP 配置写入: http://192.168.1.100:59324/sse 4. Kilo 连接 MCP → 成功(192.168.1.100 可达) 5. Kilo 把配置持久化到磁盘 第二天 (WLAN IP 变成了 192.168.1.101): 1. 插件启动 MCP Server → 监听 127.0.0.1:60123 (新随机端口) 2. Kilo 启动 → 加载磁盘里上次的持久化配置 → MCP 条目: http://192.168.1.100:59324/sse ← 旧 IP + 旧端口! 3. Kilo 尝试连接 192.168.1.100:59324 → 不可达 → TCP 超时等待 (MCP 配置里 timeout: 60000ms!) 4. 插件 PATCH /global/config 注册新 MCP → http://192.168.1.101:60123/sse ← 新 IP + 新端口 5. 但 Kilo 可能还在傻等旧连接超时... 6. 用户发送 prompt_async → Kilo 忙不过来 → 长时间无响应4.3 为什么后续聊天就正常了?
第一次对话结束后,Kilo 已经:
- 放弃(或超时)了那个旧 MCP 连接
- 顺利连上了新 MCP
- 把最新的配置持久化到了磁盘
所以后面的对话不再受影响。而这个过程,完全不是插件端的异步代码导致的,阻塞发生在 Kilo 那边,根源只是一个会变的 IP 地址。
五、修复
核心问题
MCP URL 使用了会变的 WLAN IP。但插件的 MCP Server 和 Kilo 进程明明跑在同一台机器上,根本不需要走外部网络,直接用127.0.0.1就完事了。
改动
只有两处,极其简单:
1.McpManager.java—buildMcpUrl()
// 之前privatestaticStringbuildMcpUrl(intport){return"http://"+NetworkUtils.getLocalIp()+":"+port+"/sse";}// 之后privatestaticStringbuildMcpUrl(intport){return"http://127.0.0.1:"+port+"/sse";}2.McpHttpServer.java—handleSse()
// 之前StringmessagesUrl="http://"+NetworkUtils.getLocalIp()+":"+port+"/messages?sessionId="+sessionId;// 之后StringmessagesUrl="http://127.0.0.1:"+port+"/messages?sessionId="+sessionId;如果将来真的有远程访问 MCP Server 的需求,NetworkUtils已经支持通过环境变量RADARLAB_MCP_HOST手动指定,用不着让程序自己猜 IP。
六、教训
7.1 别被表面现象带偏
日志里的 204 IOException 确实是个 bug,修掉它没错,但它跟首次聊天卡顿毫无关系。修 bug 的同时,我得时刻提醒自己:不要把手边的每一条错误都当成根因。
7.2 搞清楚“谁在等,等什么”
插件这边的 async 代码确实没阻塞,但 Kilo 那边是同步式地死死等着一个永远连不上的 IP。排查分布式系统的问题,必须明确阻塞发生在哪个进程、在等什么资源,而不是一看到async就觉得万事大吉。
7.3 本地通信请用 localhost
这是一个经典的反模式:同一台机器上的两个进程通信,却用了外部 IP。127.0.0.1就是为这个设计的——它不会变,不需要经过物理网卡,失败时立刻就能知道,而不是死等几十秒的超时。
如果真的有远程访问 MCP Server 的需求,通过环境变量RADARLAB_MCP_HOST手动指定是不错的方式。
愿你我都能在各自的领域里不断成长,勇敢追求梦想,同时也保持对世界的好奇与善意!