Skip to main content

从点下发送到第一个字,后端走了哪些步

上一篇把知识检索接进了Agent。框架、人设、工具都有了,现在让它接一条真正的用户消息。

还是比特严选那位买了耳机的用户:

我上周买的AirPods Pro左耳突然没声音了,要是修不好我想退。

点击发送,问题立刻出现在聊天列表里,下面多出一个正在生成的回复。接下来可能先出现查询订单的工具卡,等查询结束,回答才开始出来。也可能Agent觉得信息不够,直接问一句耳机现在是什么状态。

站在用户这边,看到的是一段等待。从后端看,请求却已经经过了会话检查、消息保存、上下文准备、模型调用;如果要先查订单,还得加上工具执行。

我们就沿着这次请求往下走,看到模型输出的第一段文字如何到达页面。这里的首字可能属于思考,也可能属于回答正文;系统报错提示单独看,不算模型已经开口。工具发现、并发控制和停止机制,各有对应的专题,这一篇重点看它们在整条链路上放在哪里。

页面开始等待时,后端在做什么​

1. 先分清收到请求和开始回答​

用户点击发送后,前端先显示用户的问题,并创建一条助手占位消息,提示“等待响应…”,随后向后端发起请求。收到后端推送的内容后,前端会逐步更新这条消息。因此,页面上出现等待提示和停止按钮,并不意味着后端已经调用了模型。

服务端接到请求后,会先检查这轮能不能处理,准备会话与运行信息,再启动Agent。它通过SSE(服务端事件推送)持续把执行进展送回来,前端不需要等整条回答完成才刷新。

这条连接上传来的内容有不同含义:

前端收到的内容说明什么
meta后端交回会话号和任务号,前端可以把后续内容关联到这次运行
toolAgent提出了工具调用,或工具执行状态发生了变化,页面据此显示工具卡
message,类型为think收到了模型的思考文本增量
message,类型为response收到了模型的回答文本增量
message,类型为error系统在说明异常,不能据此判断模型已经生成了回答

所以,收到meta和收到首个回答字之间,还可能隔着一次状态读取、上下文处理,甚至一轮工具查询。下面以Agent先查订单、再开始回答为例。

工具卡先出现,至少让用户知道Agent正在处理什么。但卡片出现后正文还没来,可能是在等工具,也可能是后续模型调用较慢。

2. Controller返回了,响应还没有结束​

AgentChatController的入口很短,它创建一个SseEmitter,把问题交给服务层,再把emitter返回给Spring:

@GetMapping(value = "/agent/v1/chat", produces = "text/event-stream;charset=UTF-8")
public SseEmitter chat(@RequestParam @ChatQuestion String question,
@RequestParam(required = false) String conversationId) {
SseEmitter emitter = new SseEmitter(agentProperties.getSseTimeoutMs());
agentChatService.streamChat(question, conversationId, emitter);
return emitter;
}

返回emitter之后,HTTP响应仍可以持续接收内容。后面的模型输出、工具状态和结束事件,都会沿这条响应回到浏览器。

不过,SseEmitter只负责响应通道。它不了解会话里有没有待确认操作,也不知道模型还要执行几轮,更不会替应用保存聊天记录。

Controller返回emitter之前,服务层会先检查当前用户是否已有任务在执行,再准备Agent、保存用户消息,最后启动本轮Agent调用。这些准备工作也需要时间,所以页面显示加载状态时,模型可能还没开始生成回答。

先看清各层分工​

1. 服务层负责把这次运行组织起来​

请求进入AgentChatServiceImpl后,先由服务层处理应用侧的事情:这个用户当前还能不能继续发消息,这条问题属于哪段会话,用户点了停止以后该终止哪个任务,以及本轮结束时要释放哪些资源。

这些判断依赖登录身份、业务消息和任务状态,因此更适合留在应用层。检查通过后,AgentChatServiceImpl再把用户问题和本轮运行所需的上下文交给ReActAgent。

接下来由ReActAgent驱动整个执行过程。它会恢复会话状态、调用模型;如果模型决定使用工具,就执行对应工具,再把结果交还给模型继续判断下一步。比如用户问耳机问题时,是先查订单,还是先查故障资料,并不是服务层提前写死的,而是模型结合当前上下文在执行过程中决定。

最终产生的内容会经过AgentStreamEventBridge输出。框架内部的事件在这里被转换成前端能够识别的SSE消息,思考过程、最终回答和工具执行进度,也会分别映射到页面对应的展示区域。

正文里顺着入口、运行编排、Agent循环、事件桥往下读,就能跟住主要路径。会话服务和任务管理器在旁边配合,不需要把每个辅助类都记下来。

学Ragent2.0和看一些开源代码其实也差不多,先把整体思路和主流程捋清楚,不要一上来就陷进某个类、某个方法的实现细节里。先站在全局把各个模块之间的关系看明白,再回过头去研究具体实现和细微操作。这样读起来会更有方向,学习效率也会高很多。

2. 聊天记录和Agent状态各有用途​

链路里都会提到保存和读取,先把它们的用途分开,否则容易误以为模型直接读取的就是页面上的聊天列表。

AgentConversationServiceImpl维护业务会话与消息。用户刷新页面后,要看到自己问了什么、助手答了什么、调用过哪些工具,这些内容来自业务消息记录。

Agent执行时还需要另一份状态。除了对话上下文,它还可能包含工具调用及等待确认等执行信息。Ragent通过PgAgentStateStore把这份框架状态保存在PostgreSQL,下一次运行从中恢复。

两者虽然都与同一段会话有关,使用方却不同:页面从业务消息恢复界面,Agent从执行状态继续处理问题。文章后面说的“用户消息先保存”,指的是前者;说的“进入循环前加载状态”,指的是后者。

这样的分工也意味着,页面显示结束、Agent执行结束、消息保存完成,需要分别处理。它们可以在正常流程中前后发生,却不能靠其中一个信号代替所有事情都完成了。

启动之前,先把这一轮准备好​

1. 当前会话能不能接收新问题​

1.1先拿到用户的运行位​

服务层先确定会话号,生成本轮任务号,然后向AgentRunGate申请会话锁和用户名额。同一个会话同一时刻只能运行一轮;同一个用户的不同会话可以同时运行,但不能超过配置的并发上限,默认是5个。

如果当前会话正在运行,或者用户的并发会话数已经达到上限,请求会在这里直接被拒绝,不排队。

这道检查发生在Agent启动之前。会话锁保护同一份会话状态,用户名额限制一个用户同时占用的运行资源,具体实现放到并发控制专题里展开。

1.2再检查有没有等待处理的确认​

取得会话锁和用户名额后,如果请求带了已有会话号,服务层还会检查该会话有没有尚未处理的确认卡。

如果有,普通提问入口会拒绝本次请求,提示“上一步操作还在等你确认,请先确认或取消”。用户需要先处理确认卡,由确认入口继续执行。

这项检查失败时,Agent流还没有启动,服务层会通过cleanupFailedStart()归还已取得的会话锁和用户名额,并尝试注销本轮任务。

2. 确定Agent,保存问题​

检查通过后,startRun()先从ReActAgentProvider取得当前Agent,再维护会话、保存用户消息。下面这段真实代码保留了启动准备的先后关系:

ActiveAgent activeAgent = agentProvider.getAgent();

String title = conversationService.touchConversation(conversationId, userId, question);
// 首次创建用户的记忆控制记录时,会把创建时间作为记忆抽取的起点
// 必须先初始化再保存本轮消息,否则消息可能早于起点,后续抽取会一直跳过它
memoryPipeline.ensureExtractionBaseline(userId);
String questionMessageId = conversationService.addUserMessage(conversationId, userId, question);

这里获取Agent,还不代表已经开始调用模型。供给器会先结合当前人设和工具目录,判断已有实例能不能继续复用;如果不能,再重新构建。Agent确定之后,本轮要使用的Agent实例,以及与它对应的工具目录快照,会一起交给后面的执行流程。

Agent准备好以后,还要把会话记录处理好。新会话会根据用户的第一条问题生成一个简短标题,已有会话则更新最近活动时间。随后,当前这条用户消息会先写入业务消息表,并拿到自己的消息ID。后面保存助手回复时,会通过这个ID建立关联,明确这条回答对应的是哪一个用户问题。

开启长期记忆后,后端会先确定从哪个时间点开始抽取用户消息:首次初始化时记录当前时间,已有记录则继续沿用。这一步要在保存本轮消息之前完成,确保刚发来的消息也在抽取范围内。等本轮执行结束,后端再安排后台任务,从用户消息中提取值得长期保留的信息。具体的抽取流程,会在后面的长期记忆章节中展开。

解锁付费内容,👉戳​