Spring AI Alibaba 实战项目-智能聊天助手-4 联网搜索工具实现
第四期:联网搜索工具实现
前言
第三期我们完成了 RAG 知识库和后台管理系统,AI 已经能基于公司私有知识回答业务问题了。但有几个明显的问题:
- 知识是静态的 — 知识库中的 QA 是预先上传的,AI 不知道最新信息(如实时天气、新闻)
- 消息不可靠 — 依赖 Redis checkpoint 存储聊天记录,偶尔丢消息
- 前端体验细节 — 无会话时需手动点击"新会话"、刷新后搜索开关重置、输入框焦点丢失等
本期针对这些问题做改造:
- 联网搜索 — 引入 Tavily 搜索 API,让 AI 在有需要时自主搜索互联网
- 消息持久化重构 — 从 Redis checkpoint 迁移到 MySQL,彻底解决历史消息丢失
- 若干前端细节优化 — 自动创建会话、搜索状态持久化、光标自动聚焦等
1. 联网搜索功能
技术选型
选择 Tavily Search API 作为搜索源,原因是:
- 专门为 AI Agent 设计,返回结构化 JSON(标题、摘要、内容),不需要自行解析 HTML
- 支持
include_answer: true,自动生成 AI 摘要 - REST API,通过
RestTemplate调用即可,不引入额外依赖
WebSearchTool 实现
@Component
public class WebSearchTool {
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Value("${tavily.api-key}")
private String apiKey;
@Value("${tavily.endpoint}")
private String endpoint;
@Tool(description = "搜索互联网获取最新信息,适用于实时信息、新闻、当前事件、知识查询等场景")
public String webSearch(@ToolParam(description = "搜索关键词") String query) {
TavilyRequest request = new TavilyRequest();
request.setApiKey(apiKey);
request.setQuery(query);
request.setSearchDepth("basic");
request.setIncludeAnswer(true);
request.setMaxResults(5);
String response = restTemplate.postForObject(endpoint, request, String.class);
TavilyResponse tavilyResponse = objectMapper.readValue(response, TavilyResponse.class);
StringBuilder result = new StringBuilder();
if (tavilyResponse.getAnswer() != null) {
result.append("摘要:").append(tavilyResponse.getAnswer()).append("\n\n");
}
for (int i = 0; i < tavilyResponse.getResults().size(); i++) {
TavilyResult r = tavilyResponse.getResults().get(i);
result.append(i + 1).append(". ").append(r.getTitle()).append("\n");
result.append(" 内容:").append(r.getContent()).append("\n\n");
}
return result.toString();
}
}
@Tool 的透明调用机制
这是 Spring AI 中最关键的认知——@Tool 的调用对开发者是透明的:
UserMessage("今天深圳天气如何?")
│
▼
模型收到请求,发现需要实时信息
│
▼
模型返回 ToolCall(tool=webSearch, args="深圳天气")
│
▼
Spring AI 自动执行 webSearchTool.webSearch("深圳天气")
│
▼
模型收到 ToolCallResult("今天深圳雷阵雨,25-31°C...")
│
▼
模型生成 AssistantMessage("今天深圳雷阵雨,25-31°C...")
│
▼
stream().content() 吐出最终文字 token(工具调用过程对开发者完全隐藏)
这意味着:
- 开发者只需要注册 tool,调用链由框架自动完成
stream().content()只吐出文字 token,不会吐出 ToolCall / ToolCallResult 中间过程- 前端 SSE 收到的仍然是完整文字,不需要任何改动
前端联网搜索开关
加入了"联网搜索"开关,状态持久化到 localStorage:
const [webSearchEnabled, setWebSearchEnabled] = useState(() => {
return localStorage.getItem('webSearchEnabled') === 'true';
});
const handleWebSearchChange = (checked) => {
setWebSearchEnabled(checked);
localStorage.setItem('webSearchEnabled', checked);
};
刷新页面后状态保持,和 DeepSeek 的体验一致。
架构设计:为什么只用 @Tool 不走 MCP?
本期的联网搜索直接使用 @Tool 在 app 模块中实现,原因是:
- 仅一个工具,单独启动一个 MCP Server 成本高于收益
- 搜索逻辑简单,就是调一个 REST API,不需要独立服务
后续如果接入查快递单号这类需要独立服务的功能,再改造成 MCP 模式。
2. 消息持久化:从 Redis Checkpoint 到 MySQL
问题:第三期留下的坑
第三期的多轮对话依赖 saveConversationState() 在 doFinally 中异步写入 Redis checkpoint:
.doFinally(signalType -> {
saveConversationState(conversationId, fullResponse.toString());
});
但这个机制有两个严重问题:
- 时序问题 —
doFinally触发时,checkpoint 可能还没写入 Redis,compiledGraph.getState()抛出Missing Checkpoint! - Flux 无法序列化 —
ChatNode返回的 state 中包含assistant: Flux<String>,RedisSaver 无法正常序列化,导致 checkpoint 写入失败 - 消息丢失 — 异常被 catch 吞掉,AI 回复永远存不进去。刷新页面后最多只显示用户消息,AI 回复全没了
解决方案:MySQL 独立存储
新增 message 表,把消息存储的职责从 Graph checkpoint 解耦出来:
CREATE TABLE message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
conversation_id VARCHAR(36) NOT NULL,
role VARCHAR(10) NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_conversation_id (conversation_id)
);
写入流程改为先存用户消息,再流式回复,最后存 AI 回复:
// 1. 流开始前:立即存用户消息
Message userMsg = new Message();
userMsg.setConversationId(conversationId);
userMsg.setUserId(userId);
userMsg.setRole("USER");
userMsg.setContent(req.getMessage());
userMsg.setCreatedAt(LocalDateTime.now());
messageMapper.insert(userMsg);
// 2. 流式调用
return compiledGraph.stream(initialState, config)
.ofType(StreamingOutput.class)
.map(...)
.doFinally(signalType -> {
// 3. 流结束后:存 AI 回复
Message assistantMsg = new Message();
assistantMsg.setConversationId(conversationId);
assistantMsg.setUserId(userId);
assistantMsg.setRole("ASSISTANT");
assistantMsg.setContent(fullResponse.toString());
assistantMsg.setCreatedAt(LocalDateTime.now());
messageMapper.insert(assistantMsg);
});
这样用户消息零丢失,即使 SSE 断连,用户消息已经落库。
历史消息加载
每次 stream() 之前,从 MySQL 加载历史消息,转换为 Spring AI 的 Message 类型传入 initialState:
// 加载历史消息
List<Message> dbMessages = messageMapper.selectList(
new LambdaQueryWrapper<Message>()
.eq(Message::getConversationId, conversationId)
.orderByAsc(Message::getCreatedAt)
);
// 转为 Spring AI Message 列表
List<org.springframework.ai.chat.messages.Message> historyMessages = dbMessages.stream()
.map(msg -> {
if ("USER".equals(msg.getRole())) {
return new UserMessage(msg.getContent());
} else {
return new AssistantMessage(msg.getContent());
}
})
.collect(Collectors.toList());
Map<String, Object> initialState = new HashMap<>();
initialState.put("message", req.getMessage());
initialState.put("messages", historyMessages);
initialState.put("webSearchEnabled", ...);
return compiledGraph.stream(initialState, config)...;
ChatNode 拿到的 state.value("messages") 就是完整的对话历史,不再依赖 checkpoint。
删除会话时清理 Redis
Redis 中有三个关联 key:
graph:thread:meta:{conversationId} ← 元数据
graph:checkpoint:content:{checkpointUuid} ← 内容
graph:thread:reverse:{checkpointUuid} ← 反向索引
checkpoint UUID 存在 meta key 的 thread_id 字段中:
String metaKey = "graph:thread:meta:" + conversationId;
RMap<String, String> meta = redissonClient.getMap(metaKey);
String checkpointId = meta.get("thread_id");
if (checkpointId != null) {
redissonClient.getKeys().delete(
"graph:checkpoint:content:" + checkpointId,
"graph:thread:reverse:" + checkpointId
);
}
redissonClient.getKeys().delete(metaKey);
3. 前端细节优化
SSE 从 GET 改为 POST
随着参数增多(message + conversationId + webSearchEnabled),GET 请求显得臃肿,改为 POST + JSON body:
const response = await fetch(`${baseURL}/conversation/chat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify({
message: query,
conversationId: conversationId,
webSearchEnabled: webSearchEnabled
})
});
SSE 响应仍然是 text/event-stream,response.body.getReader() 的逻辑完全不变。
无会话时自动创建
参照 DeepSeek 的行为:
const createTempSession = () => {
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
const tempSession = {
conversationId: tempId,
title: '新对话',
isTemp: true
};
setSessions(prev => [tempSession, ...prev]);
setCurrentSessionId(tempId);
return tempId;
};
const handleSendMessage = async (content) => {
let targetId = currentSessionId;
if (!targetId) {
targetId = createTempSession();
}
// ... 发送逻辑
};
AI 回复完成后自动聚焦输入框
流式回复结束后,loading 变为 false,但焦点没有回到输入框,用户需要手动点击才能继续输入。使用 useEffect 监听 loading 状态变化实现自动聚焦:
import { useRef, useEffect } from 'react';
function MessageInput({ onSend, loading, webSearchEnabled, onWebSearchChange }) {
const textAreaRef = useRef(null);
useEffect(() => {
if (!loading) {
textAreaRef.current?.focus();
}
}, [loading]);
return (
<TextArea ref={textAreaRef} ... />
);
}
头像不被撑开
长回答时 AI 头像图标被 flex 布局压扁,加上 flexShrink: 0 解决:
<Avatar
icon={<RobotOutlined />}
style={{ marginRight: '8px', background: '#52c41a', flexShrink: 0 }}
/>
同时主内容区加 minWidth: 0,pre > code 加 word-break: break-word; white-space: pre-wrap,防止长代码块撑破容器。
日志降噪
Graph 引擎的 NodeExecutor 每收到一个 token 就打印一行 INFO 日志:
logging:
level:
com.alibaba.cloud.ai.graph.executor.NodeExecutor: WARN
4. 架构演化回顾
经过四期迭代,项目的架构已经变得清晰:
前端(React 19 + Ant Design 6)
├── 登录 / 注册页面
├── 聊天页面(SSE 流式 + 会话管理 + 联网搜索开关)
└── 管理后台(用户管理 + 文档管理)
后端(Spring Boot 3.5 + StateGraph)
├── 认证系统(Token + Redis + ThreadLocal)
├── Graph 编排
│ ├── RetrieveNode(强制检索向量知识库)
│ └── ChatNode(条件注册 webSearchTool)
├── RAG 检索(RetrieveNode → VectorStore → Redis Stack)
├── 联网搜索工具(WebSearchTool → Tavily API)
└── 消息存储(MySQL message 表)
各期关键词:
| 期 | 主题 | 核心引入 |
|---|---|---|
| 第一期 | 项目开篇与流式聊天 | StateGraph、SSE streaming |
| 第二期 | 用户认证、多轮对话、会话隔离 | Auth、RedisSaver、threadId |
| 第三期 | RAG 知识库与后台管理 | RetrieveNode、VectorStore、Admin |
| 第四期 | 联网搜索与架构演进 | @Tool 联网搜索、MySQL 消息存储、前端体验优化 |
效果展示
踩坑记录
- AI 提及工具 — 模型会在回答中说"我调用了工具来获取...",需要在 system prompt 中明确禁止
- Missing Checkpoint 异常 —
doFinally中调用compiledGraph.getState()时序不可靠,改为 MySQL 独立存储消息,彻底解决 - 头像被压扁 — flex 布局中头像默认
flex-shrink: 1,长回答时会压缩图标,加flexShrink: 0解决 - Redis checkpoint 残留 — 删除会话后 Redis 中的 graph checkpoint key 不会自动清理,需要手动从 meta key 中提取 checkpoint UUID 后逐一删除
- 流结束后输入框失去焦点 — 流式回复完成后需要用户手动点击输入框才能继续输入,使用
useRef+useEffect监听loading状态自动聚焦
下期预告
第五期计划引入 MCP(Model Context Protocol)——将独立服务(如查快递单号)通过 MCP Server 接入,体验真正的工具服务化架构。
项目完整代码已上传至 GitHub:tenny-peng/spring-ai-agent-demo

浙公网安备 33010602011771号