本地大模型部署实战:Ollama + Spring AI构建零成本AI应用
本地大模型部署实战:Ollama + Spring AI构建零成本AI应用
一个月1.5万的API账单,把创始人逼出了一条新路
2025年10月,杭州某医疗AI创业公司的技术负责人陈博文盯着阿里云的账单,手有点抖。
过去3个月,他们的产品——一款帮助基层医生快速生成病历摘要的AI助手——用户量从200人增长到了1800人。看起来是好事,但账单也跟着飞涨:7月5,200元,8月9,800元,9月15,400元。
按这个增速,等用户到5000人,月均API费用会突破4万元。而公司刚刚拿到天使轮融资,总计300万,这个烧法,18个月就见底了。
陈博文在技术群里发了一条消息:"有没有人用过本地部署的模型做生产环境?稳不稳?"
回复的人里,有一位叫周立峰的工程师,已经在某B2B SaaS公司跑了半年本地模型。他的数字很直接:
- 之前:GPT-4 API,月均12,000元
- 现在:本地DeepSeek-R1-7B + 一台二手A100服务器,月均电费220元
- 模型质量:垂直场景下,医疗/法律文本处理准确率差距不到3%
陈博文拍板:改造。整个迁移过程历时3周,期间他们遇到了你能想到的所有坑——Ollama配置、Spring AI版本冲突、GPU驱动不兼容、流式输出中文乱码……但最终,他把一套完整的方案梳理了出来。
这篇文章,就是基于那套方案的完整技术还原。
本地模型 vs 云端API:一张真实对比表
在动手之前,我们先搞清楚这个选择的本质。很多工程师的误区是把本地模型当成"穷人版OpenAI",实际上两者有完全不同的适用场景。
全维度对比
| 维度 | 云端API(GPT-4/Claude) | 本地模型(Ollama+DeepSeek) |
|---|---|---|
| 成本结构 | 按Token计费,可预测性差 | 固定服务器成本,边际成本接近0 |
| 隐私安全 | 数据出境,受服务商隐私政策约束 | 数据不离开内网,完全可控 |
| 响应延迟 | 取决于网络+服务器负载,P99约800ms-2s | 取决于本地GPU,P99约300-600ms |
| 模型质量 | 顶尖(GPT-4级别) | 中等偏上(7B-70B差异显著) |
| 并发能力 | 几乎无限(受速率限制) | 受单机GPU显存限制 |
| 可用性SLA | 99.9%+,有官方保障 | 自行维护,依赖运维能力 |
| 模型迭代 | 服务商控制,可能突然变更 | 版本完全由自己控制 |
| 离线能力 | 无(必须联网) | 完全支持内网离线 |
| 合规门槛 | 金融/医疗等行业存在数据出境风险 | 可满足数据本地化要求 |
成本计算:什么规模该用本地模型?
假设你的应用平均每次对话消耗2000 tokens(输入1500 + 输出500):
云端方案(GPT-4o):
- 输入:$2.5/1M tokens × 1500 = $0.00375/次
- 输出:$10/1M tokens × 500 = $0.005/次
- 单次成本:约$0.009,即0.065元
- 日均1万次调用:日成本约650元,月成本约1.95万元
本地方案(A100 80G × 1张):
- 服务器折旧(二手A100约3万,3年折旧):约833元/月
- 电费(300W × 24h × 30天 × 1.2元/度):约260元/月
- 合计:约1100元/月,无论调用量多少
盈亏平衡点: 月均约5500次调用时,本地方案开始有优势。
对于日均1万次以上的应用,本地方案成本节省超过94%。这就是陈博文做出选择的底层逻辑。
Ollama:让本地大模型部署变得像pip install一样简单
Ollama是目前最成熟的本地大模型运行时,它把模型下载、GPU调度、API服务封装成了一个极简的工具链。
安装
macOS / Linux一键安装:
curl -fsSL https://ollama.com/install.sh | sh验证安装:
ollama --version
# ollama version is 0.3.12查看服务状态:
# Ollama默认以系统服务运行,监听 11434 端口
curl http://localhost:11434/api/tags模型管理常用命令大全
# ===== 模型下载 =====
ollama pull deepseek-r1:7b # 下载DeepSeek-R1 7B(约4.7GB)
ollama pull deepseek-r1:14b # 下载DeepSeek-R1 14B(约9GB)
ollama pull qwen2.5:7b # 下载Qwen2.5 7B
ollama pull llama3.2:3b # 下载Llama3.2 3B(轻量级)
ollama pull nomic-embed-text # 下载嵌入模型(约274MB)
# ===== 查看已下载模型 =====
ollama list
# NAME ID SIZE MODIFIED
# deepseek-r1:7b 28f8fd6cdc67 4.7 GB 2 hours ago
# qwen2.5:7b 845dbda0ea48 4.7 GB 1 day ago
# ===== 运行模型(交互式)=====
ollama run deepseek-r1:7b
# >>> 你好,请介绍一下自己
# ===== 删除模型 =====
ollama rm deepseek-r1:7b
# ===== 查看模型详情 =====
ollama show deepseek-r1:7b
# ===== 复制模型(用于创建自定义版本)=====
ollama cp deepseek-r1:7b my-medical-model
# ===== 查看运行中的模型 =====
ollama ps
# NAME ID SIZE PROCESSOR UNTIL
# deepseek-r1:7b 28f8fd6cdc67 6.0 GB 100% GPU 4 minutes from now
# ===== 停止运行中的模型 =====
ollama stop deepseek-r1:7b
# ===== REST API直接调用(不通过Spring AI)=====
curl http://localhost:11434/api/generate \
-d '{
"model": "deepseek-r1:7b",
"prompt": "用Java写一个单例模式",
"stream": false
}'
# ===== OpenAI兼容格式调用 =====
curl http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek-r1:7b",
"messages": [{"role": "user", "content": "你好"}]
}'自定义Modelfile(为特定场景优化模型)
# 创建医疗场景专用模型
cat > Modelfile << 'EOF'
FROM deepseek-r1:7b
# 系统提示词
SYSTEM """
你是一位专业的医疗文书助手,专门帮助基层医生生成规范的病历摘要。
输出必须包含:主诉、现病史、体格检查、初步诊断、处理意见五个部分。
不得凭空捏造任何医学数据,如信息不足请明确指出。
"""
# 模型参数调优
PARAMETER temperature 0.3 # 降低随机性,提高一致性
PARAMETER top_p 0.9
PARAMETER num_ctx 8192 # 上下文窗口
PARAMETER num_predict 2048 # 最大输出token数
EOF
# 构建自定义模型
ollama create medical-assistant -f Modelfile
# 运行验证
ollama run medical-assistant "患者男,45岁,主诉头痛3天,体温37.8度..."主流本地模型选型:DeepSeek-R1、Qwen2.5、Llama3.2对比
选模型是本地部署最重要的决策,选错了后面全白费。
各模型核心特性
| 模型 | 参数量 | 显存需求 | 中文能力 | 代码能力 | 推理能力 | 适用场景 |
|---|---|---|---|---|---|---|
| DeepSeek-R1:7b | 7B | 8G | ★★★★★ | ★★★★☆ | ★★★★★ | 中文推理、分析 |
| DeepSeek-R1:14b | 14B | 16G | ★★★★★ | ★★★★★ | ★★★★★ | 复杂任务 |
| Qwen2.5:7b | 7B | 8G | ★★★★★ | ★★★★☆ | ★★★★☆ | 中文通用 |
| Qwen2.5-Coder:7b | 7B | 8G | ★★★★☆ | ★★★★★ | ★★★★☆ | 代码生成 |
| Llama3.2:3b | 3B | 4G | ★★★☆☆ | ★★★☆☆ | ★★★☆☆ | 边缘设备、轻量任务 |
| Llama3.1:8b | 8B | 10G | ★★★☆☆ | ★★★★☆ | ★★★★☆ | 英文场景 |
性能基准测试(在A100 80G上的实测数据)
测试场景:生成500字的产品说明文案(中文)
DeepSeek-R1:7b
首Token延迟:180ms
生成速度:42 tokens/s
总耗时:约4.2s
Qwen2.5:7b
首Token延迟:165ms
生成速度:45 tokens/s
总耗时:约3.9s
DeepSeek-R1:14b
首Token延迟:310ms
生成速度:24 tokens/s
总耗时:约7.5s
Llama3.2:3b(CPU推理,无GPU)
首Token延迟:2100ms
生成速度:8 tokens/s
总耗时:约25s选型建议:
- 中文业务场景(客服、文档、摘要):优先 Qwen2.5:7b 或 DeepSeek-R1:7b
- 需要深度推理(分析报告、复杂问答):选 DeepSeek-R1:14b
- 代码生成/Code Review:选 Qwen2.5-Coder:7b
- 显存只有4-6G:选 Llama3.2:3b 或使用量化版本(:q4_0后缀)
Spring AI集成Ollama:完整项目配置
项目结构
ollama-spring-ai/
├── pom.xml
├── src/main/
│ ├── java/com/laozhang/ollama/
│ │ ├── OllamaApplication.java
│ │ ├── config/
│ │ │ ├── OllamaConfig.java
│ │ │ └── ChatClientConfig.java
│ │ ├── controller/
│ │ │ ├── ChatController.java
│ │ │ └── EmbeddingController.java
│ │ ├── service/
│ │ │ ├── LocalChatService.java
│ │ │ └── LocalEmbeddingService.java
│ │ └── dto/
│ │ ├── ChatRequest.java
│ │ └── ChatResponse.java
│ └── resources/
│ └── application.ymlpom.xml(完整依赖)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>com.laozhang</groupId>
<artifactId>ollama-spring-ai</artifactId>
<version>1.0.0</version>
<name>ollama-spring-ai</name>
<description>Ollama + Spring AI 本地大模型集成示例</description>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot WebFlux(流式SSE必须)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring AI Ollama Starter(核心)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- Spring AI Core(工具调用、RAG等高级功能)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
</dependency>
<!-- Redis(对话历史缓存)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Actuator(健康检查和监控)-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Micrometer Prometheus(性能指标)-->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>application.yml(完整配置)
server:
port: 8080
servlet:
context-path: /api
spring:
application:
name: ollama-spring-ai
# Ollama AI配置
ai:
ollama:
base-url: http://localhost:11434 # Ollama服务地址
chat:
model: deepseek-r1:7b # 默认对话模型
options:
temperature: 0.7 # 温度(0=确定性,1=创造性)
top-p: 0.9
top-k: 40
num-ctx: 8192 # 上下文长度
num-predict: 2048 # 最大生成token数
num-thread: 8 # CPU线程数(GPU加速时忽略)
# GPU加速配置(有GPU时开启)
# num-gpu: 99 # 99表示全部层放GPU
embedding:
model: nomic-embed-text # 嵌入模型
options:
num-ctx: 2048
# Redis配置(对话历史)
data:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
# Actuator监控
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
show-details: always
metrics:
tags:
application: ${spring.application.name}
# 自定义配置
laozhang:
ai:
# 对话历史保留轮数
history-max-turns: 10
# 对话历史TTL(秒)
history-ttl: 3600
# 流式输出chunk大小(字符数)
stream-chunk-size: 1
# 是否启用混合部署(本地+云端)
hybrid-mode: false
# 云端API备用配置(hybrid-mode=true时生效)
fallback-provider: openai
# 本地模型不可用时的fallback超时(ms)
fallback-timeout: 5000
logging:
level:
com.laozhang: DEBUG
org.springframework.ai: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"OllamaApplication.java
package com.laozhang.ollama;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
public class OllamaApplication {
public static void main(String[] args) {
SpringApplication.run(OllamaApplication.class, args);
}
}OllamaConfig.java(核心配置类)
package com.laozhang.ollama.config;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.OllamaEmbeddingModel;
import org.springframework.ai.ollama.api.OllamaApi;
import org.springframework.ai.ollama.api.OllamaOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* Ollama 多模型配置
* 支持为不同场景注入不同模型实例
*/
@Configuration
public class OllamaConfig {
@Value("${spring.ai.ollama.base-url:http://localhost:11434}")
private String ollamaBaseUrl;
/**
* 通用对话模型(主力模型)
*/
@Bean
@Primary
public OllamaChatModel primaryChatModel() {
OllamaApi ollamaApi = new OllamaApi(ollamaBaseUrl);
OllamaOptions options = OllamaOptions.builder()
.withModel("deepseek-r1:7b")
.withTemperature(0.7f)
.withTopP(0.9f)
.withNumCtx(8192)
.withNumPredict(2048)
.build();
return new OllamaChatModel(ollamaApi, options);
}
/**
* 代码生成模型(专用)
*/
@Bean("codeChatModel")
public OllamaChatModel codeChatModel() {
OllamaApi ollamaApi = new OllamaApi(ollamaBaseUrl);
OllamaOptions options = OllamaOptions.builder()
.withModel("qwen2.5-coder:7b")
.withTemperature(0.1f) // 代码场景低温度
.withTopP(0.95f)
.withNumCtx(16384) // 代码场景需要更大上下文
.withNumPredict(4096)
.build();
return new OllamaChatModel(ollamaApi, options);
}
/**
* 轻量快速模型(高并发简单任务)
*/
@Bean("fastChatModel")
public OllamaChatModel fastChatModel() {
OllamaApi ollamaApi = new OllamaApi(ollamaBaseUrl);
OllamaOptions options = OllamaOptions.builder()
.withModel("llama3.2:3b")
.withTemperature(0.5f)
.withNumCtx(4096)
.withNumPredict(512)
.build();
return new OllamaChatModel(ollamaApi, options);
}
/**
* 嵌入模型
*/
@Bean
public OllamaEmbeddingModel embeddingModel() {
OllamaApi ollamaApi = new OllamaApi(ollamaBaseUrl);
OllamaOptions options = OllamaOptions.builder()
.withModel("nomic-embed-text")
.build();
return new OllamaEmbeddingModel(ollamaApi, options);
}
}ChatClientConfig.java
package com.laozhang.ollama.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Configuration
public class ChatClientConfig {
@Bean
@Primary
public ChatClient primaryChatClient(OllamaChatModel primaryChatModel) {
return ChatClient.builder(primaryChatModel)
.defaultSystem("你是一位专业的AI助手,请用中文回答问题,语言简洁准确。")
.build();
}
@Bean("codeChatClient")
public ChatClient codeChatClient(@Qualifier("codeChatModel") OllamaChatModel codeChatModel) {
return ChatClient.builder(codeChatModel)
.defaultSystem("""
你是一位资深的Java工程师,请提供生产级别的代码实现。
代码要求:
1. 包含完整的异常处理
2. 添加必要的注释
3. 遵循阿里巴巴Java开发规范
4. 不省略任何关键代码
""")
.build();
}
}DTO定义
package com.laozhang.ollama.dto;
import lombok.Data;
@Data
public class ChatRequest {
/**
* 用户消息
*/
private String message;
/**
* 会话ID(用于保持上下文)
*/
private String sessionId;
/**
* 使用的模型类型:primary/code/fast
*/
private String modelType = "primary";
/**
* 是否使用流式输出
*/
private boolean stream = false;
/**
* 自定义系统提示词(可选)
*/
private String systemPrompt;
}package com.laozhang.ollama.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ChatResponse {
private String sessionId;
private String content;
private String model;
private long latencyMs;
private int inputTokens;
private int outputTokens;
}流式输出实现(SSE)
流式输出是AI应用体验的关键——用户不用等待整个回答生成完毕,可以看到逐字输出的效果。
LocalChatService.java(完整服务层)
package com.laozhang.ollama.service;
import com.laozhang.ollama.dto.ChatRequest;
import com.laozhang.ollama.dto.ChatResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 本地模型对话服务
*/
@Slf4j
@Service
public class LocalChatService {
private final ChatClient primaryChatClient;
private final ChatClient codeChatClient;
private final RedisTemplate<String, Object> redisTemplate;
@Value("${laozhang.ai.history-max-turns:10}")
private int historyMaxTurns;
@Value("${laozhang.ai.history-ttl:3600}")
private long historyTtl;
private static final String HISTORY_KEY_PREFIX = "chat:history:";
public LocalChatService(
ChatClient primaryChatClient,
@Qualifier("codeChatClient") ChatClient codeChatClient,
RedisTemplate<String, Object> redisTemplate) {
this.primaryChatClient = primaryChatClient;
this.codeChatClient = codeChatClient;
this.redisTemplate = redisTemplate;
}
/**
* 普通同步对话(适合短文本)
*/
public ChatResponse chat(ChatRequest request) {
long startTime = System.currentTimeMillis();
// 1. 获取或创建会话ID
String sessionId = request.getSessionId();
if (sessionId == null || sessionId.isBlank()) {
sessionId = UUID.randomUUID().toString();
}
// 2. 加载历史消息
List<Message> messages = loadHistory(sessionId);
// 3. 选择对应的ChatClient
ChatClient chatClient = selectChatClient(request.getModelType());
// 4. 构建本次消息
messages.add(new UserMessage(request.getMessage()));
// 5. 调用模型
String responseContent;
try {
responseContent = chatClient.prompt()
.messages(messages)
.call()
.content();
} catch (Exception e) {
log.error("本地模型调用失败, sessionId={}, error={}", sessionId, e.getMessage(), e);
throw new RuntimeException("AI服务暂时不可用,请稍后重试", e);
}
// 6. 保存历史
messages.add(new AssistantMessage(responseContent));
saveHistory(sessionId, messages);
long latency = System.currentTimeMillis() - startTime;
log.info("Chat completed, sessionId={}, latency={}ms", sessionId, latency);
return ChatResponse.builder()
.sessionId(sessionId)
.content(responseContent)
.model(request.getModelType())
.latencyMs(latency)
.build();
}
/**
* 流式对话(SSE输出,适合长文本)
*/
public Flux<String> chatStream(ChatRequest request) {
// 1. 获取或创建会话ID
String sessionId = request.getSessionId() != null
? request.getSessionId()
: UUID.randomUUID().toString();
// 2. 加载历史消息
List<Message> messages = loadHistory(sessionId);
messages.add(new UserMessage(request.getMessage()));
// 3. 选择ChatClient
ChatClient chatClient = selectChatClient(request.getModelType());
// 4. 收集完整回答用于保存历史
StringBuilder fullResponse = new StringBuilder();
return chatClient.prompt()
.messages(messages)
.stream()
.content()
.doOnNext(chunk -> {
fullResponse.append(chunk);
})
.doOnComplete(() -> {
// 流结束后保存历史
messages.add(new AssistantMessage(fullResponse.toString()));
saveHistory(sessionId, messages);
log.info("Stream completed, sessionId={}, responseLength={}",
sessionId, fullResponse.length());
})
.doOnError(e -> {
log.error("Stream error, sessionId={}, error={}", sessionId, e.getMessage(), e);
});
}
/**
* 选择ChatClient
*/
private ChatClient selectChatClient(String modelType) {
if ("code".equals(modelType)) {
return codeChatClient;
}
return primaryChatClient;
}
/**
* 从Redis加载对话历史
*/
@SuppressWarnings("unchecked")
private List<Message> loadHistory(String sessionId) {
String key = HISTORY_KEY_PREFIX + sessionId;
List<Object> rawHistory = redisTemplate.opsForList().range(key, 0, -1);
if (rawHistory == null || rawHistory.isEmpty()) {
return new ArrayList<>();
}
List<Message> messages = new ArrayList<>();
for (Object item : rawHistory) {
if (item instanceof String str) {
// 格式:role|content
String[] parts = str.split("\\|", 2);
if (parts.length == 2) {
if ("user".equals(parts[0])) {
messages.add(new UserMessage(parts[1]));
} else if ("assistant".equals(parts[0])) {
messages.add(new AssistantMessage(parts[1]));
}
}
}
}
// 只保留最近N轮
int maxMessages = historyMaxTurns * 2;
if (messages.size() > maxMessages) {
messages = messages.subList(messages.size() - maxMessages, messages.size());
}
return messages;
}
/**
* 保存对话历史到Redis
*/
private void saveHistory(String sessionId, List<Message> messages) {
String key = HISTORY_KEY_PREFIX + sessionId;
redisTemplate.delete(key);
List<String> serialized = new ArrayList<>();
for (Message msg : messages) {
if (msg instanceof UserMessage) {
serialized.add("user|" + msg.getContent());
} else if (msg instanceof AssistantMessage) {
serialized.add("assistant|" + msg.getContent());
}
}
if (!serialized.isEmpty()) {
redisTemplate.opsForList().rightPushAll(key, serialized.toArray());
redisTemplate.expire(key, historyTtl, TimeUnit.SECONDS);
}
}
}ChatController.java(完整控制器)
package com.laozhang.ollama.controller;
import com.laozhang.ollama.dto.ChatRequest;
import com.laozhang.ollama.dto.ChatResponse;
import com.laozhang.ollama.service.LocalChatService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
/**
* 对话控制器
* 支持普通请求和SSE流式输出两种模式
*/
@Slf4j
@RestController
@RequestMapping("/chat")
@RequiredArgsConstructor
public class ChatController {
private final LocalChatService chatService;
/**
* 普通对话接口
* POST /api/chat
*/
@PostMapping
public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
log.info("Received chat request, sessionId={}, modelType={}",
request.getSessionId(), request.getModelType());
ChatResponse response = chatService.chat(request);
return ResponseEntity.ok(response);
}
/**
* 流式对话接口(SSE)
* POST /api/chat/stream
*
* 前端使用示例(JavaScript):
* const eventSource = new EventSource('/api/chat/stream');
* eventSource.onmessage = (e) => console.log(e.data);
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestBody ChatRequest request) {
log.info("Received stream chat request, sessionId={}", request.getSessionId());
return chatService.chatStream(request)
// 将每个chunk包装成SSE格式
.map(chunk -> chunk)
// 错误处理:出错时发送错误标记
.onErrorReturn("[ERROR]");
}
/**
* 健康检查接口(测试Ollama连通性)
* GET /api/chat/health
*/
@GetMapping("/health")
public ResponseEntity<String> health() {
try {
// 使用极简请求测试连通性
ChatRequest pingRequest = new ChatRequest();
pingRequest.setMessage("Hi");
pingRequest.setModelType("fast");
chatService.chat(pingRequest);
return ResponseEntity.ok("Ollama连接正常");
} catch (Exception e) {
return ResponseEntity.status(503).body("Ollama连接失败: " + e.getMessage());
}
}
}本地嵌入模型:用nomic-embed-text替代OpenAI Embedding
嵌入模型是RAG(检索增强生成)的核心组件。OpenAI的text-embedding-ada-002按量计费,大规模使用时成本可观。nomic-embed-text是一个开源的本地嵌入模型,可以完全替代它。
性能对比
| 模型 | 维度 | 中文效果 | 成本 | 速度(文本/s) |
|---|---|---|---|---|
| text-embedding-ada-002 | 1536 | 良好 | $0.0001/1K tokens | 云端依赖 |
| nomic-embed-text | 768 | 良好 | 0(本地) | ~200(CPU) |
| bge-m3 | 1024 | 优秀 | 0(本地) | ~150(CPU) |
LocalEmbeddingService.java
package com.laozhang.ollama.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.embedding.EmbeddingRequest;
import org.springframework.ai.embedding.EmbeddingResponse;
import org.springframework.ai.ollama.OllamaEmbeddingModel;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 本地嵌入服务
* 使用 nomic-embed-text 模型生成文本向量
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class LocalEmbeddingService {
private final OllamaEmbeddingModel embeddingModel;
/**
* 生成单个文本的嵌入向量
*/
public float[] embed(String text) {
long start = System.currentTimeMillis();
try {
EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
float[] embedding = response.getResults().get(0).getOutput();
log.debug("Embedding generated, dim={}, latency={}ms",
embedding.length, System.currentTimeMillis() - start);
return embedding;
} catch (Exception e) {
log.error("Embedding generation failed: {}", e.getMessage(), e);
throw new RuntimeException("嵌入向量生成失败", e);
}
}
/**
* 批量生成嵌入向量
*/
public List<float[]> embedBatch(List<String> texts) {
long start = System.currentTimeMillis();
try {
EmbeddingResponse response = embeddingModel.embedForResponse(texts);
List<float[]> results = response.getResults()
.stream()
.map(r -> r.getOutput())
.toList();
log.info("Batch embedding generated, count={}, latency={}ms",
texts.size(), System.currentTimeMillis() - start);
return results;
} catch (Exception e) {
log.error("Batch embedding generation failed: {}", e.getMessage(), e);
throw new RuntimeException("批量嵌入向量生成失败", e);
}
}
/**
* 计算余弦相似度
*/
public double cosineSimilarity(float[] vec1, float[] vec2) {
if (vec1.length != vec2.length) {
throw new IllegalArgumentException("向量维度不匹配");
}
double dotProduct = 0;
double norm1 = 0;
double norm2 = 0;
for (int i = 0; i < vec1.length; i++) {
dotProduct += vec1[i] * vec2[i];
norm1 += vec1[i] * vec1[i];
norm2 += vec2[i] * vec2[i];
}
if (norm1 == 0 || norm2 == 0) return 0;
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
}性能调优:GPU加速、并发请求、模型预加载
架构图
GPU加速配置
# 验证GPU是否被Ollama识别
ollama run deepseek-r1:7b "hello" 2>&1 | grep -i gpu
# 查看GPU使用情况
nvidia-smi
# 设置GPU内存分配(环境变量)
export OLLAMA_GPU_OVERHEAD=0 # 不为系统预留GPU内存
export OLLAMA_MAX_LOADED_MODELS=2 # 最多同时加载2个模型
export OLLAMA_NUM_PARALLEL=4 # 最大并行请求数
export OLLAMA_FLASH_ATTENTION=1 # 开启FlashAttention(提速30%)
# 写入systemd服务(永久生效)
sudo systemctl edit ollama
# 添加以下内容:
# [Service]
# Environment="OLLAMA_MAX_LOADED_MODELS=2"
# Environment="OLLAMA_NUM_PARALLEL=4"
# Environment="OLLAMA_FLASH_ATTENTION=1"模型预加载(解决首次请求冷启动)
package com.laozhang.ollama.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
/**
* 应用启动时预热模型,消除首次请求的冷启动延迟
*/
@Slf4j
@Component
public class ModelWarmupRunner implements ApplicationRunner {
private final ChatClient primaryChatClient;
private final ChatClient codeChatClient;
public ModelWarmupRunner(
ChatClient primaryChatClient,
@Qualifier("codeChatClient") ChatClient codeChatClient) {
this.primaryChatClient = primaryChatClient;
this.codeChatClient = codeChatClient;
}
@Override
public void run(ApplicationArguments args) {
log.info("开始预热本地模型...");
// 异步预热,不阻塞应用启动
Thread.ofVirtual().start(() -> {
warmupModel(primaryChatClient, "primary");
});
Thread.ofVirtual().start(() -> {
warmupModel(codeChatClient, "code");
});
}
private void warmupModel(ChatClient client, String modelName) {
try {
long start = System.currentTimeMillis();
// 发送一个极简请求加载模型到GPU
client.prompt().user("hi").call().content();
log.info("模型预热完成: {}, 耗时: {}ms", modelName,
System.currentTimeMillis() - start);
} catch (Exception e) {
log.warn("模型预热失败: {}, 原因: {}", modelName, e.getMessage());
}
}
}并发限制与熔断
package com.laozhang.ollama.config;
import io.github.resilience4j.bulkhead.BulkheadConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* 本地模型的并发保护配置
* GPU资源有限,必须做好并发控制,否则OOM
*/
@Configuration
public class ResilienceConfig {
/**
* 限制同时请求Ollama的并发数
* A100 80G + DeepSeek-R1:7b,建议最大并发4-8
*/
@Bean
public BulkheadConfig ollamaBulkheadConfig() {
return BulkheadConfig.custom()
.maxConcurrentCalls(6) // 最大并发6个
.maxWaitDuration(Duration.ofSeconds(30)) // 最多等待30s
.build();
}
/**
* 熔断配置:Ollama挂了不影响整个应用
*/
@Bean
public CircuitBreakerConfig ollamaCircuitBreakerConfig() {
return CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 50%失败率触发熔断
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10)
.build();
}
}生产部署:Docker容器化Ollama
Dockerfile(Ollama服务)
# Dockerfile.ollama
FROM ollama/ollama:latest
# 环境变量配置
ENV OLLAMA_MAX_LOADED_MODELS=2
ENV OLLAMA_NUM_PARALLEL=4
ENV OLLAMA_FLASH_ATTENTION=1
ENV OLLAMA_ORIGINS="*"
# 暴露API端口
EXPOSE 11434
# 启动脚本:拉取模型后启动服务
COPY scripts/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]#!/bin/bash
# scripts/entrypoint.sh
# 后台启动Ollama服务
ollama serve &
OLLAMA_PID=$!
# 等待服务就绪
echo "等待Ollama服务启动..."
until curl -s http://localhost:11434/api/tags > /dev/null 2>&1; do
sleep 1
done
echo "Ollama服务已就绪"
# 拉取所需模型(如果不存在)
for MODEL in "deepseek-r1:7b" "qwen2.5-coder:7b" "llama3.2:3b" "nomic-embed-text"; do
echo "检查模型: $MODEL"
if ! ollama list | grep -q "$MODEL"; then
echo "下载模型: $MODEL"
ollama pull "$MODEL"
fi
done
echo "所有模型准备就绪"
# 等待Ollama进程
wait $OLLAMA_PIDdocker-compose.yml(完整部署)
version: '3.8'
services:
# Ollama服务(需要GPU)
ollama:
image: ollama/ollama:latest
container_name: ollama-service
ports:
- "11434:11434"
volumes:
- ollama_models:/root/.ollama
environment:
- OLLAMA_MAX_LOADED_MODELS=2
- OLLAMA_NUM_PARALLEL=4
- OLLAMA_FLASH_ATTENTION=1
# GPU配置(需要nvidia-container-toolkit)
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
interval: 30s
timeout: 10s
retries: 3
# Spring Boot应用
app:
build:
context: .
dockerfile: Dockerfile
container_name: ollama-spring-app
ports:
- "8080:8080"
environment:
- SPRING_AI_OLLAMA_BASE_URL=http://ollama:11434
- SPRING_DATA_REDIS_HOST=redis
depends_on:
ollama:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
# Redis(对话历史)
redis:
image: redis:7-alpine
container_name: redis-service
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
volumes:
ollama_models:
driver: local
redis_data:
driver: local混合部署:简单任务本地,复杂任务云端
真实生产环境往往不是非此即彼,而是需要"智能路由"——根据任务复杂度动态决定用本地还是云端。
HybridChatService.java
package com.laozhang.ollama.service;
import com.laozhang.ollama.dto.ChatRequest;
import com.laozhang.ollama.dto.ChatResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* 混合部署服务
* 根据任务复杂度智能路由:简单任务用本地模型,复杂任务用云端
*/
@Slf4j
@Service
public class HybridChatService {
private final LocalChatService localChatService;
private final ChatClient openaiChatClient; // 云端备用
@Value("${laozhang.ai.hybrid-mode:false}")
private boolean hybridMode;
// 触发云端模型的关键词(需要强推理能力的场景)
private static final String[] COMPLEX_TASK_INDICATORS = {
"深度分析", "架构设计", "复杂推理",
"对比报告", "全面评估", "系统方案"
};
// 触发云端的输入长度阈值(字符数)
private static final int COMPLEX_INPUT_THRESHOLD = 2000;
public HybridChatService(LocalChatService localChatService,
ChatClient openaiChatClient) {
this.localChatService = localChatService;
this.openaiChatClient = openaiChatClient;
}
public ChatResponse smartChat(ChatRequest request) {
if (!hybridMode) {
// 未开启混合模式,全部走本地
return localChatService.chat(request);
}
// 判断是否需要云端模型
boolean needCloudModel = isComplexTask(request.getMessage());
log.info("Task complexity check: message='{}', needCloud={}",
request.getMessage().substring(0, Math.min(50, request.getMessage().length())),
needCloudModel);
if (needCloudModel) {
return callCloudModel(request);
} else {
return localChatService.chat(request);
}
}
private boolean isComplexTask(String message) {
// 规则1:消息长度超过阈值
if (message.length() > COMPLEX_INPUT_THRESHOLD) {
return true;
}
// 规则2:包含复杂任务关键词
for (String indicator : COMPLEX_TASK_INDICATORS) {
if (message.contains(indicator)) {
return true;
}
}
return false;
}
private ChatResponse callCloudModel(ChatRequest request) {
long start = System.currentTimeMillis();
try {
String content = openaiChatClient.prompt()
.user(request.getMessage())
.call()
.content();
return ChatResponse.builder()
.sessionId(request.getSessionId())
.content(content)
.model("openai-cloud")
.latencyMs(System.currentTimeMillis() - start)
.build();
} catch (Exception e) {
log.warn("云端模型调用失败,降级到本地模型: {}", e.getMessage());
// 云端失败时自动降级到本地
return localChatService.chat(request);
}
}
}性能测试数据汇总
压测环境
- 服务器:NVIDIA A100 80G,64核CPU,256G内存
- JVM:JDK 17,-Xms2g -Xmx4g
- 并发:JMeter,50并发,持续5分钟
压测结果
| 场景 | 模型 | 平均响应时间 | P99响应时间 | 吞吐量(req/s) | GPU利用率 |
|---|---|---|---|---|---|
| 短文本生成(100字) | DeepSeek-R1:7b | 1.2s | 2.1s | 12.5 | 78% |
| 长文本生成(500字) | DeepSeek-R1:7b | 5.8s | 8.3s | 4.2 | 92% |
| 代码生成(50行) | Qwen2.5-Coder:7b | 3.1s | 5.2s | 7.8 | 85% |
| 嵌入向量生成 | nomic-embed-text | 35ms | 80ms | 180 | 25% |
| 轻量问答 | Llama3.2:3b | 0.8s | 1.5s | 28.3 | 45% |
与云端API对比:
- 响应速度:本地模型P50延迟略低(无网络RTT),P99延迟受并发影响较大
- 成本:日均1万次调用,年节省约20万元
- 可用性:本地部署通过合理运维可达99.5%以上
FAQ
Q1:Ollama支持哪些操作系统?GPU要求是什么?
Ollama支持macOS(M1/M2/M3原生支持)、Linux(推荐Ubuntu 20.04+)、Windows(WSL2)。GPU方面,NVIDIA(CUDA 11.8+)、AMD(ROCm)、Apple Silicon均支持。无GPU时CPU也可运行,但速度较慢(7B模型约8 tokens/s)。
Q2:7B模型和14B模型的实际效果差多少?
在通用任务上,14B约比7B好10-15%。但在垂直领域经过Prompt调优的7B,往往能追平14B的通用能力。如果显存不够,建议先用7B量化版(:q4_K_M后缀),显存占用降低40%,质量损失约5%。
Q3:Spring AI版本更新很快,如何避免版本冲突?
始终使用spring-ai-bom统一管理版本,不要单独指定各模块版本。目前(2026年)稳定版本是1.0.0,LTS版本在2026 Q2发布。遇到依赖冲突,检查是否有旧版Spring AI传递依赖(mvn dependency:tree | grep spring-ai)。
Q4:本地模型如何处理中文乱码问题?
主要原因是JVM的默认字符集不是UTF-8。解决方案:启动参数添加 -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8,同时确保application.yml中HTTP编码配置正确:server.servlet.encoding.charset=UTF-8。Ollama返回的JSON本身是UTF-8编码,问题通常出在Java侧的序列化。
Q5:如何监控本地模型的调用情况?
Ollama本身不提供Prometheus指标,需要在Spring AI层面添加监控。推荐方案:使用Micrometer记录每次调用的延迟、成功率、token数,配合Prometheus + Grafana展示。文章中的pom.xml已包含micrometer-registry-prometheus依赖,在Service层添加@Timed注解即可。
Q6:本地模型能用于生产环境吗?稳定性如何?
已有大量企业在生产使用Ollama(包括金融、医疗、法律行业)。稳定性关键点:①使用Docker部署并配置重启策略;②做好内存/GPU监控和告警;③配置熔断降级(文章中ResilienceConfig);④关键业务保留云端API作为fallback。日活10万以下的应用,单A100基本可以稳定承载。
