--- title: "01-从用户提问到答案返回的总流程" created: 2026-05-21 aliases: - 从用户提问到答案返回的总流程 tags: - 项目 --- # 从用户提问到答案返回的总流程 这篇文档会把 Super Agent 聊天系统后端的完整链路拆开来讲,从用户点击"发送"的那一刻开始,一直到答案流式输出到前端、最后落库收尾为止。每个关键步骤都会贴出对应的源码,加上注释说明它在整条链路里的作用。 看完这篇,你会对"一个问题是怎么从 Controller 一路走到模型输出再回到前端"有一个完整的认知。 ## 总流程概览 先看一张全局流程图,对整条链路有个直观印象: ![[Fv6c6Yms0QdCgnIwQwnETI8VPGQJ-2202ad22.png]] 接下来我们按照这张图的顺序,逐步拆解每个阶段的源码。 ## 入口:Controller 接收请求 一切从前端的 POST 请求开始。前端把用户的问题、会话 ID、聊天模式等信息打包成 `ChatRequestDto`,发到 `/api/chat/stream` 接口。 先看请求参数长什么样: ```java public class ChatRequestDto { @NotBlank(message = "question 不能为空") private String question; // 用户输入的问题 private String conversationId; // 会话 ID,不传则自动生成新会话 @NotBlank(message = "chatMode 不能为空") private String chatMode; // 聊天模式:OPEN_CHAT / AUTO_DOCUMENT / DOCUMENT private String selectedDocumentId; // 当前文档问答模式下,用户选择的文档 ID } ``` Controller 就做两件事:接参数、转交给 Service: ```java @AllArgsConstructor @RestController @RequestMapping("/api/chat") public class BusinessChatController { private final BusinessChatService businessChatService; /** * 打开一个流式会话。 *
* 该接口返回的是 SSE 文本流,前端可以持续接收“思考中”、“正文增量”、“引用”、“推荐追问”等事件。 *
* * @param dto 前端提交的聊天请求,包含问题、会话 ID、聊天模式、选中文档等信息 * @return SSE 字符串流,内容由服务层按事件格式持续输出 */ @PostMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8") public Flux* 这一步会完成问题与会话 ID 规范化、聊天模式解析、所选文档校验,以及时间锚点准备。 *
*/ private StreamLaunchPlan buildLaunchPlan(ChatRequestDto request) { // 先校验并规整用户问题,确保下游不会处理空白问题。 String question = normalizeQuestion(request.getQuestion()); // conversationId 允许前端不传;如果不传则为新会话自动生成一个稳定 ID。 String conversationId = normalizeConversationId(request.getConversationId()); ChatQueryMode chatMode = parseRequiredChatMode(request.getChatMode()); // 在当前文档问答模式下,这里会校验 selectedDocumentId 是否合法、是否可检索。 KnowledgeDocumentDescriptor selectedDocument = resolveSelectedDocument(chatMode, request.getSelectedDocumentId()); // 当前日期会被写入 prompt 和上下文,作为处理“今天/最新/本周”等相对时效语义的统一基准。 LocalDate currentDate = LocalDate.now(CHAT_ZONE_ID); String currentDateText = formatCurrentDate(currentDate); return new StreamLaunchPlan( question, conversationId, chatMode, selectedDocument == null ? null : selectedDocument.getDocumentId(), selectedDocument == null ? "" : selectedDocument.getDocumentName(), selectedDocument == null ? null : selectedDocument.getLastIndexTaskId(), // 每个会话共用一个运行租约键,用来防止并发生成。 buildChatLeaseKey(conversationId), // ownerToken 代表本次请求对租约的“所有权”,续期和释放时都靠它校验。 UUID.randomUUID().toString(), currentDate, currentDateText ); } ``` 这一步做的事情不复杂,但很重要——它把外部不可控的前端参数,转换成了内部稳定、可信赖的数据结构。后续所有环节都基于这个 `StreamLaunchPlan` 来工作。 我们展开看看里面几个关键的子方法。 ### normalizeConversationId:会话 ID 规范化 ```java // BusinessChatService.java —— 规范化 conversationId private String normalizeConversationId(String conversationId) { // 前端传了就直接用(去掉首尾空格) if (StrUtil.isNotBlank(conversationId)) { return conversationId.trim(); } // 没传就自动生成一个 UUID 作为新会话的 ID return UUID.randomUUID().toString().replace("-", ""); } ``` 这个设计让前端可以灵活控制:传了 `conversationId` 就是继续已有会话,不传就是开启新会话。 ### parseRequiredChatMode:聊天模式解析 ```java // BusinessChatService.java —— 解析聊天模式 private ChatQueryMode parseRequiredChatMode(String value) { ChatQueryMode chatMode = parseOptionalChatMode(value); if (chatMode == null) { throw new IllegalArgumentException("chatMode 不能为空"); } return chatMode; } private ChatQueryMode parseOptionalChatMode(String value) { // 空值或 "ALL" 表示不过滤(用于列表查询场景) if (StrUtil.isBlank(value) || "ALL".equalsIgnoreCase(value.trim())) { return null; } try { // 把前端传的字符串转成枚举,大小写不敏感 return ChatQueryMode.valueOf(value.trim().toUpperCase()); } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("chatMode 非法: " + value, exception); } } ``` 前端传的是字符串(比如 `"OPEN_CHAT"`),这里负责转成枚举。如果传了个不认识的值,直接抛异常拒绝,不会让非法模式流入后续链路。 ### resolveSelectedDocument:文档校验 这个方法根据聊天模式来校验 `selectedDocumentId` 是否合法,不同模式有不同的规则: ```java // BusinessChatService.java —— 校验所选文档 private KnowledgeDocumentDescriptor resolveSelectedDocument(ChatQueryMode chatMode, String selectedDocumentId) { String normalizedDocumentId = StrUtil.trimToNull(selectedDocumentId); if (chatMode == ChatQueryMode.OPEN_CHAT) { // 开放问答模式不绑定文档,传了 selectedDocumentId 就报错 if (normalizedDocumentId != null) { throw new IllegalArgumentException("开放式提问模式下不能传 selectedDocumentId"); } return null; } if (chatMode == ChatQueryMode.AUTO_DOCUMENT) { // 自动知识问答模式也不允许手动指定文档 if (normalizedDocumentId != null) { throw new IllegalArgumentException("自动知识问答模式下不能传 selectedDocumentId"); } return null; } // 当前文档问答模式(DOCUMENT):必须传,而且必须是当前可检索的文档 if (normalizedDocumentId == null) { throw new IllegalArgumentException("当前文档问答模式下必须选择一个文档"); } final Long resolvedDocumentId = parseRequiredLong(normalizedDocumentId, "selectedDocumentId"); // 只允许命中"当前可检索"的文档,避免引用已下线或不可用的数据源 return documentKnowledgeService.listRetrievableDocuments().stream() .filter(item -> Objects.equals(item.getDocumentId(), resolvedDocumentId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("所选文档当前不可检索: " + normalizedDocumentId)); } ``` 这里的校验逻辑可以总结成一张表: | 聊天模式 | selectedDocumentId 规则 | | --- | --- | | `OPEN_CHAT` | 不允许传,传了就报错 | | `AUTO_DOCUMENT` | 不允许传,文档由系统自动路由 | | `DOCUMENT` | 必须传,且文档必须当前可检索 | 这种"在入口处就把非法参数拦住"的做法,让后续的编排器和执行器可以放心地使用这些参数,不用再做重复校验。 ## 抢占分布式租约 启动计划构建好之后,紧接着就是抢占 Redis 分布式租约。这是为了保证**同一个会话在任意时刻只有一个生成任务在运行**: ```java // BusinessChatService.java —— 抢占租约 private boolean claimConversationLease(StreamLaunchPlan launchPlan) { // 用 Redis 实现分布式锁,TTL 30 秒,后续会定期续期 return redisLeaseManager.acquire( launchPlan.getLeaseKey(), // 键:chat:running:{conversationId} launchPlan.getLeaseOwnerToken(), // 值:本次请求的唯一 token CHAT_RUNNING_LEASE_TTL // 过期时间:30 秒 ); } ``` 如果抢占失败,说明这个会话已经有一个任务在跑了,直接返回拒绝流: ```text // 租约抢占失败,返回错误提示 if (!leaseClaimed) { return rejectionFlux("该会话当前正在执行中,请稍后再试", launchPlan.getConversationId(), null); } ``` > 为什么需要分布式租约? > > 在集群部署场景下,用户可能快速连续点击发送,或者前端重试请求。如果没有租约机制,同一个会话可能在多个节点上同时生成回答,导致数据混乱。Redis 租约保证了全局唯一性。 ## Bootstrap:创建轮次、构建 TaskInfo、注册运行态 拿到租约之后,进入 `bootstrapConversation()`,这一步要做三件事: - 在数据库里创建一条新的轮次(exchange)记录 - 构建 `TaskInfo` 运行时上下文对象 - 把任务注册到内存运行态注册表 ```java // BusinessChatService.java —— 会话 bootstrap /** * 对会话做启动前置处理。 ** 包括创建一条新的 exchange 归档记录、构建运行时任务对象、注册到运行时注册表,并把 SSE 通道与任务绑定。 *
* * @param launchPlan 已规范化后的启动计划 * @return bootstrap 结果;可能是可执行的流,也可能是一个拒绝原因 */ private BootstrapResult bootstrapConversation(StreamLaunchPlan launchPlan) { // exchangeView 表示本次问答轮次的归档记录,后续无论成功还是失败都依赖它进行收尾落库。 ConversationExchangeView exchangeView = null; try { // 一旦启动流程开始,就先在归档层生成一条“新轮次”,这样后续异常也能被定位到具体 exchange。 exchangeView = conversationArchiveStore.startExchange( launchPlan.getConversationId(), launchPlan.getQuestion(), launchPlan.getChatMode(), launchPlan.getSelectedDocumentId(), launchPlan.getSelectedDocumentName() ); // TaskInfo 聚合了本次会话运行所需的所有状态:SSE sink、trace、引用、上下文等。 TaskInfo taskInfo = createTaskInfo(launchPlan, exchangeView); if (!chatRuntimeRegistry.register(taskInfo)) { // 极端情况下,即使抢到租约,也可能在运行态注册时发现已有同会话任务占用,必须补偿性收尾。 failBootstrappedExchange(launchPlan.getConversationId(), exchangeView.getExchangeId(), "该会话当前正在执行中,请稍后再试"); releaseLeaseQuietly(launchPlan.getLeaseKey(), launchPlan.getLeaseOwnerToken()); return BootstrapResult.rejected("该会话当前正在执行中,请稍后再试"); } // 只有在归档、运行态、SSE 通道都准备好之后,才把流返回给上层。 return BootstrapResult.ready(bindClientChannel(taskInfo)); } catch (RuntimeException exception) { // bootstrap 过程中只要失败,就先释放租约,再把已经创建的轮次标记为失败,避免悬空数据。 releaseLeaseQuietly(launchPlan.getLeaseKey(), launchPlan.getLeaseOwnerToken()); if (exchangeView != null) { failBootstrappedExchange(launchPlan.getConversationId(), exchangeView.getExchangeId(), buildErrorMessage(exception)); } return BootstrapResult.rejected(buildErrorMessage(exception)); } } ``` ### TaskInfo:运行时的"万能上下文" `TaskInfo` 是整条执行链路的核心数据载体,几乎所有组件都要从它身上拿东西。看看它都装了什么: ```java // TaskInfo.java —— 运行时任务上下文 public class TaskInfo { private final String conversationId; // 会话 ID private final long exchangeId; // 轮次 ID private final String question; // 用户问题 private final ChatQueryMode chatMode; // 聊天模式 private volatile ConversationExecutionPlan executionPlan; // 执行计划(后续填充) private final RunnableConfig runnableConfig; // Agent 运行配置 private final ConversationTraceRecorder traceRecorder; // 执行追踪记录器 private final Sinks.Many* 这里会初始化 SSE sink、RunnableConfig、调试追踪对象、引用集合、工具集合等上下文, * 后续执行链路中的各个组件都会围绕这个 {@link TaskInfo} 协作。 *
*/ private TaskInfo createTaskInfo(StreamLaunchPlan launchPlan, ConversationExchangeView exchangeView) { // 每个会话只有一个单播 sink,确保一条流只服务当前订阅的前端连接。 Sinks.Many* 只有前端真正订阅时,才会触发生成任务启动;如果前端断开订阅,则主动停止当前任务。 *
*/ private Flux* 链路大致分为:发送“正在分析”提示 -> 准备执行计划 -> 按计划选择执行器 -> 消费模型输出 -> * 正常完成时收尾,异常时失败收尾。 *
*/ private Flux* 这一层会调用编排器分析历史上下文、决定执行模式、构造 agentQuestion,并在必要时刷新会话绑定的文档范围。 *
*/ private ConversationExecutionPlan prepareExecutionPlan(TaskInfo taskInfo) { // 编排器会综合问题、历史、摘要、文档选择等信息生成本轮执行计划。 ConversationExecutionPlan executionPlan = chatPreparationOrchestrator.prepare(taskInfo); // agentQuestion 是最终喂给 Agent 的问题文本,会补充时间锚点和上下文摘要。 executionPlan.setAgentQuestion(buildAgentQuestion(executionPlan)); if (executionPlan.getSelectedDocumentId() != null && !Objects.equals(executionPlan.getSelectedDocumentId(), taskInfo.selectedDocumentId())) { // 如果编排阶段修正了文档范围,需要同步刷新归档中的会话范围与运行上下文。 conversationArchiveStore.refreshSessionScope( taskInfo.conversationId(), executionPlan.getChatMode(), executionPlan.getSelectedDocumentId(), executionPlan.getSelectedDocumentName() ); putContextIfNotNull(taskInfo.runnableConfig(), ChatContextKeys.SELECTED_DOCUMENT_ID, executionPlan.getSelectedDocumentId()); putContextIfNotBlank(taskInfo.runnableConfig(), ChatContextKeys.SELECTED_DOCUMENT_NAME, executionPlan.getSelectedDocumentName()); putContextIfNotNull(taskInfo.runnableConfig(), ChatContextKeys.SELECTED_TASK_ID, executionPlan.getSelectedTaskId()); } // 把最终执行计划和对应的调试轨迹回写到任务对象,供执行链路和收尾阶段复用。 taskInfo.setExecutionPlan(executionPlan); taskInfo.setDebugTrace(initializeDebugTrace(executionPlan)); taskInfo.runnableConfig().context().put(ChatContextKeys.DEBUG_TRACE, taskInfo.debugTrace()); return executionPlan; } ``` 编排器内部的 `prepare()` 方法做了很多事,我们拆开来看核心逻辑: ```java // ChatPreparationOrchestrator.java —— 编排器核心逻辑 public ConversationExecutionPlan prepare(TaskInfo taskInfo) { String conversationId = taskInfo.conversationId(); String question = taskInfo.question(); ChatQueryMode chatMode = taskInfo.chatMode(); Long selectedDocumentId = taskInfo.selectedDocumentId(); String selectedDocumentName = taskInfo.selectedDocumentName(); Long selectedTaskId = taskInfo.selectedTaskId(); LocalDate currentDate = taskInfo.currentDate(); String currentDateText = taskInfo.currentDateText(); ConversationTraceRecorder traceRecorder = taskInfo.traceRecorder(); ConversationTraceRecorder.StageHandle memoryStage = traceRecorder == null ? null : traceRecorder.startStage(ConversationTraceStageCode.MEMORY, chatMode == null ? "" : chatMode.name(), "正在装载会话记忆与最近窗口。", null); ConversationMemoryContext memoryContext; try { // 第一步:加载会话记忆(长期摘要 + 最近对话窗口) memoryContext = summarizeHistory(conversationId, traceRecorder); if (traceRecorder != null) { traceRecorder.completeStage(memoryStage, "会话记忆装载完成。", java.util.Map.of( "compressionApplied", memoryContext != null && memoryContext.isCompressionApplied(), "coveredExchangeId", memoryContext == null ? 0L : memoryContext.getCoveredExchangeId(), "coveredExchangeCount", memoryContext == null ? 0 : memoryContext.getCoveredExchangeCount(), "compressionCount", memoryContext == null ? 0 : memoryContext.getCompressionCount(), "longTermSummary", memoryContext == null ? "" : safeText(memoryContext.getLongTermSummary()), "recentTranscript", memoryContext == null ? "" : safeText(memoryContext.getRecentTranscript()), "answerRecentTranscript", memoryContext == null ? "" : safeText(memoryContext.getAnswerRecentTranscript()) )); } } catch (RuntimeException exception) { if (traceRecorder != null) { traceRecorder.failStage(memoryStage, "会话记忆装载失败。", exception.getMessage(), null); } throw exception; } HistoryPlanningContext historyPlanningContext = buildHistoryPlanningContext(memoryContext); // 第二步:构建历史上下文,供后续问题改写和回答使用 String historySummary = buildPlanningHistory(memoryContext, historyPlanningContext); AnswerHistoryContext answerHistoryContext = buildAnswerHistoryContext( question, memoryContext == null ? "" : memoryContext.getAnswerRecentTranscript() ); // 第三步:判断时效性——用户问的是不是"今天""最新"这类需要实时信息的问题 boolean requiresCurrentDateAnchoring = TimeSensitiveQueryHelper.requiresCurrentDateAnchoring(question); boolean requiresFreshSearch = TimeSensitiveQueryHelper.requiresFreshSearch(question); if (chatMode == null) { throw new IllegalArgumentException("chatMode 不能为空"); } // 第四步:根据聊天模式走不同分支 if (chatMode == ChatQueryMode.OPEN_CHAT) { ConversationExecutionPlan plan = basePlan(question, chatMode, memoryContext, historyPlanningContext, historySummary, answerHistoryContext, currentDate, currentDateText, requiresCurrentDateAnchoring, requiresFreshSearch) .mode(ExecutionMode.REACT_AGENT) .build(); if (traceRecorder != null) { ConversationTraceRecorder.StageHandle routeStage = traceRecorder.startStage(ConversationTraceStageCode.ROUTE, ExecutionMode.REACT_AGENT.name(), "路由到开放式 Agent。", null); traceRecorder.completeStage(routeStage, "已判定走开放式 Agent 路径。", java.util.Map.of( "chatMode", chatMode.name(), "executionMode", ExecutionMode.REACT_AGENT.name(), "requiresFreshSearch", requiresFreshSearch, "requiresCurrentDateAnchoring", requiresCurrentDateAnchoring )); } return plan; } // 文档问答模式 → 需要问题改写 + 知识路由 + 执行模式判定 // ...(后续文档会详细展开) } ``` 编排器的路由决策可以用这张图来概括: ![[FjrgQP3AMZoVuMwQffj9JXO1dDhE-789354b4.png]] ### buildAgentQuestion:构造最终喂给 Agent 的问题 编排器生成执行计划之后,还需要把用户的原始问题"包装"一下,加上时间锚点和历史摘要,让 Agent 有足够的上下文来回答: ```java // BusinessChatService.java —— 构造 Agent 问题 /** * 构造最终发给 Agent 的问题文本。 ** 这里会把时间锚点、时效性约束、历史摘要和原始问题拼接成统一 prompt, * 让 Agent 在处理“今天/最新/本周”等表达时有明确基准。 *
*/ private String buildAgentQuestion(ConversationExecutionPlan executionPlan) { StringBuilder builder = new StringBuilder(); // 注入系统时间信息,作为处理相对时间的统一基准 builder.append("系统时间信息:\n"); builder.append("当前日期是 ").append(executionPlan.getCurrentDateText()) .append(",时区为 Asia/Shanghai。\n"); if (executionPlan.isRequiresCurrentDateAnchoring()) { // 对强时效问题补充更严格的日期约束 builder.append("当前问题包含相对时间或强时效语义。"); builder.append("当用户提到"今天、明天、昨天、现在、当前、最新、本周、本月、今年"等表达时,"); builder.append("必须以这个日期为准,不要把搜索结果里的旧日期误当成今天。\n"); } else { builder.append("当用户提到"今天、明天、昨天、现在、当前、最新"等相对时间时,必须以这个日期为准。\n"); } if (executionPlan.isRequiresFreshSearch()) { // 强制联网核实最新事实 builder.append("当前问题需要核实最新外部事实,回答前必须优先调用联网搜索工具。\n"); builder.append("如果搜索结果里的日期与当前日期不一致,必须明确说明来源日期。\n"); builder.append("如果无法找到与当前日期匹配的可靠结果,要明确说明不确定性,不要编造最新信息。\n"); } // 如果有历史摘要,也一并注入,让 Agent 知道之前聊了什么 if (StrUtil.isNotBlank(executionPlan.getHistorySummary())) { builder.append("\n相关会话背景:\n"); builder.append(executionPlan.getHistorySummary()).append("\n"); } // 最后才是用户的原始问题 builder.append("\n用户问题:\n"); builder.append(executionPlan.getOriginalQuestion()); return builder.toString(); } ``` 这个方法的设计思路是:**不要让 Agent 裸接用户问题**。用户问"今天天气怎么样",如果不告诉 Agent 今天是几号,它可能会用训练数据里的旧日期来回答。通过在问题前面注入时间锚点和历史摘要,Agent 就有了足够的上下文来给出准确的回答。 ## 执行器注册表:策略模式路由 编排器确定了执行模式之后,`ConversationExecutorRegistry` 负责找到对应的执行器。这里用的是经典的**策略模式 + 注册表**: ```java // ConversationExecutorRegistry.java —— 执行器注册表 @Component public class ConversationExecutorRegistry { // 用 EnumMap 存储执行模式到执行器的映射,查找效率 O(1) private final Map* 每收到一个 chunk,就同时做三件事:追加答案缓冲区、记录首包耗时、向前端发送文本事件。 *
*/ private void emitModelChunk(TaskInfo taskInfo, String chunk) { // answerBuffer 持续累积,最终落库时用它拿到完整答案 taskInfo.answerBuffer().append(chunk); // 首包耗时只记录第一次收到正文输出的时刻 if (taskInfo.firstResponseTimeMs().get() == 0L) { taskInfo.firstResponseTimeMs() .compareAndSet(0L, System.currentTimeMillis() - taskInfo.startTime()); } // 每个 chunk 都即时推给前端,形成"边生成边展示"的效果 safeEmit(taskInfo.sink(), streamEventWriter.text(chunk, taskInfo.eventMetadata())); } ``` `StreamEventWriter` 负责把内容包装成标准的 JSON 事件格式: ```java // StreamEventWriter.java —— SSE 事件格式化 @Component public class StreamEventWriter { private final ObjectMapper objectMapper; // 文本事件:模型输出的正文增量 public String text(String content, StreamEventMetadata metadata) { return write(event("text", content, metadata)); } // 思考事件:分析中的状态提示 public String thinking(String content, StreamEventMetadata metadata) { return write(event("thinking", content, metadata)); } // 错误事件:执行失败时的错误信息 public String error(String content, StreamEventMetadata metadata) { return write(event("error", content, metadata)); } // 引用事件:检索命中的来源文档 public String references(List* 这个方法位于“模型正文已经正常输出完成”之后,是一次成功对话真正结束前的最后一道总收口。 * 它承担的不是单一动作,而是一整套按顺序执行的完成态闭环: * 1. 通过 CAS 把任务标记为 finalized,确保成功收尾只执行一次; * 2. 从运行态缓冲区中冻结最终答案、引用和推荐追问所需的数据快照; * 3. 在追踪体系中开启 finalize/recommendation 阶段,便于调试和耗时分析; * 4. 生成或提取推荐追问,并把推荐阶段标记为完成; * 5. 向前端补发“引用”和“推荐追问”事件; * 6. 关闭 SSE 流,告诉前端这一轮输出已经彻底结束; * 7. 以 {@link ChatTurnStatus#COMPLETED} 状态把最终结果完整落库; * 8. 异步刷新会话摘要,并清理租约、订阅、运行态注册表等临时资源。 *
** 这里的顺序不能随意打乱。特别是: * “补发事件”必须发生在“关闭 SSE 流”之前,否则前端会收不到引用和推荐; * “落库和清理”要放在 finally 中,保证即使补发事件失败,也不会让会话停留在未收尾状态。 *
*/ private void finishSuccessfully(TaskInfo taskInfo) { // finalized 从 false 置为 true 说明当前线程拿到了“唯一一次成功收尾权”; // 如果这里失败,表示别的线程已经做过停止/失败/成功收尾,本次直接退出避免重复落库。 if (!taskInfo.finalized().compareAndSet(false, true)) { return; } // answer 是最终要持久化的完整回答文本; // uniqueReferences 先对运行态引用做快照再去重,避免后续落库和前端展示出现重复证据。 String answer = taskInfo.answerBuffer().toString(); List