渲染LLM流式输出的最佳解决方案:HTML+JS+SSE实现Markdown与LaTeX完美渲染
本文探讨使用HTML+JS+SSE技术实现LLM流式输出的最佳方案,重点解决Markdown和LaTeX的实时渲染问题。通过对比SSE与WebSocket等技术的优劣,指出SSE因其简单性、自动重连机制和HTTP基础成为LLM场景的理想选择。文章详细介绍了包括前后端组件划分、数据流设计在内的系统架构,以及实现Markdown解析和LaTeX公式渲染的具体方法。该方案显著改善了用户体验,降低了感知延
渲染LLM流式输出的最佳解决方案:HTML+JS+SSE实现Markdown与LaTeX完美渲染
引言
在当今人工智能飞速发展的时代,大型语言模型(LLM)如GPT系列、LLaMA、Claude等已成为各种应用的核心组件。然而,这些模型生成内容时往往需要较长的处理时间,特别是在生成长篇文本或复杂推理时。传统的同步请求-响应模式会导致用户长时间等待,体验较差。
流式输出技术通过逐步传输和显示生成内容,显著改善了用户体验。本文将深入探讨如何使用HTML、JavaScript和Server-Sent Events(SSE)技术实现LLM流式输出的最佳解决方案,特别关注Markdown和LaTeX数学公式的实时渲染问题。
目录
技术背景与核心概念
流式输出的重要性
流式输出对于LLM应用至关重要,主要原因包括:
- 改善用户体验:用户无需等待完整响应即可看到部分结果
- 降低感知延迟:即使总生成时间相同,逐步显示内容让用户感觉更快
- 实时交互:用户可以中途停止不满意的生成过程
- 内存效率:服务器无需缓存完整响应,可逐步发送数据
技术选型比较
在实现流式输出时,主要有以下几种技术选择:
| 技术方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Server-Sent Events (SSE) | 简单易用、自动重连、HTTP协议 | 单向通信(服务器到客户端) | 实时通知、数据流 |
| WebSocket | 全双工通信、低延迟 | 复杂、需要额外协议 | 实时交互、聊天应用 |
| 长轮询 | 兼容性好、简单 | 高延迟、服务器压力大 | 旧浏览器兼容 |
| 短轮询 | 实现简单 | 实时性差、资源浪费 | 更新频率低的场景 |
对于LLM流式输出,SSE通常是最佳选择,因为:
- 通信模式主要是服务器向客户端推送生成的内容
- 基于HTTP,无需额外协议或端口
- 浏览器原生支持,API简单
- 自动重连机制提高稳定性
核心组件架构
一个完整的LLM流式输出系统包含以下核心组件:
- 前端界面:负责接收和渲染流式内容
- SSE客户端:建立和维护与服务器的连接
- 渲染引擎:实时解析和渲染Markdown与LaTeX
- 后端API:处理LLM请求并流式返回结果
- LLM服务:实际的语言模型推理服务
SSE技术详解
SSE基础知识
Server-Sent Events是一种服务器向客户端推送数据的技术规范,基于HTTP协议。与WebSocket不同,SSE是单向的——服务器可以主动向客户端发送数据,但客户端只能通过常规HTTP请求向服务器发送数据。
SSE核心特性
- 基于HTTP:无需特殊协议或端口
- 自动重连:内置重连机制,连接断开时自动尝试重新连接
- 事件类型:支持不同类型的事件,便于客户端区分处理
- 简单API:浏览器原生支持,使用简单
SSE事件格式
服务器发送的SSE数据需要遵循特定格式:
event: message
data: 这是一条普通消息
event: update
data: {"type": "token", "content": "Hello"}
event: error
data: 错误信息
: 这是注释行
SSE与WebSocket对比分析
为了更清晰地理解SSE的优势,让我们详细比较SSE和WebSocket:
| 特性 | Server-Sent Events (SSE) | WebSocket |
|---|---|---|
| 协议 | HTTP | WS/WSS (独立协议) |
| 通信方向 | 服务器→客户端(单向) | 双向通信 |
| 数据格式 | 文本(可编码二进制) | 文本和二进制 |
| 浏览器支持 | 除IE外主流浏览器支持 | 广泛支持(包括IE10+) |
| 自动重连 | 内置支持 | 需要手动实现 |
| 使用复杂度 | 简单 | 相对复杂 |
| 性能开销 | 低(基于HTTP) | 低(建立连接后) |
| 适用场景 | 实时通知、数据流 | 实时交互、游戏、聊天 |
对于LLM流式输出这种主要是服务器向客户端推送数据的场景,SSE的简单性和自动重连机制使其成为更合适的选择。
SSE浏览器兼容性
尽管SSE在现代浏览器中得到良好支持,但仍需了解具体的兼容性情况:
| 浏览器 | 版本支持 | 备注 |
|---|---|---|
| Chrome | 6+ | 完全支持 |
| Firefox | 6+ | 完全支持 |
| Safari | 5+ | 完全支持 |
| Edge | 79+ | 完全支持 |
| Opera | 12+ | 完全支持 |
| Internet Explorer | 不支持 | 需要使用polyfill |
对于不支持的浏览器(主要是IE),可以使用以下polyfill库:
系统架构设计
整体架构图
┌─────────────────┐ SSE流 ┌──────────────────┐ HTTP请求 ┌──────────────┐
│ 前端客户端 │ ◄────────── │ 后端API服务器 │ ◄───────────── │ LLM服务 │
│ │ │ │ │ │
│ • HTML页面 │ │ • 请求路由 │ │ • 模型推理 │
│ • JavaScript │ │ • SSE连接管理 │ │ • 流式输出 │
│ • 渲染引擎 │ │ • 协议转换 │ │ • Token生成 │
│ • Markdown解析 │ │ • 缓存管理 │ └──────────────┘
│ • LaTeX渲染 │ └──────────────────┘
└─────────────────┘
组件职责划分
前端组件
- SSE连接管理器:负责建立、维护和关闭SSE连接
- 数据处理器:解析接收到的SSE数据,提取有效内容
- Markdown渲染器:实时将Markdown转换为HTML
- LaTeX处理器:检测和渲染数学公式
- UI更新器:将渲染后的内容更新到页面
- 错误处理器:处理连接错误和渲染错误
后端组件
- SSE端点:处理客户端的SSE连接请求
- LLM请求代理:向LLM服务发送请求并处理流式响应
- 数据格式转换器:将LLM输出转换为标准SSE格式
- 连接管理器:管理多个SSE连接的生命周期
- 缓存层:可选,缓存常用响应提高性能
数据流设计
系统数据流遵循以下步骤:
- 用户在前端输入提示词并提交
- 前端建立SSE连接到后端
- 后端接收请求并向LLM服务发送请求
- LLM服务开始流式返回生成的tokens
- 后端将每个token通过SSE发送到前端
- 前端逐步接收并渲染内容
- 当生成完成或用户停止时,关闭连接
前端实现方案
基础HTML结构
首先,我们需要设计一个合适的前端界面来展示流式输出的内容:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM流式输出渲染器</title>
<link rel="stylesheet" href="styles.css">
<!-- Markdown渲染样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.1.0/github-markdown.min.css">
<!-- MathJax for LaTeX渲染 -->
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>LLM流式输出演示</h1>
</header>
<main>
<section class="input-section">
<textarea id="prompt-input" placeholder="请输入您的提示词..." rows="4"></textarea>
<div class="controls">
<button id="submit-btn">发送</button>
<button id="stop-btn" disabled>停止</button>
<label>
<input type="checkbox" id="auto-scroll" checked> 自动滚动
</label>
</div>
</section>
<section class="output-section">
<div class="output-header">
<h2>模型响应</h2>
<div class="status" id="status">就绪</div>
</div>
<div class="output-content markdown-body" id="output-content">
<!-- 流式内容将在这里渲染 -->
</div>
</section>
</main>
<footer>
<p>基于SSE的LLM流式输出渲染系统</p>
</footer>
</div>
<script src="script.js"></script>
</body>
</html>
CSS样式设计
为了提供良好的阅读体验,我们需要精心设计样式:
/* styles.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
background-color: white;
min-height: 100vh;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.input-section {
margin-bottom: 30px;
}
#prompt-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
resize: vertical;
font-family: inherit;
}
.controls {
margin-top: 10px;
display: flex;
gap: 15px;
align-items: center;
}
button {
padding: 8px 16px;
background-color: #007cba;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background-color: #005a87;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.output-section {
border: 1px solid #eee;
border-radius: 4px;
overflow: hidden;
}
.output-header {
padding: 12px 15px;
background-color: #f9f9f9;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.output-content {
padding: 20px;
min-height: 400px;
max-height: 70vh;
overflow-y: auto;
}
.status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
.status.ready {
background-color: #e7f4e4;
color: #2d5016;
}
.status.streaming {
background-color: #e1f5fe;
color: #01579b;
}
.status.error {
background-color: #ffebee;
color: #c62828;
}
/* Markdown内容样式增强 */
.markdown-body {
font-size: 16px;
}
.markdown-body pre {
background-color: #f6f8fa;
border-radius: 6px;
padding: 16px;
overflow: auto;
}
.markdown-body code {
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
padding: 0.2em 0.4em;
font-size: 85%;
}
.markdown-body pre code {
background: none;
padding: 0;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
}
.markdown-body table th,
.markdown-body table td {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
.markdown-body table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
}
/* 数学公式样式 */
.math-block {
text-align: center;
margin: 1em 0;
overflow-x: auto;
}
.math-inline {
display: inline;
}
/* 加载动画 */
.typing-cursor {
display: inline-block;
width: 2px;
height: 1em;
background-color: #333;
margin-left: 2px;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 50% {
opacity: 1;
}
51%, 100% {
opacity: 0;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 10px;
}
.controls {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
JavaScript核心实现
现在,让我们实现JavaScript部分,包括SSE连接管理和基础渲染功能:
// script.js
class StreamRenderer {
constructor() {
this.eventSource = null;
this.isStreaming = false;
this.contentBuffer = '';
this.outputElement = document.getElementById('output-content');
this.promptInput = document.getElementById('prompt-input');
this.submitBtn = document.getElementById('submit-btn');
this.stopBtn = document.getElementById('stop-btn');
this.statusElement = document.getElementById('status');
this.autoScrollCheckbox = document.getElementById('auto-scroll');
this.initializeEventListeners();
this.updateStatus('ready');
}
initializeEventListeners() {
this.submitBtn.addEventListener('click', () => this.startStream());
this.stopBtn.addEventListener('click', () => this.stopStream());
// 支持Enter键提交(Ctrl+Enter或Cmd+Enter)
this.promptInput.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
this.startStream();
}
});
// 自动滚动处理
this.outputElement.addEventListener('scroll', () => {
// 如果用户手动滚动,暂停自动滚动
const isAtBottom = this.isElementAtBottom(this.outputElement);
if (!isAtBottom) {
this.autoScrollCheckbox.checked = false;
}
});
}
async startStream() {
const prompt = this.promptInput.value.trim();
if (!prompt) {
alert('请输入提示词');
return;
}
if (this.isStreaming) {
alert('已有请求在处理中,请等待完成或停止当前请求');
return;
}
this.isStreaming = true;
this.contentBuffer = '';
this.outputElement.innerHTML = '<div class="typing-cursor"></div>';
this.updateUIForStreaming(true);
this.updateStatus('streaming', '生成中...');
try {
// 建立SSE连接
this.eventSource = this.createEventSource(prompt);
} catch (error) {
console.error('SSE连接失败:', error);
this.handleError('连接失败: ' + error.message);
}
}
createEventSource(prompt) {
// 构建查询参数
const params = new URLSearchParams({
prompt: prompt,
stream: true
});
const eventSource = new EventSource(`/api/stream?${params}`);
eventSource.onopen = () => {
console.log('SSE连接已建立');
};
eventSource.onmessage = (event) => {
this.handleMessage(event.data);
};
eventSource.addEventListener('token', (event) => {
this.handleToken(JSON.parse(event.data));
});
eventSource.addEventListener('error', (event) => {
console.error('SSE错误:', event);
this.handleError('连接错误');
});
eventSource.addEventListener('end', (event) => {
this.handleStreamEnd();
});
return eventSource;
}
handleMessage(data) {
// 处理普通消息
this.appendContent(data);
}
handleToken(tokenData) {
// 处理token数据
if (tokenData.content) {
this.appendContent(tokenData.content);
}
}
appendContent(content) {
this.contentBuffer += content;
// 更新显示内容
this.renderContent();
// 自动滚动到底部
if (this.autoScrollCheckbox.checked) {
this.scrollToBottom();
}
}
renderContent() {
// 基础渲染 - 后续将增强为Markdown和LaTeX渲染
this.outputElement.innerHTML = this.escapeHtml(this.contentBuffer) +
'<div class="typing-cursor"></div>';
}
handleStreamEnd() {
console.log('流式传输结束');
this.cleanup();
this.updateStatus('ready', '生成完成');
// 移除光标
const cursor = this.outputElement.querySelector('.typing-cursor');
if (cursor) {
cursor.remove();
}
}
stopStream() {
if (this.eventSource) {
this.eventSource.close();
}
this.cleanup();
this.updateStatus('ready', '已停止');
}
cleanup() {
this.isStreaming = false;
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.updateUIForStreaming(false);
}
handleError(message) {
console.error('错误:', message);
this.cleanup();
this.updateStatus('error', message);
// 显示错误信息
this.outputElement.innerHTML =
`<div class="error-message">错误: ${this.escapeHtml(message)}</div>`;
}
updateUIForStreaming(streaming) {
this.submitBtn.disabled = streaming;
this.stopBtn.disabled = !streaming;
this.promptInput.disabled = streaming;
}
updateStatus(status, message = '') {
this.statusElement.textContent = message ||
(status === 'ready' ? '就绪' :
status === 'streaming' ? '生成中...' :
status === 'error' ? '错误' : '');
this.statusElement.className = `status ${status}`;
}
scrollToBottom() {
this.outputElement.scrollTop = this.outputElement.scrollHeight;
}
isElementAtBottom(element) {
const tolerance = 10; // 容差像素
return element.scrollHeight - element.scrollTop - element.clientHeight <= tolerance;
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
window.streamRenderer = new StreamRenderer();
});
Markdown实时渲染
Markdown解析库选择
在实现Markdown实时渲染时,我们需要选择合适的JavaScript库。以下是几个流行选项的比较:
| 库名称 | 大小 | 性能 | 特性 | 扩展性 |
|---|---|---|---|---|
| Marked | ~24KB | 非常高 | 基础Markdown、GFM | 中等 |
| Remark | ~100KB+ | 高 | 插件生态丰富、AST操作 | 非常高 |
| Markdown-it | ~90KB | 高 | 通用、可配置、插件丰富 | 高 |
| Showdown | ~30KB | 高 | 兼容性强、配置灵活 | 中等 |
对于实时流式渲染场景,Markdown-it通常是最佳选择,因为:
- 性能优秀,适合频繁的增量渲染
- 配置灵活,支持各种扩展
- 插件生态丰富,易于添加自定义功能
- 支持CommonMark标准
集成Markdown-it
让我们在前端代码中集成Markdown-it:
// markdown-renderer.js
class MarkdownRenderer {
constructor() {
// 初始化Markdown-it
this.md = window.markdownit({
html: true, // 允许HTML标签
breaks: true, // 将换行符转换为<br>
linkify: true, // 自动链接URL
typographer: true, // 启用语言中性排版替换
highlight: function (str, lang) {
// 代码高亮函数 - 后续可以集成highlight.js
if (lang && hljs.getLanguage(lang)) {
try {
return '<pre class="hljs"><code>' +
hljs.highlight(str, { language: lang, ignoreIllegals: true }).value +
'</code></pre>';
} catch (__) {}
}
return '<pre class="hljs"><code>' + this.md.utils.escapeHtml(str) + '</code></pre>';
}
});
// 添加插件
this.md.use(this.incrementalRenderPlugin());
// 状态管理
this.lastRenderedContent = '';
this.partialElements = new Map(); // 存储部分渲染的元素
}
// 自定义插件,优化增量渲染
incrementalRenderPlugin() {
return (md) => {
const defaultRender = md.renderer.rules.text || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.text = (tokens, idx, options, env, self) => {
const token = tokens[idx];
// 标记文本token,便于增量更新
token.attrSet('data-incremental', 'true');
return defaultRender(tokens, idx, options, env, self);
};
};
}
// 渲染Markdown内容
render(content, incremental = true) {
if (!incremental || this.lastRenderedContent === '') {
// 完整渲染
this.lastRenderedContent = content;
return this.md.render(content);
}
// 增量渲染 - 只渲染新增部分
return this.incrementalRender(content);
}
// 增量渲染实现
incrementalRender(newContent) {
// 简单的增量渲染策略:找到差异点,只重新渲染受影响的部分
const oldContent = this.lastRenderedContent;
// 如果内容缩短了(比如用户删除了内容),需要完全重新渲染
if (newContent.length < oldContent.length) {
this.lastRenderedContent = newContent;
return this.md.render(newContent);
}
// 查找新增内容的起始位置
let diffStart = 0;
for (let i = 0; i < Math.min(oldContent.length, newContent.length); i++) {
if (oldContent[i] !== newContent[i]) {
diffStart = i;
break;
}
}
// 如果只是末尾添加内容,可以优化渲染
if (diffStart >= oldContent.length - 10) { // 容差范围
// 只渲染新增部分
const addedContent = newContent.substring(oldContent.length);
const partialHtml = this.md.render(addedContent);
this.lastRenderedContent = newContent;
return this.getExistingHtml() + partialHtml;
} else {
// 差异较大,完全重新渲染
this.lastRenderedContent = newContent;
return this.md.render(newContent);
}
}
getExistingHtml() {
// 获取已渲染的HTML(不包含最后未闭合的标签)
// 这是一个简化的实现,实际应用中需要更复杂的逻辑
const tempDiv = document.createElement('div');
tempDiv.innerHTML = this.md.render(this.lastRenderedContent);
// 移除可能未闭合的元素
const incompleteElements = tempDiv.querySelectorAll('[data-incomplete]');
incompleteElements.forEach(el => el.remove());
return tempDiv.innerHTML;
}
// 重置渲染状态
reset() {
this.lastRenderedContent = '';
this.partialElements.clear();
}
}
更新StreamRenderer以支持Markdown
现在我们需要更新之前的StreamRenderer类来使用Markdown渲染器:
// 更新script.js中的StreamRenderer类
class StreamRenderer {
constructor() {
// 现有代码...
// 初始化Markdown渲染器
this.markdownRenderer = new MarkdownRenderer();
// 加载Markdown-it库(如果尚未加载)
this.loadMarkdownIt();
}
async loadMarkdownIt() {
if (typeof window.markdownit === 'undefined') {
// 动态加载Markdown-it
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js';
script.onload = () => {
console.log('Markdown-it loaded');
this.markdownRenderer = new MarkdownRenderer();
};
document.head.appendChild(script);
}
}
// 更新renderContent方法
renderContent() {
try {
// 使用Markdown渲染器
const renderedContent = this.markdownRenderer.render(this.contentBuffer);
this.outputElement.innerHTML = renderedContent +
'<div class="typing-cursor"></div>';
// 渲染后需要处理LaTeX(下一节实现)
this.renderMathIfNeeded();
} catch (error) {
console.error('Markdown渲染错误:', error);
// 降级为纯文本渲染
this.outputElement.innerHTML = this.escapeHtml(this.contentBuffer) +
'<div class="typing-cursor"></div>';
}
}
// 重置时也要重置Markdown渲染器
reset() {
this.contentBuffer = '';
this.markdownRenderer.reset();
this.outputElement.innerHTML = '<div class="typing-cursor"></div>';
}
// 其他现有方法保持不变...
}
LaTeX数学公式处理
LaTeX渲染方案比较
在Web环境中渲染LaTeX数学公式,主要有以下几种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| MathJax | 功能强大、支持广泛、质量高 | 体积大、加载慢、配置复杂 | 科研、出版、高质量渲染 |
| KaTeX | 速度快、体积小、简单易用 | 功能较少、兼容性稍差 | 网页应用、实时渲染 |
| MathLive | 交互式编辑、现代UI | 更专注于编辑而非渲染 | 数学编辑器 |
| 原生浏览器 | 无依赖、性能好 | 支持有限、兼容性差 | 简单公式、实验性功能 |
对于LLM流式输出场景,KaTeX通常是最佳选择,因为:
- 极快的渲染速度,适合实时更新
- 体积小,减少加载时间
- API简单,易于集成
- 对增量渲染友好
集成KaTeX
让我们在前端集成KaTeX来实现数学公式的实时渲染:
// latex-renderer.js
class LaTeXRenderer {
constructor() {
this.isKaTeXLoaded = false;
this.pendingElements = [];
// 检查是否已加载KaTeX
if (typeof katex !== 'undefined') {
this.isKaTeXLoaded = true;
} else {
this.loadKaTeX();
}
// 配置渲染选项
this.renderOptions = {
throwOnError: false, // 不抛出错误,避免中断渲染
errorColor: '#cc0000', // 错误颜色
displayMode: false, // 默认行内模式
output: 'html', // 输出格式
trust: false, // 不信任原始输入(安全)
strict: false // 宽松模式
};
}
loadKaTeX() {
// 动态加载KaTeX CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css';
document.head.appendChild(cssLink);
// 动态加载KaTeX JS
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js';
script.onload = () => {
this.isKaTeXLoaded = true;
console.log('KaTeX loaded');
// 处理等待渲染的元素
this.processPendingElements();
};
document.head.appendChild(script);
}
// 渲染元素中的数学公式
renderElement(element) {
if (!this.isKaTeXLoaded) {
this.pendingElements.push(element);
return;
}
// 查找行内数学公式:$...$
this.renderInlineMath(element);
// 查找块级数学公式:$$...$$
this.renderBlockMath(element);
}
renderInlineMath(element) {
const inlineRegex = /\$([^$]+?)\$/g;
this.renderMathInElement(element, inlineRegex, false);
}
renderBlockMath(element) {
const blockRegex = /\$\$([^$]+?)\$\$/g;
this.renderMathInElement(element, blockRegex, true);
}
renderMathInElement(element, regex, displayMode) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
for (const node of textNodes) {
const text = node.textContent;
let match;
let lastIndex = 0;
const fragments = [];
while ((match = regex.exec(text)) !== null) {
// 添加匹配前的文本
if (match.index > lastIndex) {
fragments.push(document.createTextNode(
text.substring(lastIndex, match.index)
));
}
// 渲染数学公式
const mathContent = match[1];
const mathElement = this.createMathElement(mathContent, displayMode);
fragments.push(mathElement);
lastIndex = regex.lastIndex;
}
// 如果有匹配,替换原节点
if (fragments.length > 0) {
// 添加剩余文本
if (lastIndex < text.length) {
fragments.push(document.createTextNode(text.substring(lastIndex)));
}
// 替换原文本节点
const parent = node.parentNode;
fragments.forEach(fragment => parent.insertBefore(fragment, node));
parent.removeChild(node);
}
}
}
createMathElement(mathContent, displayMode) {
const span = document.createElement('span');
span.className = displayMode ? 'math-block' : 'math-inline';
try {
katex.render(mathContent, span, {
...this.renderOptions,
displayMode: displayMode
});
} catch (error) {
console.error('KaTeX渲染错误:', error, '公式:', mathContent);
span.className += ' math-error';
span.textContent = displayMode ? `$$${mathContent}$$` : `$${mathContent}$`;
span.title = `渲染错误: ${error.message}`;
}
return span;
}
processPendingElements() {
while (this.pendingElements.length > 0) {
const element = this.pendingElements.shift();
this.renderElement(element);
}
}
// 安全地渲染用户提供的数学公式
sanitizeMathContent(content) {
// 移除潜在的恶意内容
return content
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+=/gi, '');
}
}
更新StreamRenderer以支持LaTeX
现在我们需要更新StreamRenderer来使用LaTeX渲染器:
// 继续更新script.js中的StreamRenderer类
class StreamRenderer {
constructor() {
// 现有代码...
// 初始化LaTeX渲染器
this.latexRenderer = new LaTeXRenderer();
}
renderContent() {
try {
// 使用Markdown渲染器
const renderedContent = this.markdownRenderer.render(this.contentBuffer);
// 创建临时容器来更新内容
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderedContent;
// 渲染数学公式
this.latexRenderer.renderElement(tempDiv);
// 更新输出元素
this.outputElement.innerHTML = tempDiv.innerHTML +
'<div class="typing-cursor"></div>';
} catch (error) {
console.error('内容渲染错误:', error);
// 降级为纯文本渲染
this.outputElement.innerHTML = this.escapeHtml(this.contentBuffer) +
'<div class="typing-cursor"></div>';
}
// 自动滚动到底部
if (this.autoScrollCheckbox.checked) {
this.scrollToBottom();
}
}
// 专门处理数学公式渲染(用于内容更新后)
renderMathIfNeeded() {
if (this.latexRenderer.isKaTeXLoaded) {
this.latexRenderer.renderElement(this.outputElement);
}
}
}
性能优化策略
渲染性能优化
在流式输出场景中,渲染性能至关重要。以下是一些关键优化策略:
- 增量渲染:只更新发生变化的部分,而不是重新渲染整个内容
- 防抖处理:避免过于频繁的渲染操作
- 虚拟滚动:对于超长内容,只渲染可见区域
- 懒加载:延迟加载非关键资源
让我们实现这些优化:
// performance-optimizer.js
class PerformanceOptimizer {
constructor(renderer) {
this.renderer = renderer;
this.renderQueue = [];
this.isRendering = false;
this.lastRenderTime = 0;
this.renderInterval = 50; // 最小渲染间隔(ms)
// 性能监控
this.performanceMetrics = {
renderCount: 0,
averageRenderTime: 0,
totalRenderTime: 0
};
}
// 计划渲染(防抖)
scheduleRender() {
// 清除现有定时器
if (this.renderTimer) {
clearTimeout(this.renderTimer);
}
// 设置新的渲染定时器
this.renderTimer = setTimeout(() => {
this.executeRender();
}, this.renderInterval);
}
// 执行渲染
executeRender() {
const startTime = performance.now();
// 检查是否达到最小渲染间隔
const now = Date.now();
if (now - this.lastRenderTime < this.renderInterval && this.isRendering) {
// 如果正在渲染且间隔太短,重新调度
this.scheduleRender();
return;
}
this.isRendering = true;
try {
this.renderer.renderContent();
// 更新性能指标
const renderTime = performance.now() - startTime;
this.updatePerformanceMetrics(renderTime);
} catch (error) {
console.error('渲染错误:', error);
} finally {
this.isRendering = false;
this.lastRenderTime = Date.now();
}
}
// 更新性能指标
updatePerformanceMetrics(renderTime) {
this.performanceMetrics.renderCount++;
this.performanceMetrics.totalRenderTime += renderTime;
this.performanceMetrics.averageRenderTime =
this.performanceMetrics.totalRenderTime / this.performanceMetrics.renderCount;
// 定期记录性能数据(每100次渲染)
if (this.performanceMetrics.renderCount % 100 === 0) {
console.log(`渲染性能 - 平均时间: ${this.performanceMetrics.averageRenderTime.toFixed(2)}ms, ` +
`总次数: ${this.performanceMetrics.renderCount}`);
}
}
// 重置性能指标
resetMetrics() {
this.performanceMetrics = {
renderCount: 0,
averageRenderTime: 0,
totalRenderTime: 0
};
}
// 获取性能报告
getPerformanceReport() {
return {
...this.performanceMetrics,
currentBufferSize: this.renderer.contentBuffer.length,
estimatedMemoryUsage: this.estimateMemoryUsage()
};
}
estimateMemoryUsage() {
// 估算内存使用(简化版)
const contentSize = new Blob([this.renderer.contentBuffer]).size;
const htmlSize = new Blob([this.renderer.outputElement.innerHTML]).size;
return contentSize + htmlSize;
}
}
更新StreamRenderer以集成性能优化
// 继续更新script.js中的StreamRenderer类
class StreamRenderer {
constructor() {
// 现有初始化代码...
// 初始化性能优化器
this.performanceOptimizer = new PerformanceOptimizer(this);
}
appendContent(content) {
this.contentBuffer += content;
// 使用性能优化器调度渲染
this.performanceOptimizer.scheduleRender();
// 自动滚动到底部
if (this.autoScrollCheckbox.checked) {
requestAnimationFrame(() => this.scrollToBottom());
}
}
// 添加性能监控方法
getPerformanceReport() {
return this.performanceOptimizer.getPerformanceReport();
}
// 重置时也要重置性能监控
reset() {
this.contentBuffer = '';
this.markdownRenderer.reset();
this.performanceOptimizer.resetMetrics();
this.outputElement.innerHTML = '<div class="typing-cursor"></div>';
}
}
错误处理与容错机制
全面的错误处理
在流式输出系统中,健壮的错误处理机制至关重要:
// error-handler.js
class ErrorHandler {
constructor(renderer) {
this.renderer = renderer;
this.errorCount = 0;
this.maxErrors = 10;
this.errorTypes = new Map();
}
// 处理SSE连接错误
handleSSEError(error) {
console.error('SSE连接错误:', error);
this.trackError('sse_connection');
// 根据错误类型采取不同策略
if (error.type === 'error' && error.target.readyState === EventSource.CLOSED) {
this.handleConnectionLost();
} else if (error.target.readyState === EventSource.CONNECTING) {
this.handleReconnecting();
}
}
// 处理连接丢失
handleConnectionLost() {
this.renderer.updateStatus('error', '连接丢失,尝试重连...');
// 显示重连按钮
this.showReconnectUI();
// 自动重连(最多3次)
this.attemptReconnect(3);
}
// 尝试重连
attemptReconnect(maxAttempts) {
let attempts = 0;
const tryReconnect = () => {
if (attempts >= maxAttempts) {
this.renderer.updateStatus('error', '重连失败,请手动重试');
return;
}
attempts++;
console.log(`重连尝试 ${attempts}/${maxAttempts}`);
setTimeout(() => {
// 模拟重连逻辑
if (Math.random() > 0.3) { // 70%成功率
this.handleReconnectSuccess();
} else {
tryReconnect();
}
}, 2000 * attempts); // 指数退避
};
tryReconnect();
}
// 处理渲染错误
handleRenderError(error, context) {
console.error('渲染错误:', error, '上下文:', context);
this.trackError('render_error');
// 降级处理
this.fallbackRender();
// 通知用户
this.showErrorMessage('内容渲染出现问题,已启用兼容模式');
}
// 降级渲染
fallbackRender() {
try {
// 尝试纯文本渲染
this.renderer.outputElement.innerHTML =
this.renderer.escapeHtml(this.renderer.contentBuffer) +
'<div class="typing-cursor"></div>';
} catch (fallbackError) {
// 最终降级方案
this.renderer.outputElement.textContent = this.renderer.contentBuffer;
}
}
// 显示错误信息
showErrorMessage(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-notification';
errorDiv.innerHTML = `
<span>⚠️ ${message}</span>
<button onclick="this.parentElement.remove()">×</button>
`;
// 添加到页面顶部
this.renderer.outputElement.parentNode.insertBefore(
errorDiv,
this.renderer.outputElement
);
// 3秒后自动消失
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.remove();
}
}, 3000);
}
// 显示重连UI
showReconnectUI() {
const reconnectDiv = document.createElement('div');
reconnectDiv.className = 'reconnect-ui';
reconnectDiv.innerHTML = `
<div class="reconnect-message">
<p>连接已断开</p>
<button id="manual-reconnect">重新连接</button>
<button id="cancel-reconnect">取消</button>
</div>
`;
document.body.appendChild(reconnectDiv);
// 事件处理
document.getElementById('manual-reconnect').onclick = () => {
this.renderer.startStream();
reconnectDiv.remove();
};
document.getElementById('cancel-reconnect').onclick = () => {
reconnectDiv.remove();
};
}
// 处理重连成功
handleReconnectSuccess() {
this.renderer.updateStatus('streaming', '已重新连接,继续生成...');
this.showErrorMessage('连接已恢复');
}
// 处理重连中
handleReconnecting() {
this.renderer.updateStatus('streaming', '重新连接中...');
}
// 跟踪错误统计
trackError(type) {
this.errorCount++;
this.errorTypes.set(type, (this.errorTypes.get(type) || 0) + 1);
// 如果错误过多,建议刷新页面
if (this.errorCount > this.maxErrors) {
this.suggestRefresh();
}
}
// 建议刷新页面
suggestRefresh() {
const refreshDiv = document.createElement('div');
refreshDiv.className = 'refresh-suggestion';
refreshDiv.innerHTML = `
<div class="refresh-message">
<p>遇到多个问题,建议刷新页面</p>
<button onclick="location.reload()">刷新页面</button>
</div>
`;
document.body.appendChild(refreshDiv);
}
// 获取错误报告
getErrorReport() {
return {
totalErrors: this.errorCount,
errorTypes: Object.fromEntries(this.errorTypes),
timestamp: new Date().toISOString()
};
}
// 重置错误统计
reset() {
this.errorCount = 0;
this.errorTypes.clear();
}
}
集成错误处理到StreamRenderer
// 继续更新script.js中的StreamRenderer类
class StreamRenderer {
constructor() {
// 现有初始化代码...
// 初始化错误处理器
this.errorHandler = new ErrorHandler(this);
}
createEventSource(prompt) {
const params = new URLSearchParams({
prompt: prompt,
stream: true
});
const eventSource = new EventSource(`/api/stream?${params}`);
eventSource.onopen = () => {
console.log('SSE连接已建立');
this.errorHandler.reset(); // 重置错误统计
};
eventSource.onmessage = (event) => {
this.handleMessage(event.data);
};
eventSource.addEventListener('token', (event) => {
this.handleToken(JSON.parse(event.data));
});
eventSource.addEventListener('error', (event) => {
this.errorHandler.handleSSEError(event);
});
eventSource.addEventListener('end', (event) => {
this.handleStreamEnd();
});
return eventSource;
}
renderContent() {
try {
const renderedContent = this.markdownRenderer.render(this.contentBuffer);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = renderedContent;
this.latexRenderer.renderElement(tempDiv);
this.outputElement.innerHTML = tempDiv.innerHTML +
'<div class="typing-cursor"></div>';
} catch (error) {
this.errorHandler.handleRenderError(error, {
contentLength: this.contentBuffer.length,
bufferPreview: this.contentBuffer.substring(0, 100)
});
}
if (this.autoScrollCheckbox.checked) {
requestAnimationFrame(() => this.scrollToBottom());
}
}
// 添加错误报告方法
getErrorReport() {
return this.errorHandler.getErrorReport();
}
}
安全考虑
内容安全策略
在渲染用户生成的内容时,安全是首要考虑因素:
// security-handler.js
class SecurityHandler {
constructor() {
this.allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:'];
this.allowedAttributes = {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'code': ['class'],
'pre': ['class'],
'span': ['class', 'style'],
'div': ['class', 'style']
};
}
// 清理HTML内容
sanitizeHTML(html) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
this.sanitizeNode(tempDiv);
return tempDiv.innerHTML;
}
// 递归清理节点
sanitizeNode(node) {
// 处理元素节点
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
// 移除不允许的标签
if (!this.isTagAllowed(tagName)) {
node.parentNode.removeChild(node);
return;
}
// 清理属性
this.sanitizeAttributes(node, tagName);
// 处理特殊标签
if (tagName === 'a') {
this.sanitizeLink(node);
} else if (tagName === 'img') {
this.sanitizeImage(node);
} else if (tagName === 'script' || tagName === 'iframe') {
// 绝对不允许的标签
node.parentNode.removeChild(node);
return;
}
}
// 递归处理子节点
let child = node.firstChild;
while (child) {
const nextChild = child.nextSibling;
this.sanitizeNode(child);
child = nextChild;
}
}
// 检查标签是否允许
isTagAllowed(tagName) {
const allowedTags = Object.keys(this.allowedAttributes);
return allowedTags.includes(tagName) ||
tagName.startsWith('h') && tagName.length === 2; // h1-h6
}
// 清理属性
sanitizeAttributes(node, tagName) {
const allowedAttrs = this.allowedAttributes[tagName] || [];
const attributes = Array.from(node.attributes);
for (const attr of attributes) {
if (!allowedAttrs.includes(attr.name)) {
node.removeAttribute(attr.name);
continue;
}
// 特殊属性处理
if (attr.name === 'style') {
this.sanitizeStyle(attr.value, node);
} else if (attr.name === 'class') {
this.sanitizeClass(attr.value, node);
}
}
}
// 清理链接
sanitizeLink(link) {
const href = link.getAttribute('href');
if (!href) return;
try {
const url = new URL(href, window.location.origin);
// 检查协议
if (!this.allowedProtocols.includes(url.protocol)) {
link.parentNode.removeChild(link);
return;
}
// 添加安全属性
link.setAttribute('rel', 'noopener noreferrer');
link.setAttribute('target', '_blank');
} catch (error) {
// 无效URL,移除链接
const parent = link.parentNode;
while (link.firstChild) {
parent.insertBefore(link.firstChild, link);
}
parent.removeChild(link);
}
}
// 清理图片
sanitizeImage(img) {
const src = img.getAttribute('src');
if (!src) return;
try {
const url = new URL(src, window.location.origin);
// 只允许HTTP/HTTPS协议
if (!['http:', 'https:'].includes(url.protocol)) {
img.parentNode.removeChild(img);
return;
}
// 添加加载限制
img.setAttribute('loading', 'lazy');
} catch (error) {
// 无效URL,移除图片
img.parentNode.removeChild(img);
}
}
// 清理样式
sanitizeStyle(style, element) {
// 简单的样式清理 - 实际应用中可能需要更复杂的处理
const allowedProperties = [
'color', 'background-color', 'font-weight', 'font-style',
'text-decoration', 'text-align', 'margin', 'padding'
];
const cleanStyle = style.split(';')
.filter(rule => {
const [property] = rule.split(':');
return allowedProperties.some(allowed =>
property.trim().toLowerCase().startsWith(allowed)
);
})
.join(';');
element.setAttribute('style', cleanStyle);
}
// 清理类名
sanitizeClass(className, element) {
// 只允许特定的类名
const allowedClasses = [
'language-', 'hljs', 'math-inline', 'math-block',
'math-error', 'typing-cursor'
];
const cleanClasses = className.split(' ')
.filter(cls => allowedClasses.some(allowed => cls.startsWith(allowed)))
.join(' ');
element.setAttribute('class', cleanClasses);
}
// 清理Markdown内容
sanitizeMarkdown(markdown) {
// 移除潜在的恶意模式
return markdown
.replace(/```.*?```/gs, (match) => {
// 保持代码块完整,但清理其中的HTML
return match.replace(/<script/gi, '<script')
.replace(/<\/script/gi, '</script');
})
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+=/gi, 'data-removed=');
}
// 清理LaTeX内容
sanitizeLaTeX(latex) {
// 移除危险的LaTeX命令
const dangerousCommands = [
'\\input', '\\include', '\\write', '\\open', '\\immediate',
'\\catcode', '\\begingroup', '\\def', '\\let'
];
let sanitized = latex;
dangerousCommands.forEach(cmd => {
const regex = new RegExp(cmd.replace('\\', '\\\\') + '\\b', 'gi');
sanitized = sanitized.replace(regex, '\\removed');
});
return sanitized;
}
}
集成安全处理
// 继续更新script.js中的StreamRenderer类
class StreamRenderer {
constructor() {
// 现有初始化代码...
// 初始化安全处理器
this.securityHandler = new SecurityHandler();
}
appendContent(content) {
// 安全清理内容
const safeContent = this.securityHandler.sanitizeMarkdown(content);
this.contentBuffer += safeContent;
this.performanceOptimizer.scheduleRender();
if (this.autoScrollCheckbox.checked) {
requestAnimationFrame(() => this.scrollToBottom());
}
}
renderContent() {
try {
// 安全渲染
const safeBuffer = this.securityHandler.sanitizeMarkdown(this.contentBuffer);
const renderedContent = this.markdownRenderer.render(safeBuffer);
const safeHTML = this.securityHandler.sanitizeHTML(renderedContent);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = safeHTML;
// 安全地渲染数学公式
this.safelyRenderMath(tempDiv);
this.outputElement.innerHTML = tempDiv.innerHTML +
'<div class="typing-cursor"></div>';
} catch (error) {
this.errorHandler.handleRenderError(error, {
contentLength: this.contentBuffer.length
});
}
if (this.autoScrollCheckbox.checked) {
requestAnimationFrame(() => this.scrollToBottom());
}
}
safelyRenderMath(element) {
try {
// 安全处理数学公式内容
const mathElements = element.querySelectorAll('.math-inline, .math-block');
mathElements.forEach(mathEl => {
const originalContent = mathEl.textContent;
const safeContent = this.securityHandler.sanitizeLaTeX(originalContent);
if (originalContent !== safeContent) {
mathEl.textContent = safeContent;
mathEl.classList.add('math-sanitized');
}
});
this.latexRenderer.renderElement(element);
} catch (error) {
console.warn('数学公式渲染安全警告:', error);
}
}
}
实际应用案例
完整示例集成
现在让我们将所有组件集成到一个完整的示例中:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM流式输出渲染系统</title>
<style>
/* 包含之前所有的CSS样式 */
/* 为了简洁,这里省略具体样式,实际使用时需要包含 */
</style>
<!-- Markdown渲染样式 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.1.0/github-markdown.min.css">
<!-- KaTeX CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.css">
</head>
<body>
<div class="container">
<header>
<h1>LLM流式输出渲染系统</h1>
<p>基于SSE + Markdown + LaTeX的完整解决方案</p>
</header>
<main>
<section class="input-section">
<textarea id="prompt-input" placeholder="请输入您的提示词..." rows="4">
请解释以下数学公式:$$E = mc^2$$,并说明其在相对论中的意义。同时用Markdown格式整理你的回答。
</textarea>
<div class="controls">
<button id="submit-btn">发送请求</button>
<button id="stop-btn" disabled>停止生成</button>
<label>
<input type="checkbox" id="auto-scroll" checked> 自动滚动
</label>
<button id="clear-btn">清空内容</button>
</div>
</section>
<section class="output-section">
<div class="output-header">
<h2>模型响应</h2>
<div class="status" id="status">就绪</div>
</div>
<div class="output-content markdown-body" id="output-content">
<!-- 流式内容将在这里渲染 -->
</div>
</section>
<section class="debug-section">
<details>
<summary>调试信息</summary>
<div id="debug-info"></div>
<button id="refresh-debug">刷新调试信息</button>
</details>
</section>
</main>
<footer>
<p>基于SSE的LLM流式输出渲染系统 © 2024</p>
</footer>
</div>
<!-- 加载必要的JavaScript库 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"></script>
<!-- 加载我们的模块 -->
<script src="markdown-renderer.js"></script>
<script src="latex-renderer.js"></script>
<script src="performance-optimizer.js"></script>
<script src="error-handler.js"></script>
<script src="security-handler.js"></script>
<script src="script.js"></script>
<script>
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
const renderer = new StreamRenderer();
window.app = renderer;
// 调试信息
document.getElementById('refresh-debug').addEventListener('click', () => {
const debugInfo = document.getElementById('debug-info');
const performanceReport = renderer.getPerformanceReport();
const errorReport = renderer.getErrorReport();
debugInfo.innerHTML = `
<h3>性能报告</h3>
<pre>${JSON.stringify(performanceReport, null, 2)}</pre>
<h3>错误报告</h3>
<pre>${JSON.stringify(errorReport, null, 2)}</pre>
`;
});
// 清空内容按钮
document.getElementById('clear-btn').addEventListener('click', () => {
renderer.reset();
});
});
</script>
</body>
</html>
服务器端示例
为了完整起见,这里提供一个简单的Node.js服务器示例:
// server.js
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
// 中间件
app.use(cors());
app.use(express.json());
app.use(express.static('public')); // 静态文件服务
// 模拟LLM流式响应
function* simulateLLMStream(prompt) {
const responses = [
"让我来解释质能方程 \\(E = mc^2\\)。",
"\n\n",
"## 质能方程 \\(E = mc^2\\) 的解释\n\n",
"这个著名的公式由阿尔伯特·爱因斯坦在1905年提出,是狭义相对论的核心内容。\n\n",
"### 公式含义\n\n",
"- **E** 代表能量(Energy)\n",
"- **m** 代表质量(Mass)\n",
"- **c** 代表光速(Speed of light),约 \\(3 \\times 10^8 m/s\\)\n\n",
"### 物理意义\n\n",
"这个公式揭示了质量和能量之间的等价关系:\n\n",
"\\[\\Delta E = \\Delta m \\cdot c^2\\]\n\n",
"其中:\n",
"- \\(\\Delta E\\) 是能量变化\n",
"- \\(\\Delta m\\) 是质量变化\n\n",
"### 实际应用\n\n",
"1. **核能产生**:核反应中质量亏损转化为巨大能量\n",
"2. **粒子物理**:解释基本粒子的质量和能量关系\n",
"3. **宇宙学**:理解恒星能量来源和宇宙演化\n\n",
"这个简单的公式改变了我们对宇宙的基本理解。"
];
for (const chunk of responses) {
yield chunk;
}
}
// SSE流式端点
app.get('/api/stream', (req, res) => {
const { prompt } = req.query;
if (!prompt) {
return res.status(400).json({ error: 'Missing prompt' });
}
// 设置SSE头部
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
// 模拟流式响应
const stream = simulateLLMStream(prompt);
let tokenCount = 0;
const sendInterval = setInterval(() => {
const next = stream.next();
if (next.done) {
// 发送结束事件
res.write('event: end\n');
res.write('data: Stream completed\n\n');
clearInterval(sendInterval);
res.end();
return;
}
const chunk = next.value;
tokenCount++;
// 发送token事件
res.write('event: token\n');
res.write(`data: ${JSON.stringify({
token: tokenCount,
content: chunk
})}\n\n`);
}, 100); // 每100ms发送一个chunk
// 客户端断开连接时清理
req.on('close', () => {
clearInterval(sendInterval);
res.end();
});
});
// 健康检查端点
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`SSE endpoint: http://localhost:${PORT}/api/stream`);
});
总结
本文详细介绍了使用HTML、JavaScript和SSE技术实现LLM流式输出的完整解决方案。我们涵盖了从基础架构到高级功能的各个方面,包括:
- SSE技术的优势和应用:相比WebSocket,SSE更适合LLM流式输出场景
- Markdown实时渲染:使用Markdown-it库实现高效的增量渲染
- LaTeX数学公式处理:集成KaTeX实现高质量的数学公式渲染
- 性能优化策略:包括增量渲染、防抖处理和性能监控
- 错误处理与容错:全面的错误处理机制和降级方案
- 安全考虑:内容安全策略和恶意输入防护
关键优势
- 用户体验优秀:流式输出显著降低感知延迟
- 渲染质量高:支持丰富的Markdown格式和数学公式
- 性能出色:优化后的渲染系统支持长时间流式会话
- 健壮可靠:完善的错误处理和自动恢复机制
- 安全可控:多层安全防护确保内容安全
扩展可能性
这个基础框架可以进一步扩展:
- 多模型支持:同时连接多个LLM服务
- 对话历史:支持多轮对话和上下文管理
- 自定义主题:可切换的渲染主题和样式
- 导出功能:支持将内容导出为PDF、Markdown等格式
- 协作功能:多用户实时协作编辑
通过本文提供的完整解决方案,开发者可以快速构建高质量的LLM流式输出应用,为用户提供流畅、丰富的交互体验。
参考资料
- MDN Web Docs: Server-Sent Events
- Markdown-it Documentation
- KaTeX Documentation
- HTML5 Rocks: Stream Updates with Server-Sent Events
附录
完整文件结构
llm-stream-renderer/
├── index.html # 主页面
├── styles.css # 样式文件
├── script.js # 主JavaScript文件
├── markdown-renderer.js # Markdown渲染器
├── latex-renderer.js # LaTeX渲染器
├── performance-optimizer.js # 性能优化器
├── error-handler.js # 错误处理器
├── security-handler.js # 安全处理器
├── server.js # 示例服务器
└── package.json # 项目配置
部署说明
- 安装依赖:
npm install express cors - 启动服务器:
node server.js - 访问应用:
http://localhost:3000
这个解决方案为LLM流式输出提供了完整的技术栈,既可以作为独立应用部署,也可以作为更大系统的组件集成。
更多推荐
所有评论(0)