Spring AI 对接 vLLM 部署的 DeepSeek 报 400?一次 HTTP 分块传输的排查实录
【免费下载链接】spring-aiAn Application Framework for AI Engineering项目地址: https://gitcode.com/GitHub_Trending/spr/spring-ai
本文记录在 Spring AI 中使用 OpenAiChatModel 组件对接 vLLM 部署的 DeepSeek 大模型时,遇到的 "body 字段缺失" 400 报错的完整排查过程,并给出通过切换 Jetty HTTP 客户端彻底解决该问题的操作方法。
一句话结论:请求被"分段发货",服务端拒收
先把答案放在前面:这个 400 报错和你的 baseUrl、API Key、model 参数统统无关,根因在 HTTP 请求体的传输方式上——vLLM 对分块传输编码(chunked transfer encoding)的请求解析有问题。
分块传输编码是什么白话概念?正常发 HTTP POST 时,你相当于寄一个封好的整箱货物,箱子上贴着"总件数"(Content-Length 头),收件方开箱即知全貌。而分块传输则是"先说一声我要分几批寄,具体几批不告诉你",边写边发、发完为止。vLLM 这边只认整箱货,见到分批发来的请求就当成"没带货"处理,于是回你一个"缺少必填字段"的报错。
这个坑最折磨人的地方在于:同样的服务,用 curl 直接打是通的,只有走 Spring AI 才会挂。下面按我实际的排查顺序走一遍。
场景还原:curl 能通,OpenAiChatModel 就报 400
当时我手上有三样东西:一台跑着 vLLM 的机器(上面加载了 DeepSeek 系列模型)、一份能正常对话的 curl 命令、以及一个标准的 Spring AI 工程。
vLLM 服务健康、模型能推理,这些我都先用 curl 验证过,/v1/chat/completions端点返回完全正常。然后切到 Java 侧:
ChatModel chatModel = OpenAiChatModel.builder() .openAiApi(openAiApi) // baseUrl 指向 vLLM 的 /v1,apiKey 任意占位 .defaultOptions(OpenAiChatOptions.builder().model("deepseek").build()) .build();一调用,直接炸出这段报错:
400 - {"object":"error","message":"[{'type': 'missing', 'loc': ('body',), 'msg': 'Field required', 'input': None}]","type":"BadRequestError","param":null,"code":400}注意看报错里的'input': None:vLLM 认为它收到的 body 是空的。可我们明明把 prompt 塞进去了。这就是整个案子唯一的"矛盾点"——货物明明发了,对方却说没收到。
排查路线:从外到内,三层排除法
第一层,排除配置问题。逐项核对spring.ai.openai.base-url、api-key、model三个配置,并且用 Postman 复现了一遍 Spring AI 发出去的请求(header、body 完全一致)——Postman 通了。这说明"配置错了"和"vLLM 挂了"两个嫌疑人当场出局。
第二层,排除框架序列化问题。我一度怀疑是 Jackson 把 body 序列化空了,于是把请求体打到日志里检查,JSON 完整、字段齐全。这条线也断了。
第三层,抓包看传输层。既然"内容"没问题,问题只能出在"内容的发送方式"上。用mitmproxy抓了 Spring AI 发出的请求头,关键差异出现了:
Transfer-Encoding: chunkedcurl 和 Postman 发出的请求是带Content-Length的整包传输,而 Spring 默认 HTTP 栈在某些场景(尤其是响应式链路或 body 由流式构造时)会退化为分块传输。对比抓包结果的那一刻,元凶就锁定了:vLLM 无法正确解析 chunked 请求体。
解决操作:把默认 HTTP 客户端换成 Jetty
确认是"发货方式"的问题之后,解法就不多了:要么让服务端学会收分块(vLLM 侧的事),要么换一家"只发整箱货"的快递公司。我们可控的是前者不可行、后者可行,于是把 Spring AI 底层用的 HTTP 客户端换成 Jetty 实现。
分两步走。
第 1 步,补上 Jetty 的两个依赖:
<dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-client</artifactId> </dependency> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-reactive-httpclient</artifactId> </dependency>一个是 Jetty 的阻塞式客户端,对应RestClient;另一个提供响应式连接器,对应WebClient(流式对话走的就是这条线)。两个都要,缺一个就会出现"非流式好了、流式还是坏"的半残状态。
第 2 步,构造 OpenAiApi 时显式指定这两套客户端:
OpenAiApi openAiApi = OpenAiApi.builder() .baseUrl(chatConfig.getBaseUrl()) .apiKey(chatConfig.getApiKey()) .restClientBuilder(RestClient.builder() .requestFactory(new JettyClientHttpRequestFactory())) .webClientBuilder(WebClient.builder() .clientConnector(new JettyClientHttpConnector())) .build();这里restClientBuilder管的是同步调用(chatModel.call()),webClientBuilder管的是流式调用(chatModel.stream())。改完再跑一遍,Transfer-Encoding: chunked消失了,400 报错随之消失,DeepSeek 正常出词。
避坑备忘:一张表对齐检查点
| 检查项 | 预期 | 踩坑表现 |
|---|---|---|
curl 直连 vLLM/v1/chat/completions | 200 | 通了却报 400,说明问题在客户端侧 |
报错 body 里input字段 | 非 null | 为None说明服务端根本没收到 body |
| 抓包看请求头 | 有Content-Length | 出现Transfer-Encoding: chunked即命中本坑 |
| Jetty 依赖 | 两个都加 | 只加jetty-client时流式调用仍会失败 |
| Spring AI 版本 | 1.x 可用OpenAiApi.builder() | 2.0 已重构,见下方提示 |
最后这条值得展开一句:当前仓库主分支(2.0 线)的 OpenAI 模块已经换了底座,models/spring-ai-openai/ 下的OpenAiChatModel直接构建在 OpenAI 官方 Java SDK 之上,HTTP 层是自带 OkHttp 实现的SpringAiOpenAiHttpClient(见 models/spring-ai-openai/src/main/java/org/springframework/ai/openai/http/okhttp/),不再有OpenAiApi.builder()这个入口。也就是说本文的 Jetty 换法适用于 1.x 版本;如果你在 2.0 上复现类似症状,思路仍然成立(先抓包确认传输编码),只是替换客户端的代码路径不同。
给同坑的三句话
- 400 加 "body missing" 不等于你没传 body,先抓包看
Transfer-Encoding,再看服务端日志,顺序反了会浪费一整天。 - curl 通、框架不通,优先怀疑"发送方式"而非"发送内容"——序列化、参数绑定这些"内容"问题用日志就能查掉,传输层问题只能靠抓包。
- 临时方案(换 Jetty)救急没问题,但记得盯一下 vLLM 侧对分块传输的支持进展;上游修复后,这套绕行的客户端替换就可以摘掉了。
【免费下载链接】spring-aiAn Application Framework for AI Engineering项目地址: https://gitcode.com/GitHub_Trending/spr/spring-ai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考