老板逼我上AI,我偷偷在浏览器里跑LLaMA
老板看了竞品,眼睛发光:“我们也上AI!用户问啥都得秒回!” 我默默算了算OpenAI的账单——一个月2万,一年24万,够全组去三亚团建三次。于是我干了件疯狂的事:把AI模型塞进用户浏览器里。不用服务器,不花一分钱API,用户电脑自己跟自己聊天。老板看着账单上的“0”,问我是不是偷偷充了值。
前言
这事儿起因很简单:老板要AI客服。大模型API便宜吗?初看几分钱一次,用户一多,一个月一辆特斯拉没了。而且用户问的重复问题占80%,每问一次就烧一次钱,像开着水龙头浇花。
我寻思:能不能把模型直接扔到用户浏览器里?现在电脑、手机性能过剩,跑个小模型绰绰有余。说干就干,我找到了Transformers.js——一个能在浏览器里跑Hugging Face模型的库,完全本地推理,不花一分钱API,隐私还安全。
今天我就带你手把手在里集成一个本地AI问答模型(用的还是微软的Phi-3 mini,效果媲美GPT-3.5,体积只有2GB左右,量化后更小)。用户打开网页,模型自动下载到IndexedDB,然后所有对话都在他电脑上完成。老板再也不用看账单了。
一、为什么敢在浏览器里跑AI?
- 硬件进步: + WebGL,现代CPU/GPU能跑几十亿参数的小模型。
- 模型变小:Phi-3、TinyLLaMA、Gemma 2B,量化后几十到几百MB。
- 隐私:数据不上传,用户放心(尤其金融、医疗行业)。
- 成本:固定成本(服务器带宽),没有按次收费。
缺点:首次加载慢(下载模型),低端设备可能卡。但你可以用闲时下载+缓存策略,用户第一次访问花一分钟,之后秒开。
二、技术选型:Transformers.js + Phi-3
Transformers.js 是Hugging Face官方库,支持在浏览器里运行Transformer模型。它自动利用WebGL加速,比纯CPU快5-10倍。
我们要用的模型:Phi-3-mini-4k-instruct(微软出品,38亿参数,量化后约2GB)。太大了?别急,有128k上下文版更小,或者用TinyLLaMA 1.1B(量产后几百MB)。我推荐先上onnx-community/Phi-3-mini-4k-instruct-onnx,经过ONNX优化,体积更友好。
三、实战:React + Transformers.js 实现本地问答
安装依赖
npm install @xenova/transformers创建一个AI Hook
import { pipeline, env } from '@xenova/transformers';
// 设置模型缓存路径(IndexedDB)
env.localModelPath = '/models/';
env.useBrowserCache = true;
export function useLocalLLM() {
const [generator, setGenerator] = useState(null);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState(0);
useEffect(() => {
const loadModel = async () => {
// 加载文本生成模型(这里用Phi-3的ONNX版本)
const pipe = await pipeline('text-generation', 'onnx-community/Phi-3-mini-4k-instruct-onnx', {
progress_callback: (p) => {
if (p.status === 'downloading') {
setProgress(p.progress);
}
},
});
setGenerator(() => pipe);
setLoading(false);
};
loadModel();
}, []);
const generate = async (prompt, options = {}) => {
if (!generator) return;
const result = await generator(prompt, {
max_new_tokens: 256,
temperature: 0.7,
...options,
});
return result[0].generated_text;
};
return { generate, loading, progress };
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
在组件中使用
function AIChat() {
const { generate, loading, progress } = useLocalLLM();
const [question, setQuestion] = useState('');
const [answer, setAnswer] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const ask = async () => {
if (!question.trim() || isGenerating) return;
setIsGenerating(true);
// Phi-3 指令格式
const prompt = `<|user|>\n${question}\n<|end|>\n<|assistant|>\n`;
const response = await generate(prompt);
setAnswer(response.replace(prompt, '').trim());
setIsGenerating(false);
};
if (loading) {
return <div>正在加载AI模型 {Math.round(progress * 100)}% ... (首次约需1分钟)</div>;
}
return (
<div>
<textarea value={question} onChange={e => setQuestion(e.target.value)} />
<button onClick={ask} disabled={isGenerating}>问AI</button>
{isGenerating && <div>AI在你电脑里拼命想...</div>}
{answer && <div className="answer">{answer}</div>}
</div>
);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
四、效果与优化
首次访问:下载模型(约1.5GB,看网速),之后缓存在IndexedDB,第二次秒加载。
推理速度:Intel i7 16GB 上,生成20个token约2秒。M1 Mac 更快。手机上可换更小模型。
优化技巧:
- 用Web Worker运行模型,避免阻塞UI。
- 提前预加载:用户鼠标悬停在聊天按钮时就开始下模型。
- 量化:选择int8或fp16版本,体积减半。
五、这和Vercel AI SDK有什么区别?
- Vercel AI SDK:后端调API,前端拿流式响应。还是要花钱,但开发快。
- Transformers.js:完全本地,零成本,但首次加载慢,设备性能要求高。
我的建议:混合模式。默认用本地模型,如果用户设备太老或模型下载失败,fallback到云端API。既省钱又不丢用户体验。
六、老板的反应
上线后,老板问:“这月AI账单怎么是0?” 我说:“我把AI搬到用户浏览器里了。” 他沉默了三秒:“那岂不是我们没数据了?” 我说:“要数据干嘛?又卖不掉。省下的钱给我们加鸡腿。” 老板居然同意了。
七、总结:本地AI不是梦,是未来
- Transformers.js 让浏览器跑大模型成为可能。
- 适合隐私敏感、成本敏感的场景(客服、笔记、翻译)。
- 首次加载慢,但配合缓存和进度提示,用户能接受。
- 技术选型:模型选Phi-3/TinyLLaMA,量化版几十到几百MB。
下次老板再让你接入AI,你可以淡定地说:“本地跑,不花钱,隐私好。” 然后默默打开这篇文章——代码都给他准备好了。
前文是一个基于 React 的实现,而且用的还是旧的
@xenova/transformers包。下面我用 3 + + Transformers.js v4(@huggingface/transformers) 重新实现,同时融入 Web Worker 避免阻塞 UI、 构建配置、以及最新的模型推荐。
一、安装依赖
npm install @huggingface/transformers注意:这是 Transformers.js v4 的新包名,旧版
@xenova/transformers是v2.x,不再推荐使用。
Vite 项目需要处理 .onnx、.wasm 等二进制文件,Vite 会尝试用默认 loader 处理 .onnx 文件导致报错或打包体积膨胀,需要额外配置。
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
worker: {
format: 'es', // Web Worker 使用 ES 模块格式
},
optimizeDeps: {
exclude: ['@huggingface/transformers'], // 避免预构建时处理 ONNX 二进制
},
build: {
rollupOptions: {
external: ['@huggingface/transformers'], // 保持为外部依赖
},
},
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
二、Web Worker 架构
Transformers.js 的官方示例采用了 split architecture 模式:UI 状态在主线程,模型加载和推理操作全部在 Web Worker 中执行。这样可以避免模型推理时阻塞页面渲染和用户交互。
定义 Worker 消息类型
// types/llm.ts
export interface WorkerRequest {
type: 'load' | 'generate';
modelId?: string;
prompt?: string;
options?: GenerateOptions;
}
export interface WorkerResponse {
type: 'progress' | 'ready' | 'result' | 'error';
progress?: number;
status?: string;
text?: string;
fullResponse?: string;
message?: string;
tps?: number; // tokens per second
}
export interface GenerateOptions {
max_new_tokens?: number;
temperature?: number;
top_p?: number;
do_sample?: boolean;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Worker 脚本
// workers/llm.worker.ts
import { pipeline, env } from '@huggingface/transformers';
import type { WorkerRequest, WorkerResponse, GenerateOptions } from '../types/llm';
// 配置:使用浏览器缓存(IndexedDB)
env.useBrowserCache = true;
env.allowLocalModels = false; // 首次从 HF 下载,后续从缓存读取
let generator: any = null;
self.onmessage = async (e: MessageEvent<WorkerRequest>) => {
const { type, modelId, prompt, options } = e.data;
try {
if (type === 'load') {
await loadModel(modelId ?? 'onnx-community/Phi-3-mini-4k-instruct-onnx');
const msg: WorkerResponse = { type: 'ready', status: '模型已就绪' };
self.postMessage(msg);
} else if (type === 'generate') {
if (!generator) {
throw new Error('模型尚未加载,请先发送 load 消息');
}
const response = await generate(prompt!, options ?? {});
const msg: WorkerResponse = { type: 'result', text: response };
self.postMessage(msg);
}
} catch (err: any) {
const msg: WorkerResponse = { type: 'error', message: err.message };
self.postMessage(msg);
}
};
async function loadModel(modelId: string) {
generator = await pipeline('text-generation', modelId, {
progress_callback: (p: any) => {
if (p.status === 'downloading') {
const msg: WorkerResponse = {
type: 'progress',
progress: p.progress,
status: p.file,
};
self.postMessage(msg);
}
},
});
}
async function generate(prompt: string, options: GenerateOptions): Promise<string> {
const result = await generator(prompt, {
max_new_tokens: 256,
temperature: 0.7,
top_p: 0.9,
do_sample: true,
...options,
});
// v4 中 text-generation 返回的是数组,取第一项的 generated_text
const out = Array.isArray(result) ? result[0] : result;
return out.generated_text ?? out;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
三、Vue 3 Composable
Vue 3 的 Composables 对标 React Hooks,下面用 Composable 封装模型加载与推理的全部逻辑。
// composables/useLocalLLM.ts
import { ref, onUnmounted, type Ref } from 'vue';
import type { WorkerRequest, WorkerResponse } from '../types/llm';
export function useLocalLLM() {
const loading: Ref<boolean> = ref(false);
const ready: Ref<boolean> = ref(false);
const progress: Ref<number> = ref(0);
const progressFile: Ref<string> = ref('');
const error: Ref<string | null> = ref(null);
let worker: Worker | null = null;
function initWorker(modelId?: string) {
if (worker) return; // 防止重复初始化
// Vite 中通过 new URL 的方式创建 Worker,支持 HMR
worker = new Worker(new URL('../workers/llm.worker.ts', import.meta.url), {
type: 'module',
});
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
const { type, progress: p, status, text, message } = e.data;
switch (type) {
case 'progress':
progress.value = p ?? 0;
progressFile.value = status ?? '';
break;
case 'ready':
loading.value = false;
ready.value = true;
error.value = null;
break;
case 'result':
// 通过回调将结果传出
break;
case 'error':
loading.value = false;
error.value = message ?? '未知错误';
break;
}
};
loading.value = true;
error.value = null;
const msg: WorkerRequest = {
type: 'load',
modelId: modelId ?? 'onnx-community/Phi-3-mini-4k-instruct-onnx',
};
worker.postMessage(msg);
}
function generate(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!worker) {
reject(new Error('Worker 尚未初始化'));
return;
}
const handler = (e: MessageEvent<WorkerResponse>) => {
if (e.data.type === 'result') {
worker!.removeEventListener('message', handler);
resolve(e.data.text ?? '');
} else if (e.data.type === 'error') {
worker!.removeEventListener('message', handler);
reject(new Error(e.data.message));
}
};
worker.addEventListener('message', handler);
const msg: WorkerRequest = {
type: 'generate',
prompt,
};
worker.postMessage(msg);
});
}
function terminate() {
worker?.terminate();
worker = null;
ready.value = false;
loading.value = false;
}
onUnmounted(() => {
terminate();
});
return {
loading,
ready,
progress,
progressFile,
error,
initWorker,
generate,
terminate,
};
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
四、Vue 3 组件中使用
<template>
<div class="ai-chat">
<!-- 模型加载进度 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-content">
<p>正在加载 AI 模型...</p>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: (progress * 100) + '%' }" />
</div>
<p class="progress-text">{{ Math.round(progress * 100) }}%</p>
<p v-if="progressFile" class="file-name">下载中:{{ progressFile }}</p>
</div>
</div>
<!-- 对话区域 -->
<div v-if="ready" class="chat-area">
<div
v-for="(msg, index) in messages"
:key="index"
:class="['message', msg.role]"
>
<div class="message-content">{{ msg.content }}</div>
</div>
<div v-if="isGenerating" class="message assistant">
<div class="typing-indicator">AI 正在思考...</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<textarea
v-model="question"
placeholder="在这里输入你的问题..."
:disabled="!ready || isGenerating"
@keydown.enter.exact.prevent="ask"
/>
<button
@click="ask"
:disabled="!ready || isGenerating || !question.trim()"
>
{{ isGenerating ? '思考中...' : '问 AI' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useLocalLLM } from '../composables/useLocalLLM';
interface Message {
role: 'user' | 'assistant';
content: string;
}
const PHI3_USER_PREFIX = '<|user|>\n';
const PHI3_USER_SUFFIX = '\n<|end|>\n';
const PHI3_ASSISTANT_PREFIX = '<|assistant|>\n';
const { loading, ready, progress, progressFile, initWorker, generate } = useLocalLLM();
const question = ref('');
const messages = ref<Message[]>([]);
const isGenerating = ref(false);
onMounted(() => {
// 页面加载时自动初始化模型
initWorker('onnx-community/Phi-3-mini-4k-instruct-onnx');
});
async function ask() {
const q = question.value.trim();
if (!q || isGenerating.value) return;
messages.value.push({ role: 'user', content: q });
question.value = '';
isGenerating.value = true;
try {
// 拼接 Phi-3 的指令格式
const prompt = `${PHI3_USER_PREFIX}${q}${PHI3_USER_SUFFIX}${PHI3_ASSISTANT_PREFIX}`;
const response = await generate(prompt);
// 去除 prompt 前缀,只保留助手回复
const cleanResponse = response.replace(prompt, '').trim();
messages.value.push({ role: 'assistant', content: cleanResponse });
} catch (err: any) {
messages.value.push({
role: 'assistant',
content: `出错了:${err.message}`,
});
} finally {
isGenerating.value = false;
}
}
</script>
<style scoped>
.ai-chat {
max-width: 700px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, -apple-system, sans-serif;
}
.loading-overlay {
text-align: center;
padding: 60px 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4f46e5, #7c3aed);
transition: width 0.3s ease;
}
.chat-area {
min-height: 300px;
margin-bottom: 20px;
}
.message {
margin-bottom: 16px;
padding: 12px 16px;
border-radius: 12px;
max-width: 85%;
}
.message.user {
background: #4f46e5;
color: white;
margin-left: auto;
}
.message.assistant {
background: #f3f4f6;
color: #1f2937;
}
.typing-indicator {
color: #9ca3af;
font-style: italic;
}
.input-area {
display: flex;
gap: 10px;
align-items: flex-end;
}
.input-area textarea {
flex: 1;
min-height: 60px;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
resize: vertical;
font-size: 14px;
}
.input-area button {
padding: 10px 24px;
background: #4f46e5;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.input-area button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
</style>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
五、关键优化与注意事项
首屏加载优化
Transformers.js 生产部署面临三大瓶颈:ONNX 模型体积导致的下载延迟、模型反序列化耗时、以及 WASM/WebGPU 初始化开销。推荐两种预加载策略:闲时预加载(页面 onload 后静默初始化 Worker)或 悬停预加载(用户鼠标悬停在聊天入口时触发加载)。
Vite 构建配置
一个常见坑是,Vite 在 npm run dev 时能正常加载模型,但 npm run build 后报 404。这是因为生产构建会将模型文件哈希化但路径引用未同步更新。解决方案:
// vite.config.ts 补充
export default defineConfig({
// ...
build: {
rollupOptions: {
output: {
// 确保 .onnx / .wasm 文件原样输出,不被哈希化
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.onnx') || assetInfo.name?.endsWith('.wasm')) {
return 'models/[name][extname]';
}
return 'assets/[name]-[hash][extname]';
},
},
},
},
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
WebGPU 加速
Transformers.js v4 的 WebGPU 运行时用 C++ 重写,默认在支持的浏览器上自动启用。如果你的目标设备支持 WebGPU,推理速度可比 WASM 快 4 倍左右。Chrome 113+、Edge 113+ 均已支持,无需额外配置。
模型选择建议
前文推荐的 onnx-community/Phi-3-mini-4k-instruct-onnx(约 2GB)是通用选择。如果希望更快的首次加载速度,可考虑 2026 年的更轻量替代品,如 Xenova/Qwen1.5-0.5B-Chat 或面壁智能的 MiniCPM5-1B(INT4 量化仅 0.5GB)。
六、总结
| 维度 | 前文(React / v2) | 本文(Vue 3 / v4) |
|---|---|---|
| 框架 | React + useState | Vue 3 Composition API + Composable |
| Transformers.js 版本 | v2(@xenova/transformers) | v4(@huggingface/transformers) |
| 推理后端 | WebGL | WebGPU(C++ 运行时,速度提升 4×) |
| 线程模型 | 主线程直接调用 | Web Worker(不阻塞 UI) |
| 状态管理 | React useState | Vue 3 ref + Composable |
| 构建工具 | 未涉及 | Vite + 专项配置 |
| 最新轻量模型 | 未涉及 | MiniCPM5-1B / Qwen1.5-0.5B |
Vue 3 的 Composition API 与 React Hooks 在思想上高度相似(都是函数式、响应式的组合逻辑),所以从 React 版本迁移到 Vue 3 并不复杂。核心差异在于:Vue 3 用 ref / reactive 管理状态,React 用 useState / useRef;而在 Transformers.js v4 中,最大的升级是 WebGPU C++ 运行时的引入,让浏览器端 AI 从"能跑"变成了"能快速跑"。
