适用版本 :Spring AI 1.1.3、Spring Boot 3.4、Java 21
模型:Qwen3.5-9B-Instruct(本地 Ollama)/ Qwen3.5-27B(远程 vLLM)
前言
Spring AI 1.0 在 2025 年 5 月正式 GA,1.1 在同年 11 月跟进,带来了 Recursive Advisors、ToolCallAdvisor、完整 MCP 集成------Java 开发者第一次拥有了不依赖 Python 的完整 Agent 构建能力。
但"能用"和"好用"之间,有一条踩坑之路。
Qwen3.5-9B 是个有思维链的模型,和 Spring AI 的工具调用机制放在一起,产生了一些不那么直觉的行为。本文记录了从"Hello World 工具调用"到"多步 Agent 稳定运行"的完整踩坑过程,10 个坑,每个都有可直接用的修复方案。
文章结构:
- 依赖配置与环境准备
- 第一个工具调用(以及它为什么没按你想的方式工作)
- Qwen3.5 思维链与工具调用的冲突(3 个坑)
- 多步工具调用循环(2 个坑)
- 对话记忆与状态管理(2 个坑)
- 并发与线程安全(2 个坑)
- ReAct vs Function Calling 的选择
一、依赖配置
pom.xml
XML
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Ollama 本地模型(对接 Qwen3.5-9B) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<!-- 对话记忆 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
</dependencies>
application.yml
XML
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
model: qwen3.5:9b # Ollama 标签格式
options:
temperature: 0.1 # Agent 场景降低温度,减少随机性
num-ctx: 8192 # 上下文窗口,多步 Agent 需要更大窗口
Ollama 准备:
ollama pull qwen3.5:9b # 验证启动 ollama run qwen3.5:9b "你好,测试一下工具调用能力"
二、第一个工具调用
Spring AI 1.1 用 @Tool 注解定义工具,语法干净,不需要写 JSON Schema:
java
// tools/CompanyTools.java
@Component
public class CompanyTools {
@Tool(description = "根据员工姓名查询员工信息,返回职位、部门、联系方式")
public String queryEmployee(
@ToolParam(description = "员工姓名,支持中文模糊匹配") String name) {
// 模拟数据库查询
return switch (name) {
case "张三" -> "张三,后端工程师,研发部,电话:138xxxx0001";
case "李四" -> "李四,产品经理,产品部,电话:138xxxx0002";
default -> "未找到员工:" + name;
};
}
@Tool(description = "查询指定部门的所有员工列表")
public String listDepartmentEmployees(
@ToolParam(description = "部门名称,如研发部、产品部") String department) {
return switch (department) {
case "研发部" -> "研发部成员:张三(后端)、王五(前端)、赵六(算法)";
case "产品部" -> "产品部成员:李四(PM)、钱七(设计)";
default -> department + " 暂无员工记录";
};
}
}
最简 ChatClient 配置和调用:
java
// agent/SimpleAgent.java
@Service
public class SimpleAgent {
private final ChatClient chatClient;
public SimpleAgent(ChatClient.Builder builder, CompanyTools companyTools) {
this.chatClient = builder
.defaultSystem("你是一个企业内部助手,可以查询员工信息。回答简洁准确。")
.defaultTools(companyTools) // 注册工具
.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
跑一下:
java
agent.ask("帮我查一下张三的联系方式");
三、Qwen3.5 思维链与工具调用的冲突
坑1:工具调用前塞满了 <think> 标签,响应慢且格式乱
现象 :调用工具之前,模型输出了大量 <think> 内容,然后才执行工具调用,最终响应里包含了整个推理过程。
python
<think>
用户想查张三的联系方式,我需要调用 queryEmployee 工具......
让我分析一下参数......张三是中文名字......
</think>
我来帮你查询张三的信息。
[调用 queryEmployee("张三")]
张三,后端工程师,研发部,电话:138xxxx0001
原因 :Qwen3.5-9B-Instruct 的 chat_template 默认开启思维链模式(见本系列微调踩坑篇)。工具调用前的推理在某些场景是有价值的,但如果你不需要,它只会增加延迟和噪音。
解法一(推荐) :在 application.yml 禁用思维链:
XML
spring:
ai:
ollama:
chat:
model: qwen3.5:9b
options:
# Qwen3 系列专用参数:关闭思维链
# 等效于使用 qwen3_nothink 模板
think: false
解法二 :如果你确实需要思维链辅助复杂推理,在输出后过滤 <think> 块:
java
public String ask(String question) {
String raw = chatClient.prompt()
.user(question)
.call()
.content();
// 过滤思维链内容,只返回最终答案
return raw.replaceAll("(?s)<think>.*?</think>", "").trim();
}
坑2:工具名称推断错误,模型调用了不存在的工具
现象 :模型调用了一个叫 get_employee_info 的工具,但你定义的工具叫 queryEmployee,导致工具调用失败,模型进入"找不到工具"的错误循环。
原因 :@Tool 注解不指定 name 时,Spring AI 默认用方法名作为工具名。Qwen3.5 在工具选择时偏向英文下划线命名风格,遇到驼峰命名的 Java 方法名时可能推断失误。
java
// ❌ 工具名是驼峰,模型可能调用下划线版本
@Tool(description = "查询员工信息")
public String queryEmployee(String name) { ... }
// ✅ 显式指定工具名,使用下划线风格
@Tool(name = "query_employee", description = "查询员工信息")
public String queryEmployee(String name) { ... }
额外建议 :description 字段要写明触发条件,不只是功能描述:
java
// ❌ 太简略,模型不知道什么时候调用
@Tool(name = "query_employee", description = "查询员工")
// ✅ 明确触发场景和返回内容
@Tool(name = "query_employee",
description = "当用户询问某个员工的信息(联系方式、部门、职位)时调用此工具。" +
"输入员工姓名,返回该员工的完整信息。")
坑3:@ToolParam 的 required 默认为 true,可选参数报错
现象 :工具有一个可选参数(如 limit,控制返回数量),不传时模型调用失败,日志报 parameter 'limit' is required。
java
// ❌ required 默认 true,不传就报错
@Tool(name = "list_employees", description = "列出部门员工")
public String listEmployees(
@ToolParam(description = "部门名称") String department,
@ToolParam(description = "返回数量限制") Integer limit) { ... }
// ✅ 可选参数显式设置 required = false,并在方法里处理 null
@Tool(name = "list_employees", description = "列出部门员工,limit 可不传默认返回全部")
public String listEmployees(
@ToolParam(description = "部门名称") String department,
@ToolParam(description = "返回数量上限,不传则返回全部", required = false)
Integer limit) {
int maxSize = (limit != null) ? limit : Integer.MAX_VALUE;
// ...
}
四、多步工具调用循环
坑4:默认工具调用只执行一轮,复杂任务中途停止
现象:让 Agent 完成"查出研发部所有员工,然后逐个查询他们的联系方式汇总"这类任务,模型只调用了第一个工具就返回了,没有继续调用第二个。
原因 :Spring AI 1.1 之前,默认的工具调用在 ChatModel 内部处理,调用一轮后就返回结果,Advisor 链无法介入控制循环次数。1.1 引入了 ToolCallAdvisor,把工具调用循环提升到 Advisor 层,可以配置最大迭代次数。
java
// agent/MultiStepAgent.java
@Service
public class MultiStepAgent {
private final ChatClient chatClient;
public MultiStepAgent(ChatClient.Builder builder, CompanyTools tools) {
// ToolCallAdvisor:把工具调用循环放进 Advisor 链
// 关键:设置 conversationHistoryEnabled = false,
// 避免和 MessageChatMemoryAdvisor 冲突(见坑6)
var toolCallAdvisor = ToolCallAdvisor.builder()
.advisorOrder(Ordered.HIGHEST_PRECEDENCE + 300)
.conversationHistoryEnabled(false)
.build();
var chatMemory = MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
var memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
.order(Ordered.HIGHEST_PRECEDENCE + 1000)
.build();
this.chatClient = builder
.defaultSystem("""
你是一个企业内部助手,擅长分解复杂任务并逐步完成。
当需要多个步骤时,先调用必要的工具收集信息,再给出综合回答。
每次工具调用都要基于上一步的结果决定下一步。
""")
.defaultTools(tools)
.defaultAdvisors(toolCallAdvisor, memoryAdvisor)
.build();
}
public String execute(String sessionId, String task) {
return chatClient.prompt()
.user(task)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.content();
}
}
现在 Agent 会自动循环调用工具,直到模型判断任务完成或达到默认最大次数(默认 10 次)。
坑5:工具调用死循环------模型反复调用同一个工具
现象 :Agent 陷入死循环,反复调用 query_employee("张三"),每次得到相同结果,但不知道什么时候停。请求超时后报 MaxToolCallIterationsExceededException。
原因有两个:
原因 A:工具返回了模型不知道怎么处理的结果,模型"不确定",选择再问一次。
java
// ❌ 返回格式不清晰,模型可能认为没有收到有效答案
public String queryEmployee(String name) {
Employee emp = db.find(name);
return emp == null ? null : emp.toString(); // toString() 格式不可控
}
// ✅ 返回清晰的自然语言,让模型明确知道结果
public String queryEmployee(String name) {
Employee emp = db.find(name);
if (emp == null) {
return "系统中没有找到名为「" + name + "」的员工,请确认姓名是否正确。";
}
return String.format("查询成功:%s,职位:%s,部门:%s,电话:%s",
emp.getName(), emp.getTitle(), emp.getDept(), emp.getPhone());
}
原因 B:没有设置最大迭代次数,加一个硬上限:
java
// ToolCallAdvisor 设置最大迭代次数(默认 10,复杂任务可调高,但要设上限)
var toolCallAdvisor = ToolCallAdvisor.builder()
.advisorOrder(Ordered.HIGHEST_PRECEDENCE + 300)
.conversationHistoryEnabled(false)
// Spring AI 1.1.3 通过 ToolCallingManager 控制最大次数
.build();
// 或在 application.yml 全局配置
// spring.ai.chat.client.tool-calling.max-tool-calls=5
同时在 System Prompt 里加入停止条件的明确指示:
java
.defaultSystem("""
你是一个企业助手。完成任务时:
1. 每个信息只查询一次,不要重复调用同一工具获取相同数据
2. 获得足够信息后立即给出最终答案,不要继续调用工具
3. 如果工具返回"未找到",直接在回答中说明,不要重试
""")
五、对话记忆与状态管理
坑6:ToolCallAdvisor 和 MessageChatMemoryAdvisor 冲突,历史消息重复
现象 :开启 ToolCallAdvisor 后,对话历史被记录了两遍------工具调用的中间过程消息和最终消息都进了记忆,导致上下文越来越长,最终超出 num-ctx 限制,输出截断。
原因 :ToolCallAdvisor 内部也在维护工具调用过程中的消息历史。如果同时开启 ToolCallAdvisor 的 conversationHistoryEnabled(true)(默认值),和外部的 MessageChatMemoryAdvisor,消息会被记录两次。
解法 :ToolCallAdvisor 关闭内部历史记录,由 MessageChatMemoryAdvisor 统一管理:
java
// ✅ 正确配置:ToolCallAdvisor 关闭内部历史,由 Memory Advisor 统一管理
var toolCallAdvisor = ToolCallAdvisor.builder()
.advisorOrder(Ordered.HIGHEST_PRECEDENCE + 300)
.conversationHistoryEnabled(false) // 关键:关闭内部历史
.build();
var memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory)
.order(Ordered.HIGHEST_PRECEDENCE + 1000) // Memory Advisor 后执行
.build();
Advisor 执行顺序 :order 值越小越先执行。推荐顺序:
执行顺序(order 从小到大):
1. ToolCallAdvisor(HIGHEST_PRECEDENCE + 300)
→ 负责工具调用循环
2. MessageChatMemoryAdvisor(HIGHEST_PRECEDENCE + 1000)
→ 负责注入历史消息
坑7:多用户场景下 conversationId 没隔离,用户 A 看到用户 B 的历史
现象:两个用户同时使用 Agent,用户 B 的对话上下文里出现了用户 A 的查询历史。
原因 :MessageChatMemoryAdvisor 需要通过 conversationId 参数区分不同会话,如果没有传入或传入了固定值(如测试时写死的 "test"),所有用户共享同一个记忆空间。
java
// ❌ 写死 conversationId,所有请求共享记忆
chatClient.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "fixed-id"))
.call()
.content();
// ✅ 每个用户会话使用唯一 ID
@PostMapping("/ask")
public String ask(@RequestBody AskRequest req, HttpSession session) {
// 用 HTTP Session ID 作为 conversationId
String conversationId = session.getId();
return chatClient.prompt()
.user(req.question())
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
生产环境建议用 Redis 持久化记忆,防止服务重启丢失上下文:
java
// 使用 Redis 持久化对话记忆(需要 spring-ai-redis 依赖)
@Bean
public ChatMemory redisChatMemory(RedisTemplate<String, Object> redisTemplate) {
return RedisChatMemory.builder()
.redisTemplate(redisTemplate)
.defaultConversationTtl(Duration.ofHours(24))
.build();
}
六、并发与线程安全
坑8:ChatClient 不是线程安全的,高并发时状态互串
现象 :压测时,偶发性地出现一个请求的工具调用结果出现在另一个请求的响应里,或 conversationId 混乱。
原因 :ChatClient 实例本身是线程安全的(Immutable),但通过 .prompt() 创建的 ChatClient.ChatClientRequestSpec 不是线程安全的------它是每次调用的上下文,在并发场景下不能跨线程共享。
java
// ❌ 错误:把 ChatClientRequestSpec 存为成员变量
@Service
public class BadAgent {
private ChatClient.ChatClientRequestSpec requestSpec; // 非线程安全!
public String ask(String q) {
requestSpec = chatClient.prompt().user(q); // 多线程竞争
return requestSpec.call().content();
}
}
// ✅ 正确:每次请求创建新的 ChatClientRequestSpec
@Service
public class GoodAgent {
private final ChatClient chatClient; // 线程安全,可以共享
public String ask(String q) {
return chatClient.prompt() // 每次调用创建新实例
.user(q)
.call()
.content();
}
}
坑9:工具方法里的共享状态竞争
现象:Agent 并发执行时,工具里的计数器、缓存等共享状态出现数据竞争。
原因 :@Tool 注解的方法由 Spring 管理为 Bean,默认是单例,如果工具方法里有实例变量状态,在并发场景下会产生竞争。
java
// ❌ 工具类有共享可变状态
@Component
public class BadQueryTools {
private int callCount = 0; // 多线程竞争!
@Tool(name = "query_employee")
public String queryEmployee(String name) {
callCount++; // 线程不安全
return db.find(name);
}
}
// ✅ 工具类无状态,或用线程安全数据结构
@Component
public class GoodQueryTools {
private final AtomicInteger callCount = new AtomicInteger(0); // 线程安全
private final EmployeeRepository repository; // 注入,Spring 管理线程安全
@Tool(name = "query_employee")
public String queryEmployee(String name) {
callCount.incrementAndGet();
return repository.findByName(name)
.map(this::format)
.orElse("未找到员工:" + name);
}
private String format(Employee e) {
return String.format("姓名:%s,职位:%s,部门:%s,电话:%s",
e.getName(), e.getTitle(), e.getDept(), e.getPhone());
}
}
坑10:流式响应(Streaming)与工具调用的兼容问题
现象 :切换到流式响应(.stream() 替换 .call())后,工具调用结果不再返回,或只返回工具调用的中间 token。
原因 :Spring AI 1.1 的流式模式和 ToolCallAdvisor 需要特殊配置------必须开启 streamToolCallResponses(true),告诉 Advisor 在流式模式下也要等待工具调用完成再输出:
java
var toolCallAdvisor = ToolCallAdvisor.builder()
.advisorOrder(Ordered.HIGHEST_PRECEDENCE + 300)
.conversationHistoryEnabled(false)
.streamToolCallResponses(true) // 流式模式必须开启!
.build();
// 流式调用
public Flux<String> askStream(String sessionId, String question) {
return chatClient.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
.stream()
.content();
}
// Spring MVC SSE 接口
@GetMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamAsk(
@RequestParam String question,
HttpSession session) {
return chatClient.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, session.getId()))
.stream()
.content()
.map(token -> ServerSentEvent.builder(token).build());
}
七、ReAct vs Function Calling:应该选哪个?
Spring AI 支持两种 Agent 模式,在遇到复杂任务时应该怎么选?
Function Calling(工具调用,上文的做法):
- 模型输出结构化的工具调用请求(JSON),框架自动执行并把结果返回给模型
- 优点:延迟低、响应稳定、不依赖模型输出格式的稳定性
- 缺点:调试困难,不知道模型为什么选择了某个工具
- 适用:工具数量少(< 10 个)、任务路径相对固定的场景
ReAct 模式(推理 + 行动):
- 模型先输出"Thought"(推理)、再输出"Action"(行动),交替迭代
- 优点:推理过程透明可观察,适合需要解释 Agent 决策的场景
- 缺点:依赖模型严格遵循输出格式,Qwen3.5 有时会偏离格式
- 适用:工具数量多、任务路径不固定、需要调试/解释的场景
Qwen3.5 的实际表现 :Function Calling 比 ReAct 稳定得多。Qwen3.5-9B 的 ReAct 格式遵循率约 85%,剩下 15% 会出现格式偏离导致解析失败。建议生产环境优先选 Function Calling + ToolCallAdvisor。
如果需要观察工具调用推理,Spring AI 1.1.3 的 AugmentedToolCallbackProvider 是更好的选择:
java
// 用 AugmentedToolCallbackProvider 捕获工具调用推理(无需 ReAct 格式)
record AgentThinking(String innerThought) {}
AugmentedToolCallbackProvider<AgentThinking> provider =
AugmentedToolCallbackProvider.<AgentThinking>builder()
.toolObject(companyTools)
.argumentType(AgentThinking.class)
.build();
ChatClient chatClient = builder
.defaultToolCallbacks(provider) // 替换 defaultTools
.defaultAdvisors(toolCallAdvisor, memoryAdvisor)
.build();
// 调用时可同时获取推理和结果
record Response(String answer, AgentThinking thinking) {}
var result = chatClient.prompt()
.user(question)
.call()
.entity(Response.class);
log.info("推理过程:{}", result.thinking().innerThought());
log.info("最终答案:{}", result.answer());
八、踩坑速查表
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | Qwen3.5 默认开启思维链 | 响应包含大量 <think> 标签 |
options.think: false 或过滤标签 |
| 2 | 工具名推断错误 | 模型调用不存在的工具名 | @Tool(name = "snake_case_name") 显式指定 |
| 3 | 可选参数报错 | required parameter missing |
@ToolParam(required = false) |
| 4 | 工具调用只执行一轮 | 复杂任务中途停止 | 接入 ToolCallAdvisor |
| 5 | 工具调用死循环 | 反复调用同一工具 | 工具返回明确结果 + System Prompt 加停止条件 |
| 6 | 历史消息重复记录 | 上下文超长,输出截断 | ToolCallAdvisor 设 conversationHistoryEnabled(false) |
| 7 | 多用户记忆不隔离 | 用户 A 看到用户 B 历史 | 每用户使用唯一 conversationId |
| 8 | ChatClientRequestSpec 被共享 |
并发时状态互串 | 每次请求用 chatClient.prompt() 新建实例 |
| 9 | 工具类有共享可变状态 | 并发数据竞争 | 工具类无状态,或用 AtomicXxx |
| 10 | 流式模式工具调用失效 | 只输出中间 token | ToolCallAdvisor.streamToolCallResponses(true) |
参考资料
- Spring AI 1.1 GA Release Notes
- Spring AI Advisors API 官方文档
- Create Self-Improving AI Agents Using Spring AI Recursive Advisors
- Explainable AI Agents with Tool Argument Augmenter
- ToolCallAdvisor API 文档
- Spring AI 1.1 and MCP: Building Production AI Agents Without the Python Tax
- Qwen3.5-9B HuggingFace
如果这篇文章帮到你,欢迎点赞收藏。Agent 开发踩坑是个持续的过程,欢迎在评论区分享你遇到的其他坑,后续持续更新。