ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

一次「首次聊天卡顿」的排查:当 WLAN IP 变化遇上 MCP

一次「首次聊天卡顿」的排查:当 WLAN IP 变化遇上 MCP 一次「首次聊天卡顿」的排查当 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标志。一旦holdtrueflushNow()就不会分发任何 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找到了 commit492e53frefactor(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){returnhttp://NetworkUtils.getLocalIp():port/sse;// ^^^^^^^^^^^^^^^^^^^// 返回 WLAN IP比如 192.168.1.100}// McpHttpServer.java - handleSse()StringmessagesUrlhttp://NetworkUtils.getLocalIp():port/messages?sessionIdsessionId;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){returnhttp://NetworkUtils.getLocalIp():port/sse;}// 之后privatestaticStringbuildMcpUrl(intport){returnhttp://127.0.0.1:port/sse;}2.McpHttpServer.java—handleSse()// 之前StringmessagesUrlhttp://NetworkUtils.getLocalIp():port/messages?sessionIdsessionId;// 之后StringmessagesUrlhttp://127.0.0.1:port/messages?sessionIdsessionId;如果将来真的有远程访问 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手动指定是不错的方式。愿你我都能在各自的领域里不断成长勇敢追求梦想同时也保持对世界的好奇与善意!
返回列表