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)
没有任何超时类异常(SocketTimeoutException、ReadTimeoutException),直接就是 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 退化为 JdkClientHttpConnector → JDK 自带的 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),然后用 ReactorClientHttpConnector 设 responseTimeout。这是最直接的方案——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. 经验教训
- JDK HttpClient 的默认超时是无限——只要没显式设置,它永远不会主动超时。Spring AI 的
OpenAiApi不会替你设。 - 流式场景下,服务端网关空闲断开是常态,不是 bug。客户端必须有超时兜底。
- Reactor 的
.timeout()不等于 HTTP 层的readTimeout——前者是"元素间超时",后者是"字节间超时"。对于 token 间隔较长的 LLM 场景,前者够用;对于网络抖动场景,推荐后者。 - 别让 OkHttp 躺在 pom.xml 里吃灰——要么用起来,要么删掉,免得误导排查方向。
- Stack trace 的第一行不一定指向根因——
SocketDispatcher.read0只是表象,往上翻三层才找到JdkClientHttpConnector。

浙公网安备 33010602011771号