Spring AI 对接 vLLM 400 报错实战排查:换掉默认 HTTP 客户端即可
【免费下载链接】spring-aiAn Application Framework for AI Engineering项目地址: https://gitcode.com/GitHub_Trending/spr/spring-ai
TL;DR:Spring AI 的 OpenAiChatModel 调用 vLLM 部署的 deepseek 模型时,配置全对却稳定返回 HTTP 400("Field required")。根因是默认 HTTP 客户端用 chunked 分块传输编码发请求体,vLLM 解析不到 body。当前最优解:把 RestClient 和 WebClient 的请求工厂换成 Jetty 客户端,两步依赖 + 几行 builder 配置即可绕过。
🔍 复现路径:在什么条件下会踩到
同时满足以下条件时,几乎必现 400:
- 服务端是 vLLM 起的 OpenAI 兼容接口,上面跑的 deepseek 系列模型(含 DeepSeek-R1 蒸馏版)。
- 客户端使用 Spring AI 的 OpenAI 模块(OpenAiChatModel),未对底层 HTTP 客户端做任何定制。
base-url、api-key、model三项配置全部正确,健康检查接口能通。- 用
curl直接 POST/v1/chat/completions返回 200,换成 Spring AI 调用立刻 400——同一请求、两个客户端、两种结果。 - 只要 body 是 Spring 侧序列化后整体发出去(非手工拼字符串)就会触发,与请求大小无关。
对照一遍:如果 curl 通、Spring AI 不通,且服务端是 vLLM,基本可以按本文继续。
🚨 报错现场:日志里最关键的一行
400 - {"object":"error","message":"[{'type': 'missing', 'loc': ('body',), 'msg': 'Field required', 'input': None}]","type":"BadRequestError","code":400}定位钥匙是'input': None。Pydantic 的校验报错里,input字段记录的是服务端实际收到的入参。它是None而不是一个 JSON 对象,说明 vLLM 根本没拿到请求体——问题不在 body 内容,在传输方式。这直接排除了"参数拼错""字段缺失"这类方向。
根因链路:从协议层到框架层
因果链只有四步:
- Spring 默认客户端发 POST 请求
- 采用 chunked 编码,不带 Content-Length
- vLLM 解析 chunked body 失败,视为空
- body 校验缺失,抛 400 BadRequest
也就是说,Spring AI 侧序列化的 JSON 本身没问题,是它在传输层以 chunked 分块编码发出(分块传输编码:HTTP 里不预先告知 body 长度、边写边发的一种编码方式),vLLM 的接收端吃不下这种编码,把请求体读成了空。
为什么默认行为会走到这条路
Spring 的RestClient/WebClient在没有显式指定请求工厂时,使用 JDK 内置的 HTTP 实现。它对"长度已知的 JSON body"也可能走 chunked 路径,而这个分支恰好踩中 vLLM 尚未修复的解析短板。Jetty 客户端在发送前会算好长度、以 Content-Length 方式发 body,于是绕开了这个坑。
🛠 修复方案:改哪一行代码就够
核心动作一句话:给 OpenAiApi 的 builder 注入 Jetty 请求工厂,让请求体带 Content-Length 一次性发出,vLLM 就能正常解析。改动只有两处,不动任何业务代码。
最小依赖改动
在模块 pom 中加入 Jetty 客户端及其响应式连接器:
<!-- 同步 RestClient 用的 Jetty 请求工厂 --> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-client</artifactId> </dependency> <!-- 流式 WebClient 用的 Jetty 连接器 --> <dependency> <groupId>org.eclipse.jetty</groupId> <artifactId>jetty-reactive-httpclient</artifactId> </dependency>验证方式:依赖解析无冲突即可,此步不产生行为变化。
Jetty 客户端配置
构建 OpenAiApi 时显式传入两个 builder,同步和流式两条链路都要覆盖:
OpenAiApi openAiApi = OpenAiApi.builder() .baseUrl(chatConfig.getBaseUrl()) // 指向 vLLM 服务 .apiKey(chatConfig.getApiKey()) .restClientBuilder(RestClient.builder() .requestFactory(new JettyClientHttpRequestFactory())) // 同步调用走 Jetty .webClientBuilder(WebClient.builder() .clientConnector(new JettyClientHttpConnector())) // 流式调用走 Jetty .build();验证方式:发一条普通 chat 请求,HTTP 200 且正常返回 token 即修复生效;再发一条stream: true的流式请求,确认增量输出同样正常。
临时方案与长期方案的边界
- 临时方案(可立即上线):上面的 Jetty 客户端替换,行为完全兼容,只换传输层。
- 长期方案(依赖上游):vLLM 侧补齐对 chunked 编码请求体的解析。补丁合入后,Jetty 配置可保留(无副作用)也可回退默认客户端。
举一反三:同类集成中还要警惕什么
关注 vLLM 的传输层修复进度:跟踪其 issue 与 release notes 中关于 chunked /Transfer-Encoding的修复项,合入后即可把 HTTP 客户端配置收敛回默认,减少一个外部依赖。
排查 Ollama、DeepSeek 官方接口等 OpenAI 兼容服务端:凡是拿"curl 通、Spring AI 不通"的 400,都先查响应体里input字段是否为None。是,则优先怀疑传输编码而非参数;Spring AI 文档中关于 vLLM 的 extra-body、reasoning_content 等适配说明,可参考 openai-chat.adoc。
统一收口 Spring AI 的 API 构建入口:如果项目里多处 newOpenAiApi/OpenAiChatModel,把 RestClient 与 WebClient 的 Jetty 工厂集中到一个配置类或OpenAiHttpClientBuilderCustomizer风格的定制点里,避免某处遗漏后问题"复活"。vLLM + deepseek 的推理类模型在 Spring AI 里走的就是 OpenAI 兼容链路,多轮场景的 COT 输出形态可以参考官方示例:
先换客户端,再等补丁。
【免费下载链接】spring-aiAn Application Framework for AI Engineering项目地址: https://gitcode.com/GitHub_Trending/spr/spring-ai
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考