大模型本地部署与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)
- 本地持久化历史对话(数据库)