大模型数据流处理实战: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();}
};
安全注意事项
- 始终使用DOMPurify:即使你信任数据来源,也要净化HTML
- 内容安全策略(CSP):设置适当的CSP头来进一步保护应用
- 避免直接使用v-html:虽然我们这里使用了,但确保内容已经过净化
- 限制数据大小:对于特别大的流,考虑设置最大长度限制
总结
通过结合Vue的响应式系统、NDJSON流式处理、Markdown渲染和安全净化,我们构建了一个能够高效处理大模型流式响应的解决方案。这种方法特别适合需要实时显示大模型生成内容的场景,如AI聊天、代码生成或内容创作工具。
关键点在于:
- 使用NDJSON格式高效传输流数据
- 正确解析和处理流式响应
- 安全地渲染Markdown内容
- 提供良好的用户体验和性能优化
相关文章:

大模型数据流处理实战:Vue+NDJSON的Markdown安全渲染架构
在Vue中使用HTTP流接收大模型NDJSON数据并安全渲染 在构建现代Web应用时,处理大模型返回的流式数据并安全地渲染到页面是一个常见需求。本文将介绍如何在Vue应用中通过普通HTTP流接收NDJSON格式的大模型响应,使用marked、highlight.js和DOMPurify等库进…...
python项目如何创建docker环境
这里写自定义目录标题 python项目创建docker环境docker配置国内镜像源构建一个Docker 镜像验证镜像合理的创建标题,有助于目录的生成如何改变文本的样式插入链接与图片如何插入一段漂亮的代码片生成一个适合你的列表创建一个表格设定内容居中、居左、居右SmartyPant…...
Eureka 高可用集群搭建实战:服务注册与发现的底层原理与避坑指南
引言:为什么 Eureka 依然是存量系统的核心? 尽管 Nacos 等新注册中心崛起,但金融、电力等保守行业仍有大量系统运行在 Eureka 上。理解其高可用设计与自我保护机制,是保障分布式系统稳定的必修课。本文将手把手带你搭建生产级 Eur…...

PyTorch--池化层(4)
池化层(Pooling Layer) 用于降低特征图的空间维度,减少计算量和参数数量,同时保留最重要的特征信息。 池化作用:比如1080p视频——720p 池化层的步长默认是卷积核的大小 ceil 允许有出界部分;floor 不允许…...
GPU加速与非加速的深度学习张量计算对比Demo,使用PyTorch展示关键差异
import torch import time # 创建大型随机张量 (10000x10000) tensor_size 10000 x_cpu torch.randn(tensor_size, tensor_size) x_gpu x_cpu.cuda() # 转移到GPU # CPU矩阵乘法 start time.time() result_cpu torch.mm(x_cpu, x_cpu.t()) cpu_time time.time() - sta…...
Vue中的自定义事件
一、前言 在 Vue 的组件化开发中,组件之间的数据通信是构建复杂应用的关键。而其中最常见、最推荐的方式之一就是通过 自定义事件(Custom Events) 来实现父子组件之间的交互。 本文将带你深入了解: Vue 中事件的基本概念如何在…...

2025年大模型平台落地实践研究报告|附75页PDF文件下载
本报告旨在为各行业企业在建设落地大模型平台的过程中,提供有效的参考和指引,助力大模型更高效更有价值地规模化落地。本报告系统性梳理了大模型平台的发展背景、历程和现状,结合大模型平台的特点提出了具体的落地策略与路径,同时…...

PPTAGENT:让PPT生成更智能
想要掌握如何将大模型的力量发挥到极致吗?叶梓老师带您深入了解 Llama Factory —— 一款革命性的大模型微调工具。 1小时实战课程,您将学习到如何轻松上手并有效利用 Llama Factory 来微调您的模型,以发挥其最大潜力。 CSDN教学平台录播地址…...
Kotlin 中 companion object 扩展函数和普通函数区别
在 Kotlin 中,companion object 的扩展函数与普通函数(包括普通成员函数和普通扩展函数)有显著区别。以下是它们的核心差异和适用场景: 1. 定义位置与归属 特性companion object 扩展函数普通函数定义位置在类外部为伴生对象添加…...

《汇编语言》第13章 int指令
中断信息可以来自 CPU 的内部和外部,当 CPU 的内部有需要处理的事情发生的时候,将产生需要马上处理的中断信息,引发中断过程。在第12章中,我们讲解了中断过程和两种内中断的处理。 这一章中,我们讲解另一种重要的内中断…...

Redis实战-基于redis和lua脚本实现分布式锁以及Redission源码解析【万字长文】
前言: 在上篇博客中,我们探讨了单机模式下如何通过悲观锁(synchronized)实现"一人一单"功能。然而,在分布式系统或集群环境下,单纯依赖JVM级别的锁机制会出现线程并发安全问题,因为这…...
Ubuntu崩溃修复方案
当Ubuntu系统崩溃时,可依据崩溃类型(启动失败、运行时崩溃、完全无响应)选择以下修复方案。以下方法综合了官方推荐和社区实践,按操作风险由低到高排序: 一、恢复模式(Recovery Mode) 适用场景:系统启动卡顿、登录后黑屏、软件包损坏等。 操作步骤: …...

计算机网络 : 应用层自定义协议与序列化
计算机网络 : 应用层自定义协议与序列化 目录 计算机网络 : 应用层自定义协议与序列化引言1. 应用层协议1.1 再谈协议1.2 网络版计算器1.3 序列化与反序列化 2. 重新理解全双工3. socket和协议的封装4. 关于流失数据的处理5. Jsoncpp5.1 特性5.2 安装5.3…...

Python Day42 学习(日志Day9复习)
补充:关于“箱线图”的阅读 以下图为例 浙大疏锦行 箱线图的基本组成 箱体(Box):中间的矩形,表示数据的中间50%(从下四分位数Q1到上四分位数Q3)。中位线(Median)&#…...

CMake在VS中使用远程调试
选中CMakeLists.txt, 右键-添加调试配置-选中"C\C远程windows调试" 之后将 aunch.vs.json文件改为如下所示: CMake在VS中使用远程调试时,Launch.vs.json中远程调试设置 ,远程电脑开启VS专用的RemoteDebugger {"version": "0.2.1","defaul…...

《图解技术体系》How Redis Architecture Evolves?
Redis架构的演进经历了多个关键阶段,从最初的内存数据库发展为支持分布式、多模型和持久化的高性能系统。以下为具体演进路径: 单线程模型与基础数据结构 Redis最初采用单线程架构,利用高效的I/O多路复用(如epoll)处…...
从零搭建到 App Store 上架:跨平台开发者使用 Appuploader与其他工具的实战经验
对于很多独立开发者或小型团队来说,开发一个 iOS 应用并不难,真正的挑战在于最后一步:将应用成功上架到 App Store。尤其是当你主要在 Windows 或 Linux 系统上开发,缺乏苹果设备和 macOS 环境时,上架流程往往变得繁琐…...
Spring Cloud 2025 正式发布啦
文章目录 一、版本兼容性二、Spring Cloud Gateway 重大更新1、新增功能1.1 Function & Stream 处理器集成1.2 Bucket4j 限流器支持 2、重要弃用2.1. WebClientRouting 基础设施2.2. 模块和启动器重命名 3、破坏性变更3.1 X-Forwarded-* 头部默认禁用3.2 配置受信任代理:3.…...

一文速通Python并行计算:12 Python多进程编程-进程池Pool
一文速通 Python 并行计算:12 Python 多进程编程-进程池 Pool 摘要: 在Python多进程编程中,Pool类用于创建进程池,可并行执行多个任务。通过map、apply等方法,将函数和参数分发到子进程,提高CPU利用率&…...
相机Camera日志分析之二十五:高通相机Camx 基于预览1帧的process_capture_request四级日志分析详解
【关注我,后续持续新增专题博文,谢谢!!!】 上一篇我们讲了:相机Camera日志分析之二十四:高通相机Camx 基于预览1帧的process_capture_request三级日志分析详解 ok 这一篇我们开始讲: 相机Camera日志分析之二十五:高通相机Camx 基于预览1帧的process_capture_…...
React从基础入门到高级实战:React 实战项目 - 项目一:在线待办事项应用
React 实战项目:在线待办事项应用 欢迎来到本 React 开发教程专栏的第 26 篇!在之前的 25 篇文章中,我们从 React 的基础概念逐步深入到高级技巧,涵盖了组件、状态、路由和性能优化等核心知识。这一次,我们将通过一个…...
云部署实战:基于AWS EC2/Aliyun ECS与GitHub Actions的CI/CD全流程指南
在当今快速迭代的软件开发环境中,云部署与持续集成/持续交付(CI/CD)已成为现代开发团队的标配。本文将详细介绍如何利用AWS EC2或阿里云ECS结合GitHub Actions构建高效的CI/CD流水线,从零开始实现自动化部署的全过程。 最近挖到一个宝藏级人工智能学习网…...
golang 如何定义一种能够与自身类型值进行比较的Interface
定义一种具有比较能力的类型是一种常见需求,比如对一组相同类型的值进行排序,就需要进行两两比较,那么在Go语言中有没有办法定义一种具有比较能力的Interface,实现该接口的类型都具备比较能力呢,最常见最容易的办法是定…...

Web前端之原生表格动态复杂合并行、Vue
MENU 效果公共数据纯原生StyleJavaScript vue原生table 效果 原生的JavaScript原生table null 公共数据 const list [{id: "a1",title: "第一列",list: [{id: "a11",parentId: "a1",title: "第二列",list: [{ id: "…...

『uniapp』把接口的内容下载为txt本地保存 / 读取本地保存的txt文件内容(详细图文注释)
目录 预览效果思路分析downloadTxt 方法readTxt 方法 完整代码总结 欢迎关注 『uniapp』 专栏,持续更新中 欢迎关注 『uniapp』 专栏,持续更新中 预览效果 思路分析 downloadTxt 方法 该方法主要完成两个任务: 下载 txt 文件:通…...
C/C++ 面试复习笔记(2)
C语言如何实现快速排序算法? 答案:快排是一种分治算法,选择一个基准元素,将数据划分成两部分,然后递归排序 补充: void quick_sort(int arr[], int start, int end) {//判断是否需要排序if (start > …...
宝马集团推进数字化转型:强化生产物流与财务流程,全面引入SAP现代架构
2025年6月,宝马集团宣布在生产物流与财务流程领域取得重大数字化成果。这些进展标志着集团全球范围内采用基于云的新型SAP架构进入关键阶段,旨在提升运营效率、透明度和AI能力,为未来工业发展奠定技术基础。 一、生产物流全球数字化部署 宝…...

【Redis技术进阶之路】「原理分析系列开篇」分析客户端和服务端网络诵信交互实现(服务端执行命令请求的过程 - 时间事件处理部分)
揭秘高效存储模型与数据结构底层实现 【专栏简介】【技术大纲】【专栏目标】【目标人群】1. Redis爱好者与社区成员2. 后端开发和系统架构师3. 计算机专业的本科生及研究生 时间事件:serverCron函数更新服务器时间缓存更新LRU时钟-lruclock更新服务器每秒执行命令次…...

【DAY40】训练和测试的规范写法
内容来自浙大疏锦行python打卡训练营 浙大疏锦行 知识点: 彩色和灰度图片测试和训练的规范写法:封装在函数中展平操作:除第一个维度batchsize外全部展平dropout操作:训练阶段随机丢弃神经元,测试阶段eval模式关闭drop…...
C语言 标准I/O函数全面指南
C标准I/O函数全面指南 本指南详细介绍了C语言中用于文件操作的标准输入/输出函数,包括单字符I/O、字符串I/O、格式化I/O、块I/O以及文件光标操作。每个部分包含函数定义、使用说明和实用示例,适合学习、复习以及博客发布。内容采用清晰的Markdown格式&a…...