从零到一搭建Spring‑AI原生Tool‑Calling AI Agent|架构对比+完整实战源码+踩坑实录

摘要:本文详细对比了Java生态中手写JSON协议Agent与Spring‑AI原生Tool‑Calling Agent两种架构方案的优劣,并提供了从零到一搭建Spring‑AI原生Tool‑Calling AI Agent的完整实战指南。内容包括:需求拆解、整体架构数据流、核心模块源码设计(RAG配置、Query扩写、链路追踪、SSE推送、双入口Controller等)、生产环境踩坑清单(ThreadLocal跨线程失效、SSE数据包过大、RAG降级策略等)以及后续迭代方向。文章强调Spring‑AI原生Tool‑Calling在维护成本、异常容错和开发效率上的优势,同时保留手动可控循环模式以满足复杂业务场景,为Java开发者提供了一套可落地的企业级Agent工程实践方案。

作者:Java后端开发者,8年业务开发,从传统RAG、手写Agent编排,过渡到Spring‑AI原生Tool‑Calling Agent完整落地
技术栈:Spring‑AI 、DeepSeek、PGVector向量库、BGE‑Rerank、SSE流式输出、Mysql+Redis链路追踪

前言:我踩过Agent开发的第一条弯路

最开始做AI问答项目的时候,我的认知很简单:RAG = AI Agent
只要把文档切片、向量化、向量检索、把检索到的文档塞给大模型,就能解决私有知识库问答。但是随着业务需求变复杂,问题来了:
用户不仅仅是查文档。
用户可能需要先查知识库制度,再查销售报表数据,合并两份信息之后再给出最终答案

最开始我采用行业早期最通用的手写编排Agent方案:

  1. 写一大段System Prompt,强制大模型输出固定格式JSON(toolNametoolParam
  2. 后端拿到字符串,ObjectMapper手动解析JSON
  3. 做一大堆异常兼容:模型输出markdown代码块、JSON漏逗号、字段缺失、中英文引号
  4. 自己写if‑else分发调用对应工具
  5. 拿到工具返回结果手动拼接到Prompt,再次丢回大模型,循环往复
  6. 自己维护Agent最大循环次数,防止死循环

这套手写方案勉强可以跑通Demo,但是上预环境之后一堆问题:格式解析异常、编排代码臃肿、工具多了之后维护成本极高。
直到深入研究Spring‑AI @Tool注解原生Tool‑Calling能力之后,我才重新设计整套Agent架构,于是就有了本文整套可落地工程代码。

一、Java生态两种Agent架构方案深度对比

目前Java开发AI‑Agent,主流两套实现方案,我两个方案都完整实现,优缺点一目了然

对比维度 手写JSON协议Agent(旧方案) Spring‑AI原生Tool‑Calling Agent(新方案)
工具调用协议 自己定义JSON字段,靠System Prompt约束模型输出 遵循OpenAI标准tool_call协议,DeepSeek原生支持
工具注册方式 硬编码工具注册表,if/else路由分发 @Tool + @ToolParam注解,自动生成JSON‑Schema
循环调度 后端手写while循环,手动维护消息上下文 框架内置对话循环,自动执行工具、回填工具结果
异常容错 需要单独处理JSON解析失败、格式错乱 底层模型SDK原生处理tool call报文,省去大量解析代码
链路追踪 需要解析模型返回的JSON才能知道调用哪个工具 工具方法执行时天然拿到入参,直接埋点Trace日志
可控程度 完全可控,每一步都由后端代码接管 默认自动调度;也可以手动接管循环实现强可控编排
维护成本 工具越多,编排层代码越臃肿 新增业务工具只需要增加一个@Tool方法,零编排改动

重要思考:原生Tool‑Calling不是万能的。
所以我项目里面同时保留两套入口

  • /nativeAgent/stream:交给SpringAI框架自动驱动Agent循环,适合大部分通用场景,开发速度最快
  • /nativeAgent/do_stream:手动自己控制Agent循环流程,适合业务需要强制干预步骤、自定义路由、中途拦截工具调用的复杂场景

二、整体需求拆解(0‑1阶段我梳理出来全部功能点)

在开始敲代码前,先把Agent需要具备的能力全部梳理清楚:

  1. 多工具支持
    • searchKnowledge:私有知识库RAG工具,向量召回+关键词多路召回、Query扩写、BGE‑Rerank重排
    • getOrderReport:业务报表查询工具,可以扩展成数据库查询、RPC、第三方接口调用
  2. Query预处理能力
    用户原始问题语义模糊,调用LLM生成多条同义扩写问题,提升向量召回命中率,扩写失败要有降级兜底策略
  3. 流式SSE输出
    • agent_trace事件:增量推送Agent思考步骤日志(调用了哪个工具、召回多少文档、重排结果),前端可视化Agent思维链
    • answer事件:大模型回答token分片实时输出,打字机效果
  4. 完整链路Trace追踪
    • TraceId全链路透传
    • 步骤日志持久化存入MySQL
    • Redis缓存2小时完整链路,支持前端通过traceId回看整个Agent思考过程
  5. 多路召回RAG策略
    向量相似度召回 + PG数据库关键词模糊召回,结果合并去重之后送入BGE重排序
  6. 会话上下文
    通过sessionId保存当前对话历史,Agent具备短期记忆,可以做多轮对话
  7. 线程上下文传递

    大坑提醒:Tool工具执行线程和Web请求线程不是同一个线程,ThreadLocal全部失效。
    traceId、SseEmitter、步骤集合stepList不能放在ThreadLocal,全部放到ToolContext传递。

三、整体架构数据流

是,返回tool_call

否,输出最终答案

前端发起SSE请求
/nativeAgent/stream?message=xxx&sessionId=xxx

NativeAgentController

NativeToolAgentService
初始化上下文、traceId、stepList

把 emitter / traceId / stepList
存入ToolContext

SpringAI ChatClient
自动把 @Tool工具Schema下发给DeepSeek

DeepSeek模型判断
是否需要调用工具

SpringAI框架自动反射执行
NativeAgentTool对应的工具方法

工具内部执行
RAG检索 / 查询报表
推送Trace步骤日志

工具返回结果
自动回填对话上下文

流式token通过SSE返回前端

AgentSsePushUtil
思考步骤单条推送,答案分片推送

链路存入Mysql+Redis

四、核心模块源码设计解析

4.1 RAG配置类 RagProperties

把RAG全部可调参数抽离配置文件,禁止硬编码数值
向量阈值、召回条数、重排条数、文档切片大小、重叠长度、query扩写数量全部yml可配置。

@Data
@Component
@ConfigurationProperties(prefix = "rag")
public class RagProperties {
    private Search search = new Search();
    private Splitter splitter = new Splitter();
    //省略内部静态类
}

application.yml配置示例

rag:
  search:
    similarity-threshold: 0.4
    top-k: 5
    rerank-top-k: 3
    re-question: 5
    keyword-top-k: 5
    merge-max-size: 8
  splitter:
    chunk-size: 500
    overlap-size: 120

4.2 Query语义扩写工具 LlmRewriteMsg

RAG召回质量很大程度取决于查询语句。
直接使用用户原始短句去向量库检索很容易召回不相关文档,我使用DeepSeek根据原始问题生成多条同义问题。
同时做好降级:扩写接口调用异常的时候,直接使用原始问题检索,不能直接报错阻断整个知识库查询流程。

@Component
@Slf4j
public class LlmRewriteMsg {
    //省略注入
    private String doRewriteMsg(String message, DeepSeekChatOptions options) {
        //构造systemPrompt生成N个同义问题
        //异常兜底返回原始问题
    }
}

4.3 AgentTraceLogUtil 链路追踪组件

Agent可视化最核心模块,我采用双存储方案

  1. MySQL持久化:永久保存每一条Agent单步日志,用于事后排查问题
  2. Redis缓存:缓存完整步骤列表,有效期2小时,前端可以根据traceId快速查询整条思考链路

注意坑:Tool是异步线程执行,MDC会丢失traceId,每次进入工具方法需要手动从ToolContext重新设置MDC。

4.4 AgentSsePushUtil SSE推送工具

这里有一个非常关键的设计决策:每次只推送最新单一步骤,不要每次推送全量step数组
最开始我每次发生步骤变更,把整个List全部推送给前端,随着Agent步骤变多,SSE数据包越来越大,前端浏览器出现卡顿。
改造之后每次只发送刚刚新增的一条步骤描述,前端自己本地维护步骤列表,大幅降低网络传输开销。
同时拆分两种SSE事件类型

  • agent_trace:Agent思考步骤
  • answer:大模型输出token分片

4.5 Controller层双入口设计

/**
 *方案1:框架全自动循环调度Agent
 */
@GetMapping(value = "/nativeAgent/stream")
public SseEmitter nativeAgentStream(@RequestParam String message, String sessionId, HttpServletResponse httpServletResponse)

/**
 *方案2:后端手动控制Agent循环
 */
@GetMapping(value = "/nativeAgent/do_stream")
public SseEmitter nativeAgentdoStream(@RequestParam String message, String sessionId, HttpServletResponse httpServletResponse)

两个接口对应两种开发模式,这里补充缺失的NativeToolAgentService核心代码(原项目代码里面没有贴出来)

package com.example.aiagent.com.test.service;

import com.example.aiagent.com.test.util.AgentSsePushUtil;
import com.example.aiagent.com.test.util.AgentTraceLogUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolContext;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class NativeToolAgentService {

    private final ChatClient chatClient;
    private final AgentTraceLogUtil traceLogUtil;
    private final AgentSsePushUtil ssePushUtil;

    /**
     * 方案一:SpringAI框架自动驱动Agent循环
     */
    public void streamAgent(String message, String sessionId, SseEmitter emitter) {
        String traceId = traceLogUtil.genTraceId();
        List<AgentTraceLogUtil.AgentStep> stepList = new ArrayList<>();
        Map<String,Object> contextMap = new HashMap<>();
        contextMap.put("traceId",traceId);
        contextMap.put("emitter",emitter);
        contextMap.put("stepList",stepList);
        ToolContext toolContext = new ToolContext(contextMap);

        traceLogUtil.logStep("Agent开始执行,用户提问:"+message,stepList);
        chatClient.prompt(new Prompt(new UserMessage(message)))
                .toolContext(contextMap)
                .stream()
                .chatResponse()
                .subscribe(response ->{
                    String token = response.getResult().getOutput().getText();
                    if(token!=null){
                        ssePushUtil.sendAnswer(emitter,token);
                    }
                },throwable -> {
                    log.error("agent异常",throwable);
                    emitter.completeWithError(throwable);
                },()->{
                    ssePushUtil.saveTraceToRedis(traceId,stepList);
                    emitter.complete();
                    traceLogUtil.clearTrace();
                });
    }

    /**
     *方案二:后端手动控制Agent循环(强可控编排)
     * 自己维护消息列表、循环次数,可以中途拦截工具调用
     */
    public void streamdoAgent(String message, String sessionId, SseEmitter emitter){
        String traceId = traceLogUtil.genTraceId();
        List<AgentTraceLogUtil.AgentStep> stepList = new ArrayList<>();
        Map<String,Object> contextMap = new HashMap<>();
        contextMap.put("traceId",traceId);
        contextMap.put("emitter",emitter);
        contextMap.put("stepList",stepList);
        ToolContext toolContext = new ToolContext(contextMap);
        List<Message> historyMsg = new ArrayList<>();
        historyMsg.add(new UserMessage(message));
        int maxLoop =5;
        int loopCount = 0;
        traceLogUtil.logStep("手动模式Agent启动",stepList);
        while (loopCount < maxLoop){
            loopCount++;
            //手动调用模型
            var resp = chatClient.prompt(new Prompt(historyMsg))
                    .toolContext(contextMap)
                    .call()
                    .chatResponse();
            //此处可以拿到返回,自己做工具调用拦截、业务规则校验
            historyMsg.add(resp.getResult().getOutput().getMessage());
            //判断是否结束,这里简化实现,生产需要解析是否已经调用完工具
            break;
        }
        ssePushUtil.saveTraceToRedis(traceId,stepList);
        emitter.complete();
        traceLogUtil.clearTrace();
    }
}

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

4.5.1 自动循环模式序列图

MySQL/Redis AgentSsePushUtil NativeAgentTool DeepSeek模型 SpringAI ChatClient ToolContext NativeToolAgentService NativeAgentController 前端 MySQL/Redis AgentSsePushUtil NativeAgentTool DeepSeek模型 SpringAI ChatClient ToolContext NativeToolAgentService NativeAgentController 前端 alt [需要调用工具] [输出最终答案] SSE请求 /nativeAgent/stream streamAgent(message, sessionId, emitter) 生成traceId,初始化stepList 存入traceId/emitter/stepList chatClient.prompt() .toolContext(contextMap) .stream() 发送用户消息+工具Schema 返回tool_call或最终答案 自动反射执行工具方法 执行业务逻辑(RAG/查询) 推送单条步骤日志 SSE推送 agent_trace 记录步骤到MySQL 返回工具执行结果 回填结果,继续对话 流式返回token分片 SSE推送 answer 完成流式响应 保存完整链路到Redis SSE完成

4.5.2 手动循环模式序列图

渲染错误: Mermaid 渲染失败: Parse error on line 42: ...is S->>F: SSE完成 ---------------------^ Expecting 'SPACE', 'NEWLINE', 'INVALID', 'create', 'box', 'end', 'autonumber', 'activate', 'deactivate', 'title', 'legacy_title', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'loop', 'rect', 'opt', 'alt', 'par', 'par_over', 'critical', 'break', 'participant', 'participant_actor', 'destroy', 'note', 'links', 'link', 'properties', 'details', 'ACTOR', got '1'

4.6 工具实现类 NativeAgentTool

也就是业务工具层,使用@Tool注解定义工具,ToolParam定义参数描述。

重点:RAG的全部业务逻辑(query扩写,两路召回、合并去重、rerank重排)全部封装在工具方法内部,上层Agent编排服务不需要感知RAG内部细节,职责边界清晰。

五、开发途中踩坑清单(生产环境必须注意)

坑1:ThreadLocal跨线程失效

SpringAI执行Tool工具,会新开独立线程执行,Web请求线程里面的ThreadLocal、MDC都会丢失。
解决方案:全部上下文数据(traceId、emitter、stepList)放到ToolContext,工具方法第一行手动恢复MDC

String traceId = (String) toolContext.getContext().get("traceId");
MDC.put(AgentTraceLogUtil.TRACE_ID_KEY, traceId);

finally块一定要清除MDC,防止线程池复用导致日志traceId串号。

坑2:SSE推送全量步骤列表,数据包过大

一开始我每次步骤更新,把完整List<AgentStep>推送到前端,随着Agent调用工具次数变多,消息包体积膨胀。
解决方案:每次只推送最新一条步骤,前端本地拼接步骤列表

坑3:RAG‑Query扩写失败直接导致整个知识库不可用

LLM生成扩词本身有概率超时、报错,如果扩词失败直接抛出异常,整个知识库查询直接中断。
解决方案:try‑catch捕获异常,降级直接使用原始问题检索

坑4:BGE‑Rerank重排服务挂掉之后整个RAG流程卡死

重排是一个独立HTTP服务,一旦服务不可用,必须有兜底策略,直接取合并之后文档前N条返回。
代码中已经实现降级逻辑:如果rerank返回空列表,则直接截取前几条文档。

坑5:Agent无限循环调用工具

模型有可能陷入死循环,不停反复调用同一个工具。
两种方案应对

  1. 自动模式:ChatClient设置最大工具调用次数
  2. 手动循环模式:后端代码维护循环计数器,超过最大循环强制终止Agent流程

坑6:工具返回内容Token超长

向量召回+关键词合并之后文档片段过多,全部塞给大模型很容易触发上下文超限。
后续开发必须新增工具返回结果截断逻辑,根据配置最大token数量裁剪文档。

六、后续迭代优化方向

  1. PG库接入jieba分词插件,完善中文关键词检索
  2. 工具返回内容Token截断组件,防止上下文溢出
  3. 工具权限控制,不同用户可以调用不同业务工具
  4. 增加Agent全局超时控制
  5. 会话历史持久化,sessionId对话存入Redis
  6. 前端可视化页面,展示整个Agent思考链路、工具调用参数、返回结果
  7. 新增更多业务工具:数据库查询、文件解析、HTTP接口调用

七、总结

对比完手写Agent和原生Tool‑Calling两套架构之后,我的结论:

  • Demo阶段手写JSON编排可以快速跑通原型
  • 企业生产项目优先使用Spring‑AI原生Tool‑Calling,省去大量报文解析代码,架构更加清晰稳定
  • 架构上保留「自动循环 + 手动可控循环」双模式,可以覆盖绝大多数业务场景
  • Agent ≠ RAG,RAG只是Agent众多工具里面的一个;Agent本质是模型+工具调度+思考链路+记忆整套系统

博客可以直接复制到markdown编辑器发布,所有代码可以直接导入项目运行。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐