当前位置: 首页 > web >正文

大模型数据流处理实战:Vue+NDJSON的Markdown安全渲染架构

在Vue中使用HTTP流接收大模型NDJSON数据并安全渲染

在构建现代Web应用时,处理大模型返回的流式数据并安全地渲染到页面是一个常见需求。本文将介绍如何在Vue应用中通过普通HTTP流接收NDJSON格式的大模型响应,使用marked、highlight.js和DOMPurify等库进行安全渲染。

效果预览

在这里插入图片描述

技术栈概览

  • Vue 3:现代前端框架
  • NDJSON (Newline Delimited JSON):大模型常用的流式数据格式
  • marked:Markdown解析器
  • highlight.js:代码高亮
  • DOMPurify:HTML净化,防止XSS攻击

实现步骤

1. 安装依赖

首先安装必要的依赖:

npm install marked highlight.js dompurify

2. 创建流式请求工具函数

创建一个工具函数来处理NDJSON流,我使用axios,但更推荐直接是使用fetch,由于本地部署的大模型,采用的是普通HTTP的流(chunked),目前采用SSE方式的更多:

//  utils/request.js
import axios from "axios"
import { ElMessage } from 'element-plus'const request = axios.create({baseURL: import.meta.env.VITE_APP_BASE_API,timeout: 0
});// 存储所有活动的 AbortController
const activeRequests = new Map();// 生成唯一请求 ID 的函数
export function generateRequestId(config) {// 包含请求 URL、方法、参数和数据,确保唯一性const params = JSON.stringify(config.params || {});const data = JSON.stringify(config.data || {});return `${config.url}-${config.method.toLowerCase()}-${params}-${data}`;
}// 请求拦截器
request.interceptors.request.use((config) => {const requestId = generateRequestId(config);// 如果已有相同请求正在进行,则取消前一个if (activeRequests.has(requestId)) {activeRequests.get(requestId).abort('取消重复请求');}// 创建新的 AbortController 并存储const controller = new AbortController();activeRequests.set(requestId, controller);// 绑定 signal 到请求配置config.signal = controller.signal;return config;
});// 响应拦截器
request.interceptors.response.use((response) => {const requestId = generateRequestId(response.config);activeRequests.delete(requestId); // 请求完成,清理控制器return response;
}, (error) => {if (axios.isCancel(error)) {console.log('over');} else {// 修正 ElMessage 的使用,正确显示错误信息ElMessage({type: 'error',message: error.message || '请求发生错误'});}// 返回失败的 promisereturn Promise.reject(error);
});/*** 手动取消请求* @param {string} requestId 请求 ID*/
export function cancelRequest(requestId) {if (activeRequests.has(requestId)) {activeRequests.get(requestId).abort('用户手动取消');activeRequests.delete(requestId);} else {console.log(`未找到请求 ID: ${requestId},可能已完成或取消`);}
}// 导出请求实例
export default request;

通过请求封装,提升模块化能力

// apis/stream.js
import request, { cancelRequest, generateRequestId } from '@/utils/request.js'// 全局缓冲不完整的行
let buffer = '';
let currentRequestConfig = null; // 存储当前请求的配置
let lastPosition = 0;/*** qwen对话* @param {*} data 对话数据*/
export function qwenTalk(data, onProgress) {const config = {url: '/api/chat',method: 'POST',data,responseType: 'text'};currentRequestConfig = config;// 重置 bufferbuffer = '';lastPosition = 0return request({...config,onDownloadProgress: (progressEvent) => {const responseText = progressEvent.event.target?.responseText || '';const newText = responseText.slice(lastPosition);lastPosition = responseText.length;parseStreamData(newText, onProgress);},})
}/*** 解析流式 NDJSON 数据* @param {string} text 原始流文本* @param {function} onProgress 回调函数,用于处理解析后的 JSON 数据*/
function parseStreamData(text, onProgress) {// 将新接收到的文本追加到全局缓冲 buffer 中buffer += text;const lines = buffer.split('\n');// 处理完整的行for (let i = 0; i < lines.length - 1; i++) {const line = lines[i].trim();if (line) {try {const data = JSON.parse(line);onProgress(data);} catch (err) {console.error('JSON 解析失败:', err, '原始数据:', line);}}}// 保留最后一行作为不完整的部分buffer = lines[lines.length - 1];
}/*** 取消请求*/
export function cancelQwenTalk() {if (currentRequestConfig) {const requestId = generateRequestId(currentRequestConfig);cancelRequest(requestId);currentRequestConfig = null;}
}

3. 创建Markdown渲染工具

配置marked、highlight.js和DOMPurify:

// utils/markdown.js
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css'; // 选择一个高亮主题// 配置 marked
marked.setOptions({langPrefix: 'hljs language-', // 高亮代码块的class前缀breaks: true,gfm: true,highlight: (code, lang) => {// 如果指定了语言,尝试使用该语言高亮if (lang && hljs.getLanguage(lang)) {try {return hljs.highlight(code, { language: lang }).value;} catch (e) {console.warn(`代码高亮失败 (${lang}):`, e);}}// 否则尝试自动检测语言try {return hljs.highlightAuto(code).value;} catch (e) {console.warn('自动代码高亮失败:', e);return code; // 返回原始代码}}
});// 导出渲染函数
export function renderMarkdown(content) {const html = marked.parse(content);const sanitizedHtml = DOMPurify.sanitize(html);// 确保 highlight.js 应用样式setTimeout(() => {if (typeof window !== 'undefined') {document.querySelectorAll('pre code').forEach((block) => {// 检查是否已经高亮过if (!block.dataset.highlighted) {hljs.highlightElement(block);block.dataset.highlighted = 'true'; // 标记为已高亮}});}}, 0);return sanitizedHtml;
}

4. 在Vue组件中使用

创建一个Vue组件来处理流式数据并渲染:

<template><div class="chat-container"><!-- 对话消息展示区域,添加 ref 属性 --><div ref="chatMessagesRef" class="chat-messages"><div v-for="(message, index) in messages" :key="index" :class="['message', message.type]"><el-avatar :src="message.avatar" :size="48" class="avatar"></el-avatar><div class="markdown-container"><div class="markdown-content" v-html="message.content"></div><div v-if="message.loading" class="loading-dots"><span></span><span></span><span></span></div></div></div></div><!-- 输入区域 --><div class="chat-input"><el-input v-model="inputMessage" type="textarea" :rows="2" placeholder="请输入您的问题..."@keyup.enter="canSend && sendMessage()"></el-input><el-button type="primary" @click="sendMessage" :disabled="!canSend">发送</el-button><!-- 添加请求状态图标 --><el-icon v-if="currentAIReply" @click="cancelRequest"><Close /></el-icon><el-icon v-else><CircleCheck /></el-icon></div></div>
</template><script setup>
import { ref, computed, nextTick } from 'vue';
import { qwenTalk, cancelQwenTalk } from "@/api/aiAgent.js";
import { ElMessage } from 'element-plus';
// 引入图标
import { Close, CircleCheck } from '@element-plus/icons-vue';
import md from '@/utils/markdownRenderer'
import { renderMarkdown } from '@/utils/markedRenderer';const chatMessagesRef = ref(null);
const messages = ref([{type: 'assistant',content: '您好!有什么我可以帮助您的?',avatar: 'https://picsum.photos/48/48?random=2'}
]);
const inputMessage = ref('');
const canSend = computed(() => {return inputMessage.value.trim().length > 0;
});
const currentAIReply = ref(null);
// 添加请求取消标志位
const isRequestCancelled = ref(false);const scrollToBottom = () => {nextTick(() => {if (chatMessagesRef.value) {chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;}});
};const sendMessage = () => {if (!canSend.value) return;isRequestCancelled.value = false;messages.value.push({type: 'user',content: inputMessage.value,avatar: 'https://picsum.photos/48/48?random=1'});messages.value.push({type: 'assistant',content: '',avatar: 'https://picsum.photos/48/48?random=2',loading: true});const aiMessageIndex = messages.value.length - 1;currentAIReply.value = {index: aiMessageIndex,content: ''};scrollToBottom();let accumulatedContent = '';qwenTalk({"model": "qwen2.5:32b","messages": [{"role": "user","content": inputMessage.value,"currentModel": "qwen2.5:32b"},{"role": "assistant","content": "","currentModel": "qwen2.5:32b"}],"stream": true,}, (data) => {// 如果请求已取消,不再处理后续数据if (isRequestCancelled.value) return;if (data.message?.content !== undefined) {accumulatedContent += data.message.content;try {// 实时进行 Markdown 渲染const renderedContent = renderMarkdown(accumulatedContent);messages.value[aiMessageIndex].content = renderedContent;} catch (err) {console.error('Markdown 渲染失败:', err);messages.value[aiMessageIndex].content = accumulatedContent;}scrollToBottom();}if (data.done) {messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;}}).catch(error => {messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;scrollToBottom();});inputMessage.value = '';
};const cancelRequest = () => {if (currentAIReply.value) {cancelQwenTalk();const aiMessageIndex = currentAIReply.value.index;messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;ElMessage.warning('请求已取消');// 设置请求取消标志位isRequestCancelled.value = true;scrollToBottom();}
};
</script><style scoped>
.chat-container {display: flex;flex-direction: column;height: 80vh;width: 100%;margin: 0;padding: 0;background-color: #f5f5f5;
}.chat-messages {flex: 1;/* 消息区域占据剩余空间 */overflow-y: auto;/* 内容超出时垂直滚动 */padding: 20px;background-color: #ffffff;
}.message {display: flex;margin-bottom: 20px;align-items: flex-start;
}.user {flex-direction: row-reverse;
}.avatar {margin: 0 12px;
}/* 添加基本的 Markdown 样式 */
.markdown-container {max-width: 70%;padding: 8px;border-radius: 8px;font-size: 16px;line-height: 1.6;
}.markdown-container h1,
.markdown-container h2,
.markdown-container h3 {margin-top: 1em;margin-bottom: 0.5em;
}.markdown-container p {margin-bottom: 1em;
}.user .markdown-container {background-color: #409eff;color: white;
}.assistant .markdown-container {background-color: #eeecec;color: #333;text-align: left;
}.chat-input {display: flex;gap: 12px;padding: 20px;background-color: #ffffff;border-top: 1px solid #ddd;
}/* 代码样式---------------| */
.markdown-content {line-height: 1.6;
}.markdown-container pre code.hljs {display: block;overflow-x: auto;padding: 1em;border-radius: 10px;
}.markdown-container code {font-family: 'Fira Code', 'Consolas', 'Monaco', 'Andale Mono', monospace;font-size: 14px;line-height: 1.5;
}
.chat-input .el-input {flex: 1;/* 输入框占据剩余空间 */
}/* 添加禁用状态样式------------------- */
.chat-input .el-button:disabled {opacity: 0.6;cursor: not-allowed;
}.loading-dots {display: inline-flex;align-items: center;height: 1em;margin-left: 8px;
}.loading-dots span {display: inline-block;width: 8px;height: 8px;border-radius: 50%;background-color: #999;margin: 0 2px;animation: bounce 1.4s infinite ease-in-out both;
}.loading-dots span:nth-child(1) {animation-delay: -0.32s;
}.loading-dots span:nth-child(2) {animation-delay: -0.16s;
}@keyframes bounce {0%,80%,100% {transform: scale(0);}40% {transform: scale(1);}
}.chat-input .el-icon {font-size: 24px;cursor: pointer;color: #409eff;
}.chat-input .el-icon:hover {color: #66b1ff;
}
</style>

高级优化

1. 节流渲染

对于高频更新的流,可以使用节流来优化性能:

let updateTimeout;
const throttledUpdate = (newContent) => {clearTimeout(updateTimeout);updateTimeout = setTimeout(() => {this.content = newContent;}, 100); // 每100毫秒更新一次
};// 在onData回调中使用
(data) => {if (data.content) {throttledUpdate(this.content + data.content);}
}

2. 自动滚动

保持最新内容可见:

scrollToBottom() {this.$nextTick(() => {const container = this.$el.querySelector('.content');container.scrollTop = container.scrollHeight;});
}// 在适当的时候调用,如onData或onComplete

3. 中断请求

添加中断流的能力,取消请求,详见上篇文章:


const cancelRequest = () => {if (currentAIReply.value) {cancelQwenTalk();const aiMessageIndex = currentAIReply.value.index;messages.value[aiMessageIndex].loading = false;currentAIReply.value = null;ElMessage.warning('请求已取消');// 设置请求取消标志位isRequestCancelled.value = true;scrollToBottom();}
};

安全注意事项

  1. 始终使用DOMPurify:即使你信任数据来源,也要净化HTML
  2. 内容安全策略(CSP):设置适当的CSP头来进一步保护应用
  3. 避免直接使用v-html:虽然我们这里使用了,但确保内容已经过净化
  4. 限制数据大小:对于特别大的流,考虑设置最大长度限制

总结

通过结合Vue的响应式系统、NDJSON流式处理、Markdown渲染和安全净化,我们构建了一个能够高效处理大模型流式响应的解决方案。这种方法特别适合需要实时显示大模型生成内容的场景,如AI聊天、代码生成或内容创作工具。

关键点在于:

  • 使用NDJSON格式高效传输流数据
  • 正确解析和处理流式响应
  • 安全地渲染Markdown内容
  • 提供良好的用户体验和性能优化
http://www.xdnf.cn/news/12269.html

相关文章:

  • 高防服务器能够抵御哪些网络攻击呢?
  • 宠物空气净化器哪个好用?2025宠物空气净化器测评:352、希喂、有哈
  • 智慧园区数字孪生全链交付方案:降本增效30%,多案例实践驱动全周期交付
  • 基于正点原子阿波罗F429开发板的LWIP应用(5)——TFTP在线升级功能
  • Spring之事务管理方式
  • Go中的协程并发和并发panic处理
  • GitHub 趋势日报 (2025年06月04日)
  • Linux --环境变量,虚拟地址空间
  • 强化学习在LLM中应用:RLHF、DPO
  • 网络通信核心概念全解析:从IP地址到TCP/UDP实战
  • 面试题:Java多线程并发
  • JAVA之 Lambda
  • chrome使用手机调试触屏web
  • Nginx学习笔记
  • 【Go语言基础【2】】数据类型之基础数据类型:数字、字符、布尔、枚举、自定义
  • Unity3D中Newtonsoft.Json序列化优化策略
  • [蓝桥杯]倍数问题
  • 倍福 PLC程序解读
  • kubectl 命令
  • docker 搭建php 开发环境 添加扩展redis、swoole、xdebug(2)
  • 游戏设计模式 - 子类沙箱
  • 计算机网络备忘录
  • SDC命令详解:使用set_fanout_load命令进行约束
  • AI Agent 项目 SUNA 部署环境搭建 - 基于 MSYS2 的 Poetry+Python3.11 虚拟环境
  • 鸿蒙jsonToArkTS_工具exe版本来了
  • 上门服务小程序会员系统框架设计
  • 鸿蒙UI(ArkUI-方舟UI框架)- 使用弹框
  • 【react+antd+vite】优雅的引入svg和阿里巴巴图标
  • 八、Python模块、包
  • 华为OD最新机试真题-数组组成的最小数字-OD统一考试(B卷)