大模型本地部署與API服務教程
目標:在Ubuntu服務器部署本地大模型,并提供API服務,支持局域網下的Windows客戶端調用。
支持兩種部署方式:① 自建FastAPI服務(高定制) ② 使用Ollama(極簡快速)
源碼倉庫:BUAA_A503/glm-ollama-api
文章目錄
- 大模型本地部署與API服務教程
- 1. 硬件與系統準備
- 1.1 服務端要求
- 1.2 客戶端要求
- 2. 安裝Python環境與包管理工具 `uv`
- 2.1 安裝 Python 3.10+
- 2.2 安裝 `uv`
- 3. 創建虛擬環境并安裝依賴
- 3.1 創建虛擬環境
- 3.2 服務器端安裝核心依賴
- 3.3 客戶端安裝核心依賴
- 4. 使用ModelScope下載大模型
- 5. 方式一:構建自定義API服務
- 5.1 接口文檔
- 1. POST `/api/generate` - 生成文本(流式/非流式)
- 接口說明
- 請求體參數
- 響應格式(流式)
- 響應格式(非流式)
- 2. GET `/api/tags` - 列出本地模型
- 接口說明
- 響應示例
- 字段說明
- 3. GET `/` - 健康檢查接口
- 接口說明
- 響應示例
- 5.2 創建API服務腳本
- 5.3 啟動API服務
- 6. 方式二:使用Ollama一鍵部署大模型
- 6.1 安裝Ollama
- 6.2 拉取并運行模型
- 6.3 啟動API服務
- 6.4 Ollama API常用接口
- 1. POST `/api/generate` - 生成文本(流式、非流式)
- 功能說明
- 請求詳情
- 請求體參數
- 響應格式(非流式)
- 響應格式(流式)
- 錯誤響應
- 2. GET `/api/tags` - 列出本地模型
- 功能說明
- 請求詳情
- 響應格式
- 使用場景
- 3.示例調用
- 7. 客戶端開發
- 7.1 獲取服務器IP地址
- 7.2 客戶端主程序
- 7.3 創建并加載索引服務
- 7.4 客戶端運行截圖
- 8. 常見問題與優化建議
- 性能優化建議
- 9. 總結與擴展
- 兩種方式對比
- 擴展方向
1. 硬件與系統準備
1.1 服務端要求
大模型推理對計算資源要求極高,尤其是顯存(VRAM)。
組件 | 最低要求 | 推薦配置 | 說明 |
---|---|---|---|
CPU | 8核 | 16核以上(如Intel i9 / AMD Ryzen 9) | 多核可加速預處理 |
內存 | 32GB | 64GB或更高 | 模型加載和緩存需要大量內存 |
GPU | NVIDIA GPU,顯存 ≥ 12GB(如RTX 3080) | 24GB+(如RTX 4090、A100、A6000) | 必須支持CUDA,顯存越大支持的模型越大 |
存儲 | 100GB SSD | 500GB NVMe SSD | 模型文件較大,7B模型約15GB,70B可達140GB+ |
操作系統 | Ubuntu 20.04+ / Windows 10+ / macOS | Ubuntu 22.04 LTS | 推薦Linux,兼容性更好 |
CUDA & cuDNN | CUDA 11.8+ | CUDA 12.1+ | 必須安裝以啟用GPU加速 |
模型與顯存對照表(INT4量化后):
- 7B模型:約6-8GB顯存
- 13B模型:約10-14GB顯存
- 70B模型:需多卡或CPU推理
1.2 客戶端要求
客戶端僅發送HTTP請求,資源要求極低。
組件 | 要求 | 說明 |
---|---|---|
CPU | 雙核 | 無特殊要求 |
內存 | 4GB | 瀏覽器或腳本運行 |
網絡 | 穩定連接 | 訪問服務端IP:端口 |
工具 | 瀏覽器、curl、Postman、Python腳本 | 任意HTTP客戶端 |
? 結論:服務端需高性能機器,客戶端可為普通PC或手機。
2. 安裝Python環境與包管理工具 uv
uv
是由 Astral 開發的超快 Python 包安裝器和虛擬環境管理工具,性能遠超 pip
和 conda
。
2.1 安裝 Python 3.10+
確保系統已安裝 Python 3.10 或更高版本:
python --version
# 輸出示例:Python 3.10.12
2.2 安裝 uv
# 下載并安裝 uv(支持 Linux/macOS/Windows)
curl -LsSf https://astral.sh/uv/install.sh | sh
驗證安裝:
uv --version
# 輸出示例:uv 0.2.8
?
uv
支持:包安裝、虛擬環境管理、依賴解析、腳本運行,是pip
+venv
+pip-tools
的替代品。
3. 創建虛擬環境并安裝依賴
使用 uv
創建隔離環境,避免依賴沖突。
3.1 創建虛擬環境
# 創建名為 .venv 的虛擬環境
uv venv .venv# 激活虛擬環境
source .venv/bin/activate
激活后,命令行前綴會顯示 (.venv)
。
3.2 服務器端安裝核心依賴
# 升級 pip
uv pip install --upgrade pip# 安裝大模型相關庫
uv pip install \torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 # CUDA 12.1uv pip install \transformers \accelerate \ # 支持多GPU/混合精度fastapi \ # Web框架uvicorn \ # ASGI服務器pydantic \ # 數據校驗modelscope # 魔搭模型下載
3.3 客戶端安裝核心依賴
# 升級 pip
uv pip install --upgrade pip# 安裝相關庫
uv pip install llama-index-core llama-index-llms-ollama llama-index-embeddings-ollama llama-index-vector-stores-faiss faiss-cpu
4. 使用ModelScope下載大模型
ModelScope(魔搭) 是阿里開源的模型開放平臺,提供大量中文優化模型。
下載模型:
uv run modelscope download --model ZhipuAI/GLM-4-9B-0414
? 下載時間取決于網絡速度(9B模型約19GB,可能需數分鐘至數十分鐘)。
5. 方式一:構建自定義API服務
本服務基于 FastAPI
搭建,使用 transformers
加載 GLM-4-9B-0414 大模型,提供與 Ollama 兼容的 API 接口,支持流式和非流式文本生成。
- 框架:FastAPI
- 模型:ZhipuAI/GLM-4-9B-0414
- 設備:自動檢測(CUDA / CPU)
- 精度:FP16(CUDA),BF16(CPU)
- 流式支持:? 支持
application/x-ndjson
流式輸出 - 兼容性:兼容 Ollama 客戶端調用
5.1 接口文檔
1. POST /api/generate
- 生成文本(流式/非流式)
接口說明
生成文本的核心接口,支持流式(默認)和非流式(raw=true
)兩種模式。
屬性 | 值 |
---|---|
路徑 | /api/generate |
方法 | POST |
內容類型 | application/json |
流式響應 | ? 支持 |
響應類型 | application/x-ndjson (流式) 或 application/json (非流式) |
請求體參數
{"prompt": "用戶輸入的提示詞","messages": [{"role": "system", "content": "系統提示"},{"role": "user", "content": "用戶消息"},{"role": "assistant", "content": "助手回復"}],"options": {"temperature": 0.7,"top_p": 0.9,"repeat_penalty": 1.1},"raw": false
}
字段 | 類型 | 必填 | 默認值 | 說明 |
---|---|---|---|---|
prompt | string | 否 | "" | 純文本提示詞。若 messages 為空,則自動構造為 {"role": "user", "content": prompt} |
messages | array[object] | 否 | [] | 聊天消息數組,格式為 {"role": "user/system/assistant", "content": "..."} 。優先級高于 prompt |
options | object | 否 | {} | 生成參數對象 |
options.temperature | number | 否 | 0.7 | 溫度,控制生成隨機性(0.0 ~ 2.0) |
options.top_p | number | 否 | 0.9 | 核采樣(Nucleus Sampling),控制多樣性(0.0 ~ 1.0) |
options.repeat_penalty | number | 否 | 1.1 | 重復懲罰系數(>1.0 可減少重復) |
raw | boolean | 否 | false | 是否返回原始非流式響應。true :等待生成完成返回完整結果;false :流式逐 token 返回 |
?? 注意:
prompt
和messages
至少提供一個。
響應格式(流式)
當 raw=false
(默認)時,返回 NDJSON(Newline Delimited JSON) 流:
{"model":"glm-4-9b","response":"你","done":false,"done_reason":null,"context":[]}
{"model":"glm-4-9b","response":"好","done":false,"done_reason":null,"context":[]}
{"model":"glm-4-9b","response":"!","done":false,"done_reason":null,"context":[]}
{"model":"GLM-4-9B-0414","response":"","done":true,"context":[]}
響應格式(非流式)
當 raw=true
時,返回完整 JSON 對象:
{"model": "GLM-4-9B-0414","response": "你好!我是GLM-4,由智譜AI研發的大語言模型...","done": true,"context": []
}
2. GET /api/tags
- 列出本地模型
接口說明
返回當前服務支持的模型列表,兼容 Ollama 客戶端查詢模型列表。
屬性 | 值 |
---|---|
路徑 | /api/tags |
方法 | GET |
響應類型 | application/json |
響應示例
{"models": [{"name": "GLM-4-9B-0414","modified_at": "2025-04-14T00:00:00Z","size": 9000000000,"digest": "sha256:dummyglm49b","details": {"parent_model": "","format": "gguf","family": "glm","families": null,"parameter_size": "9B","quantization": "Q5_K_M"}}]
}
字段說明
字段 | 類型 | 說明 |
---|---|---|
name | string | 模型名稱 |
modified_at | string | 模型最后修改時間(ISO 8601) |
size | number | 模型文件大小(字節) |
digest | string | 模型哈希值(此處為占位符) |
details.format | string | 模型格式(如 gguf、safetensors) |
details.family | string | 模型家族(如 glm、llama、qwen) |
details.parameter_size | string | 參數規模(如 9B、30B) |
details.quantization | string | 量化方式(如 Q5_K_M) |
📌 此接口用于客戶端發現可用模型,實際僅加載一個模型。
3. GET /
- 健康檢查接口
接口說明
用于檢查服務是否正常運行。
屬性 | 值 |
---|---|
路徑 | / |
方法 | GET |
響應類型 | application/json |
響應示例
{"status": "running","model": "GLM-4-9B-0414"
}
字段 | 類型 | 說明 |
---|---|---|
status | string | 服務狀態,running 表示正常 |
model | string | 當前加載的模型名稱 |
🔍 此接口不包含在 OpenAPI 文檔中(
include_in_schema=False
)。
5.2 創建API服務腳本
創建 api_server.py
:
# app.py
import os
import json
import re
import ast
from typing import Dict, List, Optional
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
from threading import Thread
from queue import Queue, Emptyapp = FastAPI()# ================== 配置 ==================
MODEL_PATH = "/home/db/Documents/LLM_ws/models/ZhipuAI/GLM-4-9B-0414"
# MODEL_PATH = "/home/db/Documents/LLM_ws/models/Qwen/Qwen3-30B-A3B-Instruct-2507-FP8"
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {DEVICE}")tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(MODEL_PATH,device_map="auto",trust_remote_code=True,dtype=torch.float16 if DEVICE == "cuda" else torch.bfloat16,
)# ================== 流式生成器 ==================
def generate_stream(messages, temperature=0.7, top_p=0.9, repeat_penalty=1.1, max_new_tokens=1024):# 構造輸入prompt_messages = messagesinputs = tokenizer.apply_chat_template(prompt_messages,return_tensors="pt",add_generation_prompt=True,return_dict=True,).to(model.device)# 參數generate_kwargs = {"input_ids": inputs["input_ids"],"attention_mask": inputs["attention_mask"],"max_new_tokens": max_new_tokens,"temperature": temperature,"top_p": top_p,"repetition_penalty": repeat_penalty,"do_sample": True,"eos_token_id": tokenizer.eos_token_id,"pad_token_id": tokenizer.pad_token_id,}# 開始生成streamer_queue = Queue()def token_generator():try:outputs = model.generate(**generate_kwargs)output_ids = outputs[0][inputs["input_ids"].shape[1]:]text = tokenizer.decode(output_ids, skip_special_tokens=True)# 按 token 流式輸出for token in text:streamer_queue.put(token)streamer_queue.put(None) # 結束標志except Exception as e:streamer_queue.put(f"Error: {str(e)}")streamer_queue.put(None)Thread(target=token_generator, daemon=True).start()buffer = ""assistant_content = ""while True:try:token = streamer_queue.get(timeout=60)if token is None:breakbuffer += tokenif buffer.strip():yield json.dumps({"model": "glm-4-9b","response": token,"done": False,"done_reason": None,"context": []}, ensure_ascii=False) + "\n"assistant_content += tokenexcept Empty:yield json.dumps({"error": "Stream timeout"}) + "\n"break# 最終完成yield json.dumps({"model": "GLM-4-9B-0414","response": "","done": True,"context": []}, ensure_ascii=False) + "\n"# ================== Ollama 兼容接口 ==================
@app.post("/api/generate")
async def generate(request: Request):body = await request.json()prompt = body.get("prompt", "")messages = body.get("messages", [])temperature = body.get("options", {}).get("temperature", 0.7)top_p = body.get("options", {}).get("top_p", 0.9)repeat_penalty = body.get("options", {}).get("repeat_penalty", 1.1)raw = body.get("raw", False)# 如果沒有 messages,嘗試從 prompt 構造if not messages and prompt:messages = [{"role": "user", "content": prompt}]if not raw:return StreamingResponse(generate_stream(messages, temperature, top_p, repeat_penalty),media_type="application/x-ndjson")else:# 非流式:收集所有輸出full_response = ""async for chunk in generate_stream(messages, temperature, top_p, repeat_penalty):data = json.loads(chunk)if "response" in data and data["done"] is False:full_response += data["response"]return {"model": "GLM-4-9B-0414","response": full_response,"done": True,"context": []}# ================== 可用模型 ==================
@app.get("/api/tags")
def api_tags():return {"models": [{"name": "GLM-4-9B-0414","modified_at": "2025-04-14T00:00:00Z","size": 9000000000, # ~9GB FP16"digest": "sha256:dummyglm49b","details": {"parent_model": "","format": "gguf","family": "glm","families": None,"parameter_size": "9B","quantization": "Q5_K_M"}}]}# ================== 健康檢查 ==================
# 健康檢查接口
@app.get("/", include_in_schema=False)
async def health_check():return {"status": "running", "model": "GLM-4-9B-0414"}
5.3 啟動API服務
# 確保虛擬環境已激活
source .venv/bin/activate# 運行服務
uv run uvicorn api_server:app --host 0.0.0.0 --port 8080
服務啟動后,在服務器訪問:
http://localhost:8000
,或在客戶端訪問:http://196.128.1.5:8000
,若顯示{"status": "running", "model": "GLM-4-9B-0414"}
,表明服務正常。
6. 方式二:使用Ollama一鍵部署大模型
6.1 安裝Ollama
Ollama 是最簡單的本地大模型運行工具。
# Linux/macOS
curl -fsSL https://ollama.com/install.sh | sh# Windows
# 下載安裝包:https://ollama.com/download/OllamaSetup.exe
驗證:
ollama --version
# 輸出示例:ollama version 0.1.43
6.2 拉取并運行模型
# 拉取Qwen3:32B
ollama pull qwen3:32b# 運行模型(交互模式)
ollama run qwen3:32b
輸入文本即可對話:
>>> 你好,請介紹一下你自己
我是通義千問,阿里巴巴集團旗下的通義實驗室自主研發的超大規模語言模型...
6.3 啟動API服務
Ollama 自帶API服務(默認 http://localhost:11434
)。
# 啟動服務(后臺運行)
ollama serve # 通常自動運行# 調用API生成文本
curl http://localhost:11434/api/generate -d '{"model": "qwen2:7b-instruct","prompt": "請寫一首關于秋天的詩","stream": false
}'
6.4 Ollama API常用接口
對于詳細的 Ollama API 接口,請參閱官方文檔Ollama 中文API文檔。
1. POST /api/generate
- 生成文本(流式、非流式)
功能說明
向指定的大語言模型發送提示詞(prompt),并獲取模型生成的響應文本。支持流式和非流式兩種響應模式。
此接口是 Ollama 的核心推理接口,適用于問答、文本生成、代碼補全等場景。
請求詳情
屬性 | 值 |
---|---|
端點 | POST /api/generate |
內容類型 | application/json |
認證 | 無需認證(本地服務) |
流式支持 | ? 支持 (stream=true ) |
請求體參數
{"model": "llama3","prompt": "請解釋量子計算的基本原理。","stream": false,"options": {"temperature": 0.7,"max_tokens": 512,"top_p": 0.9,"repeat_penalty": 1.1}
}
字段 | 類型 | 必填 | 默認值 | 說明 |
---|---|---|---|---|
model | string | ? | - | 要使用的模型名稱(如 llama3 , qwen:7b , mistral )。必須是已通過 ollama pull <model> 下載的模型。 |
prompt | string | ? | - | 輸入的提示詞或問題。模型將基于此內容生成響應。 |
stream | boolean | 否 | false | 是否啟用流式響應: ? true :逐 token 返回(NDJSON 格式)? false :等待生成完成,返回完整結果 |
options | object | 否 | {} | 可選的生成參數配置對象。 |
options.temperature | number | 否 | 0.8 | 控制生成文本的隨機性。值越高越隨機(0.0 ~ 2.0)。 |
options.max_tokens | number | 否 | 128 | 生成的最大 token 數量。超過此長度將停止生成。 |
options.top_p | number | 否 | 0.9 | 核采樣(Nucleus Sampling)閾值,控制生成多樣性(0.0 ~ 1.0)。 |
options.repeat_penalty | number | 否 | 1.1 | 重復懲罰系數,防止模型重復輸出相同內容(>1.0 有效)。 |
?? 注意:
prompt
字段不能為null
或空字符串。- 若模型未下載,將返回 404 錯誤。
響應格式(非流式)
當 stream=false
時,返回一個完整的 JSON 對象:
{"model": "llama3","response": "量子計算是一種利用量子力學原理進行信息處理的計算方式...","done": true,"done_reason": "stop","context": [123, 456, 789],"total_duration": 1234567890,"load_duration": 987654321,"prompt_eval_count": 15,"prompt_eval_duration": 123456789,"eval_count": 256,"eval_duration": 987654321
}
字段 | 類型 | 說明 |
---|---|---|
model | string | 實際使用的模型名稱。 |
response | string | 模型生成的完整文本。 |
done | boolean | 是否生成完成。true 表示結束。 |
done_reason | string | 完成原因:stop (正常結束)、length (達到最大 token 數)。 |
context | array<number> | 上下文 token IDs,可用于后續對話的 context 字段以保持上下文連貫。 |
total_duration | number | 總耗時(納秒)。 |
load_duration | number | 模型加載耗時(納秒)。 |
prompt_eval_count | number | 提示詞評估的 token 數。 |
prompt_eval_duration | number | 提示詞處理耗時(納秒)。 |
eval_count | number | 生成的 token 數。 |
eval_duration | number | 生成耗時(納秒)。 |
響應格式(流式)
當 stream=true
時,返回 NDJSON(Newline Delimited JSON) 流,每行一個 JSON 對象:
{"model":"llama3","response":"量子","done":false}
{"model":"llama3","response":"計算","done":false}
{"model":"llama3","response":"是一","done":false}
{"model":"llama3","response":"種","done":false}
{"model":"llama3","response":"利用","done":false}
{"model":"llama3","response":"量子","done":false}
{"model":"llama3","response":"力學","done":false}
{"model":"llama3","response":"原理","done":false}
{"model":"llama3","response":"進行","done":false}
{"model":"llama3","response":"信息","done":false}
{"model":"llama3","response":"處理","done":false}
{"model":"llama3","response":"的","done":false}
{"model":"llama3","response":"計算","done":false}
{"model":"llama3","response":"方式","done":false}
{"model":"llama3","response":"...","done":true,"context":[123,456,789],"total_duration":1234567890,"load_duration":987654321,"prompt_eval_count":15,"prompt_eval_duration":123456789,"eval_count":256,"eval_duration":987654321}
done: false
:表示生成中,response
為新生成的 token。done: true
:表示生成完成,包含完整統計信息。
🌐 流式響應適用于 Web 應用實現“打字機”效果。
錯誤響應
狀態碼 | 錯誤示例 | 說明 |
---|---|---|
400 | {"error": "model is required"} | 請求參數缺失或格式錯誤 |
404 | {"error": "model 'xxx' not found"} | 指定模型未下載 |
500 | {"error": "failed to initialize model"} | 模型加載失敗(如顯存不足) |
2. GET /api/tags
- 列出本地模型
功能說明
獲取當前本地已下載并可用的所有模型列表。用于客戶端(如 Web UI、CLI 工具)展示可用模型。
請求詳情
屬性 | 值 |
---|---|
端點 | GET /api/tags |
認證 | 無需認證 |
響應類型 | application/json |
響應格式
{"models": [{"name": "llama3:8b","size": 4718592000,"digest": "sha256:abc123...","details": {"parent_model": "","format": "gguf","family": "llama","families": ["llama", "transformer"],"parameter_size": "8B","quantization": "Q4_K_M"},"modified_at": "2025-08-20T10:30:00.123Z"},{"name": "qwen:7b","size": 3984588800,"digest": "sha256:def456...","details": {"parent_model": "","format": "gguf","family": "qwen","families": ["qwen", "transformer"],"parameter_size": "7B","quantization": "Q5_K_S"},"modified_at": "2025-08-15T14:20:00.456Z"}]
}
字段 | 類型 | 說明 |
---|---|---|
models | array<object> | 模型列表數組。 |
models[].name | string | 模型名稱,可能包含標簽(如 :7b , :latest )。 |
models[].size | number | 模型文件總大小(字節)。 |
models[].digest | string | 模型內容的 SHA256 哈希值,用于唯一標識。 |
models[].modified_at | string | 模型最后修改時間(ISO 8601 UTC 格式)。 |
models[].details | object | 模型詳細信息(可選)。 |
models[].details.parent_model | string | 父模型名稱(用于微調模型)。 |
models[].details.format | string | 模型格式(如 gguf )。 |
models[].details.family | string | 模型家族(如 llama , qwen , mistral )。 |
models[].details.families | array<string> | 模型所屬的所有家族。 |
models[].details.parameter_size | string | 參數規模(如 7B , 13B )。 |
models[].details.quantization | string | 量化級別(如 Q4_K_M , Q5_K_S )。 |
使用場景
- 啟動時加載模型列表
- 用戶選擇模型下拉框
- 模型管理界面
3.示例調用
# 列出所有模型
curl http://localhost:11434/api/tags# 生成文本(非流式)
curl http://localhost:11434/api/generate -d '{"model": "llama3","prompt": "你好","stream": false
}'# 生成文本(流式)
curl http://localhost:11434/api/generate -d '{"model": "qwen:7b","prompt": "請寫一首詩","stream": true
}' --no-buffer
7. 客戶端開發
7.1 獲取服務器IP地址
在服務器上執行以下命令:
ip addr show
輸出當前系統的所有網絡接口及其配置信息,例如:
其中,lo
接口為本地回環接口,enp4s0
接口為有線網絡接口,Meta
接口為虛擬或隧道接口。在enp4s0
接口中,inet 192.168.1.5/24
即為IPv4
地址,是服務器的局域網IP,/24
表示子網掩碼255.255.255.0
,同一局域網中,IP范圍是192.168.1.1
~192.168.1.254
。因此,服務器IP地址為192.168.1.5/24
。
7.2 客戶端主程序
創建 client.py
:
import streamlit as st
import requests
import json
import base64
import os
from index import KnowledgeBaseManager
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core import QueryBundle
import shutil# 設置頁面配置
st.set_page_config(page_title="AI智能問答助手",page_icon="🤖",layout="wide"
)# 標題和描述
st.title("💬 AI智能問答助手")# 初始化會話狀態中的配置
if 'ollama_host' not in st.session_state:st.session_state.ollama_host = "192.168.1.5"
if 'ollama_port' not in st.session_state:st.session_state.ollama_port = "11434"# Ollama 服務器配置(從會話狀態獲取)
OLLAMA_HOST = f"http://{st.session_state.ollama_host}:{st.session_state.ollama_port}"# 緩存 kb_manager
@st.cache_resource
def get_kb_manager(kb_root, ollama_host=OLLAMA_HOST):return KnowledgeBaseManager(kb_root=kb_root)kb_manager = get_kb_manager(kb_root=r".\data")
# 知識庫目錄
kb_dir = kb_manager.get_kb_path("my_kb")# 檢查 Ollama 服務器是否可達
# @st.cache_resource(ttl=10) # 每10秒刷新一次連接狀態
def check_ollama_connection(host, port):try:response = requests.get(f"http://{host}:{port}/api/tags", timeout=5)return response.status_code == 200except:return False# 將多行文本轉換為 Markdown 引用塊(每行都加 >)
def format_as_quote(text):"""將文本格式化為 Markdown 引用塊,每行都以 > 開頭"""lines = text.strip().split('\n')quoted_lines = [f"> {line.strip()} " for line in lines if line.strip()]return '\n'.join(quoted_lines)# --- 圖片轉 base64 ---
def get_image_base64(image_file):image_file.seek(0)bytes_data = image_file.read()return base64.b64encode(bytes_data).decode('utf-8')# 流式調用 Ollama API
def stream_query_ollama(prompt, model="qwen3:30b", image_base64=None):try:url = f"{OLLAMA_HOST}/api/generate"# 構造 optionsoptions = {"temperature": st.session_state.get("temperature", 0.7),"top_p": st.session_state.get("top_p", 0.9),"repeat_penalty": st.session_state.get("repeat_penalty", 1.1),}if not show_thinking:options["raw"] = Trueelse:options["raw"] = Falsepayload = {"model": model,"prompt": prompt,"stream": True,"options": options}# 如果是多模態模型且有圖片if image_base64 and model in ["llama4:latest", "blaifa/InternVL3_5:8b", "gemma3:27b"]:payload["images"] = [image_base64]response = requests.post(url, json=payload, timeout=120, stream=True)if response.status_code != 200:error_msg = f"請求失敗: {response.status_code} - {response.text}"st.error(error_msg)return error_msg, ""# 創建一個占位符,用于動態更新內容message_placeholder = st.empty()full_response = ""thinking_content = ""in_thinking = False # 標記是否在 <think> 標簽內try:for line in response.iter_lines():if not line:continuetry:body = json.loads(line.decode('utf-8'))if 'response' not in body:continuecontent = body['response']# 處理 <think> 標簽if '<think>' in content:in_thinking = Truethinking_content += content.replace('<think>', '')# 實時更新思考過程display_content = ""if thinking_content.strip():display_content += f"{format_as_quote('【思考過程】: ' + thinking_content)}\n\n"if full_response:display_content += full_responsemessage_placeholder.markdown(display_content)elif '</think>' in content:in_thinking = Falsethinking_content += content.replace('</think>', '')# 實時更新思考過程display_content = ""if thinking_content.strip():display_content += f"{format_as_quote('【思考過程】: ' + thinking_content)}\n\n"if full_response:display_content += full_responsemessage_placeholder.markdown(display_content)elif in_thinking:thinking_content += content# 實時更新思考過程display_content = ""if thinking_content.strip():display_content += f"{format_as_quote('【思考過程】: ' + thinking_content)}\n\n"if full_response:display_content += full_responsemessage_placeholder.markdown(display_content)else:full_response += content# 實時更新主響應display_content = ""if thinking_content.strip():display_content += f"{format_as_quote('【思考過程】: ' + thinking_content)}\n\n"display_content += full_responsemessage_placeholder.markdown(display_content)except json.JSONDecodeError:continueexcept Exception as e:st.error(f"流式解析錯誤: {str(e)}")return full_response.strip(), thinking_content.strip()except requests.exceptions.RequestException as e:error_msg = f"連接錯誤: {str(e)}"st.error(error_msg)return error_msg, ""except Exception as e:error_msg = f"未知錯誤: {str(e)}"st.error(error_msg)return error_msg, ""# 非流式調用
def query_ollama(prompt, model="qwen3:30b", image_base64=None):"""非流式調用 Ollama API,假設 <think> 和 </think> 標簽一定存在返回: (full_response, thinking_content)"""try:url = f"{OLLAMA_HOST}/api/generate"# 構造 optionsoptions = {"temperature": st.session_state.get("temperature", 0.7),"top_p": st.session_state.get("top_p", 0.9),"repeat_penalty": st.session_state.get("repeat_penalty", 1.1),}if not show_thinking:options["raw"] = Trueelse:options["raw"] = Falsepayload = {"model": model,"prompt": prompt,"stream": False,"options": options}# 如果是多模態模型且有圖片if image_base64 and model in ["llama4:latest", "blaifa/InternVL3_5:8b", "gemma3:27b"]:payload["images"] = [image_base64]response = requests.post(url, json=payload, timeout=120)if response.status_code == 200:result = response.json()content = result.get("response", "")# 直接提取 <think> 標簽內的內容start_tag = "<think>"end_tag = "</think>"start_pos = content.find(start_tag)end_pos = content.find(end_tag)if start_pos != -1 and end_pos != -1 and start_pos < end_pos:thinking_content = content[start_pos + len(start_tag):end_pos].strip()full_response = content[end_pos + len(end_tag):].strip()else:# 如果標簽解析失敗,全部作為主響應thinking_content = ""full_response = content.strip()# 在這里統一更新 UIif thinking_content.strip() and show_thinking:st.markdown(format_as_quote('【思考過程】: ' + thinking_content))st.markdown(full_response)return full_response, thinking_contentelse:error_msg = f"請求失敗: {response.status_code} - {response.text}"st.error(error_msg)return error_msg, ""except requests.exceptions.RequestException as e:error_msg = f"連接錯誤: {str(e)}"st.error(error_msg)return error_msg, ""except Exception as e:error_msg = f"未知錯誤: {str(e)}"st.error(error_msg)return error_msg, ""# 側邊欄設置 - 可配置的IP和端口
with st.sidebar:st.header("🖥? 服務器配置")# IP地址和端口輸入(可編輯)new_host = st.text_input("IP地址", value=st.session_state.ollama_host)new_port = st.text_input("端口", value=st.session_state.ollama_port)# 保存配置按鈕if st.button("保存配置"):st.session_state.ollama_host = new_hostst.session_state.ollama_port = new_portst.rerun() # 重新加載頁面以應用新配置# 獲取連接狀態
is_connected = check_ollama_connection(st.session_state.ollama_host, st.session_state.ollama_port)# 根據連接狀態顯示不同內容
if is_connected:st.success(f"? 已成功連接到服務器 ({st.session_state.ollama_host}:{st.session_state.ollama_port})")# 獲取可用模型列表try:models_response = requests.get(f"{OLLAMA_HOST}/api/tags")models_data = models_response.json()available_models = [model["name"] for model in models_data.get("models", [])]if not available_models:available_models = [] # 默認模型列表st.warning("?? 無法獲取模型列表,使用默認模型")except:available_models = []st.warning("?? 無法獲取模型列表,使用默認模型")# 模型選擇with st.sidebar:st.markdown("---")st.header("🤖 模型選擇")# 1. ? 生成模型(主 LLM)llm_models = [m for m in available_models if "text" not in m.lower() and "rerank" not in m.lower() and "embed" not in m.lower()]selected_model = st.selectbox("生成模型",options=llm_models,index=0,key="model_selector",help="用于生成最終回答的大語言模型,如 llama4、qwen3 等")# 2. ? Embedding 模型embedding_models = [m for m in available_models if "embed" in m.lower() or "text" in m.lower()]if embedding_models:selected_embedding = st.selectbox("📚 Embedding 模型",options=embedding_models,index=0,key="embedding_selector",help="用于將文檔轉換為向量,支持語義檢索。推薦:nomic-embed-text:latest")else:selected_embedding = "nomic-embed-text:latest" # 默認回退st.info("?? 未檢測到 Embedding 模型,建議拉取:`ollama pull nomic-embed-text`", icon="💡")# 3. ?? Rerank 模型(可選)rerank_models = [m for m in available_models if "rerank" in m.lower()]use_rerank = st.checkbox("啟用 Rerank 模型", value=bool(rerank_models), key="use_rerank_toggle")if use_rerank and rerank_models:selected_rerank = st.selectbox("🔍 Rerank 模型",options=rerank_models,index=0,key="rerank_selector",help="用于對檢索結果重新排序,提升相關性。推薦:mxbai-rerank:large")elif use_rerank:selected_rerank = "mxbai-rerank:large" # 默認st.info("?? 未檢測到 Rerank 模型,建議拉取:`ollama pull mxbai-rerank:large`", icon="💡")else:selected_rerank = None# 流式傳輸選項enable_streaming = st.checkbox("啟用流式傳輸", value=True, key="streaming_toggle")# 顯示思考內容選項show_thinking = st.checkbox("顯示模型思考過程", value=True, key="thinking_toggle")st.markdown("---")st.header("?? 模型參數")# Temperaturetemperature = st.slider("Temperature", min_value=0.0, max_value=2.0, value=0.7, step=0.1,help="控制生成文本的隨機性。值越高越隨機,越低越確定。")# top_ptop_p = st.slider("Top P", min_value=0.0, max_value=1.0, value=0.9, step=0.05,help="核采樣。控制從累積概率最高的詞匯中采樣。")# repeat_penaltyrepeat_penalty = st.slider("repeat_penalty", min_value=0.0, max_value=2.0, value=1.1, step=0.1,help="懲罰重復的 token,避免循環輸出。")st.markdown("---")st.markdown("### 📁 文件上傳")uploaded_image = st.file_uploader("📸 上傳圖片",type=["png", "jpg", "jpeg", "webp"],key="sidebar_image_uploader")if uploaded_image:st.image(uploaded_image,caption="已上傳圖片",width="stretch" # ? 替代 use_container_width=True)# ? 文件上傳(放在這里)uploaded_file = st.file_uploader("📄 上傳文檔(PDF/TXT/DOCX)",type=["pdf", "txt", "docx"],accept_multiple_files=False,key="sidebar_file_uploader")if uploaded_file:st.success(f"📎 {uploaded_file.name}", icon="?")if not ('current_index' in st.session_state and 'current_file' in st.session_state and st.session_state.current_file == uploaded_file.name):# ?【新增】立即處理文件:保存 + 構建索引file_dir = os.path.join(kb_dir, "files")file_path = os.path.join(file_dir, uploaded_file.name)# 創建目錄os.makedirs(file_dir, exist_ok=True)# 清空 files 目錄for item in os.listdir(file_dir):item_path = os.path.join(file_dir, item)try:if os.path.isfile(item_path) or os.path.islink(item_path):os.unlink(item_path)elif os.path.isdir(item_path):shutil.rmtree(item_path)except Exception as e:st.warning(f"?? 刪除失敗: {item_path}, 原因: {e}")# 保存新文件try:with open(file_path, "wb") as f:f.write(uploaded_file.getbuffer())# ? 構建索引(異步或同步)with st.spinner("正在構建知識庫索引..."):my_index = kb_manager.build_index(kb_dir, selected_model=selected_model, selected_embedding=selected_embedding)st.success("? 知識庫索引已更新!", icon="🧠")# ? 可選:緩存索引到 session_statest.session_state['current_index'] = my_indexst.session_state['current_file'] = uploaded_file.nameexcept Exception as e:st.error(f"? 文件保存或索引構建失敗: {e}")# 可選:添加說明st.caption("上傳的文件將作為上下文參與對話。")else:st.error("? 無法連接到服務器,請檢查配置:")st.markdown("""- 服務器 IP 地址是否正確- 端口是否正確- 服務是否正在運行- 網絡連接是否正常- 防火墻設置是否允許連接""")# 禁用模型選擇等控件selected_model = "qwen3:30b"enable_streaming = Falseshow_thinking = False# 初始化聊天歷史
if 'messages' not in st.session_state:st.session_state.messages = []if len(st.session_state.messages) == 0:st.session_state.messages = [{"role": "assistant", "content": "您好!我是您的智能助手,請問有什么可以幫助您的?", "thinking": "", "table_data": []}]# 顯示聊天歷史
for message in st.session_state.messages:with st.chat_message(message["role"]):# 顯示思考內容(如果存在且用戶選擇顯示)if message["thinking"] and show_thinking:st.markdown(format_as_quote('【思考過程】: ' + message['thinking']))# 顯示主要回復內容st.markdown(message["content"])# 顯示檢索結果if len(message["table_data"]) != 0:st.dataframe(message["table_data"], width='content')# 用戶輸入界面
if is_connected: # 只有在連接成功時才顯示輸入框prompt = st.chat_input("輸入您的問題...", key="chat_input")if prompt:# 添加用戶消息到歷史st.session_state.messages.append({"role": "user", "content": prompt, "thinking": "", "table_data": []})# 顯示用戶消息with st.chat_message("user"):st.markdown(prompt)# 🖼? 顯示當前使用的圖片if uploaded_image and selected_model in ["llama4:latest", "blaifa/InternVL3_5:8b", "gemma3:27b"]:st.image(uploaded_image, width=120)# 📎 顯示當前使用的文件if uploaded_file:st.markdown(f"📌 當前會話使用文件: `{uploaded_file.name}`")# --- 構造上下文 ---final_prompt = promptimage_base64 = None# 獲取圖片 base64if uploaded_image:image_base64 = get_image_base64(uploaded_image)# 添加文檔內容if uploaded_file:# ? 使用已構建的索引(來自 session_state 或直接加載)if 'current_index' in st.session_state:my_index = st.session_state['current_index']else:my_index = kb_manager.load_index(kb_dir, selected_model=selected_model, selected_embedding=selected_embedding) # 兜底# 創建檢索器retriever = VectorIndexRetriever(index=my_index,similarity_top_k=5, # 檢索最相關的5個文檔片段)query_bundle = QueryBundle(query_str=prompt)retrieved_nodes = retriever.retrieve(query_bundle)# # 選出retrieved_nodes中score高于60%的# retrieved_nodes = [node for node in retrieved_nodes if node.score > 0.6]content = "\n".join([n.get_content() for n in retrieved_nodes])final_prompt = f"請結合以下知識片段回答問題:\n\n{content}\n\n問題:{prompt}\n\n回答:"# 顯示助手響應with st.chat_message("assistant"):with st.spinner("正在思考..."):if enable_streaming:# 使用流式傳輸full_response, thinking_content = stream_query_ollama(final_prompt, selected_model, image_base64=image_base64)else:# 使用非流式傳輸full_response, thinking_content = query_ollama(final_prompt, selected_model, image_base64=image_base64)# ?【新增】展示 Top5 檢索結果if uploaded_file and 'retrieved_nodes' in locals():st.markdown("---") # 分隔線st.markdown("#### 🔍 檢索到的相關文本塊(Top 5)")# 構造表格數據table_data = []for i, node in enumerate(retrieved_nodes):table_data.append({"排名": i + 1,"相似度": f"{node.score:.4f}" if node.score is not None else "N/A","文件名": node.metadata.get("file_name", "未知文件"),"文本片段": node.get_content()})st.dataframe(table_data, width='content')# 將響應添加到歷史st.session_state.messages.append({"role": "assistant", "content": full_response, "thinking": thinking_content if show_thinking else "","table_data": []})# 添加新對話按鈕
if st.button("🔄 新對話"):st.session_state.messages = [{"role": "assistant", "content": "您好!我是您的智能助手,請問有什么可以幫助您的?", "thinking": "", "table_data": []}]st.rerun()
7.3 創建并加載索引服務
創建 index.py
:
import os
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader, StorageContext, load_index_from_storage
from llama_index.llms.ollama import Ollama # 使用 Ollama LLM
from llama_index.embeddings.ollama import OllamaEmbedding # 使用 Ollama Embedding
from llama_index.vector_stores.faiss import FaissVectorStore
from llama_index.core.node_parser import SentenceSplitter
import faiss
import warningswarnings.filterwarnings("ignore", category=UserWarning, message="TypedStorage is deprecated")class KnowledgeBaseManager:def __init__(self, kb_root=".\data", ollama_host="http://localhost:11434"):self.kb_root = kb_rootself.ollama_host = ollama_hostos.makedirs(self.kb_root, exist_ok=True)def get_kb_path(self, folder_name):return os.path.join(self.kb_root, folder_name)def build_index(self, kb_dir, selected_model="gemma3:27b", selected_embedding="nomic-embed-text:latest"):file_dir = os.path.join(kb_dir, "files")vector_dir = os.path.join(kb_dir, "vectors")os.makedirs(file_dir, exist_ok=True)os.makedirs(vector_dir, exist_ok=True)# 初始化 Ollama LLM(用于生成)llm = Ollama(model=selected_model,base_url=self.ollama_host,request_timeout=120.0,)# 初始化 Ollama Embedding 模型(用于向量化)embed_model = OllamaEmbedding(model_name=selected_embedding,base_url=self.ollama_host,ollama_additional_kwargs={"keep_alive": "5m"} # 可選:保持模型在內存中)# 加載文檔documents = SimpleDirectoryReader(file_dir).load_data()if not documents:raise ValueError(f"在 {file_dir} 中未找到文檔")# 文檔切片text_splitter = SentenceSplitter(chunk_size=2500,chunk_overlap=500,separator="\n")nodes = text_splitter.get_nodes_from_documents(documents)# 獲取嵌入維度(自動)sample_embedding = embed_model.get_text_embedding("樣本文本")d = len(sample_embedding) # 自動獲取維度(如 nomic-embed-text 是 768)# 創建 Faiss 索引faiss_index = faiss.IndexFlatL2(d)vector_store = FaissVectorStore(faiss_index=faiss_index)# 構建索引index = VectorStoreIndex(nodes,llm=llm,vector_store=vector_store,embed_model=embed_model,show_progress=True)# 保存向量索引index.storage_context.persist(persist_dir=vector_dir)return indexdef load_index(self, kb_dir, selected_model="gemma3:27b", selected_embedding="nomic-embed-text:latest"):vector_dir = os.path.join(kb_dir, "vectors")if not os.path.exists(vector_dir):raise ValueError(f"向量目錄不存在: {vector_dir}")# 初始化 Ollama LLM(用于生成)llm = Ollama(model=selected_model,base_url=self.ollama_host,request_timeout=120.0,)# 初始化 Ollama Embedding 模型(用于向量化)embed_model = OllamaEmbedding(model_name=selected_embedding,base_url=self.ollama_host,ollama_additional_kwargs={"keep_alive": "5m"} # 可選:保持模型在內存中)storage_context = StorageContext.from_defaults(persist_dir=vector_dir)index = load_index_from_storage(storage_context,llm=llm,embed_model=embed_model,show_progress=True)return index
7.4 客戶端運行截圖
初始化頁面:
智能問答(支持流式傳輸、深度思考):
模型理解:RAG(檢索增強生成,支持TXT、Docx、PDF):
樣本_XK20_zh.pdf(部分):
8. 常見問題與優化建議
問題 | 解決方案 |
---|---|
CUDA out of memory | 使用 device_map="auto" 、torch_dtype=torch.float16 、或量化(如bitsandbytes) |
模型下載慢 | 使用國內鏡像或 modelscope 的 mirror_url 參數 |
Ollama無法啟動 | 檢查端口11434是否被占用,重啟服務 |
API響應慢 | 升級GPU、使用更小模型、啟用Flash Attention |
中文亂碼 | 確保分詞器支持中文,設置 tokenizer.encode(..., add_special_tokens=True) |
性能優化建議
- 使用模型量化:
bitsandbytes
(4-bit/8-bit) - 多GPU部署:
device_map="balanced_low_0"
9. 總結與擴展
兩種方式對比
項目 | 自建API服務 | Ollama |
---|---|---|
難度 | 中等 | 極低 |
靈活性 | 高(可定制) | 中 |
維護成本 | 高 | 低 |
適合場景 | 生產環境、企業級應用 | 快速原型、個人使用 |
擴展方向
- 添加API密鑰鑒權(JWT)
- 部署前端界面(Gradio / Streamlit)
- 使用Docker容器化
- 集成向量數據庫(RAG)
- 多模型路由(Model Router)
- 支持多輪對話:(POST /api/chat)
- 本地持久化歷史對話(數據庫)