dodo-agent

智能对话:从需求分析到技术落地

完整的智能问答应该具备什么? 相信大家都用过 豆包 或 ChatGPT 进行日常问答。提问之后,它会一边输出内容,一边展示思考过程;当知识不足时,会主动联网搜索;回答完成后,还会给出参考来源以及相关推荐问题。从表面上看,这只是一个对…

TL;DR

完整的智能问答应该具备什么? 相信大家都用过 豆包 或 ChatGPT 进行日常问答。提问之后,它会一边输出内容,一边展示思考过程;当知识不足时,会主动联网搜索;回答完成后,还会给出参考来源以及相关推荐问题。从表面上看,这只是一个对…

完整的智能问答应该具备什么?

相信大家都用过 豆包 或 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);
    ...

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。