完整的智能问答应该具备什么?
相信大家都用过 豆包 或 ChatGPT 进行日常问答。提问之后,它会一边输出内容,一边展示思考过程;当知识不足时,会主动联网搜索;回答完成后,还会给出参考来源以及相关推荐问题。从表面上看,这只是一个对话助手,但从系统实现角度来看,它背后是一整套完整的智能体执行机制,而不是一次简单的大模型接口调用。 在前面的课程中,我们已经使用 Spring AI 的 ChatClient 实现过基础问答功能。那种方式可以完成基本的文本对话,但本质上是一次次请求调用大模型的接口,仅此而已,只能算是一个功能演示级 Demo,并不具备产品级智能问答系统应有的结构和能力。 那么,一个完整的智能问答系统,究竟应该具备哪些核心能力?我们站在系统设计者的角度去拆解,至少应包含以下几个关键维度:
执行机制
首先是执行机制。系统是否具备“思考—行动—观察”的循环能力?当模型发现自身知识不足时,是否能够主动决定调用工具?工具返回结果后,是否能够继续推理,而不是直接结束回答?这种循环式执行,本质上是什么架构?
会话记忆能力
其次是会话记忆能力。用户关闭页面再打开,对话是否仍然存在?多轮对话中,系统是否能够基于历史上下文继续推理?上下文过长时如何裁剪或优化?这背后涉及的是会话持久化与上下文管理策略。
工具调用
然后就是联网搜索与工具调用能力。大模型存在知识盲区,系统是否能够通过 MCP 等协议接入搜索引擎或知识库进行补充?当模型在流式输出过程中发起工具调用时,系统如何识别并切换执行工具?工具执行后的结果如何解析?
参考来源
智能对话的参考来源的是怎么输出的?为什么成熟的智能问答系统往往会在答案后附带来源链接?这些链接是模型自动生成的,还是系统在工具阶段收集的?
推荐问题
此外,还有推荐问题的能力。在完成一轮问答后,系统如何生成与当前问题相关的延伸问题?是规则生成,还是再次调用轻量模型?是一次性流式输出的?还是分成两次调用输出的?
流式响应
最后,是用户体验层面的最关键能力。完整的流式输出/停止到底如何实现?是否仅仅会调用chatclient的stream方法就足够了?用户点击停止按钮时,是否真正终止了模型推理,还是只是关闭了前端连接?如何保证资源被释放,而不是继续占用计算资源? 以上这些问题,共同构成了完整智能问答系统的能力边界。
执行机制
核心思路
执行循环的核心其实就是我们前面介绍的ReactAgent流式,相关内容可以参考文章 ✅实战一:手搓 ReactAgent(流式) ✅ReAct Agent 流式输出后续问题 他的本质就是轮次调度 + 工具调用检测 + 循环终止,代码中通过 scheduleRound() + processChunk() + finishRound() 形成闭环。
实现细节
轮次调度初始化(streamInternal 方法)
scheduleRound(messages, sink, roundCounter, hasSentFinalResult, finalAnswerBuffer, useMemory, conversationId, agentState, thinkingBuffer);
- 初始化轮次计数器 roundCounter、最终结果标记 hasSentFinalResult
- 每一轮调用 scheduleRound() 触发模型推理,形成循环入口 流式解析工具调用信号(processChunk 方法)
private void processChunk(ChatResponse chunk, Sinks.Many<String> sink, RoundState state) {
Generation gen = chunk.getResult();
String text = gen.getOutput().getText();
List<AssistantMessage.ToolCall> tc = gen.getOutput().getToolCalls();
// 实时检测工具调用信号,无需等待完整输出
if (tc != null && !tc.isEmpty()) {
state.mode = RoundMode.TOOL_CALL; // 标记为工具调用模式
for (AssistantMessage.ToolCall incoming : tc) {
mergeToolCall(state, incoming); // 重要:合并流式传输的ToolCall(处理参数分片)
}
return;
}
// 非工具调用则流式输出文本
if (text != null) {
sink.tryEmitNext(createTextResponse(text));
state.textBuffer.append(text);
}
}
- 实时解析:模型输出的每个 Chunk 都被即时解析,检测到 ToolCall 立即切换模式,而非等待完整响应
- 参数合并:通过 mergeToolCall() 处理流式传输中 ToolCall 参数分片问题,保证参数的完整性
private void mergeToolCall(RoundState state, AssistantMessage.ToolCall incoming) {
for (int i = 0; i < state.toolCalls.size(); i++) {
AssistantMessage.ToolCall existing = state.toolCalls.get(i);
if (existing.id().equals(incoming.id())) {
String mergedArgs = Objects.toString(existing.arguments(), "") + Objects.toString(incoming.arguments(), "");
state.toolCalls.set(i,
new AssistantMessage.ToolCall(existing.id(), "function", existing.name(), mergedArgs)
);
return;
}
}
// 新的 toolcall
state.toolCalls.add(incoming);
}
轮次结束处理与循环控制(finishRound 方法)
private void finishRound(...) {
// 无工具调用 → 输出最终答案,终止循环
if (state.getMode() != RoundMode.TOOL_CALL) {
// 输出参考来源 + 完成流 + 标记终止
sink.tryEmitComplete();
hasSentFinalResult.set(true);
return;
}
// 有工具调用 → 检查最大轮次限制
if (maxRounds > 0 && roundCounter.get() >= maxRounds) {
forceFinalStream(...); // 触发强制终止,输出最终答案
return;
}
// 执行工具调用 + 调度下一轮,形成循环
executeToolCalls(..., () -> {
scheduleRound(...); // 工具执行完成后,调度下一轮推理
});
}
- 终止条件 1:检测到无 ToolCall(mode != TOOL_CALL)→ 输出最终答案,终止循环
- 终止条件 2:达到 maxRounds(默认可配置)→ 调用 forceFinalStream() 强制输出答案,避免无限循环
- 循环延续:工具调用完成后通过 scheduleRound() 再次触发模型推理,形成 “思考→行动→再思考” 的循环 工具调用失败降级(executeToolCalls 方法)
private void executeToolCalls(...) {
// 工具未找到降级
ToolCallback callback = findTool(toolName);
if (callback == null) {
addErrorToolResponse(messages, tc, "工具未找到:" + toolName); // 错误结果加入上下文
completeToolCall(completedCount, totalToolCalls, onComplete);
return;
}
// 工具执行异常降级
try {
Object result = callback.call(argsJson);
// 正常结果处理
} catch (Exception ex) {
addErrorToolResponse(messages, tc, "工具执行失败:" + ex.getMessage()); // 异常结果加入上下文
}
}
- 工具未找到 / 执行异常时,通过 addErrorToolResponse() 将错误信息封装为 ToolResponse 加入上下文
- 错误信息参与下一轮模型推理,保证循环不中断,实现降级处理
会话记忆
核心思路
会话记忆的实现核心在于将运行期上下文与持久化数据解耦处理。系统在每次对话开始时,根据 conversationId 从数据库加载历史消息,通过 createPersistentChatMemory() 方法重建 ChatMemory,将历史问答按时间顺序重新注入到内存窗口中,使模型能够基于真实上下文继续推理。ChatMemory 仅承担当前执行窗口的上下文管理职责,而数据库保存完整对话记录,从而保证服务重启或多实例部署时仍可恢复会话状态。在流式对话执行过程中,新的用户输入、模型输出以及工具调用结果会持续写入运行期上下文,而不是ChatMemory,ChatMemory中保存的只有用户的问题和助手的最终输出,这就叫做会话的解耦。在对话结束后通过 sessionService.update... 方法统一持久化到数据库,形成“加载历史—构建运行期上下文—更新上下文—结果入库”的完整闭环。同时通过设置最大消息窗口数量控制上下文长度,在保证语义连续性的前提下降低模型调用成本,确保多轮对话在工程层面具备稳定性与可扩展性。
实现细节
持久化 ChatMemory 创建(BaseAgent 通用方法)
public ChatMemory createPersistentChatMemory(String sessionId, int maxMessages) {
// 从数据库加载历史会话
List<AiSession> history = sessionService.findRecentBySessionId(sessionId, maxMessages);
// 创建基于消息窗口的ChatMemory(限制最大消息数,避免上下文过长)
ChatMemory chatMemory = MessageWindowChatMemory.builder().maxMessages(maxMessages).build();
// 按时间顺序加载历史消息到ChatMemory
for (int i = history.size() - 1; i >= 0; i--) {
AiSession record = history.get(i);
if (record.getQuestion() != null) {
chatMemory.add(sessionId, new UserMessage(record.getQuestion()));
}
if (record.getAnswer() != null) {
chatMemory.add(sessionId, new AssistantMessage(record.getAnswer()));
}
}
return chatMemory;
}
- 存储介质:通过 AiSessionService 从数据库读取历史会话(支持持久化)
- 裁剪策略:使用 MessageWindowChatMemory 限制最大消息数 maxMessages,简单高效的上下文裁剪
- 顺序保证:反转历史记录顺序,确保按时间正序添加到 ChatMemory* 会话记忆加载与使用(WebSearchReactAgent streamInternal 方法)
if (useMemory) {
List<Message> history = chatMemory.get(conversationId);
if (history != null && !history.isEmpty()) {
messages.add(new UserMessage("对话历史:"));
for (Message msg : history) {
if (!(msg instanceof SystemMessage)) {
messages.add(msg); // 加载历史到当前上下文
}
}
}
}
// 保存当前问题到记忆
chatMemory.add(conversationId, new UserMessage(question));
- 加载时机:每次请求时,根据 conversationId 从 ChatMemory 加载历史
- 上下文拼接:历史消息 + 当前问题 + 系统提示词,形成完整推理上下文
- 实时更新:用户新问题即时添加到 ChatMemory,保证多轮对话连续性 会话结果持久化(saveSessionResult 方法)
private void saveSessionResult(...) {
sessionService.updateAnswerWithThinkingAndToolsAndReferenceAndTime(
currentSessionId,
finalAnswerBuffer.toString(), // 完整答案
thinkingBuffer.toString(), // 思考过程
toolsStr, // 使用的工具
referenceJson, // 参考来源
firstResponseTime, // 首次响应时间
totalResponseTime // 总响应时间
);
}
对话结束后,将完整答案、思考过程、工具使用记录等持久化到数据库,为下一轮对话提供记忆数据
联网搜索
核心思路
市面上有很多商用或者开源的搜索引擎MCP,tavily 是其中使用效果非常好的一款搜索引擎,生成的结构化数据,非常适合大模型的输入,他的免费额度也比较多,当然也有一些完全免费的方案,比如searXNG,duckduckgo等等。
接入 Tavily MCP
// tavily 搜索引擎
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.header("Authorization", "Bearer tvly-dev-XXXXXXXXXXXXXXXXXXXXX");
HttpClientStreamableHttpTransport tavTransport = HttpClientStreamableHttpTransport.builder("https://mcp.tavily.com/mcp/")
.requestBuilder(requestBuilder).build();
McpSyncClient tavilyMcp = McpClient.sync(tavTransport)
.requestTimeout(Duration.ofSeconds(120))
.build();
tavilyMcp.initialize();
List<McpSyncClient> mcpClients = List.of(tavilyMcp);
SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder().mcpClients(mcpClients).build();
ToolCallback[] callbacks = provider.getToolCallbacks();
整体流程是先由 LLM 生成工具调用请求,在流式解析响应 chunk 时检测 ToolCall 并切换至 TOOL_CALL 模式,对同 ID 的 ToolCall 进行 arguments 流式合并;轮次结束后若判定为工具调用模式,则通过 MCP 客户端调用搜索引擎等外部工具,执行工具调用并实时推送 thinking 类型流式信息。执行结束后,将执行结果回填至当前上下文之中,用于大模型进行下一轮次的决策。
实现细节
工具初始化与注册(WebSearchReactAgent 构造器)
public WebSearchReactAgent(...) {
super(name, chatModel, "websearch");
this.tools = tools; // 注入Tavily等搜索工具的ToolCallback
// 初始化ChatClient,绑定工具回调
initChatClient();
}
private void initChatClient() {
ToolCallingChatOptions toolOptions = ToolCallingChatOptions.builder()
.toolCallbacks(tools) // 注册工具回调
.internalToolExecutionEnabled(false) // 禁用内置执行,自定义执行逻辑
.build();
this.chatClient = ChatClient.builder(chatModel)
.defaultOptions(toolOptions)
.defaultToolCallbacks(tools)
.build();
}
- 通过构造器注入 ToolCallback(如 Tavily 搜索工具)
- 初始化 ChatClient 时绑定工具回调,让模型感知可用工具
- internalToolExecutionEnabled设置为false,禁止chatclient自动执行工具,一切工具调用由开发者掌控。 工具调用(executeToolCalls 方法)
private void executeToolCalls(...) {
AtomicInteger completedCount = new AtomicInteger(0);
int totalToolCalls = toolCalls.size();
for (AssistantMessage.ToolCall tc : toolCalls) {
Schedulers.boundedElastic().schedule(() -> { // 并发执行工具
String toolName = tc.name();
String argsJson = tc.arguments();
// 1. 发送思考过程(前端感知搜索状态)
if (toolName.contains("tavily")) {
String queryThink = "🔍 正在搜索信息: " + query + "\n";
sink.tryEmitNext(createThinkingResponse(queryThink));
}
// 2. 执行工具调用
ToolCallback callback = findTool(toolName); // 查找对应工具
Object result = callback.call(argsJson); // 调用Tavily搜索
// 3. 结果结构化:解析搜索结果为SearchResult
if (toolName.contains("tavily")) {
parseSearchResult(result.toString(), agentState);
}
// 4. 结果回填到上下文
messages.add(ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse(
tc.id(), toolName, result.toString()
)))
.build());
// 5. 记录工具使用
recordUsedTool(toolName);
});
}
}
- 并发执行:通过 Schedulers.boundedElastic() 实现多工具并发调用
- 状态反馈:执行搜索前发送 thinking 类型响应,前端可实时展示 “正在搜索”的状态
- 结果回填:构造 ToolResponseMessage,将工具执行结果回填至当前上下文之中
参考来源
核心思路
参考来源生成的核心思路是针对搜索引擎返回结果的解析,在 WebSearchReactAgent 执行工具调用并获取返回的搜索结果 JSON 后,通过 parseSearchResult 方法解析其中的 url、title、content 等核心字段,封装为 SearchResult 对象并累积到跨轮次的 AgentState 中;当单轮对话结束且判定为最终答案输出阶段时,从 AgentState 中提取所有 SearchResult 数据,封装为 reference 类型的 AgentResponse 统一格式(包含来源链接、标题、内容摘要及数量信息),以流式推送至前端;同时在会话结束后,将完整的参考来源 JSON 数据持久化存储到数据库,保证了下次重新打开会话时前端展示参考来源列表,也保障会话记忆中参考信息的完整性与可追溯性。
实现细节
参考来源缓存(AgentState 实体)
public class AgentState {
public List<SearchResult> searchResults = new ArrayList<>(); // 跨轮次缓存搜索结果
}
- 工具调用解析的 SearchResult 存入 AgentState,跨轮次保存 搜索结果结构化解析(parseSearchResult 方法)
private void parseSearchResult(String resultJson, AgentState state) {
JsonNode root = MAPPER.readTree(resultJson);
JsonNode results = textJson.get("results");
for (JsonNode item : results) {
String url = getSafe(item, "url");
String title = getSafe(item, "title");
String content = getSafe(item, "content");
if (url != null && !url.isBlank()) {
state.searchResults.add(new SearchResult(url, title, content)); // 结构化存储
}
}
}
- 将搜索引擎工具返回的结构化 JSON 转换为 SearchResult 实体(包含 url/title/content)
- 结果存入 AgentState 跨轮次缓存,方便跨迭代轮次收集全局参考来源 参考来源输出(finishRound 方法)
if (!agentState.searchResults.isEmpty()) {
String reference = JSON.toJSONString(agentState.searchResults);
String referenceJson = createReferenceResponse(reference); // 生成reference类型响应
sink.tryEmitNext(referenceJson); // 单独输出参考来源
}
统一响应格式(BaseAgent 通用方法)
protected String createReferenceResponse(String content, Integer count) {
return AgentResponse.reference(content, count);
}
// AgentResponse.java
public static String reference(String content, Integer count) {
JSONObject obj = new JSONObject();
obj.put("type", "reference");
obj.put("content", content);
obj.put("count", count);
return obj.toJSONString();
}
- 仅在最终答案阶段(无 ToolCall)输出参考来源
- 通过 createReferenceResponse() 生成统一格式的 JSON 响应(type=reference),前端可解析展示
- 参考来源响应格式标准化,包含 type/content/count 字段,前端可统一解析
推荐问题
核心思路
推荐问题生成的核心思路是 “对话上下文 + 当前问题 + 当前模型回答 + 统一格式输出”。 在 WebSearchReactAgent 完成一轮对话的最终答案输出后,基于用户原始问题与 AI 生成的完整回答,以及上下文的历史会话,来生成推荐问题,本质还是进行了一次非流式大模型的调用,让大模型生成 3 个相关的后续问题并以 JSON 数组格式返回;生成的推荐问题会封装为 AgentResponse 统一响应格式(type 为 recommend,content 为问题数组),以流式推送紧随大模型的当前回答和参考来源之后,发送至前端,所以说对用户或者前端而言,本质上,还是一个流,并没有分成两次,同时该推荐内容会随会话数据一同持久化到数据库,方便后续的展示与回溯。
实现细节
推荐问题生成方法
protected String generateRecommendations(String conversationId, String currentQuestion, String currentAnswer) {
if (!enableRecommendations) {
return null;
}
try {
List<Message> messages = new ArrayList<>();
// 1. 添加系统提示词
messages.add(new SystemMessage(ReactAgentPrompts.getRecommendPrompt()));
// 2. 添加历史消息
loadChatHistory(conversationId, messages, true, true);
// 3. 添加当前会话的消息(最新的消息,放在最后)
messages.add(new UserMessage(currentQuestion));
if (currentAnswer != null) {
messages.add(new AssistantMessage(currentAnswer));
}
// 4. 添加格式说明消息
// 使用 BeanOutputConverter 进行结构化输出
BeanOutputConverter<List<String>> converter = new BeanOutputConverter<>(new ParameterizedTypeReference<>() {
});
// 添加格式说明消息
messages.add(new UserMessage("请根据上述对话生成3个推荐问题。输出格式为:\n" + converter.getFormat()));
// 5. 调用模型生成推荐问题
String response = ChatClient.builder(chatModel).build()
.prompt()
.messages(messages)
.call()
.content();
// 6. 使用 converter 转换响应
if (response != null && !response.isEmpty()) {
List<String> recommendations = converter.convert(response);
if (recommendations != null && !recommendations.isEmpty()) {
String jsonStr = JSON.toJSONString(recommendations);
log.info("生成推荐问题成功: {}", jsonStr);
return jsonStr;
}
}
log.warn("生成推荐问题失败,响应格式无效: {}", response);
return null;
} catch (Exception e) {
log.error("生成推荐问题异常", e);
return null;
}
}
推荐问题输出时机
private void finishRound(...) {
// 如果整轮都没有 tool_call,才是最终答案
if (state.getMode() != RoundMode.TOOL_CALL) {
String referenceJson = "";
String toolsStr = getUsedToolsString();
String finalText = state.textBuffer.toString();
// 输出参考链接
if (!agentState.searchResults.isEmpty()) {
String reference = JSON.toJSONString(agentState.searchResults);
referenceJson = createReferenceResponse(reference);
sink.tryEmitNext(referenceJson);
}
// 输出推荐问题
if (enableRecommendations) {
String recommendations = generateRecommendations(conversationId, currentQuestion, finalText);
if (recommendations != null) {
currentRecommendations = recommendations; // 保存用于数据库存储
String recommendJson = createRecommendResponse(recommendations);
sink.tryEmitNext(recommendJson);
}
}
sink.tryEmitComplete();
hasSentFinalResult.set(true);
...
