本文为作者原创首发于掘金现同步到 CSDN。项目地址https://github.com/HWYD/ai-mind本文对应版本AI Mind v0.0.1大家好今天我来给大家分享一个非常实用的技术实现不依赖云端 API也可以在本地搭建一个可流式输出的 AI 聊天应用。本文记录如何用 Next.js、Ollama 和 Qwen3 搭建本地大模型聊天应用并为后续 Tool Calling 和 Runtime 扩展打基础。一、先看效果最终我们能实现这样一个功能在本地运行大模型Qwen3:4BNext.js 作为后端服务实现流式转发前端实时展示 AI 的响应打字机效果拉满二、准备工作1. 安装并启动 OllamaOllama 是一个非常优秀的本地大模型运行工具支持各种主流开源模型。下载安装访问 Ollama 官网 下载对应系统的安装包Windows/macOS/Linux 都支持。验证安装安装完成后打开终端运行ollama--version如果看到版本号说明安装成功啦2. 下载 Qwen3:4B 模型Qwen 是阿里开源的系列模型Qwen3:4B 体积小、速度快非常适合在普通电脑上运行。在终端中运行ollama pull qwen3:4b等待下载完成后我们可以测试一下ollama run qwen3:4b如果能正常和 AI 对话说明模型已经准备好了按CtrlC退出。三、Next.js 项目搭建如果你还没有 Next.js 项目可以快速创建一个npx create-next-applatest my-ai-appcdmy-ai-app四、核心实现API 路由开发这是最关键的一步我们需要创建一个 Next.js API 路由来对接 Ollama并实现流式输出。在app/api/ollama/route.ts中import{NextRequest}fromnext/serverexportasyncfunctionPOST(request:NextRequest){try{const{prompt,modelqwen3:4b}awaitrequest.json()if(!prompt){returnnewResponse(Prompt is required,{status:400})}constollamaResponseawaitfetch(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})}// 直接转发原始流conststreamollamaResponse.bodyreturnnewResponse(stream,{headers:{Content-Type:application/x-ndjson; charsetutf-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.tsuse clientimport{useState,useCallback,useRef}fromreactinterfaceUseOllamaStreamOptions{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]useStatestring|null(null)constabortControllerRefuseRefAbortController|null(null)constcanceluseCallback((){if(abortControllerRef.current){abortControllerRef.current.abort()abortControllerRef.currentnull}},[])constsendMessageuseCallback(async(prompt:string){if(!prompt.trim())returnsetIsLoading(true)setResponse()setThinking()setError(null)constcontrollernewAbortController()abortControllerRef.currentcontrollertry{constresawaitfetch(/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})}constreaderres.body?.getReader()constdecodernewTextDecoder()if(reader){letbufferwhile(true){const{done,value}awaitreader.read()if(done)breakbufferdecoder.decode(value,{stream:true})constlinesbuffer.split(\n)bufferlines.pop()||for(constlineoflines){if(!line.trim())continuetry{constdataJSON.parse(line)if(data.response){setResponse(prevprevdata.response)options.onChunk?.(data.response)}// 显示思考过程if(data.thinking){setThinking(prevprevdata.thinking)}if(data.done){break}}catch{continue}}}}options.onComplete?.()}catch(err){if(errinstanceofDOMExceptionerr.nameAbortError){setError(Request cancelled)}else{consterrorMessageerrinstanceofError?err.message:An error occurredsetError(errorMessage)options.onError?.(errinstanceofError?err:newError(errorMessage))}}finally{setIsLoading(false)abortControllerRef.currentnull}},[options])return{response,thinking,isLoading,error,sendMessage,cancel,}}2. 创建主页面page.tsxuse clientimportReact,{useState,useCallback,useMemo,useRef,useEffect}fromreactimport{useOllamaStream}from./useOllamaStreamconstResponseDisplayReact.memo(({response,thinking,isLoading}:{response:string;thinking:string;isLoading:boolean}){constresponseRefuseRefHTMLDivElement(null)useEffect((){if(responseRef.current){responseRef.current.scrollTopresponseRef.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:/h3div style{{whiteSpace:pre-wrap,wordBreak:break-word}}{response}{!responseisLoading(span style{{opacity:0.5}}Thinking:{thinking}/span)}{isLoadingspan style{{opacity:0.5}}▋/span}/div/div)})ResponseDisplay.displayNameResponseDisplayexportdefaultfunctionPage(){const[prompt,setPrompt]useState()constresponseContainerRefuseRefHTMLDivElement(null)const{response,thinking,isLoading,error,sendMessage,cancel}useOllamaStream()consthandleSubmituseCallback((e:React.FormEvent){e.preventDefault()if(isLoading){cancel()}else{sendMessage(prompt)}},[prompt,isLoading,sendMessage,cancel])constcontainerStyleuseMemo(()({maxWidth:800px,margin:0 auto,padding:20px,}),[])consttextareaStyleuseMemo(()({width:100%,minHeight:100px,padding:10px,marginBottom:10px,fontSize:16px,resize:verticalasconst,}),[])constbuttonStyleuseMemo(()({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}h1Hello 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)}placeholderEnter your prompt here...style{textareaStyle}disabled{isLoading}/divbutton typesubmitstyle{buttonStyle}{isLoading?Cancel:Send}/button/div/formResponseDisplay 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 RoutesReadableStream API
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:4BNext.js 作为后端服务实现流式转发前端实时展示 AI 的响应打字机效果拉满二、准备工作1. 安装并启动 OllamaOllama 是一个非常优秀的本地大模型运行工具支持各种主流开源模型。下载安装访问 Ollama 官网 下载对应系统的安装包Windows/macOS/Linux 都支持。验证安装安装完成后打开终端运行ollama--version如果看到版本号说明安装成功啦2. 下载 Qwen3:4B 模型Qwen 是阿里开源的系列模型Qwen3:4B 体积小、速度快非常适合在普通电脑上运行。在终端中运行ollama pull qwen3:4b等待下载完成后我们可以测试一下ollama run qwen3:4b如果能正常和 AI 对话说明模型已经准备好了按CtrlC退出。三、Next.js 项目搭建如果你还没有 Next.js 项目可以快速创建一个npx create-next-applatest my-ai-appcdmy-ai-app四、核心实现API 路由开发这是最关键的一步我们需要创建一个 Next.js API 路由来对接 Ollama并实现流式输出。在app/api/ollama/route.ts中import{NextRequest}fromnext/serverexportasyncfunctionPOST(request:NextRequest){try{const{prompt,modelqwen3:4b}awaitrequest.json()if(!prompt){returnnewResponse(Prompt is required,{status:400})}constollamaResponseawaitfetch(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})}// 直接转发原始流conststreamollamaResponse.bodyreturnnewResponse(stream,{headers:{Content-Type:application/x-ndjson; charsetutf-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.tsuse clientimport{useState,useCallback,useRef}fromreactinterfaceUseOllamaStreamOptions{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]useStatestring|null(null)constabortControllerRefuseRefAbortController|null(null)constcanceluseCallback((){if(abortControllerRef.current){abortControllerRef.current.abort()abortControllerRef.currentnull}},[])constsendMessageuseCallback(async(prompt:string){if(!prompt.trim())returnsetIsLoading(true)setResponse()setThinking()setError(null)constcontrollernewAbortController()abortControllerRef.currentcontrollertry{constresawaitfetch(/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})}constreaderres.body?.getReader()constdecodernewTextDecoder()if(reader){letbufferwhile(true){const{done,value}awaitreader.read()if(done)breakbufferdecoder.decode(value,{stream:true})constlinesbuffer.split(\n)bufferlines.pop()||for(constlineoflines){if(!line.trim())continuetry{constdataJSON.parse(line)if(data.response){setResponse(prevprevdata.response)options.onChunk?.(data.response)}// 显示思考过程if(data.thinking){setThinking(prevprevdata.thinking)}if(data.done){break}}catch{continue}}}}options.onComplete?.()}catch(err){if(errinstanceofDOMExceptionerr.nameAbortError){setError(Request cancelled)}else{consterrorMessageerrinstanceofError?err.message:An error occurredsetError(errorMessage)options.onError?.(errinstanceofError?err:newError(errorMessage))}}finally{setIsLoading(false)abortControllerRef.currentnull}},[options])return{response,thinking,isLoading,error,sendMessage,cancel,}}2. 创建主页面page.tsxuse clientimportReact,{useState,useCallback,useMemo,useRef,useEffect}fromreactimport{useOllamaStream}from./useOllamaStreamconstResponseDisplayReact.memo(({response,thinking,isLoading}:{response:string;thinking:string;isLoading:boolean}){constresponseRefuseRefHTMLDivElement(null)useEffect((){if(responseRef.current){responseRef.current.scrollTopresponseRef.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:/h3div style{{whiteSpace:pre-wrap,wordBreak:break-word}}{response}{!responseisLoading(span style{{opacity:0.5}}Thinking:{thinking}/span)}{isLoadingspan style{{opacity:0.5}}▋/span}/div/div)})ResponseDisplay.displayNameResponseDisplayexportdefaultfunctionPage(){const[prompt,setPrompt]useState()constresponseContainerRefuseRefHTMLDivElement(null)const{response,thinking,isLoading,error,sendMessage,cancel}useOllamaStream()consthandleSubmituseCallback((e:React.FormEvent){e.preventDefault()if(isLoading){cancel()}else{sendMessage(prompt)}},[prompt,isLoading,sendMessage,cancel])constcontainerStyleuseMemo(()({maxWidth:800px,margin:0 auto,padding:20px,}),[])consttextareaStyleuseMemo(()({width:100%,minHeight:100px,padding:10px,marginBottom:10px,fontSize:16px,resize:verticalasconst,}),[])constbuttonStyleuseMemo(()({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}h1Hello 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)}placeholderEnter your prompt here...style{textareaStyle}disabled{isLoading}/divbutton typesubmitstyle{buttonStyle}{isLoading?Cancel:Send}/button/div/formResponseDisplay 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 RoutesReadableStream API