首页 / 文章

30分钟内即可使用的实用本地大语言模型:编程、文档问答与语音功能

利用Ollama、Qwen3和Whisper.cpp,30分钟内即可拥有实用的本地大语言模型:它既是编程助手,又能基于你的笔记实现RAG功能,同时还支持根据内存大小定制的离线语音循环。

发布者:REACTAPP.TOP阅读约 14 分钟3183 词

来源: Run a Useful Local LLM in 30 Minutes (Coding, RAG, Voice)

即便断开网络,使用已经放在桌上的笔记本电脑,本地模型也能解答真正的编程问题。设置过程大约需要八分钟,且无需支付任何月费。只需输入如下这样的提示语,就能看到它发挥作用。

$ ollama run qwen3:8b
>>> rewrite this function to use async/await

令人意外的是它的质量。在复杂的多步骤推理任务上,它虽不及最优秀的云端模型,但在处理常规工程任务时表现足够可靠,使得这种工具从单纯的周末娱乐项目转变为实用工具的差距已大幅缩小。

在过去一年半的时间里经过三轮迭代后,它终于具备了实用性。模型文件大小已缩小到可容纳在普通RAM中:Qwen 3的80亿参数版本大小约为5GB。2026年2月发布的Qwen3-Coder在SWE-bench测试中的得分达到70.6%,它采用300亿参数的专家混合设计,每个标记仅激活30亿参数。得益于Ollama,其安装过程也从复杂的Python环境配置简化为仅需一个二进制文件即可完成。

下面是三个独立的版本,每个版本的开发时间约为半小时:一个是编辑器内的编码助手,一个是能够回答关于文档文件夹相关问题的 Python 脚本,还有一个可以聆听并回应语音的循环系统。它们都基于五分钟的开发基础,之后再各自发展,因此明智的做法是选择最贴近日常工作的那个,其余的则留到以后再处理。

共同的基础部分

每条开发路径都需要一个推理引擎以及一组模型权重。

Ollama 扮演引擎的角色。它能处理硬件差异,判断机器是配备了 NVIDIA GPU、Apple Silicon 芯片还是仅有 CPU,并据此准备相应的执行图。它在后台运行,同时提供一个本地 HTTP API,其功能类似于大型云服务提供商的接口。

安装方式因平台而异。在 macOS 系统上,可通过 Homebrew 完成安装:

brew install ollama

Linux 有一个安装脚本:

curl -fsSL https://ollama.com/install.sh | sh

Windows用户可以从Ollama网站获取MSI安装程序。

安装好引擎后,接下来就是模型选择。这里的基准模型是qwen3:8b,它需要大约5GB的磁盘空间,16GB的RAM即可正常运行。如果没有独立显卡,Ollama会转而使用CPU进行推理,处理速度约为每秒8到12个标记,这样的速度依然足以保证文本的可读性。

下载地址:

ollama pull qwen3:8b

然后通过快速运行确认一切正常:

ollama run qwen3:8b "Write a python script to reverse a string."

回复内容会实时显示在终端中。在后台,Ollama会加载GGUF格式的文件,这种格式能够将磁盘中的模型权重直接映射到RAM中,因此查看系统监控时会发现内存使用量随文件大小而上升。

内存为8GB或更低的机器将会开始将数据交换到磁盘上,从而导致推理速度变得极其缓慢。对于这类机器,qwen3:4b或gemma3:4b是更合适的选择。而拥有24GB显存的设备则可以支持更高版本的qwen3:14b作为升级选择。

路径A:编辑器中的编程助手

那些经常使用代码编辑器的人能最快体验到其优势。其目标是将本地模型与VS Code相连,用于代码重构、文档生成以及模板编写等工作。

Qwen 3通用模型表现不错,但处理代码任务时需要专门的训练。截至2026年5月,Qwen3-Coder是功能最全面的全本地代码模型,在SWE-bench Verified测试中的得分率为70.6%,在SWE-Bench Pro测试中的得分率为44.3%。它的总参数量为300亿,但每个标记仅会激活30亿个参数,因此只需24GB的VRAM即可运行,或在拥有32GB统一内存的M系列Mac上流畅运行。

没有独立GPU的16GB笔记本很可能无法运行300亿参数的模型。此时可选用qwen2.5-coder:7b版本,它仅需4.7GB内存,且在HumanEval测试中的得分率仍可达约92%。

# For 24GB+ VRAM or 32GB+ Mac Unified Memory
ollama pull qwen3-coder:30b

# For 16GB RAM laptops
ollama pull qwen2.5-coder:7b

编辑器需要一个前端界面来与模型交互。Cline是一款拥有500多万次安装量的VS Code插件,它能够读取文件、提出差异对比,并可执行终端命令。只需在应用市场搜索其名称即可找到它。

有一个问题值得注意。Cline自带一段较长的系统提示语,说明了如何格式化差异对比、查看文件结构以及使用相关工具,而在附带任何代码之前,这段提示语的长度往往已经超过25,000个标记。Ollama为Qwen 3模型设定的默认上下文窗口大小为40,000个标记。一旦打开多个文件,总标记数很容易超过这个限制,此时Ollama会未经警告就截断提示语。这样一来,模型就会丢失格式化指令,无视相关规则,只能生成纯文本而非有效的差异对比结果。

解决方法是创建一个自定义的Modelfile,从而扩大上下文窗口大小。

为Cline定制的Modelfile

将以下内容保存到工作目录中,文件名为 Modelfile。

# Modelfile — cline-tuned qwen3-coder
# Save as: ./Modelfile

FROM qwen3-coder:30b

# Cline's system prompt is ~25-30K tokens before your code is added.
# Ollama's default num_ctx for Qwen 3 is 40K, but Cline ships an
# expanded system prompt in v3+ that comfortably exceeds it on any
# real task. 65536 is the safe floor for serious use; 131072 if
# you have RAM/VRAM to spare.
PARAMETER num_ctx 65536

# Code edits want low-variance output. The default 0.7 is too loose.
PARAMETER temperature 0.2

# Stop after the model's natural turn. Cline parses this.
PARAMETER stop "<|im_end|>"

使用 Ollama CLI 根据其构建模型,然后检查上下文设置是否已生效:

# Build the Cline-tuned model from the Modelfile in the current dir
ollama create qwen3-coder-cline -f ./Modelfile

# Verify the context window actually took
ollama show qwen3-coder-cline --modelfile | grep num_ctx
# Expected output:  PARAMETER num_ctx 65536

# Sanity-check it answers
ollama run qwen3-coder-cline "write a python one-liner to read /etc/hostname"

在 VS Code 中,通过 Cmd+Shift+P 打开命令面板,选择“Cline settings”选项。将其指向本地引擎:

API Provider:      Ollama
Base URL:          http://localhost:11434
Model:             qwen3-coder-cline
Context Window:    65536

GUI中的上下文窗口大小必须与模型文件中的num_ctx完全一致。如果该数值更小,Cline会认为窗口尺寸较小,从而悄悄限制它发送的文件数量。

想要尝试的话,打开一个包含同步函数的Python文件,调出Cline聊天面板,请求将其重构为async/await格式。Cline会读取该文件,将其发送到本地的Qwen3-Coder模型,获取响应后会在编辑器中显示清晰的差异对比。只需点击一下即可接受,整个处理过程都在离线状态下完成。

这种配置能够处理单文件重构、孤立函数的测试以及样板代码的生成。根据社区测试结果,在范围有限的任务中,它的性能大约能达到前沿云服务的70%到85%。

在涉及大规模多文件架构变更时,该方法会失效。将一个包含20个文件的Flask项目迁移到FastAPI时就会出现问题:线程丢失,导入路径出错,最终导致代码库损坏。如此规模的工作仍适合使用前沿的云模型处理。不过,对于每天需要进行的数十次小型、繁琐的编辑,本地循环就已足够。

方案B:通过自有文档进行检索

这种方案适用于那些由手动搜索的Markdown笔记、会议记录或项目文档文件夹。整个流程仅需约60行普通Python代码,仅使用Ollama客户端和numpy即可实现。

嵌入模型可将文本转换为向量。Nomic Embed Text v1.5拥有1.37亿个参数,可输出768维向量,大小低于300MB,能够处理8192个标记的上下文,并在MTEB检索基准测试中表现优异。

获取嵌入模型并安装 Python 包:

llama pull nomic-embed-text
pip install ollama numpy

然后将脚本保存为 rag.py。

"""Local RAG over a folder of notes — Ollama embeddings + numpy cosine.

No vector DB. For a few hundred docs, an in-memory list + numpy is
faster to set up and fast enough to run. Swap in sqlite-vec when
the corpus outgrows it (typically past ~5000 chunks).

Setup:
    ollama pull qwen3:8b
    ollama pull nomic-embed-text
    pip install ollama numpy

Run:
    python rag.py ./notes "what did the team decide about pricing?"
"""
from __future__ import annotations
import glob
import os
import sys

import numpy as np
import ollama

EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "qwen3:8b"
CHUNK_WORDS = 400          # ~500 tokens; fits 4 chunks in an 8K context
TOP_K = 3


def load_chunks(folder: str) -> list[str]:
    """Read every .md / .txt under folder, split into ~CHUNK_WORDS chunks."""
    chunks: list[str] = []
    for path in glob.glob(os.path.join(folder, "**/*"), recursive=True):
        if not path.endswith((".md", ".txt")):
            continue
        with open(path, encoding="utf-8") as f:
            words = f.read().split()
        for i in range(0, len(words), CHUNK_WORDS):
            chunk = " ".join(words[i : i + CHUNK_WORDS])
            if chunk.strip():
                chunks.append(chunk)
    return chunks


def embed(texts: list[str]) -> np.ndarray:
    """Embed a batch of texts via Ollama. Returns an (N, D) matrix."""
    resp = ollama.embed(model=EMBED_MODEL, input=texts)
    return np.array(resp["embeddings"], dtype=np.float32)


def top_k_indices(
    query_vec: np.ndarray, doc_mat: np.ndarray, k: int
) -> list[int]:
    """Return indices of the k most cosine-similar rows in doc_mat.

    The trick: if both vectors are unit-length (norm == 1), their
    dot product equals their cosine similarity. So we normalize
    once, then one matrix multiply gives a similarity score for
    every chunk against the query — no Python loop needed.
    """
    # Normalize query and every chunk to unit length.
    # The 1e-8 prevents division by zero on a zero vector.
    q = query_vec / (np.linalg.norm(query_vec) + 1e-8)
    d = doc_mat / (np.linalg.norm(doc_mat, axis=1, keepdims=True) + 1e-8)

    # One matmul across all chunks: scores[i] = cos(query, chunk_i).
    scores = d @ q

    # Sort descending, take the first k indices.
    return np.argsort(scores)[::-1][:k].tolist()


def answer(query: str, chunks: list[str], doc_mat: np.ndarray) -> str:
    """Retrieve top-K chunks, stuff them into a prompt, generate."""
    q_vec = embed([query])[0]
    idx = top_k_indices(q_vec, doc_mat, k=TOP_K)
    context = "\n\n---\n\n".join(chunks[i] for i in idx)

    prompt = (
        "Answer the question using ONLY the context below. "
        "If the context does not contain the answer, say so plainly.\n\n"
        f"CONTEXT:\n{context}\n\nQUESTION: {query}"
    )
    resp = ollama.chat(
        model=CHAT_MODEL,
        messages=[{"role": "user", "content": prompt}],
    )
    return resp["message"]["content"]


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit('usage: python rag.py <folder> "<question>"')

    folder, query = sys.argv[1], sys.argv[2]

    chunks = load_chunks(folder)
    if not chunks:
        sys.exit(f"no .md or .txt files found under {folder}")

    print(f"indexing {len(chunks)} chunks...")
    doc_mat = embed(chunks)

    print(answer(query, chunks, doc_mat))

将它指向笔记目录并提出问题:

python rag.py ~/Documents/meeting_notes "what did the team decide about pricing?"

该脚本会遍历文件夹中的文件,将每份文件分割成400字左右的段落。所有这些段落会一次性发送到Ollama的嵌入端点。对于每个问题,都会通过余弦相似度将查询向量与每个文档向量进行比对。

得分最高的三个段落会被加入生成提示中,随后qwen3:8b会据此撰写答案。该提示中有一条严格的要求:只能使用所提供的上下文来回答,如果上下文无法提供答案,则需明确说明。当检索结果为空时,本地模型很容易编造出看似有用的内容,而这条规则则迫使它们承认自己不知道答案。

这并非用于实际应用的检索方式。分块处理是一种粗略的字符计数方法,有时会将句子一分为二。嵌入向量存储在内存中,程序退出后就会消失,因此每次运行时都必须重新对语料库进行嵌入。当收集的文档数量超过几千份且启动变得十分耗时时,可以用 sqlite-vec 替代 numpy 矩阵,将向量存储在本地的 SQLite 文件中。对于个人笔记而言,这样的简单脚本已经足够。

路径 C:语音循环

若想在不输入文字且不将音频发送到云端服务的情况下与模型对话,此路径可在本地硬件上构建完整的语音到语音循环。

该流程由三部分组成:用于转录麦克风输入的语音识别技术、用于生成回复的大型语言模型,以及用于将回复读出的文本转语音技术。

转录功能使用的是Whisper.cpp,这是对OpenAI的Whisper进行深度优化的C++版本,可在CPU、Apple Metal及NVIDIA CUDA平台上运行。它需要ffmpeg作为音频转换的依赖项。在macOS系统中:

brew install whisper-cpp ffmpeg

在 Linux 系统上,可通过包管理器安装 ffmpeg,然后克隆 whisper.cpp 仓库并运行 make 命令。

Whisper.cpp 需要 GGML 格式的权重文件。约 150MB 大小的 ggml-base.en.bin 文件可从官方 Hugging Face 仓库获取,用于英语语音合成时速度快且准确度较高。如需处理其他语言或追求更高准确度,可使用 ggml-large-v3.bin 文件。将该文件放入如 ~/models/ 这样的已知文件夹中。

语音输出由 Kokoro ONNX 提供,这是一种轻量级的文本转语音引擎,其权重文件总大小约为 300MB,在普通 CPU 上即可实现近乎实时的语音合成。该引擎目前要求使用 Python 3.10 至 3.13 版本,在 3.14 版本上无法安装。

pip install -U sounddevice ollama kokoro-onnx

流水线脚本

将以下内容保存为voice.py,并修改WHISPER_MODEL路径,使其指向刚下载的GGML文件。

"""Local voice loop — mic → whisper.cpp → Ollama → Kokoro → speaker.

Everything runs offline. Nothing leaves the machine.

Setup:
    brew install whisper-cpp ffmpeg                  # macOS
    # (Linux: apt install ffmpeg; build whisper.cpp from source)

    pip install -U sounddevice ollama kokoro-onnx

    # Whisper GGML model — pick one:
    #   ggml-base.en.bin   (~150MB, fast, English-only)
    #   ggml-large-v3.bin  (~3GB, accurate, multilingual)
    # Download from https://huggingface.co/ggerganov/whisper.cpp

    # Kokoro model files auto-download to ~/.cache/kokoro-onnx/
    # on first run, or grab them manually from
    # https://github.com/thewh1teagle/kokoro-onnx/releases

Run:
    python voice.py
"""
from __future__ import annotations
import subprocess
import tempfile
import wave
from pathlib import Path

import sounddevice as sd
import ollama
from kokoro_onnx import Kokoro

WHISPER_MODEL = Path.home() / "models" / "ggml-base.en.bin"
CHAT_MODEL = "qwen3:8b"
KOKORO_VOICE = "af_sarah"      # see kokoro voices.json for all options
SAMPLE_RATE = 16000            # whisper.cpp requires 16kHz mono
RECORD_SECONDS = 5

# Kokoro auto-downloads the model files on first instantiation.
kokoro = Kokoro("kokoro-v1.0.onnx", "voices-v1.0.bin")


def record() -> Path:
    """Record from the default mic, write a 16kHz mono WAV, return its path."""
    print(f"listening for {RECORD_SECONDS}s...")
    audio = sd.rec(
        int(RECORD_SECONDS * SAMPLE_RATE),
        samplerate=SAMPLE_RATE,
        channels=1,                    # mono
        dtype="int16",                 # 16-bit PCM is what wave.open expects
    )
    sd.wait()                          # block until recording finishes

    # Fixed path in the system tempdir — overwritten each invocation.
    wav_path = Path(tempfile.gettempdir()) / "voice_in.wav"
    with wave.open(str(wav_path), "wb") as w:
        w.setnchannels(1)              # mono
        w.setsampwidth(2)              # 2 bytes per sample == int16
        w.setframerate(SAMPLE_RATE)    # 16 kHz — whisper.cpp's required rate
        w.writeframes(audio.tobytes())
    return wav_path


def transcribe(wav_path: Path) -> str:
    """Run whisper.cpp on a WAV file, return the transcript text."""
    result = subprocess.run(
        [
            "whisper-cli",
            "-m", str(WHISPER_MODEL),
            "-f", str(wav_path),
            "-otxt",                   # write the transcript to <input>.txt
            "-np",                     # suppress progress prints on stdout
        ],
        capture_output=True,
        text=True,
        timeout=60,
    )
    if result.returncode != 0:
        raise RuntimeError(f"whisper-cli failed: {result.stderr}")

    # -otxt writes the transcript next to the input file, with .txt
    # tacked onto the existing name — so /tmp/voice_in.wav becomes
    # /tmp/voice_in.wav.txt (same directory, same stem, extra suffix).
    txt_path = wav_path.with_suffix(wav_path.suffix + ".txt")
    return txt_path.read_text(encoding="utf-8").strip()


def respond(text: str) -> str:
    """Send the transcript to the local LLM, return the reply."""
    resp = ollama.chat(
        model=CHAT_MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a voice assistant. Keep replies to one or "
                    "two short sentences. No markdown, no lists."
                ),
            },
            {"role": "user", "content": text},
        ],
    )
    return resp["message"]["content"]


def speak(text: str) -> None:
    """Synthesize the reply with Kokoro and play it back."""
    samples, sample_rate = kokoro.create(
        text, voice=KOKORO_VOICE, speed=1.0, lang="en-us"
    )
    sd.play(samples, sample_rate)
    sd.wait()


if __name__ == "__main__":
    wav = record()
    heard = transcribe(wav)

    if not heard:
        print("nothing heard. exiting.")
        raise SystemExit(0)

    print(f"you said: {heard}")
    reply = respond(heard)
    print(f"model:    {reply}")
    speak(reply)

运行该脚本后,它会等待5秒钟,进行文本转录、生成回复并将其读出。首次运行时会暂停,直到Kokoro将其ONNX文件下载到~/.cache/kokoro-onnx/目录中。

有三种设计选择的重要性超出了表面看上去的程度。必须以16kHz单声道格式进行录音:Whisper是在16kHz音频数据上训练的,而默认麦克风输出的44.1kHz频率会导致生成虚假文本或空输出。将whisper-cli作为独立子进程运行而非Python绑定程序,可以避免锁竞争和有漏洞的C语言扩展问题,而且其文本输出选项会将转录结果保存到文件中,这样就无需再抓取标准输出内容。系统提示还明确禁止使用Markdown格式,因为Kokoro会将星号读出声来,而格式化的回复会破坏使用体验。

该循环仅处理一次对话轮次,不会实时传输音频数据块,也不会保存对话历史记录。真正的对话型智能体会加入语音活动检测功能来判断语音何时结束,而非依赖固定的五秒时间窗口。从基准测试来看,本地硬件能够在几秒钟内完成从语音识别到文本生成,再到语音合成的整个流程。

硬件所能支持的功能

本地模型的性能受限主要在于内存带宽,而非单纯的计算能力。权重在内存与计算核心之间的传输速度,比处理器的运算速度更为重要,而这一因素决定了哪种模型适合某台机器使用。

一款配备16GB内存且没有独立显卡的笔记本电脑,其系统内存带宽最高仅为50到70 GB/s。它可以在CPU上以每秒8到12个token的速度运行qwen3:8b或qwen2.5-coder:7b模型,这类速度足以满足检索和语音处理需求,但用于编码则显得较慢。而苹果的芯片架构通过统一内存将带宽提升至200到400 GB/s,因此配备24到36GB内存的M系列Pro或Max机型可以轻松处理qwen3-coder:30b模型,因为其MoE设计使得每个token仅需要激活30亿个参数。

NVIDIA显卡则用容量换取带宽。拥有12到16GB显存的显卡可以即时运行qwen3:8b模型,经过量化的qwen3-coder:30b模型也能正常运行。而配备24GB或更多显存的显卡,如RTX 3090或4090,可以让300亿参数的模型以全速运行,对于单文件处理任务而言,其性能几乎相当于付费订阅服务。若内存低于8GB,则几乎无法进行编码操作,此时实际可行的选择仅为使用qwen3:4b或phi-4-mini模型来处理语音任务及小型检索任务。

相关阅读

  • RAG中的重排序:交叉编码器与LLM评估器的对比及其延迟问题 —— 第二阶段重排序器如何解决RAG中的候选项排序问题,包括交叉编码器与LLM评估器的比较、延迟成本、故障模式以及评估方法。
  • 基于语义的视频与音频搜索:使用Gemini Embedding 2构建的多模态RAG系统 —— 如何通过为文本、音频、视频和PDF创建统一的嵌入空间来替代转录流程,以及相关技术包括分块处理、ChromaDB、查询扩展、重排序和阈值设置。
  • Graphify、OKF与RAG:您的代码库需要哪种上下文层 —— 基准测试显示Graphify在准确率上与向量RAG相当。本文介绍了Graphify、谷歌的OKF以及向量RAG之间的关联关系,包括故障触发因素及决策矩阵。
  • 在M1 Mac上实现私有多模态RAG:利用Ollama进行选择性图表读取 —— 可离线搜索机密PDF文件:使用PyMuPDF处理文本和表格,搭配小型Qwen视觉模型专门处理图表,实现数字验证,并通过ChromaDB进行父子项检索。