背景#

在开发新加坡小学数学 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.thinkThinkOption 对象:错误,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 里过滤掉了。

尝试过的无效方案#

在找到正确的绕过方式之前,我试过这些:

  1. 子类化 OllamaChatOptions 重写 toMap():失败。ModelOptionsUtils.merge() 在合并选项时会创建新的 OllamaChatOptions 实例,子类方法被丢弃。

  2. 直接用 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 字段泄漏到 options map,导致 Ollama 返回 400
  • 上游已修复(spring-ai#5435),但未发布
  • 如果你也还停留在这个版本区间,我会更倾向先用 ClientHttpRequestInterceptor 这类临时方案,把改动控制在尽量小的范围内
  • 遇到框架 bug 时,我自己更倾向优先找最小侵入的绕过方式,而不是立刻完全绕开框架;这样通常更容易在后续版本里收回来

注意:Spring Boot 4.0 中 RestClientCustomizer 的包路径从 org.springframework.boot.web.client 改为 org.springframework.boot.restclient,迁移时注意更新 import。