从零到一搭建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方案:
- 写一大段System Prompt,强制大模型输出固定格式JSON(
toolName、toolParam) - 后端拿到字符串,
ObjectMapper手动解析JSON - 做一大堆异常兼容:模型输出markdown代码块、JSON漏逗号、字段缺失、中英文引号
- 自己写if‑else分发调用对应工具
- 拿到工具返回结果手动拼接到Prompt,再次丢回大模型,循环往复
- 自己维护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需要具备的能力全部梳理清楚:
- 多工具支持
searchKnowledge:私有知识库RAG工具,向量召回+关键词多路召回、Query扩写、BGE‑Rerank重排getOrderReport:业务报表查询工具,可以扩展成数据库查询、RPC、第三方接口调用
- Query预处理能力
用户原始问题语义模糊,调用LLM生成多条同义扩写问题,提升向量召回命中率,扩写失败要有降级兜底策略 - 流式SSE输出
agent_trace事件:增量推送Agent思考步骤日志(调用了哪个工具、召回多少文档、重排结果),前端可视化Agent思维链answer事件:大模型回答token分片实时输出,打字机效果
- 完整链路Trace追踪
- TraceId全链路透传
- 步骤日志持久化存入MySQL
- Redis缓存2小时完整链路,支持前端通过traceId回看整个Agent思考过程
- 多路召回RAG策略
向量相似度召回 + PG数据库关键词模糊召回,结果合并去重之后送入BGE重排序 - 会话上下文
通过sessionId保存当前对话历史,Agent具备短期记忆,可以做多轮对话 - 线程上下文传递 大坑提醒: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可视化最核心模块,我采用双存储方案
- MySQL持久化:永久保存每一条Agent单步日志,用于事后排查问题
- 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无限循环调用工具
模型有可能陷入死循环,不停反复调用同一个工具。
两种方案应对
- 自动模式:ChatClient设置最大工具调用次数
- 手动循环模式:后端代码维护循环计数器,超过最大循环强制终止Agent流程
坑6:工具返回内容Token超长
向量召回+关键词合并之后文档片段过多,全部塞给大模型很容易触发上下文超限。
后续开发必须新增工具返回结果截断逻辑,根据配置最大token数量裁剪文档。
六、后续迭代优化方向
- PG库接入jieba分词插件,完善中文关键词检索
- 工具返回内容Token截断组件,防止上下文溢出
- 工具权限控制,不同用户可以调用不同业务工具
- 增加Agent全局超时控制
- 会话历史持久化,sessionId对话存入Redis
- 前端可视化页面,展示整个Agent思考链路、工具调用参数、返回结果
- 新增更多业务工具:数据库查询、文件解析、HTTP接口调用
七、总结
对比完手写Agent和原生Tool‑Calling两套架构之后,我的结论:
- Demo阶段手写JSON编排可以快速跑通原型
- 企业生产项目优先使用Spring‑AI原生Tool‑Calling,省去大量报文解析代码,架构更加清晰稳定
- 架构上保留「自动循环 + 手动可控循环」双模式,可以覆盖绝大多数业务场景
- Agent ≠ RAG,RAG只是Agent众多工具里面的一个;Agent本质是模型+工具调度+思考链路+记忆整套系统
博客可以直接复制到markdown编辑器发布,所有代码可以直接导入项目运行。