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

从零到一搭建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传递。

三、整体架构数据流

#mermaid-svg-cQLD9LkUytLk8M0P{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cQLD9LkUytLk8M0P .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cQLD9LkUytLk8M0P .error-icon{fill:#552222;}#mermaid-svg-cQLD9LkUytLk8M0P .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cQLD9LkUytLk8M0P .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cQLD9LkUytLk8M0P .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cQLD9LkUytLk8M0P .marker.cross{stroke:#333333;}#mermaid-svg-cQLD9LkUytLk8M0P svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cQLD9LkUytLk8M0P p{margin:0;}#mermaid-svg-cQLD9LkUytLk8M0P .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cQLD9LkUytLk8M0P .cluster-label text{fill:#333;}#mermaid-svg-cQLD9LkUytLk8M0P .cluster-label span{color:#333;}#mermaid-svg-cQLD9LkUytLk8M0P .cluster-label span p{background-color:transparent;}#mermaid-svg-cQLD9LkUytLk8M0P .label text,#mermaid-svg-cQLD9LkUytLk8M0P span{fill:#333;color:#333;}#mermaid-svg-cQLD9LkUytLk8M0P .node rect,#mermaid-svg-cQLD9LkUytLk8M0P .node circle,#mermaid-svg-cQLD9LkUytLk8M0P .node ellipse,#mermaid-svg-cQLD9LkUytLk8M0P .node polygon,#mermaid-svg-cQLD9LkUytLk8M0P .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cQLD9LkUytLk8M0P .rough-node .label text,#mermaid-svg-cQLD9LkUytLk8M0P .node .label text,#mermaid-svg-cQLD9LkUytLk8M0P .image-shape .label,#mermaid-svg-cQLD9LkUytLk8M0P .icon-shape .label{text-anchor:middle;}#mermaid-svg-cQLD9LkUytLk8M0P .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cQLD9LkUytLk8M0P .rough-node .label,#mermaid-svg-cQLD9LkUytLk8M0P .node .label,#mermaid-svg-cQLD9LkUytLk8M0P .image-shape .label,#mermaid-svg-cQLD9LkUytLk8M0P .icon-shape .label{text-align:center;}#mermaid-svg-cQLD9LkUytLk8M0P .node.clickable{cursor:pointer;}#mermaid-svg-cQLD9LkUytLk8M0P .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cQLD9LkUytLk8M0P .arrowheadPath{fill:#333333;}#mermaid-svg-cQLD9LkUytLk8M0P .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cQLD9LkUytLk8M0P .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cQLD9LkUytLk8M0P .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cQLD9LkUytLk8M0P .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cQLD9LkUytLk8M0P .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cQLD9LkUytLk8M0P .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cQLD9LkUytLk8M0P .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cQLD9LkUytLk8M0P .cluster text{fill:#333;}#mermaid-svg-cQLD9LkUytLk8M0P .cluster span{color:#333;}#mermaid-svg-cQLD9LkUytLk8M0P div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cQLD9LkUytLk8M0P .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cQLD9LkUytLk8M0P rect.text{fill:none;stroke-width:0;}#mermaid-svg-cQLD9LkUytLk8M0P .icon-shape,#mermaid-svg-cQLD9LkUytLk8M0P .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cQLD9LkUytLk8M0P .icon-shape p,#mermaid-svg-cQLD9LkUytLk8M0P .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cQLD9LkUytLk8M0P .icon-shape .label rect,#mermaid-svg-cQLD9LkUytLk8M0P .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cQLD9LkUytLk8M0P .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cQLD9LkUytLk8M0P .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cQLD9LkUytLk8M0P :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是,返回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可配置。

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

application.yml配置示例

yaml 复制代码
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根据原始问题生成多条同义问题。

同时做好降级:扩写接口调用异常的时候,直接使用原始问题检索,不能直接报错阻断整个知识库查询流程。

java 复制代码
@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层双入口设计

java 复制代码
/**
 *方案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核心代码(原项目代码里面没有贴出来)

java 复制代码
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 前端 #mermaid-svg-EtQiKczW0GmTAAmT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EtQiKczW0GmTAAmT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EtQiKczW0GmTAAmT .error-icon{fill:#552222;}#mermaid-svg-EtQiKczW0GmTAAmT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EtQiKczW0GmTAAmT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EtQiKczW0GmTAAmT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EtQiKczW0GmTAAmT .marker.cross{stroke:#333333;}#mermaid-svg-EtQiKczW0GmTAAmT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EtQiKczW0GmTAAmT p{margin:0;}#mermaid-svg-EtQiKczW0GmTAAmT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EtQiKczW0GmTAAmT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-EtQiKczW0GmTAAmT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-EtQiKczW0GmTAAmT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-EtQiKczW0GmTAAmT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-EtQiKczW0GmTAAmT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-EtQiKczW0GmTAAmT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-EtQiKczW0GmTAAmT .sequenceNumber{fill:white;}#mermaid-svg-EtQiKczW0GmTAAmT #sequencenumber{fill:#333;}#mermaid-svg-EtQiKczW0GmTAAmT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-EtQiKczW0GmTAAmT .messageText{fill:#333;stroke:none;}#mermaid-svg-EtQiKczW0GmTAAmT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EtQiKczW0GmTAAmT .labelText,#mermaid-svg-EtQiKczW0GmTAAmT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-EtQiKczW0GmTAAmT .loopText,#mermaid-svg-EtQiKczW0GmTAAmT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-EtQiKczW0GmTAAmT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-EtQiKczW0GmTAAmT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-EtQiKczW0GmTAAmT .noteText,#mermaid-svg-EtQiKczW0GmTAAmT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-EtQiKczW0GmTAAmT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EtQiKczW0GmTAAmT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EtQiKczW0GmTAAmT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EtQiKczW0GmTAAmT .actorPopupMenu{position:absolute;}#mermaid-svg-EtQiKczW0GmTAAmT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-EtQiKczW0GmTAAmT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EtQiKczW0GmTAAmT .actor-man circle,#mermaid-svg-EtQiKczW0GmTAAmT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-EtQiKczW0GmTAAmT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt需要调用工具输出最终答案 SSE请求 /nativeAgent/streamstreamAgent(message, sessionId, emitter)生成traceId,初始化stepList存入traceId/emitter/stepListchatClient.prompt().toolContext(contextMap).stream()发送用户消息+工具Schema返回tool_call或最终答案自动反射执行工具方法执行业务逻辑(RAG/查询)推送单条步骤日志SSE推送 agent_trace记录步骤到MySQL返回工具执行结果回填结果,继续对话流式返回token分片SSE推送 answer完成流式响应保存完整链路到RedisSSE完成

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

java 复制代码
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编辑器发布,所有代码可以直接导入项目运行。

相关推荐
mmsx1 小时前
置灰按钮为什么自己又“亮“了?一次状态缓存“双写冲突“的排查记录
java·缓存·bug·livedata
rannn_1111 小时前
【力扣hot100】图论专题+模板|DFS、BFS、拓扑排序...
java·算法·leetcode·深度优先·图论
用户3126874877201 小时前
ReentrantLock 到底怎么排队的?AQS 源码拆解
java
Zane19941 小时前
ArrayList 插入慢,LinkedList 一定快吗
java·后端
吃饱了得干活1 小时前
从类爆炸到协作——DDD战略设计登场
java·后端·架构
wno7042 小时前
Spring Boot异常处理
java·spring boot·后端
君顾12 小时前
智慧场馆解决方案小程序系统开发实战指南
java·开发语言·智慧场馆
学习星球2 小时前
【LeetCode算法题精讲】二分查找精讲
java·数据结构·算法·leetcode·职场和发展·图搜索
平安的平安2 小时前
飞算JavaAI 能处理设备借用审批流吗?从权限校验到库存扣减的实测
ai