二哥项目资料

✅派聪明 RAG 聊天助手面试题预测,针对 WebSocket 和 prompt

面试篇 7,443 字 #25 原文
本文目录 · 13 节

1.我们来聊聊这个聊天助手。它最吸引人的特点之一就是像真人聊天一样,答案一个字一个字地蹦出来。这种‘流式响应’或‘打字机效果’,在技术上是如何实现的?

我先从整体的流程说起:当前端用户开始一次对话时,浏览器会通过 WebSocket 与后端建立一个长连接。这是一种双向的、长时间保持的连接,非常适合实时交互的场景,比如流式响应、打字机效果。

一旦用户在前端发出提问,这个消息就会通过 WebSocket 通道发送到后端。后端接收到消息后会去调用知识库去做一次混合检索,找出相关的文本内容后,再拼接上用户的历史上下文,构建一个完整的 Prompt。

接着去调用 DeepSeek,我们调用的是流式响应的 API。这一步是实现打字机效果的关键:我们用 Spring WebFlux 的 WebClient 作为 HTTP 客户端,在请求 LLM 的时候也以流式的方式订阅返回的数据流。也就是说,LLM 一边生成内容,一边把内容分成一小段一小段地推给我们,我们这边就一边接收一边处理。

每接收一段内容,就通过 WebSocket 立刻推送给前端。前端收到一小段字符后,就直接追加到聊天窗口中,给用户的感觉就是“打字机一点一点显示”的效果。

2.既然用到了WebSocket,那它和我们更常用的HTTP请求相比,有什么本质区别?为什么在这个场景下,必须用WebSocket?

WebSocket 能够支持服务端主动、实时地向客户端推送数据,而 HTTP 不具备这个能力。

具体来说,HTTP 是一种无状态、单向的请求-响应协议,它的工作机制决定了只能由客户端发起请求,服务端只是被动响应。哪怕我们用长轮询等手段模拟实时性,本质上还是客户端不断地问“有没有新的消息”,服务器无法主动发“有了”。

WebSocket 是一种有状态、全双工的协议,一旦连接建立,前后端就可以随时互相发送消息。在派聪明中,前端发起 WebSocket 请求建立连接后,后端就会一直监听前端的请求并保持连接。用户一旦发送请求,派聪明就会实时将 DeepSeek 返回的是流式数据通过 WebSocket 传回给前端。

总的来说,WebSocket 是我们实现流式响应和打字机效果的最佳选择,它解决了 HTTP 无法实时双向通信的问题,真正做到了后端一边接收大模型响应、一边实时推送给前端的效果。

3.WebSocket连接是长连接,它比HTTP要脆弱。如果用户的网络抖动一下,连接断了,会发生什么?你们有什么异常处理和重连机制吗?

考虑到用户可能在使用过程中遇到网络波动或者临时断网的情况,我们在前端增加了重连机制,在后端增加了会话恢复能力。

前端这边,我们使用了 @vueuse 库来管理 WebSocket 连接,它内置了强大的心跳重连机制。

// ... existing code ...
  } = useWebSocket(`/proxy-ws/chat/${store.token}`, {
    autoReconnect: true
  });
// ... existing code ...

一旦连接意外断开,它就会自动尝试重连。而且这个重连不是死磕式的,也就是说每次失败后等待时间都会变长,避免高频的重试对服务器造成压力。除此之外,它还支持心跳机制,会定期发送 ping 消息检测连接是否健康,如果发现连接已经“jiang死”,也能主动触发重连。

后端这边我们遵循的是“无状态连接”原则。比如用户重连后重新发一条消息,我们会通过消息中携带的会话 ID 去缓存中找回上下文,然后接着处理,就好像这条连接从来没断过一样。

所以整体上来说,我们通过前端的“自动重连+心跳检测”,加上后端的“无状态设计+会话恢复”,实现了一个非常稳健的实时通信机制。

4.WebSocket连接建立时,如何知道是哪个用户在和我聊天?它的身份认证是怎么做的?

当用户登录后,前端会获取到一个 JWT,这个 Token 是用户的唯一身份凭证。在用户发起聊天请求时,前端会将这个 Token 附加到 WebSocket 的连接请求中。

后端在收到这个 WebSocket 连接请求后,并不会立即建立连接,而是先进行身份校验。具体的做法是,每次处理消息之前,都会从 WebSocketSession 中解析出前端发送过来的 Token,然后去校验这个 Token 的有效性。

如果校验成功,再从 Token 中提取出当前用户的唯一身份标识,然后再把这个身份信息用于权限控制、聊天记录的绑定等等。

5.聊天助手能记住我们上一轮聊天的内容。这种‘多轮对话’的能力,背后需要什么样的技术来支撑?

为了区分不同用户、不同轮次的对话,系统会为每一次完整的对话分配一个唯一的 conversationId 。这个 ID 是关联所有历史对话的“主键”。

// ... existing code ...
  private String getOrCreateConversationId(String userId) {
      String key = "user:" + userId + ":current_conversation";
      String conversationId = redisTemplate.opsForValue().get(key);

      if (conversationId == null) {
          conversationId = UUID.randomUUID().toString();
          redisTemplate.opsForValue().set(key, conversationId, Duration.ofDays(7));
      }
      return conversationId;
  }
// ... existing code ...

然后我们会将每一轮对话序列化成 JSON 字符串存入 Redis。下一次请求时,我们会把相关的历史对话作为上下文信息一起发送给 DeepSeek。

// ... existing code ...
  private void updateConversationHistory(String conversationId, String userMessage, String response) {
      String key = "conversation:" + conversationId;
      List<Map<String, String>> history = getConversationHistory(conversationId);
      // ... 将 userMessage 和 response 添加到 history ...
      String json = objectMapper.writeValueAsString(history);
      redisTemplate.opsForValue().set(key, json, Duration.ofDays(7));
  }
// ... existing code ...

当 DeepSeek 完成回答后,我们会将最新的用户提问和模型回答追加到历史列表中,然后再次序列化为JSON,写回 Redis,覆盖旧的记录。这样,下一次交互时就能加载到最新的上下文了。

为了避免上下文过长导致 Token 超限和性能下降,我们只保留了最近的 20 条消息。

6.为什么选择Redis来存储对话历史,而不是直接存入MySQL数据库?

主要是从两方面来考虑。

首先,聊天的历史记录在每一次用户输入时,都需要作为上下文封装到提示词中去请求 DeepSeek 大模型,然后在答案生成后还需要将新一轮的对话再次写入到历史记录中。这意味着历史记录是一个高频读写的场景。

MySQL 的读写速度虽然已经非常快了,但和内存数据库 Redis 相比,还是存在数量级的差距。Redis 的读写速度可以达到微秒级,非常适合用来支撑这种实时性要求很高的业务。

其次,对于聊天这种历史数据,并不需要持久化,它有明确的“保质期”——只需要保留最近一段时间的上下文来辅助 LLM 生成回答就可以了。我们只需要设置一个 TTL,Redis 就能在到期后自动清除这些数据。如果用 MySQL 来存储,还需要起一个定时任务,去清理数据。

7.能详细描述一下你在Redis里是如何设计数据结构的吗?比如,如何找到一个用户的对话历史?

为了找到指定用户的聊天记录,我们主要使用了两种类型的 Key:

第一种 key 用于定位用户的会话,格式为 user:{userId}:current_conversation_id,value 是一个 UUID 的字符串。

比如说当用户 itwanger 发起新的对话时,系统会通过 user:itwanger:current_conversation_id 这个 Key 来查找他上一次的会话 ID。如果找到了,就继续使用;如果没找到或者已经过期,就创建一个新的会话 ID 并存入这个 Key。

第二种 key 用于存储真正的聊天记录,它的格式是:

conversation:{conversationId},value 是一个 JSON 字符串,该字符串序列化了一个包含多条对话的列表。

也就是说,只要有会话 ID,我们就可以从 Redis 中获取到完整的聊天记录(一个JSON数组)。

8.大语言模型的输入长度(Token窗口)是有限的。如果对话越来越长,你是如何处理这个上下文窗口,避免它超出限制的?

派聪明目前采用的策略是滑动窗口。也就是每次只保留最近的几轮对话历史,大概 20 条消息。

// ... existing code ...
    private void updateConversationHistory(String conversationId, List<Map<String, String>> history) {
        try {
            // 截取最近的20条历史记录
            if (history.size() > 20) {
                history = history.subList(history.size() - 20, history.size());
            }
            String historyJson = objectMapper.writeValueAsString(history);
// ... existing code ...

在我们更新 Redis 中的聊天记录时,会先判断一下历史消息的数量,如果超过了 20 条,就直接截断,只保留最后的 20 条。这样,无论用户聊了多长时间,我们拿去拼接提示词的历史上下文长度都是可控的。

一方面,这个策略实现起来非常简单,适合快速上线并验证效果;另一方面,最近的内容,通常也是对当前话题最相关的部分,能在一定程度上保证语义连贯。

当然,这只是一个基础策略。下一个版本当中我们打算再做两种尝试:

第一种是摘要机制。比如我们可以每隔几轮就对之前的对话做一个总结,把前面的信息“压缩”成一句或几句话,用于后续的 Prompt 构建。这样即使窗口变小了,我们也不会完全丢失早期的信息,只是用“摘要”的方式保留记忆。

第二种是基于向量的语义检索机制。我们可以把每一轮的对话都转成向量并存到向量数据库中。用户每增加一次对话,我们就将最新的这句话也转成向量,然后去 ES 中查找之前最相关的几轮历史,再把它们拉出来放入 Prompt。这种方式的优点是,能够“按需召回”,不用记住整个历史对话,效率高,成本也更可控。

9.RAG在回答专业问题时,不仅仅是在“创作”,而是在“引用”一些知识。派聪明是怎么集成本地知识库的?

第一步,接收到用户的提问后,我们会先进行一次混合检索。我们会根据用户输入的内容,在 Elasticsearch 里查出前 N 个最相关的知识片段。

第二步,我们会把这些知识片段组织成一个结构化的上下文。比如加一些格式说明、标注每一段的来源和标题,最终形成一个格式友好的提示词,让模型能更好地理解和引用这些知识。

第三步,将用户的原始问题,提示词和聊天记录一并发给 DeepSeek,让 DeepSeek 在充分理解历史对话的基础上,结合知识库中真实、可信的内容来进行回答。

10.我们知道,聊天助手需要调用后端的知识库检索接口来获取信息。那么,聊天助手本身是否需要关心权限问题?还是说它可以完全信任检索接口返回的结果?换句话说,你是如何确保一个e意用户,不能通过向聊天助手提一些特殊的问题,来诱导它去检索并暴露该用户本无权查看的文档内容的?

为了遵守单一职责原则,聊天助手本身不应该去关注任何权限逻辑,因为它应该完全信任我们的知识库检索接口。

而我们会在知识库检索接口中实现权限校验,确保用户只能看到他有权限的文档。

权限的判断逻辑包括,这个用户属于哪个组织、文档是否公开等等。

这样做有几个好处:第一,职责划分非常清晰,聊天助手可以专注于构建上下文、调用大模型。第二,权限相关的敏感判断统一放在知识库检索服务里。

11.我们现在有了两部分信息:用户之前的聊天记录(来自Redis)和刚从ES里检索到的知识。系统是如何将这两部分信息,以及用户的当前问题,组合成一份高质量的指令,最终交给大语言模型的?

首先,我们在配置文件中定义了一个提示词模版。包含了预设的占位符,如规则、引用开始/结束符和无检索结果时的提示等。

在动态构建提示词的时候,我们首先会构建一个 system 指令,然后将检索到的知识片段包裹在引用开始/结束符中,并且要求模型遵守我们自定义的规则。

然后再增加一个 user 指令,添加用户当前的提问,以及历史聊天记录,从而让大模型能够理解对话的上下文,并能完成连贯的多轮对话。

12.当检索到的知识和用户问题不完全匹配,甚至完全不相关时,系统是如何处理的?有什么策略来避免LLM‘一本正经地胡说八道’?

首先,在检索这层,我们会给 Elasticsearch 设置一个相关性评分的门槛。比如我们会把低于 0.3 分的结果全部过滤掉。也就是说,如果一个文档跟用户的问题只有一点点关联关系,分数达不到要求,它就根本不会进入到下一步的上下文构建中。

第二,我们在 Prompt 的 System 指令里给出了明确的规则,“如果你发现上下文里没有足够信息来回答用户的问题,请直接说‘无法回答’或者‘没有找到相关资料’,而不是强行输出。”

13.回答中的知识引用和来源标注(比如[文档1])是如何实现的?

第一步,我们会给每一条从 Elasticsearch 中检索到的文档打上一个临时编号,比如 [1]、[2]、[3] 这样,格式是 [编号] 文档内容。这个编号从 1 开始,主要的目的是方便后续在回答中引用。

第二步,在构建 Prompt 的时候,我们会通过规则明确告知大模型:“你在回答用户问题时,如果参考了某段知识,请在句末加上它的编号,比如 [1]、[2]。”

最后一步是在用户收到回答后,前端会解析回答内容中的这些编号,然后给来源的文档加上可点击的链接。