渲染LLM流式输出的最佳解决方案:HTML+JS+SSE实现Markdown与LaTeX完美渲染

引言

在当今人工智能飞速发展的时代,大型语言模型(LLM)如GPT系列、LLaMA、Claude等已成为各种应用的核心组件。然而,这些模型生成内容时往往需要较长的处理时间,特别是在生成长篇文本或复杂推理时。传统的同步请求-响应模式会导致用户长时间等待,体验较差。

流式输出技术通过逐步传输和显示生成内容,显著改善了用户体验。本文将深入探讨如何使用HTML、JavaScript和Server-Sent Events(SSE)技术实现LLM流式输出的最佳解决方案,特别关注Markdown和LaTeX数学公式的实时渲染问题。

目录

  1. 技术背景与核心概念
  2. SSE技术详解
  3. 系统架构设计
  4. 前端实现方案
  5. Markdown实时渲染
  6. LaTeX数学公式处理
  7. 性能优化策略
  8. 错误处理与容错机制
  9. 安全考虑
  10. 实际应用案例

技术背景与核心概念

流式输出的重要性

流式输出对于LLM应用至关重要,主要原因包括:

  • 改善用户体验:用户无需等待完整响应即可看到部分结果
  • 降低感知延迟:即使总生成时间相同,逐步显示内容让用户感觉更快
  • 实时交互:用户可以中途停止不满意的生成过程
  • 内存效率:服务器无需缓存完整响应,可逐步发送数据

技术选型比较

在实现流式输出时,主要有以下几种技术选择:

技术方案 优点 缺点 适用场景
Server-Sent Events (SSE) 简单易用、自动重连、HTTP协议 单向通信(服务器到客户端) 实时通知、数据流
WebSocket 全双工通信、低延迟 复杂、需要额外协议 实时交互、聊天应用
长轮询 兼容性好、简单 高延迟、服务器压力大 旧浏览器兼容
短轮询 实现简单 实时性差、资源浪费 更新频率低的场景

对于LLM流式输出,SSE通常是最佳选择,因为:

  • 通信模式主要是服务器向客户端推送生成的内容
  • 基于HTTP,无需额外协议或端口
  • 浏览器原生支持,API简单
  • 自动重连机制提高稳定性

核心组件架构

一个完整的LLM流式输出系统包含以下核心组件:

  1. 前端界面:负责接收和渲染流式内容
  2. SSE客户端:建立和维护与服务器的连接
  3. 渲染引擎:实时解析和渲染Markdown与LaTeX
  4. 后端API:处理LLM请求并流式返回结果
  5. 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渲染     │             └──────────────────┘
└─────────────────┘

组件职责划分

前端组件
  1. SSE连接管理器:负责建立、维护和关闭SSE连接
  2. 数据处理器:解析接收到的SSE数据,提取有效内容
  3. Markdown渲染器:实时将Markdown转换为HTML
  4. LaTeX处理器:检测和渲染数学公式
  5. UI更新器:将渲染后的内容更新到页面
  6. 错误处理器:处理连接错误和渲染错误
后端组件
  1. SSE端点:处理客户端的SSE连接请求
  2. LLM请求代理:向LLM服务发送请求并处理流式响应
  3. 数据格式转换器:将LLM输出转换为标准SSE格式
  4. 连接管理器:管理多个SSE连接的生命周期
  5. 缓存层:可选,缓存常用响应提高性能

数据流设计

系统数据流遵循以下步骤:

  1. 用户在前端输入提示词并提交
  2. 前端建立SSE连接到后端
  3. 后端接收请求并向LLM服务发送请求
  4. LLM服务开始流式返回生成的tokens
  5. 后端将每个token通过SSE发送到前端
  6. 前端逐步接收并渲染内容
  7. 当生成完成或用户停止时,关闭连接

前端实现方案

基础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, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
    }
}

// 初始化应用
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);
        }
    }
}

性能优化策略

渲染性能优化

在流式输出场景中,渲染性能至关重要。以下是一些关键优化策略:

  1. 增量渲染:只更新发生变化的部分,而不是重新渲染整个内容
  2. 防抖处理:避免过于频繁的渲染操作
  3. 虚拟滚动:对于超长内容,只渲染可见区域
  4. 懒加载:延迟加载非关键资源

让我们实现这些优化:

// 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, '&lt;script')
                          .replace(/<\/script/gi, '&lt;/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流式输出渲染系统 &copy; 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流式输出的完整解决方案。我们涵盖了从基础架构到高级功能的各个方面,包括:

  1. SSE技术的优势和应用:相比WebSocket,SSE更适合LLM流式输出场景
  2. Markdown实时渲染:使用Markdown-it库实现高效的增量渲染
  3. LaTeX数学公式处理:集成KaTeX实现高质量的数学公式渲染
  4. 性能优化策略:包括增量渲染、防抖处理和性能监控
  5. 错误处理与容错:全面的错误处理机制和降级方案
  6. 安全考虑:内容安全策略和恶意输入防护

关键优势

  • 用户体验优秀:流式输出显著降低感知延迟
  • 渲染质量高:支持丰富的Markdown格式和数学公式
  • 性能出色:优化后的渲染系统支持长时间流式会话
  • 健壮可靠:完善的错误处理和自动恢复机制
  • 安全可控:多层安全防护确保内容安全

扩展可能性

这个基础框架可以进一步扩展:

  1. 多模型支持:同时连接多个LLM服务
  2. 对话历史:支持多轮对话和上下文管理
  3. 自定义主题:可切换的渲染主题和样式
  4. 导出功能:支持将内容导出为PDF、Markdown等格式
  5. 协作功能:多用户实时协作编辑

通过本文提供的完整解决方案,开发者可以快速构建高质量的LLM流式输出应用,为用户提供流畅、丰富的交互体验。

参考资料

  1. MDN Web Docs: Server-Sent Events
  2. Markdown-it Documentation
  3. KaTeX Documentation
  4. 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            # 项目配置

部署说明

  1. 安装依赖:npm install express cors
  2. 启动服务器:node server.js
  3. 访问应用:http://localhost:3000

这个解决方案为LLM流式输出提供了完整的技术栈,既可以作为独立应用部署,也可以作为更大系统的组件集成。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐