1. 为什么要在本地跑大模型对话助手

把大模型跑在自己机器上,这件事从 2024 年开始就不再是实验室里的玩具了。我身边不少做后端、做嵌入式的朋友,甚至一些非科班出身的产品经理,都在折腾本地部署。原因很直接:数据不出本机、调用不花钱、断网也能用、想怎么改就怎么改。而 FastAPI + Ollama 这套组合,恰好是目前上手门槛最低、扩展性又足够强的一条路。

先说清楚这套东西到底是什么。 Ollama 是一个把大模型权重、推理引擎、模型管理打包在一起的运行时,你不需要懂 CUDA、不需要手动编译 llama.cpp,一条命令就能把模型拉下来跑起来。 FastAPI 则是 Python 生态里性能靠前的 Web 框架,基于 ASGI,天生支持异步和流式响应。把这两个拼在一起,你得到的就是一个 跑在 localhost 上的、带 HTTP 接口的、支持流式输出的对话服务 。前端可以是网页、可以是桌面客户端、也可以是手机 App,只要它能发 HTTP 请求,就能接上你自己的大模型。

这套方案解决的核心问题是: 把"模型能力"和"应用逻辑"解耦 。Ollama 负责推理,FastAPI 负责会话管理、上下文拼接、流式转发、鉴权、日志。你换模型不用改业务代码,改业务逻辑也不用碰模型。适合谁来参考?我总结了三类:一是想给自己做个私人助手的开发者;二是需要在内网环境跑 AI 能力、又不能用外部接口的团队;三是正在学 AI 应用开发、想搞明白"大模型接口到底怎么封装"的学生和转行者。哪怕你只会一点 Python,跟着走也能跑通。

我踩过的第一个坑就是:很多人一上来就去研究怎么量化、怎么调 GPU 显存,结果卡在环境上三天没跑起来。其实正确的顺序是 先让最小闭环跑通,再谈优化 。下面我按这个思路,把整套东西拆开讲。

2. 整体架构设计与技术选型思路

2.1 三层结构:模型层、服务层、接入层

我习惯把这套系统画成三层,虽然实际部署可能都在一台机器上,但逻辑上必须分清,否则后期扩展会很痛苦。

模型层 就是 Ollama。它对外暴露一个默认在 11434 端口的 HTTP 服务,提供 /api/generate 、 /api/chat 、 /api/tags 等接口。你所有的模型加载、卸载、推理都在这一层完成。它的好处是模型格式统一(GGUF 为主),管理命令简单, ollama pull 、 ollama run 、 ollama list 三板斧走天下。

服务层 是 FastAPI。它不直接碰模型文件,只通过 HTTP 调用 Ollama。这一层要做的事情比想象中多:接收前端请求、校验参数、拼接历史对话、调用 Ollama、把流式结果转发回去、处理中断、记录日志、做限流。很多人以为这层就是个"转发器",其实它才是整个应用的大脑。

接入层 可以是任何东西。我自己常用的是一个简单的 HTML 页面,用 fetch 加 ReadableStream 接收 SSE 流。如果你要做 App,那就换成对应的网络库。这一层的关键是 必须支持流式读取 ,否则用户要盯着空白屏幕等十几秒,体验极差。

为什么这么分层?因为 每一层的变更频率不一样 。模型层你可能一周换一次模型,服务层你可能天天改提示词逻辑,接入层你可能随时调整 UI。分层之后,改任何一层都不影响其他层。我见过有人把 Ollama 调用直接写在前端 JS 里,结果模型地址一换,整个前端都要重新打包,这就是没分层的代价。

2.2 为什么是 FastAPI 而不是 Flask 或 Django

这个问题我被问过很多次。先说结论: 做流式 AI 接口,FastAPI 是目前 Python 里最省心的选择 。

Flask 是同步框架,虽然也能做流式(用生成器 + stream_with_context ),但一旦并发上来,每个请求占一个线程,几十个用户同时对话就会卡。Django 太重,ORM、模板、中间件一大堆,做个纯 API 服务属于杀鸡用牛刀。FastAPI 基于 Starlette 和 Pydantic,原生 async,流式响应用 StreamingResponse 一行搞定,请求体校验用 Pydantic 模型自动完成,还能自动生成 Swagger 文档,调试接口特别方便。

具体到代码层面,FastAPI 处理流式的写法是这样的:

from fastapi.responses import StreamingResponse

async def event_generator():
    async for chunk in call_ollama_stream(prompt):
        yield f"data: {chunk}\n\n"

@app.post("/chat")
async def chat(req: ChatRequest):
    return StreamingResponse(event_generator(), media_type="text/event-stream")

StreamingResponse 接收一个异步生成器,边生成边发送,内存占用极低。这一点在长回答场景下优势明显——模型生成 2000 字,不会先在内存里攒完再发,而是生成一个字发一个字。

2.3 为什么用 Ollama 而不是直接上 vLLM 或 Transformers

vLLM 吞吐量确实高,适合多并发生产环境,但它的部署复杂度也高,要配 CUDA 版本、要调显存参数、模型格式还得转换。Transformers 更底层,写个推理脚本要处理 tokenizer、device map、量化加载,对新手不友好。

Ollama 的定位是 单机、低门槛、够用 。它内置了 llama.cpp 作为推理后端,支持 CPU 和 GPU 混合推理,模型拉下来就能跑。对于个人助手、内网小团队这种场景,它的性能完全够。我实测过,一台 16G 内存的普通笔记本跑 7B 量化模型,对话响应速度在可接受范围内。等你真的遇到并发瓶颈,再迁移到 vLLM 也不迟,因为服务层的代码几乎不用改,只换模型层的调用地址就行。

提示:选型时不要一上来就追求"最强性能",先问自己"我的并发量有多大"。个人用和百人团队用,方案完全不同。

2.4 流式输出为什么必须做

大模型生成是逐 token 的,如果不用流式,用户要等整个回答生成完才能看到内容。一个 500 字的回答,7B 模型可能要 10 到 20 秒。这 20 秒里界面一片空白,用户会以为程序卡死了。

流式输出把"等待"变成了"阅读"。用户看到字一个个蹦出来,心理感受完全不同。技术上,Ollama 的 /api/chat 接口本身就支持 stream: true ,返回的是 NDJSON(每行一个 JSON 对象)。FastAPI 要做的是把这些 JSON 解析出来,提取 message.content 字段,再以 SSE 格式推给前端。SSE 的格式很简单,就是 data: 内容\n\n ,前端用 EventSource 或者 fetch 的流读取都能处理。

这里有个细节: SSE 的每条消息必须以两个换行结尾 ,否则浏览器不会触发 onmessage 。我第一次写的时候只写了一个 \n ,调试了半天才发现问题。

3. 环境搭建与 Ollama 模型管理实操

3.1 Ollama 安装与国内下载加速

Ollama 的安装本身很简单,官网下载对应系统的安装包,双击或者 curl | sh 就行。但真正的痛点在 下载模型 。官方源在国内速度经常只有几十 KB/s,一个 7B 模型 4GB 多,下到天荒地老。

我的做法是配置镜像源。Ollama 支持通过环境变量指定模型仓库地址,在启动前设置好就行。Linux 下可以写进 systemd 的服务配置,Windows 下设置系统环境变量。配置完之后 ollama pull 的速度能提升一个数量级。具体用哪个镜像,建议去搜当前可用的公共镜像,因为镜像地址会变,我这里不写死。

另一个办法是 离线安装包 。如果你有能高速下载的机器,可以先在那台机器上 ollama pull 好模型,然后整个 ~/.ollama/models 目录拷贝到目标机器。模型文件是分层的,拷贝过去直接能用,不用重新下载。这个技巧在内网部署时特别有用。

安装完成后验证:

ollama --version
ollama list

list 显示空列表是正常的,说明还没拉模型。

3.2 模型选择:7B 还是 14B,量化选哪个

模型选择是新手最容易纠结的地方。我的建议是 先看内存,再看需求 。

内存大小 推荐模型规模 量化等级 体验
8GB 3B 以下 Q4 能跑,回答质量一般
16GB 7B Q4_K_M 流畅,日常够用
32GB 14B Q4_K_M 质量明显提升
64GB+ 32B 或更大 Q4/Q5 接近商用体验

量化等级里, Q4_K_M 是性价比最高的,模型体积约为原始的一半不到,质量损失很小。 Q5 和 Q6 质量更好但更占内存, Q8 基本接近原始精度但体积大。我一般推荐 Q4_K_M 起步,觉得不够再往上加。

至于具体选哪个模型,这取决于你的用途。写代码、做推理任务,选擅长逻辑的;做中文对话,选中文语料训练充分的;做科研辅助,选知识面广的。我的经验是 同时装两三个不同定位的模型 ,用 ollama list 管理,需要时切换。切换模型在服务层只是一个参数的事。

拉模型的命令:

ollama pull qwen2.5:7b
ollama pull deepseek-r1:7b

拉完之后 ollama run qwen2.5:7b 可以直接在终端对话,用来快速验证模型是否正常。

3.3 Ollama 服务化与开机自启

默认情况下 Ollama 安装后会自动作为后台服务运行,监听 127.0.0.1:11434 。但如果你想让局域网内其他机器访问,需要改监听地址。设置环境变量 OLLAMA_HOST=0.0.0.0:11434 后重启服务。

注意:改成 0.0.0.0 意味着同网络下任何人都能调用你的模型,如果是在公共网络,务必配合防火墙规则限制来源 IP。我一般只在可信内网这么做。

Linux 下用 systemd 管理:

sudo systemctl edit ollama.service

在编辑器中加入:

[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_MODELS=/data/ollama/models"

OLLAMA_MODELS 用来把模型存到大容量磁盘,默认存在用户目录下,系统盘小的话很快就满了。改完 systemctl daemon-reload && systemctl restart ollama 生效。

3.4 验证 Ollama 接口是否正常

在写 FastAPI 之前,先用 curl 确认 Ollama 能正常响应:

curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:7b",
  "messages": [{"role": "user", "content": "你好"}],
  "stream": false
}'

能返回 JSON 就说明模型层没问题。这一步很重要, 先把模型层单独验证通过,再写服务层 ,否则出了问题你不知道是 Ollama 的锅还是 FastAPI 的锅。我见过太多人两层一起写,最后排查到崩溃。

4. FastAPI 服务层核心实现

4.1 项目目录结构怎么组织

一个能长期维护的 FastAPI 项目,目录结构不能太随意。我常用的结构是这样的:

ai-assistant/
├── app/
│   ├── __init__.py
│   ├── main.py              # 应用入口
│   ├── config.py            # 配置管理
│   ├── models/
│   │   └── schemas.py       # Pydantic 模型
│   ├── routers/
│   │   └── chat.py          # 对话路由
│   ├── services/
│   │   └── ollama_client.py # Ollama 调用封装
│   └── utils/
│       └── logger.py        # 日志工具
├── requirements.txt
└── .env

为什么这么分? routers 放路由, services 放业务逻辑, models 放数据结构。路由层只负责接收请求和返回响应,具体怎么调 Ollama 交给 service。这样你以后要加"文档问答"、"图片理解"等功能,只需要新增 router 和 service,不用动现有代码。

config.py 用 Pydantic 的 BaseSettings 读取 .env 文件,把 Ollama 地址、默认模型名、超时时间这些配置集中管理。硬编码在代码里的配置是维护噩梦,改一次要翻遍所有文件。

4.2 请求与响应模型设计

Pydantic 模型是 FastAPI 的灵魂。定义好请求体,FastAPI 会自动校验,参数不对直接返回 422,省掉大量手写校验代码。

from pydantic import BaseModel, Field
from typing import List, Optional

class Message(BaseModel):
    role: str = Field(..., pattern="^(user|assistant|system)$")
    content: str

class ChatRequest(BaseModel):
    model: str = "qwen2.5:7b"
    messages: List[Message]
    stream: bool = True
    temperature: float = Field(0.7, ge=0.0, le=2.0)

class ChatResponse(BaseModel):
    content: str
    model: str
    done: bool

Field 里的约束很关键。 temperature 限制在 0 到 2 之间, role 用正则限制只能是三个值之一。这些约束在请求进来时就生效,脏数据根本进不到业务逻辑。我强烈建议 把所有能约束的字段都加上约束 ,这是防御性编程的第一道墙。

4.3 调用 Ollama 的异步封装

调用 Ollama 用 httpx 的异步客户端,不要用 requests ,因为 requests 是同步的,会阻塞事件循环。

import httpx
import json
from typing import AsyncGenerator

class OllamaClient:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self.client = httpx.AsyncClient(timeout=120.0)

    async def chat_stream(self, model: str, messages: list, temperature: float) -> AsyncGenerator[str, None]:
        payload = {
            "model": model,
            "messages": messages,
            "stream": True,
            "options": {"temperature": temperature}
        }
        async with self.client.stream("POST", f"{self.base_url}/api/chat", json=payload) as resp:
            async for line in resp.aiter_lines():
                if not line.strip():
                    continue
                data = json.loads(line)
                content = data.get("message", {}).get("content", "")
                if content:
                    yield content
                if data.get("done"):
                    break

这段代码有几个要点。第一, timeout 要设长,大模型生成慢,默认 5 秒肯定超时。第二,用 client.stream 而不是 client.post ,后者会把整个响应读完才返回,流式就失效了。第三, aiter_lines 按行读取,因为 Ollama 返回的是 NDJSON。第四,遇到 done: true 要主动 break,否则会多读一次空行。

4.4 SSE 流式响应与中断处理

服务层的路由把 Ollama 的流转换成 SSE:

from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse

router = APIRouter()

@router.post("/chat")
async def chat(req: ChatRequest, request: Request):
    async def generate():
        try:
            async for chunk in ollama_client.chat_stream(
                req.model, [m.dict() for m in req.messages], req.temperature
            ):
                if await request.is_disconnected():
                    break
                yield f"data: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
            yield "data: [DONE]\n\n"
        except Exception as e:
            yield f"data: {json.dumps({'error': str(e)})}\n\n"

    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
    )

request.is_disconnected() 是中断处理的关键。用户点了"停止生成"或者关掉页面,连接断开,这个检查会返回 True,生成器就 break,不再继续调用 Ollama。 没有这个检查,即使用户走了,后端还在傻傻地生成,浪费算力 。

X-Accel-Buffering: no 这个响应头是给 Nginx 看的。如果你前面挂了 Nginx 反代,不加这个头,Nginx 会缓冲整个响应,流式效果就没了。这个坑我踩过,本地测试好好的,一上 Nginx 就变成"等半天然后一次性出现"。

4.5 上下文管理与历史裁剪

对话助手必须记住上下文,但上下文不能无限增长。大模型的上下文窗口有限,7B 模型通常 8K 到 32K token,塞太多历史会超限,而且推理变慢。

我的做法是 保留最近 N 轮对话 + 系统提示词 。N 一般取 10 到 20。实现上就是在拼接 messages 时做裁剪:

def build_messages(history: list, system_prompt: str, max_turns: int = 15):
    messages = [{"role": "system", "content": system_prompt}]
    recent = history[-max_turns * 2:]
    messages.extend(recent)
    return messages

max_turns * 2 是因为一轮对话包含 user 和 assistant 两条消息。系统提示词永远放在最前面,且不参与裁剪。

更精细的做法是按 token 数裁剪而不是按轮数,用 tiktoken 之类的库估算 token。但对个人助手来说,按轮数足够了,简单可靠。我试过按 token 裁剪,代码复杂度上升不少,收益不明显。

提示:系统提示词决定了助手的"人设"和"能力边界"。写提示词时把角色、语气、禁止事项都写清楚,比事后调参有效得多。

5. 前端接入与流式渲染实战

5.1 用 fetch 读取 SSE 流

浏览器端接收 SSE 有两种方式: EventSource 和 fetch + ReadableStream 。 EventSource 只支持 GET 请求,而我们的对话接口是 POST,所以只能用 fetch 。

async function sendMessage(messages) {
  const resp = await fetch('/chat', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({messages, stream: true}),
    signal: abortController.signal
  });

  const reader = resp.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const {done, value} = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, {stream: true});
    const lines = buffer.split('\n\n');
    buffer = lines.pop();
    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data === '[DONE]') return;
        const parsed = JSON.parse(data);
        appendToUI(parsed.content);
      }
    }
  }
}

这里有个容易出错的地方: 网络传输是分片的,一个 SSE 消息可能被拆成多个 chunk 。所以不能假设每次 reader.read() 拿到的就是一个完整消息,必须用 buffer 累积,按 \n\n 分割,最后一段留在 buffer 里等下次。我第一次写的时候直接对每个 chunk 做 JSON.parse,结果频繁报错,就是这个原因。

5.2 中断生成:AbortController 的正确用法

用户点"停止"按钮时,要真正中断请求,而不是只停止渲染。用 AbortController :

const abortController = new AbortController();

// 发送时传入 signal
fetch('/chat', {signal: abortController.signal, ...});

// 停止时调用
abortController.abort();

abort() 会触发 fetch 的 reject,同时浏览器会关闭连接,后端的 is_disconnected() 就会返回 True,形成完整的中断链路。 前端 abort 和后端 disconnect 检测必须配套 ,只做一边都是假中断。

5.3 渲染性能与 Markdown 处理

大模型返回的内容经常包含 Markdown 格式,代码块、列表、表格都有。直接塞进 innerHTML 会有 XSS 风险,而且每次追加都重新渲染整个内容,长回答会卡。

我的做法是: 流式过程中用纯文本渲染,回答结束后再整体转 Markdown 。流式时用 textContent 追加,性能好且安全; [DONE] 之后调用 markdown 库渲染一次。这样既保证了流式的流畅,又保证了最终格式的正确。

如果一定要流式渲染 Markdown,那就用增量渲染库,但复杂度高很多。对个人项目来说,先纯文本后渲染的方案性价比最高。

5.4 多轮对话的状态管理

前端要维护一个 messages 数组,每次发送时把完整历史带上。用户发一条,push 一条 user 消息;收到完整回答后,push 一条 assistant 消息。注意是 收到完整回答后 才 push,流式过程中不要 push,否则历史里会有半截内容。

let messages = [];

async function handleSend(text) {
  messages.push({role: 'user', content: text});
  let fullReply = '';
  await sendMessage(messages, (chunk) => {
    fullReply += chunk;
    appendToUI(chunk);
  });
  messages.push({role: 'assistant', content: fullReply});
}

这个模式简单可靠。历史存在内存里,刷新页面就没了。如果要持久化,可以存 localStorage 或者后端数据库。存后端的话,FastAPI 配合 SQLAlchemy 就能做,会话表加消息表,按 session_id 关联。这块展开又是一大篇,先不细说。

6. 常见问题排查与避坑经验

6.1 模型下载慢、下载中断怎么办

这是最高频的问题。除了前面说的镜像源和离线拷贝,还有几个技巧。一是 分批下载 ,先拉小模型验证流程,再拉大模型。二是 断点续传 ,Ollama 的下载本身支持续传,中断后重新 pull 会从断点继续,不用从头来。三是 检查磁盘空间 ,空间不足时下载会静默失败, ollama list 里看不到模型,但也不报错,很坑。

6.2 首次响应特别慢是什么原因

第一次调用某个模型时,Ollama 要把模型加载进内存,这个过程可能几十秒。加载完之后,后续请求就快了。这是正常的,不是 bug。解决办法是 服务启动后预热一次 ,发一个空请求把模型加载起来。或者配置 OLLAMA_KEEP_ALIVE 环境变量,让模型在内存里多待一会儿,默认是 5 分钟,可以设成 -1 永久驻留(内存够的话)。

6.3 流式输出变成一次性输出

这个问题几乎每个新手都会遇到。排查顺序:第一,确认 Ollama 请求里 stream: true ;第二,确认 FastAPI 用的是 StreamingResponse 而不是普通返回;第三,确认响应头有 X-Accel-Buffering: no ;第四,确认前端是用流读取而不是 await resp.json() 。这四步走完,基本能定位。

6.4 中文乱码与编码问题

SSE 传输中文时,如果编码没处理好会出现乱码。关键是 json.dumps 时加 ensure_ascii=False ,否则中文会被转成 \uXXXX 形式,虽然前端能解析,但可读性差。另外响应头 Content-Type 要带 charset=utf-8 ,虽然 text/event-stream 默认就是 UTF-8,但显式写上更保险。

6.5 并发请求导致模型反复加载

Ollama 默认同时只能跑一个模型。如果两个请求用了不同模型,它会卸载一个加载另一个,来回切换,性能极差。解决办法是 统一模型 ,或者用 OLLAMA_MAX_LOADED_MODELS 环境变量允许同时加载多个(吃内存)。个人助手场景,统一用一个模型最省事。

问题现象 可能原因 排查方向
下载卡住不动 镜像源慢或磁盘满 换源、查磁盘
首次响应几十秒 模型冷加载 预热、调 keep_alive
流式变一次性 缓冲未关闭 查 stream 参数和响应头
中文乱码 编码未指定 ensure_ascii=False
并发变慢 模型反复切换 统一模型或调并发数
连接莫名断开 超时太短 调大 httpx timeout

6.6 内存不足与 OOM 处理

跑大模型最怕 OOM。系统内存不够时,进程会被系统杀掉,表现为服务突然无响应。预防措施:一是选合适大小的模型,别硬上;二是限制 Ollama 的并发数;三是监控内存,快满时主动卸载模型。Linux 下可以用 free -h 看,或者写个定时脚本检查。

我个人的经验是, 留出至少 2GB 内存给系统和 FastAPI 服务 ,剩下的才给模型。16GB 机器跑 7B Q4 模型,实际占用大概 5 到 6GB,加上系统开销,还算宽裕。但如果你同时开浏览器、IDE,就要小心了。

7. 后续可扩展的方向

跑通最小闭环之后,这套东西能扩展的地方很多。我列几个我实际做过或者正在做的方向。

加 RAG 检索增强 。把本地文档切片、向量化,存进向量库,对话时先检索相关片段塞进提示词。这样助手就能回答你私有资料里的问题。向量库可以用轻量的方案,不一定上重型数据库。

加会话持久化 。用 SQLAlchemy 把会话和消息存进 SQLite 或 PostgreSQL,刷新页面历史还在,还能做多会话切换。FastAPI 和 SQLAlchemy 的配合很成熟,异步版本用 asyncpg 或 aiosqlite 。

加多模型路由 。根据问题类型自动选择模型,代码问题走代码模型,闲聊走通用模型。服务层加一个路由逻辑就行,对前端透明。

加鉴权 。如果服务暴露在内网,至少加个 API Key 校验。FastAPI 的依赖注入做这个很优雅,一个 Depends 就搞定。

打包成桌面应用 。用 Electron 或 Tauri 把前端包起来,后端 FastAPI 和 Ollama 作为子进程启动,做成一个双击就能用的桌面助手。这个方向我折腾过,Tauri 体积小,但 Ollama 的跨平台打包有点麻烦,需要针对不同系统处理。

最后分享一个我踩过的坑: 不要把 Ollama 和 FastAPI 放在同一个进程里管理 。有人图省事用 Python 的 subprocess 启动 Ollama,结果 Ollama 崩了 FastAPI 也不知道,服务就挂了。正确的做法是让 Ollama 作为独立服务运行,FastAPI 只当客户端。两者解耦,各自重启互不影响。这个原则在分布式系统里叫"关注点分离",听起来高大上,其实就是"各管各的,别互相拖累"。

更多推荐