Spring AI 流式调用报 IOException:一次完整的排障与优化

Spring AI 流式调用报 IOException:一次完整的排障与优化

一个农业知识问答小程序,后端 Spring Boot + Spring AI 调用阿里云 DashScope,流式返回时频繁报 java.io.IOException: 你的主机中的软件中止了一个已建立的连接。本文记录从现象到根因、从修复到优化的完整过程。


1. 现象

用户在小程序里提问,有时能正常得到回答,有时页面卡在"正在思考..."然后弹错误提示。后端日志:

java.io.IOException: 你的主机中的软件中止了一个已建立的连接
    at java.base/sun.nio.ch.SocketDispatcher.read0(Native Method)

没有任何超时类异常(SocketTimeoutExceptionReadTimeoutException),直接就是 IOException。


2. 排查

2.1 定位 HTTP 客户端

先搞清楚"到底是谁在发 HTTP 请求"。

项目依赖里有 okhttp:4.10.0,但 grep 整个 src/ 目录发现 零引用——OkHttp 只是出现在 pom.xml 里,代码从未 import。

真正发请求的是 Spring AI 的 OpenAiApi,它内部持有两个客户端:

// 同步调用 → RestClient
private final RestClient restClient;

// 流式调用 → WebClient  ← 报错路径
private final WebClient webClient;

再看 WebClient 的底层连接器是谁:

$ mvn dependency:tree | grep -i "reactor-netty\|webflux"

// 输出:spring-webflux 存在,但 reactor-netty-http 不存在

没有 Reactor Netty,WebClient 退化为 JdkClientHttpConnectorJDK 自带的 HttpClient

2.2 JDK HttpClient 的默认超时

JDK HttpClient 构造时不设超时的话:

参数 默认值
connect timeout 系统默认(通常无限制)
read timeout 无限

也就是说,JDK HttpClient 会一直等,直到服务端主动断开。它自己没有"读超时"的概念。

2.3 为什么服务端会断

DashScope 的 API 网关有空闲超时机制(约 60~120 秒)。流式生成过程中,如果 LLM 思考时间较长、两段 token 之间间隔太久,网关就认为连接空闲,主动发送 TCP RST 断开。

客户端 JDK HttpClient 正在 InputStream.read() 上阻塞,突然收到 RST → 直接抛 IOException。

2.4 为什么没设超时

回溯代码,OpenAiApi 的构建在 [ModelRouter.buildModel()]:

OpenAiApi api = OpenAiApi.builder()
        .baseUrl(cfg.getBaseUrl())
        .apiKey(cfg.getApiKey())
        .build();  // ← 没调 .webClientBuilder(),用的是默认 WebClient

OpenAiApi.Builder 明明提供了 .webClientBuilder() 注入口,让调用方传入带超时的 builder,但这里没用——拿了默认的,也就是超时无限的 JDK HttpClient。

2.5 为什么不直接加 Reactor Netty

最初计划是加 reactor-netty-http 依赖(~2MB),然后用 ReactorClientHttpConnectorresponseTimeout。这是最直接的方案——Reactor Netty 原生支持连接级读超时,一个 responseTimeout(Duration.ofSeconds(120)) 就解决了。

但用户希望零新依赖


3. 修复

3.1 第一层:Flux 超时

不动底层 HTTP 客户端,在 Reactor 流这一层加保护:

// ChatOrchestrator.doChat()
return client.prompt()
        .user(composed)
        .stream()
        .content()
        .as(truncator::apply)
        .timeout(Duration.ofSeconds(readTimeoutSeconds));  // ← 只加这一行

Flux.timeout() 的含义是:如果指定时间内没有任何元素发出,就抛 TimeoutException。对于流式场景,每个 token 是一个元素,120 秒内无 token → 超时。

这不如 HTTP 层超时精确(网关断开后,JDK HttpClient 读到 RST 仍然会先抛 IOException),但它提供了上限保护——即使 DashScope 没有主动断开,LLM 卡死 120 秒也会被终止。

3.2 第二层:修复 streamUsage 引发的 NPE

后续加 token 统计时,发现 cr.getResult() 偶发返回 null:

Cannot invoke "Generation.getOutput()" because "ChatResponse.getResult()" is null

根因:streamUsage(true) 让 DashScope 在流末尾多返回一个只含 usage、不含 choices 的空 chunk。Spring AI 把这个 chunk 也转成了 ChatResponse,但 getResult() 为 null。

修复:.map() 加 null 保护 + .filter() 过滤空串。

.map(cr -> cr.getResult() != null ? cr.getResult().getOutput().getText() : "")
.filter(s -> !s.isEmpty())

4. 顺手修的附带问题

排查过程中发现几个"顺便就修了"的问题:

4.1 对话历史没有传给 LLM

ChatClientFactory 配置了 MessageChatMemoryAdvisor(注入最近 20 条对话),但因为 ChatOrchestrator 把所有内容拼成一个大字符串塞进 .user(),advisor 注入的历史被淹没在杂烩里,多轮对话实际上靠 LLM 自己的注意力硬记

修复:系统提示词 + 知识上下文 + 长期记忆放 .system(),用户当前输入放 .user(),中间由 advisor 自动插入对话历史。

system:   你是农业专家… [知识上下文] [长期记忆]
user:     上次问的番茄黄叶…            ← 对话历史(advisor 注入)
assistant: 多菌灵 800 倍液…
user:     浓度再低点行吗?             ← 对话历史
assistant: 可以…
user:     需要喷几次?                 ← 当前输入

4.2 前端流式渲染空白

后端 token 逐字输出,但前端 index.vue 的处理逻辑是:

case 'chunk':
    jsonBuffer += msg.content
    const parsed = JSON.parse(jsonBuffer)  // ← LLM 输出 JSON,没拼完时解析失败
    if (parsed) {
        this.currentAssistantMsg.structured = parsed  // 只有完整 JSON 才显示
    }
    // ← 不完整时啥也不干,content 保持 ''

JSON 没拼完 → parsed 为 null → 页面空白。修复:加 else 分支,JSON 不完整时显示原始文本。

} else {
    this.currentAssistantMsg.content = this.jsonBuffer  // 流式感
}

4.3 断连后还在转圈

onSocketError 回调只设了 streaming = false,漏了关 loading。模板里 v-if="m.loading" 优先级最高,导致 error 消息不显示。

修复:统一走 showError(),关 loading → 显示错误 + 重试按钮。


5. 最终架构

┌─ 前端小程序 ───────────────────────────────────────────┐
│ WebSocket ws://host/ws/chat                             │
│ {"type":"question","userId":1,"chatId":"xxx","content":"?"} │
└──────────────────────┬──────────────────────────────────┘
                       │
┌─ ChatWebSocketHandler ─────────────────────────────────┐
│ → ChatOrchestrator.chat(input, chatId, userId, usageRef)│
└──────────────────────┬──────────────────────────────────┘
                       │
┌─ ChatOrchestrator ────────────────────────────────────┐
│ 1. InputSanitizer    → 清洗+截断(500字)                │
│ 2. IntentRouter      → 分类(专业/闲聊/FAQ)             │
│ 3. Neo4jKnowledge    → 知识检索(2000字上限)            │
│ 4. 知识不足 → 固定 JSON 短路(零 LLM 调用)             │
│ 5. PromptTemplate    → 系统提示词模板                   │
│ 6. SessionManager    → 长期记忆摘要(最近3条)            │
│ 7. ModelRouter       → 模型路由+熔断(qwen-max→deepseek→plus) │
│                                                         │
│ client.prompt()                                         │
│   .system(persona + knowledge + memory)  ← 人设+知识    │
│   .user(input)                           ← 当前问题     │
│   ← MessageChatMemoryAdvisor 自动插入 20 条对话历史     │
│   .stream().chatResponse()                              │
│   .doOnNext(捕获 usage)                                 │
│   .map(提取文本).filter(去空).as(寒暄截断)               │
│   .timeout(120s)                    ← 超时保护          │
└────────────────────────────────────────────────────────┘

6. Token 节省机制

项目通过 11 种机制控制 token 消耗:

层级 机制 效果
输入 InputSanitizer 截断 500 字符 -
输入 IntentRouter 闲聊跳过检索 省知识检索 LLM 调用
输入 知识不足直接返回固定 JSON 零 LLM 调用(最大节省)
上下文 知识上下文硬限制 2000 字符 省 prompt token
上下文 知识检索 Redis 缓存 24h 省 Cypher + 分词调用
上下文 PromptComposer 跳过空段 不传空标记
输出 OutputTruncator 8 个寒暄模式截断 省 completion token
输出 Flux timeout(120s) 防止空转消耗
记忆 Redis 短期记忆上限 20 条 控制历史长度
记忆 10 条消息触发 LLM 压缩为 200 字摘要 反复用不重复传
记忆 摘要只注入最近 3 条 控制 prompt 长度
容错 熔断:3 次失败 → 30s 冷却 → 降级备用模型 避免反复无效调用

7. 经验教训

  1. JDK HttpClient 的默认超时是无限——只要没显式设置,它永远不会主动超时。Spring AI 的 OpenAiApi 不会替你设。
  2. 流式场景下,服务端网关空闲断开是常态,不是 bug。客户端必须有超时兜底。
  3. Reactor 的 .timeout() 不等于 HTTP 层的 readTimeout——前者是"元素间超时",后者是"字节间超时"。对于 token 间隔较长的 LLM 场景,前者够用;对于网络抖动场景,推荐后者。
  4. 别让 OkHttp 躺在 pom.xml 里吃灰——要么用起来,要么删掉,免得误导排查方向。
  5. Stack trace 的第一行不一定指向根因——SocketDispatcher.read0 只是表象,往上翻三层才找到 JdkClientHttpConnector
posted @ 2026-06-30 14:50  xz_move_on  阅读(38)  评论(0)    收藏  举报