前端 + AI进阶 Day4: 接入真实 AI 流式接口(SSE)
前端 + AI 进阶学习路线|Week 1-2:流式体验优化
Day 4:接入真实 AI 流式接口(SSE)
学习时间:2025年12月28日(星期日)
关键词:SSE、流式 API、fetchEventSource、Ollama、OpenAI、token 流
📁 项目文件结构
day04-ai-streaming/
├── src/
│ ├── components/
│ │ ├── StreamingChat.jsx # 主聊天组件(含流式控制)
│ │ ├── VirtualChatList.jsx # 虚拟列表(支持动态高度)
│ │ └── StreamingMarkdown.jsx # 流式 Markdown 渲染器(复用 Day 2)
│ ├── lib/
│ │ └── aiClient.js # 通用流式 AI 客户端(对接 Ollama)
│ └── App.jsx # 主应用入口
├── package.json # 需添加 proxy 配置
└── public/
🎯 今日学习目标
- 理解服务器发送事件(SSE)在 AI 流式响应中的作用
- 使用
fetchEventSource消费兼容 OpenAI / Ollama 的流式接口 - 将真实 token 数据逐块拼接并渲染到聊天界面
- 处理连接状态(加载中、错误、完成)
💡 为什么需要真实流式接口?
前 3 天我们用 模拟字符串 演示了流式效果,但真实 AI 应用需与后端模型通信。主流本地/云模型均支持 流式响应:
| 模型平台 | 流式协议 | 响应格式 |
|---|---|---|
| Ollama | SSE(Server-Sent Events) | {"model":"llama3","response":"Hello"} |
| OpenAI | SSE | data: {"choices": [{"delta": {"content": "world"}}]} |
| vLLM / LM Studio | SSE | 兼容 OpenAI 格式 |
✅ 目标:编写一个 通用流式客户端,可无缝切换不同后端
📚 核心技术:SSE 与 fetchEventSource
- SSE(Server-Sent Events):基于 HTTP 的单向流(服务器 → 客户端)
- 浏览器原生支持
EventSource,但功能有限(无法传 headers、难重试) - 推荐库:
@microsoft/fetch-event-source
→ 支持自定义 headers、重连、取消、兼容 OpenAI/Ollama
npm install @microsoft/fetch-event-source
⚠️ CORS 解决方案(开发阶段)
在package.json中添加代理:{ "proxy": "http://localhost:11434" }
🔧 动手实践:构建通用流式 AI 客户端
步骤 1:创建项目并安装依赖
npx create-react-app day04-ai-streaming
cd day04-ai-streaming
npm install react-virtual react-markdown remark-gfm rehype-highlight rehype-katex katex @microsoft/fetch-event-source
步骤 2:配置代理(package.json)
在 package.json 中添加(必须):
{
"name": "day04-ai-streaming",
"proxy": "http://localhost:11434",
"dependencies": {
// ... 其他依赖
}
}
步骤 3:创建通用流式客户端
// src/lib/aiClient.js
import { fetchEventSource } from '@microsoft/fetch-event-source';
/**
* 通用流式 AI 请求函数
* @param {Object} options
* @param {string} options.prompt - 用户输入
* @param {string} [options.model='llama3'] - 模型名称
* @param {function} options.onToken - (token: string) => void
* @param {function} options.onComplete - () => void
* @param {function} options.onError - (error: Error) => void
*/
export const streamAIResponse = async ({
prompt,
model = 'llama3',
onToken,
onComplete,
onError,
}) => {
// 构建 Ollama 兼容请求体
const payload = {
model,
prompt,
stream: true,
};
let fullResponse = '';
try {
await fetchEventSource('/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
openWhenHidden: true, // 页面后台时也保持连接
onmessage(event) {
if (event.data === '[DONE]') {
onComplete?.();
return;
}
try {
const data = JSON.parse(event.data);
// Ollama 的 token 在 response 字段
const token = data.response;
if (token) {
fullResponse += token;
onToken?.(token);
}
} catch (e) {
console.warn('解析 SSE 消息失败:', event.data);
}
},
onerror(error) {
console.error('SSE 连接错误:', error);
onError?.(error);
throw error; // 触发终止
},
});
} catch (error) {
onError?.(error);
}
};
步骤 4:复用并集成组件
复用文件(可直接从 Day 2/3 复制):
src/components/StreamingMarkdown.jsxsrc/components/VirtualChatList.jsx(需支持动态高度)
主聊天组件(src/components/StreamingChat.jsx)
// src/components/StreamingChat.jsx
import { useState, useRef, useEffect } from 'react';
import VirtualChatList from './VirtualChatList';
import { streamAIResponse } from '../lib/aiClient';
const StreamingChat = () => {
const [messages, setMessages] = useState([
{ id: 0, role: 'assistant', content: '你好!我是基于本地 Llama 3 的 AI 助手。你可以问我任何问题。' }
]);
const [inputValue, setInputValue] = useState('');
const [streamingMessage, setStreamingMessage] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState(null);
const chatContainerRef = useRef(null);
const userIsScrolling = useRef(false);
const streamingMessageRef = useRef('');
// 监听滚动,判断用户是否在查看历史
const handleScroll = () => {
if (!chatContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatContainerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
userIsScrolling.current = !isAtBottom;
};
// 自动滚动到底部
useEffect(() => {
if (chatContainerRef.current && !userIsScrolling.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, [messages, streamingMessage]);
// 保持最新 streamingMessage(避免闭包问题)
useEffect(() => {
streamingMessageRef.current = streamingMessage;
}, [streamingMessage]);
const handleSend = async () => {
const userMsg = inputValue.trim();
if (!userMsg) return;
// 添加用户消息
const newUserMsg = { id: Date.now(), role: 'user', content: userMsg };
setMessages(prev => [...prev, newUserMsg]);
setInputValue('');
setError(null);
// 启动 AI 流式响应
setIsStreaming(true);
setStreamingMessage('');
try {
await streamAIResponse({
prompt: userMsg,
model: 'llama3',
onToken: (token) => {
setStreamingMessage(prev => prev + token);
},
onComplete: () => {
setMessages(prev => [
...prev,
{ id: Date.now(), role: 'assistant', content: streamingMessageRef.current }
]);
setIsStreaming(false);
},
onError: (err) => {
setError('AI 响应失败:' + (err.message || '未知错误'));
setIsStreaming(false);
}
});
} catch (err) {
setError('请求出错');
setIsStreaming(false);
}
};
// 构建渲染消息列表
const renderMessages = [...messages];
if (streamingMessage) {
renderMessages.push({
id: 'streaming',
role: 'assistant',
content: streamingMessage,
});
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 聊天区域 */}
<div
ref={chatContainerRef}
onScroll={handleScroll}
style={{
flex: 1,
overflow: 'auto',
border: '1px solid #e8e8e8',
borderRadius: '8px',
padding: '8px',
marginBottom: '16px',
backgroundColor: '#fff',
}}
>
<VirtualChatList messages={renderMessages} />
</div>
{/* 输入区域 */}
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入你的问题(如:JavaScript 闭包是什么?)"
disabled={isStreaming}
style={{
flex: 1,
padding: '10px 12px',
border: '1px solid #d9d9d9',
borderRadius: '6px',
fontSize: '16px',
}}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isStreaming}
style={{
padding: '10px 20px',
backgroundColor: (!inputValue.trim() || isStreaming) ? '#f5f5f5' : '#1890ff',
color: (!inputValue.trim() || isStreaming) ? '#ccc' : 'white',
border: 'none',
borderRadius: '6px',
cursor: (!inputValue.trim() || isStreaming) ? 'not-allowed' : 'pointer',
}}
>
发送
</button>
</div>
{/* 错误提示 */}
{error && (
<div style={{ marginTop: '12px', color: '#ff4d4f', fontSize: '14px' }}>
❌ {error}
</div>
)}
</div>
);
};
export default StreamingChat;
步骤 5:在 App 中集成
// src/App.jsx
import 'katex/dist/katex.min.css'; // 必须引入 KaTeX CSS
import StreamingChat from './components/StreamingChat';
function App() {
return (
<div style={{
padding: '20px',
fontFamily: 'Inter, -apple-system, sans-serif',
maxWidth: '800px',
margin: '0 auto',
height: '90vh'
}}>
<h1 style={{ textAlign: 'center', fontSize: '24px', fontWeight: '700', color: '#333' }}>
🤖 本地 AI 聊天(Ollama + Llama 3)
</h1>
<p style={{ textAlign: 'center', color: '#666', marginBottom: '20px' }}>
所有对话在本地运行,无需联网
</p>
<StreamingChat />
</div>
);
}
export default App;
💡 复用组件说明:
StreamingMarkdown.jsx:来自 Day 2,支持代码高亮、表格、KaTeX 公式VirtualChatList.jsx:来自 Day 3,使用measureElement支持动态高度
✅ 效果验证
前提:已安装 Ollama 并运行
ollama run llama3
- 启动 Ollama:
ollama run llama3 - 启动项目:
npm start - 输入问题(如“JavaScript 闭包是什么?”)
- 观察 真实 token 逐块流式输出
- 断网时显示错误提示
- 支持 Markdown(代码/公式/表格)正确渲染
🤔 思考与延伸
-
如何支持 OpenAI 而不暴露 Key?
→ 需要简单后端代理(如 Express +axios转发) -
流式中断后如何续传?
→ 记录已生成内容,下次请求带上完整上下文 -
能否自动检测后端类型(Ollama vs OpenAI)?
→ 发送试探请求,根据响应结构判断
提示:Ollama 默认不支持跨域,必须使用代理 或启动时加
--host 0.0.0.0
📅 明日预告
Day 5:多模态输入初探 —— 图片上传与预览
- 实现文件拖拽上传 + 实时预览
- 支持截图粘贴(Clipboard API)
- 为“图文混合 AI 对话”打下基础
✍️ 小结
今天,我们跨越了“模拟”与“真实”的界限,将本地运行的 AI 模型(如 Ollama)接入前端,实现了 端到端的流式对话。这不仅是一个技术里程碑,更是迈向 AI 原生应用 的关键一步。真实数据流,让流式体验从演示走向生产。
💬 实践提示:如果遇到 CORS 错误,请确保
package.json中有"proxy": "http://localhost:11434"。欢迎分享你与本地大模型的对话截图!

浙公网安备 33010602011771号