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})}}技术要点解析:
- 流式转发的核心:使用
ReadableStream创建自定义流式响应 - 数据解析:Ollama 返回的是每行一个 JSON 对象,我们逐行解析并提取
response字段 - 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>)}六、性能优化亮点
useCallback缓存函数:避免不必要的函数重新创建useMemo缓存样式:样式对象每次渲染都是新的,用useMemo可以避免子组件不必要的重渲染React.memo缓存子组件:ResponseDisplay组件只有在response或isLoading变化时才重新渲染AbortController取消请求:支持中途取消生成,体验更好- 自动滚动到底部:内容过长时自动跟随
七、运行项目
启动 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 支持的模型都可以用。
总结
今天我们实现了:
- ✅ 本地 Ollama 服务搭建
- ✅ Qwen3 模型下载和运行
- ✅ Next.js API 路由流式转发
- ✅ 前端流式响应展示
- ✅ 请求取消、自动滚动等优化
这篇是 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
