FastAPI + Ollama:本地大模型对话助手实战指南
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 只当客户端。两者解耦,各自重启互不影响。这个原则在分布式系统里叫"关注点分离",听起来高大上,其实就是"各管各的,别互相拖累"。
更多推荐


所有评论(0)