从0开始搭建本地(Agent)生成式AI聊天窗口,基于Ollma安装、调用本地大模型(qwen2.5:3b)

下载安装Python

Download Python | Python.orgThe official home of the Python Programming Languagehttps://www.python.org/downloads/

下载【离线安装包】直接点击https://www.python.org/ftp/python/3.15.0/python-3.15.0-amd64.exe

开始安装Python

下载安装Ollama

具体流程参考这个文章

用Ollama在本地CMD窗口运行deepseek大模型_运行ollama命令-CSDN博客文章浏览阅读2.2k次,点赞10次,收藏7次。博客介绍了在本地CMD窗口用Ollama运行deepseek大模型的方法。Ollama安装过程全自动,默认装在C盘,若不想装在C盘,可在安装包所在目录运行CMD执行特定命令。首次运行需下载约5GB的deepseek大模型包,第二次运行直接输入特定命令即可。https://blog.csdn.net/qq_37860634/article/details/145511369

下载安装千问大模型(qwen2.5:3b)

运行cmd

输入代码

复制代码
ollama pull qwen2.5:3b

回车

根据网速快慢等待约10~20分钟安装完毕

下载安装VSCODE

用Ollama在本地CMD窗口运行deepseek大模型_运行ollama命令-CSDN博客文章浏览阅读2.2k次,点赞10次,收藏7次。博客介绍了在本地CMD窗口用Ollama运行deepseek大模型的方法。Ollama安装过程全自动,默认装在C盘,若不想装在C盘,可在安装包所在目录运行CMD执行特定命令。首次运行需下载约5GB的deepseek大模型包,第二次运行直接输入特定命令即可。https://blog.csdn.net/qq_37860634/article/details/145511369

安装过程超简单这里略过截图

新建项目文件夹

起什么名字无所谓,这里我用AgentDemo

打开VSCODE-文件-打开文件夹-找到AgentDemo

初始化Python项目

Ctrl+J打开底部【终端】窗口

输入

bash 复制代码
python -m pip install openai fastapi uvicorn

回车

编写并运行main.py代码

在左侧右键新建文件main.py

复制以下代码粘贴到main.py

python 复制代码
from fastapi import FastAPI, Query, Body
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from openai import AsyncOpenAI

app = FastAPI(title="本地大模型流式接口")

# 解决跨域,教学演示允许全部来源
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 对接ollama AsyncOpenAI兼容接口
client = AsyncOpenAI(base_url="http://localhost:11434/v1/", api_key="demo")


async def stream_llm_response(user_msg: str):
    # AsyncOpenAI sdk stream是异步,放到线程执行避免阻塞事件循环
    stream = await client.chat.completions.create(
        model="qwen2.5:3b",
        messages=[
            {"role": "system", "content": "你是一个助教,回答简洁易懂,适合学生学习"},
            {"role": "user", "content": user_msg},
        ],
        stream=True,
    )
    async for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            text = chunk.choices[0].delta.content
            print(text, end="", flush=True)
            yield f"data: {text}\n\n"
    yield "data: [DONE]\n\n"


@app.api_route("/chat/stream", methods=["GET", "POST"])
async def chat_stream(
    q: str | None = Query(default=None),
    q_post: str | None = Body(default=None, embed=True),
):
    """
    GET请求示例:
    http://127.0.0.1:8000/chat/stream?q=你的问题

    POST请求示例(json body):
    POST http://127.0.0.1:8000/chat/stream
    Content-Type: application/json
    {
        "q_post": "你的问题"
    }
    """
    # 优先取post参数,没有再取get查询参数
    user_query = q_post if q_post is not None else q
    if not user_query:
        return StreamingResponse(
            iter(["data: 缺少提问参数 q\n\ndata: [DONE]\n\n"]),
            media_type="text/event-stream",
        )
    return StreamingResponse(
        stream_llm_response(user_query), media_type="text/event-stream"
    )


if __name__ == "__main__":
    import uvicorn

    uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

运行main.py

在底部【终端】输入

python 复制代码
python main.py

回车

此刻,本地调用大模型的接口已经启动了

创建并运行前端页面

在左侧新建index.html文件

python 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>本地大模型演示</title>
    <style>
        body {
            max-width: 800px;
            margin: 40px auto;
            padding: 0 20px;
            font-family: system-ui;
        }
        #input-box {
            width: 100%;
            height: 80px;
            font-size: 16px;
            padding:10px;
            box-sizing: border-box;
        }
        #btn-send {
            margin-top:10px;
            padding:8px 24px;
            font-size:16px;
            cursor:pointer;
        }
        #output-area{
            margin-top:20px;
            padding:12px;
            border:1px solid #ccc;
            min-height:200px;
            white-space: pre-wrap;
        }
    </style>
</head>
<body>
    <h3>本地Ollama大模型演示(回车 / 按钮发送,Shift+Enter换行)</h3>
    <textarea id="input-box" placeholder="请输入你的问题"></textarea>
    <br>
    <button id="btn-send">发送</button>
    <div id="output-area"></div>
    <script>
        const inputEl = document.getElementById("input-box");
        const outputEl = document.getElementById("output-area");
        const sendBtn = document.getElementById("btn-send");
        let es = null;

        // 封装发送逻辑,回车和按钮共用一套代码
        function doSend(){
            const question = inputEl.value.trim();
            if(!question) return;
            // 关闭上一次SSE连接
            if(es){
                es.close();
            }
            outputEl.innerText = "";
            inputEl.disabled = true;
            sendBtn.disabled = true;

            const url = `http://127.0.0.1:8000/chat/stream?q=${encodeURIComponent(question)}`;
            es = new EventSource(url);

            es.onmessage = function(event){
                if(event.data === "[DONE]"){
                    es.close();
                    inputEl.disabled = false;
                    sendBtn.disabled = false;
                    return;
                }
                outputEl.innerText += event.data;
            }

            es.onerror = function(){
                outputEl.innerText += "\n\n【连接出错,请确认ollama和后端服务已经启动】";
                es.close();
                inputEl.disabled = false;
                sendBtn.disabled = false;
            }
        }

        // 回车发送
        inputEl.addEventListener("keydown", function (e) {
            if(e.key === 'Enter' && !e.shiftKey){
                e.preventDefault();
                doSend();
            }
        })

        // 点击按钮发送
        sendBtn.addEventListener("click", doSend);
    </script>
</body>
</html>

然后Ctrl+X安装插件

聚焦到index.html文件

使用快捷键Atl+L Atl+O 或者右键选择【Open with Live Server】

会运行打开http://127.0.0.1:5500/index.html网页

输入你的问题,大模型会回复你

至此一个超级简单的调用本地大模型的demo完成!


进阶:

这里前端使用的

EventSource痛点

EventSource 原生是 GET 请求,不能自定义 header,如果你要传 token 就很麻烦(例如你要写一个接口是需要验证用户登录没有,才提供答案回复); 如果后面你遇到:CORS 配置好了,普通接口 ok,SSE 还是跨域:

方案 请求方式 底层 API 优点 缺点
EventSource 仅 GET 浏览器内置 代码简短,自带断线重连 不能 POST,参数暴露 URL,不能自定义请求头
fetch+ReadableStream POST / GET fetch + 流读取 支持 POST,参数放请求体,更规范 需要手动解析 SSE 字符串,代码多一点,没有自动重连

在实际的商用项目里面,我们极力推荐前端使用POST方式请求,这里就需要对index.html代码做调整,代码如下

index.html

python 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>本地大模型演示 POST流式</title>
    <style>
        body {
            max-width: 800px;
            margin: 40px auto;
            padding: 0 20px;
            font-family: system-ui;
        }
        #input-box {
            width: 100%;
            height: 80px;
            font-size: 16px;
            padding:10px;
            box-sizing: border-box;
        }
        #btn-send {
            margin-top:10px;
            padding:8px 24px;
            font-size:16px;
            cursor:pointer;
        }
        #output-area{
            margin-top:20px;
            padding:12px;
            border:1px solid #ccc;
            min-height:200px;
            white-space: pre-wrap;
        }
    </style>
</head>
<body>
    <h3>本地Ollama大模型演示(POST流式,回车 / 按钮发送,Shift+Enter换行)</h3>
    <textarea id="input-box" placeholder="请输入你的问题"></textarea>
    <br>
    <button id="btn-send">发送</button>
    <div id="output-area"></div>
    <script>
        const inputEl = document.getElementById("input-box");
        const outputEl = document.getElementById("output-area");
        const sendBtn = document.getElementById("btn-send");
        let abortController = null; // 用于中断fetch流

        // 封装发送逻辑,改用 fetch POST 读取SSE流
        async function doSend(){
            const question = inputEl.value.trim();
            if(!question) return;

            // 如果上一次请求还在运行,直接终止
            if(abortController){
                abortController.abort();
            }
            abortController = new AbortController();

            outputEl.innerText = "";
            inputEl.disabled = true;
            sendBtn.disabled = true;

            try {
                const resp = await fetch("http://127.0.0.1:8000/chat/stream", {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json"
                    },
                    body: JSON.stringify({
                        q_post: question
                    }),
                    signal: abortController.signal
                });

                if (!resp.ok) {
                    throw new Error(`HTTP ${resp.status}`);
                }

                // 获取可读流
                const reader = resp.body.getReader();
                const decoder = new TextDecoder("utf-8");
                let buffer = "";

                while(true){
                    const {done, value} = await reader.read();
                    if(done) break;

                    buffer += decoder.decode(value, {stream:true});
                    // 按SSE分隔符 \n\n 切分一条条事件
                    const parts = buffer.split("\n\n");
                    buffer = parts.pop(); // 剩下不完整片段留在buffer

                    for(const part of parts){
                        if(!part.trim()) continue;
                        // SSE格式 data: xxx
                        if(part.startsWith("data: ")){
                            const data = part.substring("data: ".length).trimEnd();
                            if(data === "[DONE]"){
                                // 流式结束
                                reader.cancel();
                                return;
                            }
                            outputEl.innerText += data;
                        }
                    }
                }

            } catch(err){
                if(err.name !== "AbortError"){
                    outputEl.innerText += "\n\n【连接出错,请确认ollama和后端服务已经启动】";
                    console.error(err);
                }
            } finally {
                inputEl.disabled = false;
                sendBtn.disabled = false;
                abortController = null;
            }
        }

        // 回车发送
        inputEl.addEventListener("keydown", function (e) {
            if(e.key === 'Enter' && !e.shiftKey){
                e.preventDefault();
                doSend();
            }
        })
        // 点击按钮发送
        sendBtn.addEventListener("click", doSend);
    </script>
</body>
</html>

重新运行main.py和index.html

也可以实现同样的提问回复效果