当前位置: 首页 > news >正文

Next.js + Ollama + Qwen3:零成本搭建本地大模型流式聊天应用

本文为作者原创,首发于掘金,现同步到 CSDN。
项目地址:https://github.com/HWYD/ai-mind
本文对应版本:AI Mind v0.0.1

大家好!今天我来给大家分享一个非常实用的技术实现:不依赖云端 API,也可以在本地搭建一个可流式输出的 AI 聊天应用。本文记录如何用 Next.js、Ollama 和 Qwen3 搭建本地大模型聊天应用,并为后续 Tool Calling 和 Runtime 扩展打基础。

一、先看效果

最终我们能实现这样一个功能:

  • 在本地运行大模型(Qwen3:4B)
  • Next.js 作为后端服务,实现流式转发
  • 前端实时展示 AI 的响应,打字机效果拉满

二、准备工作

1. 安装并启动 Ollama

Ollama 是一个非常优秀的本地大模型运行工具,支持各种主流开源模型。

下载安装:
访问 Ollama 官网 下载对应系统的安装包,Windows/macOS/Linux 都支持。

验证安装:
安装完成后,打开终端运行:

ollama--version

如果看到版本号,说明安装成功啦!

2. 下载 Qwen3:4B 模型

Qwen 是阿里开源的系列模型,Qwen3:4B 体积小、速度快,非常适合在普通电脑上运行。

在终端中运行:

ollama pull qwen3:4b

等待下载完成后,我们可以测试一下:

ollama run qwen3:4b

如果能正常和 AI 对话,说明模型已经准备好了!按Ctrl+C退出。

三、Next.js 项目搭建

如果你还没有 Next.js 项目,可以快速创建一个:

npx create-next-app@latest my-ai-appcdmy-ai-app

四、核心实现:API 路由开发

这是最关键的一步,我们需要创建一个 Next.js API 路由来对接 Ollama,并实现流式输出。

app/api/ollama/route.ts中:

import{NextRequest}from'next/server'exportasyncfunctionPOST(request:NextRequest){try{const{prompt,model='qwen3:4b'}=awaitrequest.json()if(!prompt){returnnewResponse('Prompt is required',{status:400})}constollamaResponse=awaitfetch('http://localhost:11434/api/generate',{method:'POST',headers:{'Content-Type':'application/json',},body:JSON.stringify({model,prompt,stream:true,}),})if(!ollamaResponse.ok){returnnewResponse('Failed to connect to Ollama',{status:500})}// 直接转发原始流conststream=ollamaResponse.bodyreturnnewResponse(stream,{headers:{'Content-Type':'application/x-ndjson; charset=utf-8','Transfer-Encoding':'chunked',},})}catch(error){console.error('Ollama API error:',error)returnnewResponse('Internal server error',{status:500})}}

技术要点解析:

  1. 流式转发的核心:使用ReadableStream创建自定义流式响应
  2. 数据解析:Ollama 返回的是每行一个 JSON 对象,我们逐行解析并提取response字段
  3. Transfer-Encoding: chunked:这个响应头告诉浏览器这是一个分块传输的流式响应

五、前端实现:自定义 Hook + 流式展示

为了代码的复用性和可维护性,我们把流式处理逻辑封装成一个自定义 Hook。

1. 创建useOllamaStream.ts

'use client'import{useState,useCallback,useRef}from'react'interfaceUseOllamaStreamOptions{onChunk?:(chunk:string)=>voidonError?:(error:Error)=>voidonComplete?:()=>void}exportfunctionuseOllamaStream(options:UseOllamaStreamOptions={}){const[response,setResponse]=useState('')const[thinking,setThinking]=useState('')const[isLoading,setIsLoading]=useState(false)const[error,setError]=useState<string|null>(null)constabortControllerRef=useRef<AbortController|null>(null)constcancel=useCallback(()=>{if(abortControllerRef.current){abortControllerRef.current.abort()abortControllerRef.current=null}},[])constsendMessage=useCallback(async(prompt:string)=>{if(!prompt.trim())returnsetIsLoading(true)setResponse('')setThinking('')setError(null)constcontroller=newAbortController()abortControllerRef.current=controllertry{constres=awaitfetch('/api/ollama',{method:'POST',headers:{'Content-Type':'application/json',},body:JSON.stringify({prompt}),signal:controller.signal,})if(!res.ok){thrownewError(`HTTP error! status:${res.status}`)}constreader=res.body?.getReader()constdecoder=newTextDecoder()if(reader){letbuffer=''while(true){const{done,value}=awaitreader.read()if(done)breakbuffer+=decoder.decode(value,{stream:true})constlines=buffer.split('\n')buffer=lines.pop()||''for(constlineoflines){if(!line.trim())continuetry{constdata=JSON.parse(line)if(data.response){setResponse(prev=>prev+data.response)options.onChunk?.(data.response)}// 显示思考过程if(data.thinking){setThinking(prev=>prev+data.thinking)}if(data.done){break}}catch{continue}}}}options.onComplete?.()}catch(err){if(errinstanceofDOMException&&err.name==='AbortError'){setError('Request cancelled')}else{consterrorMessage=errinstanceofError?err.message:'An error occurred'setError(errorMessage)options.onError?.(errinstanceofError?err:newError(errorMessage))}}finally{setIsLoading(false)abortControllerRef.current=null}},[options])return{response,thinking,isLoading,error,sendMessage,cancel,}}

2. 创建主页面page.tsx

'use client'importReact,{useState,useCallback,useMemo,useRef,useEffect}from'react'import{useOllamaStream}from'./useOllamaStream'constResponseDisplay=React.memo(({response,thinking,isLoading}:{response:string;thinking:string;isLoading:boolean})=>{constresponseRef=useRef<HTMLDivElement>(null)useEffect(()=>{if(responseRef.current){responseRef.current.scrollTop=responseRef.current.scrollHeight}},[response,thinking])if(!response&&!thinking&&!isLoading)returnnullreturn(<div style={{border:'1px solid #ccc',padding:'15px',borderRadius:'5px',maxHeight:'400px',overflowY:'auto',}}ref={responseRef}><h3 style={{marginTop:0}}>Response:</h3><div style={{whiteSpace:'pre-wrap',wordBreak:'break-word'}}>{response}{!response&&isLoading&&(<span style={{opacity:0.5}}>Thinking:{thinking}</span>)}{isLoading&&<span style={{opacity:0.5}}></span>}</div></div>)})ResponseDisplay.displayName='ResponseDisplay'exportdefaultfunctionPage(){const[prompt,setPrompt]=useState('')constresponseContainerRef=useRef<HTMLDivElement>(null)const{response,thinking,isLoading,error,sendMessage,cancel}=useOllamaStream()consthandleSubmit=useCallback((e:React.FormEvent)=>{e.preventDefault()if(isLoading){cancel()}else{sendMessage(prompt)}},[prompt,isLoading,sendMessage,cancel])constcontainerStyle=useMemo(()=>({maxWidth:'800px',margin:'0 auto',padding:'20px',}),[])consttextareaStyle=useMemo(()=>({width:'100%',minHeight:'100px',padding:'10px',marginBottom:'10px',fontSize:'16px',resize:'vertical'asconst,}),[])constbuttonStyle=useMemo(()=>({padding:'10px 20px',fontSize:'16px',cursor:isLoading?'not-allowed':'pointer',backgroundColor:isLoading?'#ff4444':'#0070f3',color:'white',border:'none',borderRadius:'5px',marginRight:'10px',}),[isLoading])return(<div style={containerStyle}><h1>Hello InstantMind</h1>{error&&(<div style={{backgroundColor:'#ffebee',color:'#c62828',padding:'10px',borderRadius:'5px',marginBottom:'15px'}}>Error:{error}</div>)}<form onSubmit={handleSubmit}style={{marginBottom:'20px'}}><textarea value={prompt}onChange={(e)=>setPrompt(e.target.value)}placeholder="Enter your prompt here..."style={textareaStyle}disabled={isLoading}/><div><button type="submit"style={buttonStyle}>{isLoading?'Cancel':'Send'}</button></div></form><ResponseDisplay response={response}thinking={thinking}isLoading={isLoading}/></div>)}

六、性能优化亮点

  1. useCallback缓存函数:避免不必要的函数重新创建
  2. useMemo缓存样式:样式对象每次渲染都是新的,用useMemo可以避免子组件不必要的重渲染
  3. React.memo缓存子组件ResponseDisplay组件只有在responseisLoading变化时才重新渲染
  4. AbortController取消请求:支持中途取消生成,体验更好
  5. 自动滚动到底部:内容过长时自动跟随

七、运行项目

启动 Next.js 开发服务器:

npmrun dev

然后访问 http://localhost:3000/instamind,输入问题试试看!

八、常见问题

Q: Ollama 连接失败怎么办?
A: 确保 Ollama 服务正在运行,检查 http://localhost:11434 是否可以访问。

Q: 响应速度很慢?
A: 可以尝试更小的模型,比如qwen3:1.8b,或者升级电脑硬件。

Q: 可以更换其他模型吗?
A: 当然!在 API 路由中修改model参数即可,Ollama 支持的模型都可以用。

总结

今天我们实现了:

  1. ✅ 本地 Ollama 服务搭建
  2. ✅ Qwen3 模型下载和运行
  3. ✅ Next.js API 路由流式转发
  4. ✅ 前端流式响应展示
  5. ✅ 请求取消、自动滚动等优化

这篇是 AI Mind 系列的起点:从本地大模型流式聊天开始,后续逐步扩展 Tool Calling、MCP、Skill Runtime 和 Agent。项目可在 GitHub 搜索 HWYD/ai-mind 查看。

👉 GitHub: https://github.com/HWYD/ai-mind

如果对这个方向感兴趣,也欢迎点个 Star,我会继续按版本更新实现过程和设计复盘。


参考资料:

  • Ollama 官方文档
  • Next.js API Routes
  • ReadableStream API
http://www.jsqmd.com/news/934860/

相关文章:

  • 银川市黄金回收铂金回收白银回收彩金回收店铺TOP5实力权威排行榜+联系方式推荐 2026最新诚信优选 - 亦辰小黄鸭
  • 告别Win10!手把手教你将华硕笔记本GPT分区无损转MBR装Win7(附BIOS设置详解)
  • 十二年保险拒赔维权经验 李晓伟律师很专业 - 行路心安
  • Switch大气层系统安装指南:5步完成破解并解锁完整自定义功能
  • 别再只会点下载按钮了!深度解析STM32CubeIDE下载配置与ST-LINK工作原理
  • LrcHelper:网易云音乐双语歌词下载工具全攻略
  • Python003-第二章02.常见数据类型
  • ctf.bugku-这是一张单纯的图片
  • 实测才敢推!盘点2026年用户挚爱的的降AI率平台 - 降AI小能手
  • 从ISO到Web服务:用Nginx在openEuler上为团队搭建一个高速内网yum源服务器
  • 不只是搭环境:用Veins+SUMO在OMNeT++里跑通第一个车联网仿真场景(含地图缩放与结果解读)
  • 认准官方渠道下载剑与翼,完整游戏内容+职业玩法全分享
  • 济南旧金变现怎么选?对比庆鉴伯纳等回收商,合扬整体体验更好 - 合扬奢侈品交易中心
  • Windows下MMDetection从安装到跑通第一个目标检测Demo(含权重文件下载与路径配置避坑)
  • 告别连接失败!FinalShell连不上Ubuntu虚拟机的5个常见坑及排查指南
  • 智能视频内容提取实战指南:一站式自动化解决方案
  • 单比特奇迹:如何在本地设备运行 4B 图像生成模型?
  • 聊城市黄金回收铂金回收白银回收彩金回收店铺TOP5实力权威排行榜+联系方式推荐 2026最新诚信优选 - 亦辰小黄鸭
  • ZLToolKit 源码分析(四):TaskExecutor 与 WorkThreadPool 任务调度
  • 鹰潭市黄金回收铂金回收白银回收彩金回收店铺TOP5实力权威排行榜+联系方式推荐 2026最新诚信优选 - 亦辰小黄鸭
  • IX7008@ACP#8 通道 PCIe 3.0 低功耗交换芯片,迷你主机 TRAE SOLO 稳定扩展
  • Nginx双栈配置实战:让网站同时拥抱IPv4与IPv6访客
  • 2026年6月国内质量流量计厂家十大品牌盘点:谁在真正解决计量难题? - 流量计品牌
  • 电脑硬盘的隐藏的文件夹不见了怎么办,6种恢复方式和视频详解,让你的数据顺利修复!
  • 如何快速掌握BepInEx:游戏模组开发的终极解决方案指南
  • 刷爆朋友圈的 H5!用 Stable Diffusion 动态生成与大模型流式输出(SSE) 的前端落地指南
  • 怎么选择一款合适的四级式电导率设备?哪些厂家值得信赖? - 仪表人小余
  • 告别懵圈!手把手教你用AUTOSAR工具链(ISOLAR/EB Tresos)配置LIN总线通信
  • PyTorch环境下的d2l库安装:从Jupyter Notebook到VSCode的完整配置流程
  • 广州周年庆活动策划哪个有经验