Spring AI + Qwen3.5 实现多步 Agent:从工具调用到自主任务拆解的踩坑全记录

适用版本 :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 个坑,每个都有可直接用的修复方案。

文章结构:

  1. 依赖配置与环境准备
  2. 第一个工具调用(以及它为什么没按你想的方式工作)
  3. Qwen3.5 思维链与工具调用的冲突(3 个坑)
  4. 多步工具调用循环(2 个坑)
  5. 对话记忆与状态管理(2 个坑)
  6. 并发与线程安全(2 个坑)
  7. 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:@ToolParamrequired 默认为 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:ToolCallAdvisorMessageChatMemoryAdvisor 冲突,历史消息重复

现象 :开启 ToolCallAdvisor 后,对话历史被记录了两遍------工具调用的中间过程消息和最终消息都进了记忆,导致上下文越来越长,最终超出 num-ctx 限制,输出截断。

原因ToolCallAdvisor 内部也在维护工具调用过程中的消息历史。如果同时开启 ToolCallAdvisorconversationHistoryEnabled(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 历史消息重复记录 上下文超长,输出截断 ToolCallAdvisorconversationHistoryEnabled(false)
7 多用户记忆不隔离 用户 A 看到用户 B 历史 每用户使用唯一 conversationId
8 ChatClientRequestSpec 被共享 并发时状态互串 每次请求用 chatClient.prompt() 新建实例
9 工具类有共享可变状态 并发数据竞争 工具类无状态,或用 AtomicXxx
10 流式模式工具调用失效 只输出中间 token ToolCallAdvisor.streamToolCallResponses(true)

参考资料


如果这篇文章帮到你,欢迎点赞收藏。Agent 开发踩坑是个持续的过程,欢迎在评论区分享你遇到的其他坑,后续持续更新。

相关推荐
gentle_ice2 小时前
初入社会的我该何去何从
java
Kiling_07042 小时前
Java多态、final与抽象类:面向对象编程进阶指南
java·开发语言
波动几何2 小时前
极简万能通用AI Agent:universal-agent
人工智能
行者-全栈开发2 小时前
腾讯地图 Map Skills 快速入门:从零搭建 AI 智能行程规划应用
人工智能·typescript·腾讯地图·ai agent·mcp 协议·map skills·智能行程规划
better_liang3 小时前
每日Java面试场景题知识点之-MySQL索引
java·数据库·mysql·性能优化·索引
彩虹编程3 小时前
通俗讲解LTN中的非逻辑符号、连接词、量词
人工智能·神经符号
DoUfp0bgq3 小时前
解决RDK X5(ARM64架构)板卡Remote-SSH运行Antigravity AI崩溃(SIGILL):Samba网络盘本地挂载方案
人工智能·架构·ssh
小小小怪兽3 小时前
⛏️深入RAG
人工智能·langchain
Aktx20FNz3 小时前
一文学习 Spring AOP 源码全过程
java·学习·spring