一次「首次聊天卡顿」的排查:当 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=trueflushNow() 就不会分发任何 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 注册完成之后。

但同时又想到

  1. “就算发送消息的时候 MCP 没注册成功,也不应该正常对话,对话里又不一定会用到 MCP。”
  2. “registerMcpAsync“ 这是个异步方法,它不会阻塞聊天。
  3. 如果没注册成功,后台打行日志就行,不会该卡住对话。

经过启动日志发现,启动后对话卡住是在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.javabuildMcpUrl()

// 之前
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.javahandleSse()

// 之前
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 手动指定是不错的方式。


愿你我都能在各自的领域里不断成长,勇敢追求梦想,同时也保持对世界的好奇与善意!

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐