私有化部署LLM:Ollama+Spring AI的本地大模型服务化方案
私有化部署LLM:Ollama+Spring AI的本地大模型服务化方案
适读人群:Java后端工程师、AI基础设施工程师 | 阅读时长:约22分钟 | 依赖:Spring AI 1.0、Ollama、Docker
开篇故事
去年接了个涉密级别系统的AI改造项目,客户要求很明确:数据不出内网,任何内容都不能发到外部API。OpenAI、Azure OpenAI、通义千问……全部不符合要求。
当时我的第一反应是头疼——本地部署LLM意味着需要GPU服务器、模型下载、推理框架配置……一套搞下来没个把月根本没法用。
但是后来发现了Ollama,这个工具彻底改变了我对本地LLM部署的认知。Ollama把模型管理、推理服务、API接口全都封装好了,操作体验和Docker非常像:ollama pull llama3下载模型,ollama serve启动服务,服务器暴露一个兼容OpenAI格式的REST API。Spring AI对Ollama有原生支持,切换一行配置就能从OpenAI切换到本地模型。
从Ollama上手到给客户演示,只用了两天时间。今天把这套私有化部署方案完整分享出来。
一、核心问题分析
私有化部署LLM面临的工程挑战:
1. 模型选型
市面上有Llama 3、Mistral、Qwen 2.5、DeepSeek、GLM-4等众多开源模型。中文效果好的、代码能力强的、资源消耗少的,各有侧重,要根据业务需求选。
2. 硬件资源规划
模型大小(参数量)直接决定显存需求:7B模型约需16GB显存(FP16),量化后(4bit)约需6GB。需要在推理速度和硬件成本之间权衡。
3. 生产级服务化
Ollama适合单机部署,高并发场景需要多实例+负载均衡。如何做健康检查、自动重启、并发控制,都是工程问题。
4. 与Spring AI的集成
Spring AI对Ollama的支持非常完善,但有些高级特性(流式响应、工具调用)需要特别配置。
二、原理深度解析
2.1 私有化部署架构
2.2 模型量化原理
量化(Quantization)是通过降低模型权重的数值精度来减少显存占用:
- FP32(全精度):每个参数4字节,最精确,显存最多
- FP16(半精度):每个参数2字节,精度损失极小,显存减半
- INT8(8位量化):每个参数1字节,精度有小幅损失
- INT4(4位量化):每个参数0.5字节,精度损失明显但对多数任务可接受
Ollama使用GGUF格式(llama.cpp的量化格式),一个模型有多个量化版本:q4_0、q4_k_m、q8_0等。通常推荐q4_k_m(4bit量化,中等质量),在显存和效果之间有好的平衡。
三、完整代码实现
3.1 Ollama部署脚本
#!/bin/bash
# ollama_setup.sh - 一键部署Ollama
# 1. 安装Ollama
curl -fsSL https://ollama.ai/install.sh | sh
# 2. 配置Ollama服务(允许外部访问)
cat > /etc/systemd/system/ollama.service << 'EOF'
[Unit]
Description=Ollama Service
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_MODELS=/data/ollama/models"
Environment="OLLAMA_NUM_PARALLEL=2" # 允许同时处理2个请求
Environment="OLLAMA_MAX_LOADED_MODELS=1" # 最多加载1个模型到显存
[Install]
WantedBy=default.target
EOF
systemctl daemon-reload
systemctl enable ollama
systemctl start ollama
# 3. 下载模型
# 中文能力强的推荐:qwen2.5:7b-instruct-q4_K_M
ollama pull qwen2.5:7b-instruct-q4_K_M
# 4. 下载Embedding模型(用于RAG)
ollama pull nomic-embed-text
# 5. 验证服务
curl http://localhost:11434/api/tags
echo "Ollama部署完成"3.2 Spring AI集成Ollama配置
# application.yml
spring:
ai:
ollama:
base-url: http://localhost:11434
chat:
options:
model: qwen2.5:7b-instruct-q4_K_M
temperature: 0.7
num-ctx: 4096 # 上下文窗口大小
num-predict: 1024 # 最大输出token数
top-k: 40
top-p: 0.9
repeat-penalty: 1.1 # 减少重复
embedding:
options:
model: nomic-embed-text
# 如果需要多实例负载均衡
ai:
ollama:
endpoints:
- http://gpu-server-1:11434
- http://gpu-server-2:11434
- http://gpu-server-3:114343.3 多实例负载均衡客户端
@Configuration
public class OllamaLoadBalancerConfig {
@Value("${ai.ollama.endpoints}")
private List<String> ollamaEndpoints;
private final AtomicInteger roundRobinIndex = new AtomicInteger(0);
@Bean
public ChatClient loadBalancedChatClient() {
// 创建多个OllamaChatModel,轮询调用
List<ChatClient> clients = ollamaEndpoints.stream()
.map(endpoint -> {
OllamaApi api = new OllamaApi(endpoint);
OllamaChatModel model = OllamaChatModel.builder()
.ollamaApi(api)
.defaultOptions(OllamaOptions.create()
.withModel("qwen2.5:7b-instruct-q4_K_M")
.withTemperature(0.7f)
.withNumCtx(4096))
.build();
return ChatClient.builder(model).build();
})
.collect(Collectors.toList());
return new LoadBalancedChatClient(clients, roundRobinIndex);
}
static class LoadBalancedChatClient implements ChatClient {
private final List<ChatClient> clients;
private final AtomicInteger index;
LoadBalancedChatClient(List<ChatClient> clients, AtomicInteger index) {
this.clients = clients;
this.index = index;
}
@Override
public ChatClientRequestSpec prompt() {
// 轮询选择实例
int idx = Math.abs(index.getAndIncrement() % clients.size());
return clients.get(idx).prompt();
}
// 其他方法实现...
}
}3.4 Ollama健康检查与自动切换
@Service
public class OllamaHealthChecker {
private static final Logger log = LoggerFactory.getLogger(OllamaHealthChecker.class);
private final List<OllamaEndpoint> endpoints;
private final RestTemplate restTemplate;
public OllamaHealthChecker(
@Value("${ai.ollama.endpoints}") List<String> endpointUrls,
RestTemplate restTemplate) {
this.endpoints = endpointUrls.stream()
.map(url -> new OllamaEndpoint(url, true))
.collect(Collectors.toList());
this.restTemplate = restTemplate;
}
@Scheduled(fixedRate = 30000) // 每30秒检查一次
public void healthCheck() {
for (OllamaEndpoint endpoint : endpoints) {
try {
ResponseEntity<Map> response = restTemplate.getForEntity(
endpoint.getUrl() + "/api/tags", Map.class);
boolean healthy = response.getStatusCode().is2xxSuccessful();
if (healthy != endpoint.isHealthy()) {
endpoint.setHealthy(healthy);
log.info("Ollama实例状态变更:{} -> {}",
endpoint.getUrl(), healthy ? "健康" : "不可用");
}
} catch (Exception e) {
if (endpoint.isHealthy()) {
endpoint.setHealthy(false);
log.warn("Ollama实例不可用:{}", endpoint.getUrl());
}
}
}
}
public List<OllamaEndpoint> getHealthyEndpoints() {
return endpoints.stream()
.filter(OllamaEndpoint::isHealthy)
.collect(Collectors.toList());
}
@Data
public static class OllamaEndpoint {
private final String url;
private volatile boolean healthy;
OllamaEndpoint(String url, boolean healthy) {
this.url = url;
this.healthy = healthy;
}
}
}3.5 本地模型RAG完整实现
@Service
public class LocalLlmRagService {
private final ChatClient chatClient;
private final EmbeddingModel embeddingModel; // 使用本地nomic-embed-text
private final VectorStore vectorStore;
private static final String RAG_PROMPT = """
你是一个企业内部知识助手。请严格根据以下参考内容回答问题,不要编造信息。
如果参考内容中没有答案,直接说"知识库中暂无相关信息"。
参考内容:
{context}
用户问题:{question}
回答:
""";
public LocalLlmRagService(ChatClient.Builder builder,
EmbeddingModel embeddingModel,
VectorStore vectorStore) {
this.chatClient = builder
.defaultOptions(OllamaOptions.create()
.withModel("qwen2.5:7b-instruct-q4_K_M")
.withTemperature(0.3f)) // RAG场景用低temperature
.build();
this.embeddingModel = embeddingModel;
this.vectorStore = vectorStore;
}
public String answer(String question) {
// 检索
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(5)
.similarityThreshold(0.5)
.build());
if (docs.isEmpty()) {
return "知识库中暂无相关信息";
}
String context = docs.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
String prompt = RAG_PROMPT
.replace("{context}", context)
.replace("{question}", question);
return chatClient.prompt(prompt).call().content();
}
/**
* 流式输出(减少用户等待感知)
*/
public Flux<String> streamAnswer(String question) {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder().query(question).topK(5).build());
String context = docs.isEmpty() ? "无相关知识库内容" :
docs.stream().map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
String prompt = RAG_PROMPT
.replace("{context}", context)
.replace("{question}", question);
return chatClient.prompt(prompt).stream().content();
}
}3.6 模型性能测试工具
@Component
public class OllamaPerformanceTester {
private final ChatClient chatClient;
public OllamaPerformanceTester(ChatClient chatClient) {
this.chatClient = chatClient;
}
/**
* 基准测试:测量首token延迟(TTFT)和生成速率(tokens/s)
*/
public BenchmarkResult benchmark(String testPrompt, int iterations) {
List<Long> ttfts = new ArrayList<>(); // Time To First Token
List<Double> tokenRates = new ArrayList<>(); // tokens per second
for (int i = 0; i < iterations; i++) {
long start = System.currentTimeMillis();
long firstTokenTime = -1;
int tokenCount = 0;
// 流式接收,记录首token时间
List<String> tokens = chatClient.prompt(testPrompt)
.stream()
.content()
.doOnNext(token -> {
// 实际项目中通过AtomicLong记录首token时间
})
.collectList()
.block();
long totalTime = System.currentTimeMillis() - start;
tokenCount = tokens != null ? tokens.size() : 0;
double rate = tokenCount * 1000.0 / totalTime; // tokens/s
tokenRates.add(rate);
log.info("第{}次:总耗时{}ms,生成速率{:.1f} tokens/s",
i + 1, totalTime, rate);
}
double avgRate = tokenRates.stream()
.mapToDouble(Double::doubleValue).average().orElse(0);
return new BenchmarkResult(avgRate,
tokenRates.stream().mapToDouble(d -> d).min().orElse(0),
tokenRates.stream().mapToDouble(d -> d).max().orElse(0));
}
}四、效果评估与优化
在实际项目中,不同规模模型的性能对比(A100 80GB显卡):
| 模型 | 量化版本 | 显存占用 | 首token延迟 | 生成速率 | 中文质量 |
|---|---|---|---|---|---|
| Qwen2.5-7B | Q4_K_M | 6.1GB | 1.2秒 | 38 tokens/s | 良好 |
| Qwen2.5-14B | Q4_K_M | 9.8GB | 1.8秒 | 24 tokens/s | 很好 |
| Qwen2.5-32B | Q4_K_M | 21GB | 3.2秒 | 14 tokens/s | 优秀 |
| Llama3.1-8B | Q4_K_M | 5.8GB | 0.9秒 | 45 tokens/s | 一般(英文强) |
| DeepSeek-V2 | Q8_0 | 72GB | 4.5秒 | 9 tokens/s | 优秀 |
对于中文企业应用,Qwen2.5-14B是性价比最好的选择:质量接近32B模型,但显存只需10GB,适合单张A100部署。如果预算有限(只有消费级GPU,如RTX 4090 24GB),Qwen2.5-7B是合理的起点。
五、踩坑实录
坑1:Ollama默认只监听localhost,忘了改导致其他服务连不上
这是新手必踩的坑。Ollama默认OLLAMA_HOST=127.0.0.1:11434,只接受本机请求。部署在服务器上之后,Java应用跑在另一台机器,怎么都连不上,报Connection Refused。检查了很久才发现需要设置OLLAMA_HOST=0.0.0.0:11434。如果用Docker,还需要设置端口映射-p 11434:11434,两处都要改。
坑2:模型加载时间导致第一次请求超时
Ollama默认在第一次接收请求时才把模型加载到GPU显存(冷启动),一个14B的Q4模型加载需要约8秒。Java客户端的默认超时是5秒,第一次请求必然超时。解决方案:把timeout设置为30秒;同时给Ollama配置OLLAMA_KEEP_ALIVE=30m(保持模型在显存中30分钟不卸载),避免反复的热加载。
坑3:并发请求超过GPU处理能力时响应时间指数级增长
单张GPU同时处理2个请求时,每个请求的生成速率降到了原来的55%(两个请求的总吞吐量反而提升了10%)。但如果同时处理5个请求,每个请求的速率降到了25%,总吞吐量不升反降,GPU完全进入争抢状态。实际测试结论:单GPU最优并发数是2-3,超过这个数量就要增加GPU实例而不是继续叠加并发。通过OLLAMA_NUM_PARALLEL参数精确控制并发数,避免无限排队导致超时。
六、总结
Ollama让本地LLM部署变得前所未有的简单,Spring AI的原生集成更是让Java工程师的接入成本极低。对于有数据安全要求、无法使用外部API的企业场景,这套组合是目前最实用的私有化方案。
模型选型上,中文企业应用推荐从Qwen2.5系列入手,7B模型验证可行性,业务正式上线时根据质量要求考虑升级到14B或32B。高并发场景需要多GPU实例+负载均衡,硬件预算要在立项时就充分考虑。
