山东大学软件学院项目实训-基于大模型的模拟面试系统-个人博客(三)

AI面试官聊天系统开发周报 - 技术实现与思考

一、本周核心工作内容

本周主要完成了AI面试官聊天系统的核心功能开发,重点实现了以下功能模块:

  1. 分支式对话管理系统:完整实现了多分支对话逻辑
  2. AI消息轮询机制:优化了长文本流式接收体验
  3. 文件上传与对话整合:支持简历等文件的上传解析
  4. Markdown渲染:优化AI回答的展示效果

二、关键技术实现解析

1. 分支式对话管理系统

架构设计

我们采用树形结构管理对话分支,每个分支节点包含:

{
  branchId: String,       // 分支唯一ID
  index: Number,          // 分支索引
  parentBranchIndex: Number, // 父分支索引
  children: Array,        // 子分支数组
  messageLocals: Array    // 当前分支消息列表
}
核心逻辑实现
  • 分支创建:用户编辑或重新生成消息时自动创建新分支

分支操作逻辑
节点的切割(用于产生新的分支):

创造一个空节点作为辅助根节点

对一个message进行产生分支操作时:

判断message顺序:

直接根据message在数组中的索引确定,因为所有的操作都可以保证messagelist逻辑上的顺序

如果自己是这个分支的第一个message

创建brach,其parentId为自己的parentId,并在父branch的children中加入新增的branchId,再在新增的branch中加入分支操作产生的message以及后续的message

同步记录兄弟的branchId

如果不是第一个message

需要分割brach产生新的节点:

  • 将该message以及之后的message继承原来的branch节点(主要是继承children和children的父子关系)
  • 将该message之前的message创建一个新的branch节点放入,该branch节点继承原来branch的parentId,并将原来branch节点的parentId改为该branch节点的Id
  • 新产生的分支节点parentId设为步骤二产生的branch节点的id
async newChatBranch(index) {
        
        try {
          // 情况1:是分支的第一个消息(index为0)
          if (index == 0) {
           
            const newBranch = {
              branchId: this.generateUuid(),
              chatId: this.chatRecordId,
              index: this.allBranches.length,
              parentBranchIndex: this.currentBranch.parentBranchIndex,
              children: [],
              messageLocals: []
            };
            
            // 添加到分支列表
            this.allBranches.push(newBranch);
          
            // 在父分支的children中添加新分支
            const parentBranch = this.allBranches.find(
              b => b.index == this.currentBranch.parentBranchIndex
            );
            
            if (parentBranch) {
              parentBranch.children.push({
                branchIndex: newBranch.index,
                tag:  `分支${parentBranch.children.length + 1}`
              });
            }
          
           
            
            // 切换当前分支
            this.currentBranch = newBranch;
            this.modifiedBranch.push(parentBranch);//先不加入新增的branch,到后面消息接受完毕后再更新
          } 
          // 情况2:不是第一个消息
          else {
            
            // 创建新父分支(包含index之前的消息)
            const newParentBranch = {
              branchId: this.generateUuid(),
              chatId: this.chatRecordId,
              index: this.allBranches.length,
              parentBranchIndex: this.currentBranch.parentBranchIndex,
              children: [
              {
                branchIndex: this.currentBranch.index,  // 新index分配给当前分支
                tag: '原分支'
              }
            ],
              messageLocals: []
            };
            newParentBranch.messageLocals = this.currentBranch.messageLocals.slice(0, index).map(msg => ({
                ...msg,
                branchId: newParentBranch.branchId // 更新branchId
              }))
            // 找到newParentBranch的父分支
            const grandParentBranch = this.allBranches.find(
              b => b.index == newParentBranch.parentBranchIndex
            );
            if (grandParentBranch) {
              // 遍历父分支的children数组
              grandParentBranch.children.forEach(child => {
                if (child.branchIndex == this.currentBranch.index) {
                  // 将当前分支的引用改为新父分支
                  child.branchIndex = newParentBranch.index;
                }
              });
            }
          
            // 将当前分支从index开始的message分配给新分支
            this.currentBranch.messageLocals = this.currentBranch.messageLocals.slice(index).map(msg => ({
              ...msg,
              branchId: this.currentBranch.branchId // 保持当前branchId
            }));
            this.currentBranch.parentBranchIndex = newParentBranch.index;
          
            // 创建新子分支(包含index及之后的消息)
            const newChildBranch = {
              branchId: this.generateUuid(),
              chatId: this.chatRecordId,
              index: this.allBranches.length + 1,
              parentBranchIndex: newParentBranch.index,
              children: [],
              messageLocals: []
            };
            
            newParentBranch.children.push({
                branchIndex: newChildBranch.index,
                tag: `分支${newParentBranch.children.length + 1}`
              });
            // 添加到分支列表
            this.allBranches.push(newParentBranch, newChildBranch);
            //新建的分支在正式接受到信息之后再保存
            this.modifiedBranch.push(newParentBranch, this.currentBranch,grandParentBranch);
          
            // 切换当前分支到新创建的子分支
            this.currentBranch = newChildBranch;
          }
        
          
          //console.log(this.currentBranch)
          // 重新构建经过currentBranch的路径
          // console.log(this.branchPath)
          await this.buildPathForTargetBranch(this.currentBranch);
        
          
        } catch (error) {
          console.error('创建分支失败:', error);
          this.$message.error('创建分支失败');
        }
      },
  • 分支切换:通过构建分支路径实现快速切换
async buildPathForTargetBranch(targetBranch) {
        //如果不存在(如新建分支时接受信息,则使用兄弟分支)
        if (!targetBranch) return;
          
        
       
        // 1. 向上查找父分支链
        const parentChain = [];
        let current = targetBranch;
        
        while (current && current.parentBranchIndex !== -1) {
          const parent = this.allBranches.find(b => b.index == current.parentBranchIndex);
          if (parent) {
            parentChain.unshift(parent); // 添加到数组开头
            current = parent;
          } else {
            break;
          }
        }

        // 2. 从根分支开始向下构建路径
        // this.branchPath = []; // 重置分支路径
        this.siblingNodes = [];
        this.messageListForShow = []; // 清空消息展示列表
        this.processedFiles = [];
        let parentBranch = this.rootBranch;
        // 遍历父链中的每个分支
        for (const branch of parentChain) {

          if (branch) {
            if(branch.index != 0){
                // 获取当前分支的所有消息
                const branchMessages = branch?.messageLocals || [];
                // 将消息添加到展示列表中
                this.messageListForShow.push(...branchMessages);

               

                this.siblingNodes.push({
                  index: branch.index,
                  branchId:branch.branchId,
                  siblings: parentBranch.children.map(child => ({
                    index: child.branchIndex,
                    tag: child.tag
                  }))
                })
            
            }

            parentBranch = branch;
          }  
        }
        
     
        this.siblingNodes.push({
          index: targetBranch.index,
          branchId:targetBranch.branchId,
          siblings: parentBranch.children.map(child => ({
            index: child.branchIndex,
            tag: child.tag
          }))
        })
        
        // 3. 添加目标以及之后的分支到路径
        await this.buildBranchPath(targetBranch);
       
      
      },

完整信息的展示
输入目标节点分支,向上通过父节点索引找到到根节点的完整路径(具体实现如上面代码所示),向下默认展示每个节点的第一个孩子直到叶节点组成的路劲

//按照默认方式构建分支路径(没有指定需要经过哪个节点)
      async buildBranchPath(startBranch) {
     
        this.currentBranch = startBranch;
        let currentBranch = startBranch;
       
        while (currentBranch) {
          
          // 添加当前分支的消息
          if (currentBranch.messageLocals?.length) {
            
            this.messageListForShow.push(...currentBranch.messageLocals);
            
          }

          

          // 获取当前分支的选择
          // let childIndex = this.branchPath.find(p => p.index === currentBranch.index)?.index
          
          // 如果没有选择,则选择第一个分支
          let childIndex = -1
          if(currentBranch.children && currentBranch.children.length > 0){
            childIndex = currentBranch.children[0].branchIndex
            // console.log(childIndex)
          }else{
              //到达叶子节
              childIndex = -1; // 没有子分支时设为-
              this.branchPath.push({
                index: currentBranch.index,
                childIndex
              })
              
              break; // 退出循环
          }
          
          let child = this.allBranches.find(p => p.index == childIndex);
          
          //以当前节点添加其子节点的兄弟信息
          this.siblingNodes.push({
            index: childIndex,
            branchId: child?.branchId,
            siblings: currentBranch.children.map(child => ({
              index: child.branchIndex,
              tag: child.tag
            }))
          })
       

     
         
          
          // 移动到下一个分支
          currentBranch = childIndex ? this.allBranches.find(b => b.index == childIndex): null;
          
        }

        // 更新当前分支
        
        this.currentBranch = currentBranch;
      },

2. AI消息轮询机制

轮询流程优化

采用requestAnimationFrame实现平滑轮询:

async startPolling(messageId) {
        const POLLING_TIMEOUT = 20000; // 5秒超时
        let pollingStartTime = Date.now();

          const processBatch = async () => {
              if (!this.isPolling) return;

              try {
                
                  // 检查是否超时
                  if (Date.now() - pollingStartTime > POLLING_TIMEOUT) {
                      throw new Error('轮询超时,未收到有效响应');
                  }

                  const params = new URLSearchParams();
                  params.append('messageId', messageId);  // 确保参数名完全匹配
                  params.append('batchSize', '5');  // 字符串形式

                  const response = await this.$axios.get('/api/chat/pollMessages', {
                      params: params,
                      paramsSerializer: params => params.toString()  // 使用默认序列化
                  });
                
                  if (response.data && response.data.length) {
                      let shouldStop = false;
                      // 重置超时计时器(每次收到有效数据就重置)
                      pollingStartTime = Date.now();
                      let hasNewContent = false;  
                      // 批量处理消息
                      for (const msg of response.data) {
                          

                          // 检查是否收到停止信号
                          if (msg.finish === "stop") {
                              shouldStop = true;
                          }

                          // 更新AI消息内容
                          const aiMsg = this.messageListForShow.find(m => m.messageId === messageId);
                          if (aiMsg) {
                              aiMsg.content = aiMsg.content || { text: '' };
                              const oldLength = aiMsg.content.text.length;
                              await this.typeText(aiMsg, msg.text);
                              if (aiMsg.content.text.length > oldLength) {
                                  hasNewContent = true;  // 只有内容变化时才标记
                              }
                          }
                      }

                      // 只有内容变化时才强制更新和滚动
                      if (hasNewContent) {
                          this.$forceUpdate();
                          this.scrollToBottom();
                      }

                      // 如果收到停止信号,则中止轮询
                      if (shouldStop) {
                          this.stopPolling();
                          const aiMsg = this.messageListForShow.find(m => m.messageId === messageId);
                          if (aiMsg) {
                            
                              // 将AI消息加入currentBranch
                              this.currentBranch.messageLocals.push({
                                  messageId:aiMsg.messageId,
                                  role: 'assistant',
                                  branchId: this.currentBranch.branchId,
                                  content: {
                                      text: aiMsg.content.text,
                                      
                                      files: []
                                  },
                                  timestamp: new Date()
                              });
                              this.modifiedBranch.push(this.currentBranch);
                              // 调用封装的方法保存currentBranch,并手动传入branchList
                              console.log(this.modifiedBranch)
                              await this.saveBranchList(this.modifiedBranch);
                              this.modifiedBranch = [];
                              await this.fetchData(this.chatRecordId)
                              this.scrollToBottom();
                          }
                          this.isLoading = false;
                          return;
                      }
                  }

                  // 继续轮询
                   this.pollingAnimationFrame = requestAnimationFrame(processBatch); 

              } catch (error) {
                 
                  this.modifiedBranch = [];
                  this.stopPolling();
                  this.isLoading = false;
                  await this.loadChatMessages(this.chatRecordId)
                  await this.buildPathForTargetBranch(this.rootBranch);
                  this.$message.error('获取消息失败: ' + error.message);
                  this.scrollToBottom();
              }
          };

          this.isPolling = true;
          processBatch();
      },
      stopPolling() {
          this.isPolling = false;
          if (this.pollingTimer) {
              clearTimeout(this.pollingTimer);
              this.pollingTimer = null;
          }
      },
后端实现

后端通过发送信息,再采用新建线程,异步地将接受的内容加入到轮询队列中,来实现前后端文字地流式输出

    @PostMapping("/sendMessageWithPoll")
    public GlobalResult<String> sendMessage(
            @RequestParam(value = "chatRequest") String chatRequestStr,
            @RequestPart(value = "files", required = false) List<MultipartFile> files,
            @RequestParam(value = "fileMessageId", required = false) String fileMessageId
    ) throws JsonProcessingException {
        // 手动解析JSON字符串
        ObjectMapper objectMapper = new ObjectMapper();
        ChatRequest chatRequest = objectMapper.readValue(chatRequestStr, ChatRequest.class);
        List<MessageLocal> messageList = chatRequest.getMessageList();
        List<MessageLocalDto> collect = messageList.stream().map((item) -> {
            MessageLocalDto messageLocalDto = new MessageLocalDto(item);
            if(fileMessageId != null && !fileMessageId.isEmpty()){
                if (Objects.equals(messageLocalDto.getMessageId(), fileMessageId)) {
                    messageLocalDto.setUploadFiles(files);
                }
            }


            return messageLocalDto;
        }).collect(Collectors.toList());
        List<MessageLocal> messageLocals = chatService.convertMessageListDto(collect);
        Interviewer interviewer = chatRequest.getInterviewer();
        // 参数验证
        if (messageList == null || messageList.isEmpty() || messageList.get(messageList.size() - 1).getContent().getText().isEmpty()) {
            throw new ServiceException("缺少发送信息");
        }
        if (interviewer == null) {
            throw new ServiceException("未设置面试官");
        }
        Long idUser = UserUtils.getCurrentUserByToken().getIdUser();
        // 异步处理消息

        String messageId = String.valueOf(UUID.randomUUID());
        try {
                    chatService.sendMessageToInterviewer(
                            messageLocals,
                    interviewer,
                    idUser,
                    messageId,
                    output -> {
                        // 将每个输出添加到队列

                        MessageQueueUtil.addMessage(output);
                        //System.out.println("add queue: "+ output.getText());
                    }
            );
        } catch (Exception e) {
            e.printStackTrace();
            MessageQueueUtil.addMessage(new ChatOutput("系统错误: " + e.getMessage()));
        }
        return GlobalResultGenerator.genSuccessStringDataResult(messageId);
    }


    @GetMapping("/pollMessages")
    public GlobalResult<List<ChatOutput>> pollMessages(
            @RequestParam("messageId") String messageId,
            @RequestParam(defaultValue = "10") int batchSize) {
//        MessageQueueUtil.check();
//        System.out.println("messageId: "+ messageId);
        // 参数校验
        if (messageId == null || messageId.isEmpty()) {
            return GlobalResultGenerator.genErrorResult("messageId不能为空");
        }
        if (batchSize <= 0 || batchSize > 100) {
            batchSize = 10; // 设置合理的默认值
        }
        Long userId = UserUtils.getCurrentUserByToken().getIdUser();
        long startTime = System.currentTimeMillis();
        final long timeout = 5000; // 5秒超时
        final long pollInterval = 200; // 轮询间隔200ms
        List<ChatOutput> batch = new ArrayList<>(batchSize);
        boolean hasStopSignal = false; // 新增状态位

        try {
            while ((System.currentTimeMillis() - startTime) < timeout) {


                List<ChatOutput> messages = MessageQueueUtil.pollBatch(messageId, batchSize);



                if (!messages.isEmpty()) {
                    //增加验证信息
                    ChatOutput chatOutput = messages.get(0);
                    if(!userId.equals(chatOutput.getUserId())){
                        throw new ServiceException("验证失败");
                    }


                    // 检查当前批次是否有stop信号
                    hasStopSignal = messages.stream()
                            .anyMatch(msg -> "stop".equals(msg.getFinish())) ;

                    batch.addAll(messages);


                    if (hasStopSignal) {
                        MessageQueueUtil.removeQueue(messageId);
                        break;
                    }

                    // 如果有消息立即返回,不等待超时
                    if (!batch.isEmpty()) {
                        break;
                    }
                }

                Thread.sleep(pollInterval);
            }

            return GlobalResultGenerator.genSuccessResult(batch);
        } catch (Exception e) {
            e.printStackTrace();
            return GlobalResultGenerator.genErrorResult("获取消息失败");
        }
    }

打字机效果实现
async typeText(messageObj, newText) {
  const typingSpeed = 30; // 控制打字速度(ms/字)
  for (let i = 0; i < newText.length; i++) {
    await new Promise(resolve => setTimeout(resolve, typingSpeed));
    messageObj.content.text += newText[i];
    this.$forceUpdate();
    this.scrollToBottom();
  }
}

3. 文件上传与对话整合

采用FormData实现多文件上传:

const formData = new FormData();
formData.append('chatRequest', JSON.stringify({
  messageList: this.chatMessages,
  interviewer: this.currentInterviewer
}));
this.processedFiles.forEach(file => {
  formData.append('files', file.raw);
});
public List<MessageLocal> convertMessageListDto(List<MessageLocalDto> messageLocalDtoList){

        List<MessageLocal> messageLocalList = messageLocalDtoList.stream().map(item -> {
            //对有上传文件的处理
            //当uploadFiles有值时,说明这个信息是第一次发送,尚未执行转化,或者是重新上传文件,覆盖原来的文件
            //提取文件中的内容分析,获取fileInfoList后保存这条信息
            List<MultipartFile> uploadFiles = item.getUploadFiles();
            MessageLocal messageLocal;
            if (uploadFiles != null && !uploadFiles.isEmpty()) {
                List<FileInfo> files = item.getContent().getFiles();
                for (MultipartFile file : uploadFiles) {
                    try {
                        MessageFileDto messageFileDto = convertMessageFile(file);
                        FileInfo fileInfo = messageFileDto.getFileInfo();
                        fileInfo.setTextContent(messageFileDto.getText());
                        files.add(fileInfo);
                    } catch (IOException e) {
                        e.printStackTrace();
                        throw new ServiceException("文件分析失败");
                    }
                }
                //构建新的messageLocal并保存
                Content content = item.getContent();
                content.setFiles(files);
                item.setContent(content);
                messageLocal = item;
                updateMessageLocalWithRepository(messageLocal);
            }
            messageLocal = item;
            return messageLocal;

        }).collect(Collectors.toList());

        return messageLocalList;
    }

4. Markdown渲染优化

使用markdown-it库实现AI回答的美化展示:

renderMarkdown(text, role) {
  if (role === 'assistant') {
    const rendered = this.md.render(text || '');
    return `
${rendered}
`
; } return text; }

三、技术难点与解决方案

  1. 分支状态同步问题

    • 挑战:多分支间的状态同步复杂
    • 方案:采用modifiedBranch数组跟踪变更分支,批量提交
  2. 长消息接收卡顿

    • 挑战:大段文本直接渲染导致UI阻塞
    • 方案:分批次处理+打字机动画效果
  3. 文件上传上下文关联

    • 挑战:保持文件与对话上下文的关联
    • 方案:通过messageId绑定文件与特定消息

四、下周工作计划

  1. 增加语音输入支持
  2. 完善异常处理机制
  3. 探寻agent的具体实现技术

五、技术思考总结

本周实现的分支式对话系统为AI面试场景提供了更自然的交互方式,关键技术点包括:

  1. 树形分支管理:使对话可以像思维导图一样延展
  2. 非阻塞式消息接收:提升了长回答场景下的用户体验
  3. 上下文感知的文件处理:让AI可以基于上传文件进行针对性提问

这些技术的组合应用,使得我们的AI面试系统在交互流畅性和场景适应性上达到了较好水平。

你可能感兴趣的:(java)