一次「首次聊天卡顿」的排查:当 WLAN IP 变化遇上 MCP
一次「首次聊天卡顿」的排查:当 WLAN IP 变化遇上 MCP
明明页面Agent已经显示“就绪”,为什么第一条消息总要等几十秒?本文记录的,是如何从错误假设里爬出来,最终找到问题解原因和解决方法的真实经历。
一、症状
Coder 是 IntelliJ IDEA 里的 AI 编程助手插件。它在本地启动一个 kilo CLI 进程作为后端,通过 HTTP + SSE 跟插件 UI 通信,同时还会拉起一个本地 MCP Server,让 AI 能调用 IDE 里的工具。
有一天,出现一个很诡异的现象:
打开工具窗口后,UI 显示“就绪”,输入框也能打字,但发送第一条消息后,迟迟没有回复。等了快一分钟,AI 才开始回应。神奇的是,这之后的所有对话又完全正常。
更蹊跷的是,这个问题是在我们修复“多工程窗口无法连接 agent server”那个 bug 之后才冒出来的。我隐约觉得,一定是那次改动带来了什么副作用。
二、第一轮排查
2.1 误导性日志
习惯性地先翻日志,最先注意到的是这条异常:
java.io.IOException: unexpected content length header with 204 response
POST /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,找到了 commit 492e53f:
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():
public void registerMcpAsync() {
CompletableFuture.runAsync(() -> {
mcpManager.register(httpClient); // 内部全部 sendAsync
}).exceptionally(e -> {
LOG.warn("MCP registration failed: " + e.getMessage());
return null;
});
}
调用链是 CompletableFuture.runAsync() → mcpManager.register() → fetchGlobalConfigAsync() → sendAsync() → 立刻返回。插件这边没有任何线程被阻塞。
4.2 真正的阻塞点:Kilo 服务端
问题出在 MCP URL 的构建方式上:
// McpManager.java
private static String buildMcpUrl(int port) {
return "http://" + NetworkUtils.getLocalIp() + ":" + port + "/sse";
// ^^^^^^^^^^^^^^^^^^^
// 返回 WLAN IP,比如 192.168.1.100
}
// McpHttpServer.java - handleSse()
String messagesUrl = "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()
// 之前
private static String buildMcpUrl(int port) {
return "http://" + NetworkUtils.getLocalIp() + ":" + port + "/sse";
}
// 之后
private static String buildMcpUrl(int port) {
return "http://127.0.0.1:" + port + "/sse";
}
2. McpHttpServer.java — handleSse()
// 之前
String messagesUrl = "http://" + NetworkUtils.getLocalIp() + ":" + port
+ "/messages?sessionId=" + sessionId;
// 之后
String messagesUrl = "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 手动指定是不错的方式。
愿你我都能在各自的领域里不断成长,勇敢追求梦想,同时也保持对世界的好奇与善意!
更多推荐


所有评论(0)