Spring AI 2.0.0-M2 的 Ollama think 字段问题:排查过程与 Interceptor 临时方案
目录
背景#
在开发新加坡小学数学 AI 辅导 App 时,我使用 Ollama 本地运行 qwen3.5:2b 模型。qwen3.5 是 thinking-capable 模型,Ollama 0.12+ 默认开启 thinking 模式,会先在 thinking 字段输出推理过程,再在 content 字段输出最终答案。
我当时遇到的问题是:thinking 模式下推理耗时从 ~16s 增加到 ~52s(约 3x),而且对当时那组 PSLE 小学数学题来说,我并不需要这么长的推理链路,所以想先把 thinking 关掉。
现象#
使用 Spring AI 2.0.0-M2 提供的 API 关闭 thinking:
ChatClient.prompt()
.system(systemPrompt)
.user(userMessage)
.options(OllamaChatOptions.builder().disableThinking().build())
.call()
.content();
Ollama 返回 HTTP 400:
think must be a boolean or string ("high", "medium", "low", true, or false)
根因分析#
抓取 Spring AI 发往 Ollama 的请求体,发现 think 字段出现在了两个位置:
{
"model": "qwen3.5:2b",
"messages": [...],
"think": false,
"options": {
"temperature": 0.7,
"think": { "type": "DISABLED" }
}
}
- 顶层
"think": false:正确,Ollama 能识别 options.think传ThinkOption对象:错误,Ollama 的options字段只接受模型参数(temperature、num_predict 等),不认识think,导致了参数污染。
问题出在 OllamaChatModel.ollamaChatRequest() 方法:
.options(requestOptions) // 调用 requestOptions.toMap(),think 渗透进 options map
.think(requestOptions.getThinkOption()) // 正确设到顶层
OllamaChatOptions.filterNonSupportedFields() 的 NON_SUPPORTED_FIELDS 列表中缺少 "think",导致它没有被从 options map 中过滤掉:
// Spring AI 2.0.0-M2 源码示例 (OllamaChatOptions.java)
public Map<String, Object> toMap() {
Map<String, Object> options = ModelOptionsUtils.toMap(this);
return filterNonSupportedFields(options); // "think" 仍然留在 options 中
}
这个问题已经在上游 spring-ai#5435 修复;我写这篇时,它还没有进入我当时使用的正式版本。
提示:如果你的项目已经升级到 Spring AI 2.0.0-RC1+,请直接忽略本文,上游已经把
think从 options map 里过滤掉了。
尝试过的无效方案#
在找到正确的绕过方式之前,我试过这些:
-
子类化
OllamaChatOptions重写toMap():失败。ModelOptionsUtils.merge()在合并选项时会创建新的OllamaChatOptions实例,子类方法被丢弃。 -
直接用
ChatModel.call(new Prompt(..., options)):同样触发 bug,因为最终都走OllamaChatModel.ollamaChatRequest()。
我的修法:在 HTTP 层把泄漏的 think 字段摘掉#
另一条路是完全绕开 Spring AI 的 OllamaChatModel,用裸 RestClient 自己拼请求体。我没走这条:prompt template、advisor chain、observability 这些能力都得重新补一遍,后面要接 DeepSeek-R1 之类的云端模型时,调用层还得再收回 ChatClient。
保留 ChatClient,注册一个 RestClientCustomizer,在请求发出前从 options map 中移除泄漏的 think 字段:
@Configuration
public class OllamaConfig {
@Bean
RestClientCustomizer ollamaThinkFieldFixCustomizer(ObjectMapper objectMapper) {
return restClientBuilder -> restClientBuilder
.requestInterceptor((request, body, execution) -> {
if (body != null && body.length > 0) {
try {
var tree = objectMapper.readTree(body);
if (tree.has("options") && tree.get("options").has("think")) {
((ObjectNode) tree.get("options")).remove("think");
body = objectMapper.writeValueAsBytes(tree);
}
} catch (Exception e) {
// 非 JSON 请求或解析失败,跳过
}
}
return execution.execute(request, body);
});
}
@Bean
ChatClient chatClient(OllamaChatModel ollamaChatModel) {
return ChatClient.builder(ollamaChatModel).build();
}
}
业务代码回到标准的 ChatClient 调用:
private String callLlm(String systemPrompt, String userMessage) {
return chatClient.prompt()
.system(systemPrompt)
.user(userMessage)
.options(OllamaChatOptions.builder().disableThinking().build())
.call()
.content();
}
这样 Spring AI 那一层原样保留,等上游修复进到我实际用的版本,删掉这个 @Bean 就能退回去。代价是这个 interceptor 会解析经过该 RestClient 的所有请求体;按我当时那套配置,Spring AI 的 Ollama RestClient 只用于 Ollama API 调用,解析失败时直接跳过,不改变正常请求的发送路径。
验证#
改造后效果符合预期,think 字段不再渗透入 options,且推理过程被正确关闭:
| 场景 | 推理耗时 (Think) | 内容生成 (Content) | 总计耗时 |
|---|---|---|---|
| Thinking 开启 (默认) | ~26s | ~26s | ~52s |
Thinking 关闭 (disableThinking() + Interceptor) |
~6s | ~10s | ~16s |
小结#
- Spring AI 2.0.0-M2 的
OllamaChatOptions.disableThinking()有 bug:think字段泄漏到optionsmap,导致 Ollama 返回 400 - 上游已修复(spring-ai#5435),但未发布
- 如果你也还停留在这个版本区间,我会更倾向先用
ClientHttpRequestInterceptor这类临时方案,把改动控制在尽量小的范围内 - 遇到框架 bug 时,我自己更倾向优先找最小侵入的绕过方式,而不是立刻完全绕开框架;这样通常更容易在后续版本里收回来
注意:Spring Boot 4.0 中
RestClientCustomizer的包路径从org.springframework.boot.web.client改为org.springframework.boot.restclient,迁移时注意更新 import。