本地部署ollama
效果展示
- 这里我并没有设置大模型的流式输出,而是采用的前端策略----打字机效果 ,不过一般都是采用流式输出+SSE 来实现这种效果的

关于ollama
- Ollama 就是"本地大模型 Docker"------一键拉模型、秒级启动、离线可聊、OpenAI 兼容,8G 显存就能跑 7B,CLI/API 即开即用,零门槛拥有完全本地、免费、可编程的 AI 服务。
- 这里我使用的大模型是
mistral,它的优点就是参数少、推理快、提供了GPT-4级与代码能力,适合本地部署用来学习练习使用。
本地部署大模型
- 这里有Ollama,所以还是比较简单,只需要按照命令执行下去就OK啦
- 先安装
ollama,波煮这里用的是mac,所有就写mac使用homebrew的指令了brew install ollama
- 启动
ollama,ollama services start ollama
- 拉取
mistral模型:ollama pull mistral
- 运行
mistral模型:ollama run mistral
- OK了,然后到这里你就可以在终端或者使用js脚本去访问这个服务并获取内容了,下面我给出js案例
Nodejs+Express搭建简易后端
- 这里我们主要需要做两件事:
- 搭建一个后端并暴露给前端一个接口(与AI对话的接口)
- 在后端封装一个方法去和大模型交流并拿到大模型的响应结果
封装与大模型通信的方法
- 这段代码用
LangChain 的ChatOllama把本地 Mistral 模型包装成异步函数:接收一段提示词,实时调用并返回纯字符串,方便前端直接显示------相当于一个"即插即用"的本地 AI 问答接口。
- 这里用到了
langchain/ollama,所以需要先npm install一下
- 其中baseUrl就是你大模型所在的IP与端口,一般就是本地IP加上11434端口
js
复制代码
const { ChatOllama } = require("@langchain/ollama");
const model = new ChatOllama({
baseUrl: "http://127.0.0.1:11434",
model: "mistral",
temperature: 0.9,
streaming: true,
});
// 传入prompt,获取响应
async function getResponse(prompt) {
const response = await model.invoke(prompt);
// 规范化为字符串,方便前端直接显示
let replyText = '';
if (typeof response === 'string') {
replyText = response;
} else if (response && typeof response === 'object') {
// 常见字段优先级
replyText = response.reply ?? response.text ?? response.content ?? response.output ?? JSON.stringify(response);
} else {
replyText = String(response);
}
return replyText;
}
module.exports = { getResponse };
后端
- 这里使用Express框架,注意还需要配置一下
cors防止前端因为跨域问题无法请求
- 下面这段代码启动了一个
Express 服务,提供跨域的 /api/chat 接口:接收前端发来的消息,调用本地 Mistral 模型获取回复,并把结果以 JSON 形式返回,从而实现"前端提问 → 后端调用本地 AI → 前端拿到回答"的完整对话链路。
js
复制代码
const express = require('express')
const cors = require('cors')
const { getResponse } = require('./ollama.js')
const app = express()
app.use(cors())
app.use(express.json())
app.get('/', (req, res) => {
res.send('hello world')
})
app.post('/api/chat', (req, res) => {
console.log('POST /api/chat body:', req.body);
if (!req.body || typeof req.body.message !== 'string') {
return res.status(400).json({ error: 'Invalid request: expected JSON body with a "message" string field.' });
}
const prompt = req.body.message;
getResponse(prompt).then(responseText => {
// 始终返回一个 JSON,包含 reply 字段,便于前端解析显示
res.json({ reply: responseText });
}).catch(err => {
console.error('getResponse error:', err);
res.status(500).json({ error: (err && err.message) ? err.message : String(err) });
});
})
app.listen(10000, () => {
console.log('服务已启动...');
})
前端
- 维护消息列表与输入框状态,按 Enter 或点击按钮触发发送;
- 向后端 /api/chat 发起 POST,带打字机效果逐字展示 AI 回复;
- 网络异常时自动降级为本地模拟回复,始终保证交互可用。
核心处理代码
- 这里的核心其实就是
sendMessage这个函数,下面我详细说说这个函数
输入保护+并发锁
js
复制代码
const text = input.trim();
if (!text || isSending) return;
setIsSending(true);
- 空输入直接 return,避免发空白消息。
isSending 是并发锁------防止用户狂点按钮或连续回车导致多个请求同时飞出去(后端可能返回顺序错乱)。
封装用户信息方便与后端交换
js
复制代码
const userMsg: Message = { id: String(Date.now()), role: "user", text };
setMessages(m => [...m, userMsg]);
setInput("");
- 先 UI 后请求------让用户感知"秒发",不用等网络返回。
- Date.now() 当 id 足够,同一毫秒内连续点击概率极低。
真正发请求
js
复制代码
(async () => {
const res = await fetch("http://localhost:10000/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ message: text }),
});
- 用 立即执行函数包裹 async 逻辑,避免把外层函数变成 async(事件处理器返回 Promise 无意义)。
- 显式带 Accept: application/json ------某些后端框架靠它路由,防止返回 text/plain 导致下步 JSON 解析崩。
打字机效果
js
复制代码
const id = String(Date.now() + Math.random());
setMessages(m => [...m, { id, role: "ai", text: "" }]);
for (let i = 1; i <= replyText.length; i++) {
await new Promise(r => setTimeout(r, 8 + Math.random() * 20));
setMessages(prev =>
prev.map(msg => (msg.id === id ? { ...msg, text: replyText.slice(0, i) } : msg))
);
}
- 随机延迟 8 + Math.random() * 20 毫秒 → 更像人类打字节奏(不是匀速机器),提高用户体验
- 每条 AI 消息用 时间戳 + 随机数 当 id,同一毫秒内多条消息也不会 key 冲突。
ts
复制代码
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./chatStyles.module.css";
type Message = {
id: string;
role: "user" | "ai";
text: string;
};
export default function Home() {
const [messages, setMessages] = useState<Message[]>([
{ id: "1", role: "ai", text: "你好!我是 AI 助手,有什么我可以帮你的?" },
]);
const [input, setInput] = useState("");
const [isSending, setIsSending] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// 自动滚动到底部
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, [messages]);
function sendMessage() {
const text = input.trim();
if (!text || isSending) return;
setIsSending(true);
const userMsg: Message = {
id: String(Date.now()),
role: "user",
text,
};
// 调用接口(带 Content-Type,使用 async/await 并以打字机效果展示回复)
setMessages((m) => [...m, userMsg]);
setInput("");
(async () => {
try {
const res = await fetch("http://localhost:10000/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ message: text }),
});
if (!res.ok) {
console.error("Server returned status", res.status);
// 回退到本地模拟回复
const fallback = `收到:${text} ------ 这是一个模拟回复。`;
await (async function typewriterFallback() {
const id = String(Date.now() + Math.random());
setMessages((m) => [...m, { id, role: "ai", text: "" }] as Message[]);
for (let i = 1; i <= fallback.length; i++) {
await new Promise((r) => setTimeout(r, 8 + Math.random() * 20));
setMessages((prev) => prev.map((msg) => (msg.id === id ? { ...msg, text: fallback.slice(0, i) } : msg)));
}
setIsSending(false);
})();
} else {
// 先读取为文本,再尝试解析为 JSON
const respText = await res.text();
let replyText = "";
try {
const parsed = JSON.parse(respText);
if (parsed && typeof parsed === "object") {
// 常见字段检测
replyText = parsed.reply ?? parsed.text ?? JSON.stringify(parsed);
} else {
replyText = String(parsed);
}
} catch (e) {
// 不是 JSON,直接使用原始文本
replyText = respText;
}
// 打字机效果展示 replyText
const id = String(Date.now() + Math.random());
setMessages((m) => [...m, { id, role: "ai", text: "" }] as Message[]);
for (let i = 1; i <= replyText.length; i++) {
await new Promise((r) => setTimeout(r, 8 + Math.random() * 20));
setMessages((prev) => prev.map((msg) => (msg.id === id ? { ...msg, text: replyText.slice(0, i) } : msg)));
}
setIsSending(false);
}
} catch (err) {
console.error("fetch error:", err);
// 网络或其它错误时回退到本地模拟回复
const fallback = `收到:${text} ------ 这是一个模拟回复。`;
const id = String(Date.now() + Math.random());
setMessages((m) => [...m, { id, role: "ai", text: "" }] as Message[]);
for (let i = 1; i <= fallback.length; i++) {
await new Promise((r) => setTimeout(r, 8 + Math.random() * 20));
setMessages((prev) => prev.map((msg) => (msg.id === id ? { ...msg, text: fallback.slice(0, i) } : msg)));
}
setIsSending(false);
}
})();
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
return (
<div className={styles.pageWrapper}>
<div className={styles.container}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<div className={styles.avatar}>AI</div>
<div>
<div className={styles.title}>AI 聊天</div>
<div className={styles.subtitle}>AI对话 --- 回车发送</div>
</div>
</div>
</div>
{/* Messages */}
<div ref={listRef} className={styles.messages}>
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div className={`${m.role === "user" ? styles.userBubble : styles.aiBubble}`}>
<div className="whitespace-pre-wrap">{m.text}</div>
</div>
</div>
))}
</div>
{/* Input */}
<div className={styles.inputWrap}>
<div className={styles.inputRow}>
<input
aria-label="消息输入框"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder="输入消息,按 Enter 发送..."
className={styles.input}
/>
<button
onClick={sendMessage}
disabled={isSending || !input.trim()}
className={styles.sendButton}
>
{isSending ? "发送中..." : "发送"}
</button>
</div>
</div>
</div>
</div>
);
}