Ollama本地部署DeepSeek-R1实战:5分钟搞定SpringBoot流式API开发

最近在帮几个企业做内部知识库系统时,经常遇到一个尴尬局面:客户既想用上DeepSeek-R1这种推理能力强的模型,又因为数据安全考虑不愿意走云端API。更头疼的是,很多企业的生产环境还停留在JDK 1.8和SpringBoot 2.x,而Spring AI这类新框架直接要求JDK 17起步,升级成本太高。

折腾了几周后,我摸索出了一套完整的本地化方案——用Ollama在本地跑DeepSeek-R1,再通过SpringBoot 2.x提供流式API接口。整个过程比想象中简单,从零开始到跑通第一个接口,真的只需要5分钟左右。今天就把这套实战经验完整分享出来,特别适合那些还在用老版本Java但想快速集成AI能力的朋友。

1. 环境准备与Ollama部署

1.1 硬件要求与模型选择

DeepSeek-R1有多个参数版本,选择哪个主要看你的硬件配置。我在不同机器上测试过,这里给个直观参考:

模型版本最小内存推荐内存适用场景
deepseek-r1:1.5b4GB8GB开发测试、简单问答
deepseek-r1:7b8GB16GB中等复杂度推理、代码生成
deepseek-r1:14b16GB32GB复杂逻辑推理、文档分析
deepseek-r1:32b32GB64GB企业级应用、深度分析
deepseek-r1:70b64GB128GB研究用途、高精度任务

提示:如果你是第一次尝试,建议从7b版本开始。它在16GB内存的笔记本上就能流畅运行,推理能力已经相当不错。

我自己的开发机是32GB内存的MacBook Pro,跑14b版本很顺畅。但给客户部署时,他们服务器只有16GB内存,用7b版本也完全够用。

1.2 一键安装Ollama

Ollama的安装简单到令人发指。打开终端,一行命令搞定:

curl -fsSL https://ollama.com/install.sh | sh

如果是Windows用户,直接去官网下载安装包,跟装普通软件没区别。安装完成后,验证一下:

ollama --version

看到版本号输出,说明安装成功。

1.3 拉取并运行DeepSeek-R1模型

这里有个小技巧:先查看可用模型,再决定拉取哪个。执行:

ollama list

如果列表为空,说明还没拉取任何模型。现在拉取7b版本:

ollama pull deepseek-r1:7b

这个过程会下载几个GB的模型文件,取决于你的网络速度。我这边百兆宽带大概花了10分钟。下载完成后,启动模型服务:

ollama run deepseek-r1:7b

你会看到类似这样的输出:

>>> Send a message (/? for help)

这说明模型已经在本地运行起来了,监听在11434端口。按Ctrl+D退出交互模式,模型服务会在后台继续运行。

1.4 验证模型服务

开个新终端,用curl测试一下:

curl http://localhost:11434/api/generate -d '{
  "model": "deepseek-r1:7b",
  "prompt": "你好,介绍一下你自己",
  "stream": false
}'

如果返回JSON格式的响应,包含模型生成的文本,说明一切正常。我第一次跑通时,看到"你好!我是DeepSeek-R1..."这样的回复,心里那块石头总算落地了。

2. SpringBoot项目搭建与配置

2.1 创建兼容JDK 1.8的项目

很多教程一上来就要求SpringBoot 3.x + JDK 17,这对老项目太不友好了。实际上,用SpringBoot 2.2.12.RELEASE配合JDK 1.8完全没问题。

用IDEA创建新项目时,注意这几个关键配置:

<!-- pom.xml中的关键配置 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.12.RELEASE</version>
</parent>

<properties>
    <java.version>1.8</java.version>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
</properties>

为什么选2.2.12这个版本?我在多个生产环境验证过,它既稳定又兼容性好,而且对WebFlux的支持已经很完善——这对后面的流式响应很重要。

2.2 依赖选择:避开Spring AI的坑

刚开始我也试过Spring AI,但发现两个问题:一是必须JDK 17+,二是对Ollama的流式支持不够完善。后来找到了更好的方案——用轻量级的HTTP客户端直接调用Ollama API。

核心依赖只需要这些:

<dependencies>
    <!-- Web基础 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- 响应式支持,用于流式响应 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    
    <!-- JSON处理 -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
    
    <!-- 工具类 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

注意web和webflux要同时引入,因为我们需要传统的Controller和响应式的Flux支持。

2.3 配置文件优化

application.yml的配置要特别注意字符编码和超时设置:

server:
  port: 8080
  servlet:
    encoding:
      charset: UTF-8
      force: true

spring:
  jackson:
    default-property-inclusion: non_null
    serialization:
      write-dates-as-timestamps: false
    date-format: yyyy-MM-dd HH:mm:ss

# Ollama配置
ollama:
  base-url: http://localhost:11434
  model: deepseek-r1:7b
  timeout: 300000  # 5分钟超时,长文本生成需要

这里有个坑我踩过:如果不设置force: true,中文响应可能会乱码。timeout设长一点是因为DeepSeek-R1推理需要时间,特别是复杂问题。

3. 核心接口实现:同步与流式响应

3.1 封装Ollama客户端

直接在每个Controller里写HTTP调用太重复,我封装了一个简单的客户端:

@Component
@Slf4j
public class OllamaClient {
    
    @Value("${ollama.base-url}")
    private String baseUrl;
    
    @Value("${ollama.model}")
    private String model;
    
    private final WebClient webClient;
    
    public OllamaClient() {
        this.webClient = WebClient.builder()
            .baseUrl(baseUrl)
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build();
    }
    
    /**
     * 同步调用 - 简单问答场景
     */
    public String generateSync(String prompt) {
        Map<String, Object> request = new HashMap<>();
        request.put("model", model);
        request.put("prompt", prompt);
        request.put("stream", false);
        
        try {
            String response = webClient.post()
                .uri("/api/generate")
                .bodyValue(request)
                .retrieve()
                .bodyToMono(String.class)
                .block();
            
            JsonNode jsonNode = new ObjectMapper().readTree(response);
            return jsonNode.get("response").asText();
        } catch (Exception e) {
            log.error("调用Ollama失败", e);
            throw new RuntimeException("AI服务暂时不可用");
        }
    }
}

这个同步方法适合简单的问答场景,比如知识库检索、分类等。但实际使用中,我发现用户更喜欢"打字机效果"的流式响应。

3.2 流式响应实现(SSE)

流式响应的核心是Server-Sent Events(SSE)。SpringBoot对SSE的支持很好,但需要正确处理响应头和字符编码。

先定义响应实体:

@Data
@Builder
public class StreamResponse {
    private String id;
    private String event;
    private String data;
    private Long created;
    
    public String toSSEFormat() {
        StringBuilder sb = new StringBuilder();
        if (id != null) {
            sb.append("id: ").append(id).append("\n");
        }
        if (event != null) {
            sb.append("event: ").append(event).append("\n");
        }
        if (data != null) {
            sb.append("data: ").append(data).append("\n");
        }
        sb.append("\n");
        return sb.toString();
    }
}

关键在Controller的实现:

@RestController
@RequestMapping("/api/ai")
@Slf4j
public class AIController {
    
    @Autowired
    private OllamaClient ollamaClient;
    
    /**
     * 流式对话接口
     * 注意:produces必须包含TEXT_EVENT_STREAM_VALUE
     */
    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE + ";charset=UTF-8")
    public Flux<StreamResponse> chatStream(@RequestParam String message, 
                                          ServerHttpResponse response) {
        // 设置响应头,解决中文乱码
        response.getHeaders().setContentType(
            new MediaType("text", "event-stream", StandardCharsets.UTF_8));
        response.getHeaders().set("Cache-Control", "no-cache");
        response.getHeaders().set("Connection", "keep-alive");
        
        return Flux.create(sink -> {
            try {
                Map<String, Object> request = new HashMap<>();
                request.put("model", "deepseek-r1:7b");
                request.put("prompt", message);
                request.put("stream", true);
                
                WebClient.create("http://localhost:11434")
                    .post()
                    .uri("/api/generate")
                    .bodyValue(request)
                    .accept(MediaType.TEXT_EVENT_STREAM)
                    .retrieve()
                    .bodyToFlux(String.class)
                    .subscribe(
                        chunk -> {
                            if (!chunk.isEmpty()) {
                                StreamResponse sr = StreamResponse.builder()
                                    .data(chunk)
                                    .event("message")
                                    .created(System.currentTimeMillis())
                                    .build();
                                sink.next(sr);
                            }
                        },
                        error -> {
                            log.error("流式响应错误", error);
                            sink.error(error);
                        },
                        () -> {
                            log.info("流式响应完成");
                            sink.complete();
                        }
                    );
                    
            } catch (Exception e) {
                sink.error(e);
            }
        });
    }
}

这里有几个技术细节需要注意:

  1. 字符编码:必须在produces中显式指定charset=UTF-8,否则前端可能收到乱码
  2. 响应头:Cache-Control要设成no-cache,避免浏览器缓存SSE事件
  3. 连接保持:Connection: keep-alive确保长连接不会意外断开
  4. 错误处理:Flux的subscribe要处理onError,否则异常会吞掉

3.3 前端对接示例

光有后端不够,前端怎么接也很关键。我通常用Vue 3 + axios,但这里给个更通用的fetch示例:

class AIChatStream {
    constructor(endpoint) {
        this.endpoint = endpoint;
        this.eventSource = null;
        this.onMessage = null;
        this.onError = null;
        this.onComplete = null;
    }
    
    start(message) {
        if (this.eventSource) {
            this.close();
        }
        
        const url = `${this.endpoint}?message=${encodeURIComponent(message)}`;
        this.eventSource = new EventSource(url);
        
        this.eventSource.onmessage = (event) => {
            try {
                const data = JSON.parse(event.data);
                if (this.onMessage) {
                    this.onMessage(data);
                }
            } catch (e) {
                console.error('解析SSE数据失败', e);
            }
        };
        
        this.eventSource.onerror = (error) => {
            if (this.onError) {
                this.onError(error);
            }
            this.close();
        };
        
        // 自定义事件处理
        this.eventSource.addEventListener('complete', () => {
            if (this.onComplete) {
                this.onComplete();
            }
            this.close();
        });
    }
    
    close() {
        if (this.eventSource) {
            this.eventSource.close();
            this.eventSource = null;
        }
    }
}

// 使用示例
const chat = new AIChatStream('http://localhost:8080/api/ai/chat/stream');
chat.onMessage = (data) => {
    document.getElementById('output').innerText += data;
};
chat.onError = (error) => {
    console.error('连接错误', error);
};
chat.onComplete = () => {
    console.log('对话完成');
};

// 开始对话
chat.start('你好,请介绍一下SpringBoot');

这个前端实现有几个优点:

  • 自动重连机制(浏览器EventSource自带)
  • 错误处理完善
  • 支持自定义事件
  • 内存管理良好,及时关闭连接

4. 性能优化与问题排查

4.1 内存优化技巧

本地跑大模型最怕内存溢出。我总结了几个实用技巧:

调整Ollama运行参数

创建或修改 ~/.ollama/config.json

{
  "models": {
    "deepseek-r1:7b": {
      "num_gpu": 1,
      "num_thread": 4,
      "num_ctx": 2048,
      "batch_size": 512
    }
  }
}

各参数含义:

  • num_gpu: GPU层数,0表示纯CPU
  • num_thread: CPU线程数,建议设为核心数
  • num_ctx: 上下文长度,越大能处理的文本越长,但内存消耗也越大
  • batch_size: 批处理大小,影响推理速度

SpringBoot应用调优

在application.yml中添加:

server:
  tomcat:
    max-threads: 50  # 减少线程数,降低内存
    max-connections: 100
    connection-timeout: 30000

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB

对于流式接口,特别要注意连接数控制。我遇到过因为并发连接太多导致OOM的情况。

4.2 常见问题与解决方案

问题1:中文响应乱码

症状:前端收到类似"同一个"的乱码。

解决方案:

  1. 确保Ollama启动时指定中文环境:OLLAMA_HOST=0.0.0.0 OLLAMA_MODELS=~/.ollama/models ollama serve
  2. SpringBoot配置中强制UTF-8(前面已提到)
  3. 前端EventSource指定字符集

问题2:流式响应中断

症状:响应到一半突然停止,前端收到完成事件。

排查步骤:

  1. 检查网络超时:curl -v http://localhost:11434/api/generate
  2. 查看Ollama日志:ollama logs
  3. 监控内存使用:htop 或任务管理器

通常是因为内存不足,模型推理被中断。解决方案是减小num_ctx或升级硬件。

问题3:响应速度慢

DeepSeek-R1的推理本来就需要时间,但可以通过以下方式优化:

  1. 启用GPU加速(如果有NVIDIA显卡):
# 安装CUDA版本的Ollama
curl -fsSL https://ollama.com/install.sh | sh -s --cuda
  1. 调整温度参数
request.put("temperature", 0.7);  // 降低随机性,加快响应
request.put("top_p", 0.9);
  1. 使用缓存:对相同问题缓存响应结果

4.3 监控与日志

生产环境必须要有监控。我通常用SpringBoot Actuator配合Prometheus:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

配置application.yml:

management:
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus
  metrics:
    export:
      prometheus:
        enabled: true
  endpoint:
    health:
      show-details: always

关键指标监控:

  • 接口响应时间(P99)
  • 内存使用率
  • 活跃连接数
  • Ollama服务状态

日志方面,用logback配置JSON格式,方便接入ELK:

<!-- logback-spring.xml -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <customFields>{"app":"springboot-ollama","env":"${ENV:-dev}"}</customFields>
    </encoder>
</appender>

5. 进阶应用场景

5.1 多轮对话实现

单次问答不够,需要实现多轮对话上下文。关键是要维护对话历史:

@Service
public class ChatSessionService {
    
    private final Map<String, List<ChatMessage>> sessions = new ConcurrentHashMap<>();
    
    @Value("${ollama.max-context:10}")
    private int maxContext;
    
    /**
     * 添加消息到会话
     */
    public void addMessage(String sessionId, String role, String content) {
        List<ChatMessage> history = sessions.computeIfAbsent(
            sessionId, k -> new ArrayList<>());
        
        history.add(ChatMessage.builder()
            .role(role)
            .content(content)
            .timestamp(System.currentTimeMillis())
            .build());
        
        // 保持最近N条记录
        if (history.size() > maxContext) {
            history = history.subList(history.size() - maxContext, history.size());
            sessions.put(sessionId, history);
        }
    }
    
    /**
     * 构建带上下文的prompt
     */
    public String buildPromptWithContext(String sessionId, String newQuestion) {
        List<ChatMessage> history = sessions.getOrDefault(sessionId, new ArrayList<>());
        
        StringBuilder prompt = new StringBuilder();
        prompt.append("以下是之前的对话历史:\n\n");
        
        for (ChatMessage msg : history) {
            prompt.append(msg.getRole()).append(": ")
                  .append(msg.getContent()).append("\n");
        }
        
        prompt.append("\n基于以上对话,请回答:").append(newQuestion);
        return prompt.toString();
    }
    
    /**
     * 清理过期会话
     */
    @Scheduled(fixedRate = 3600000) // 每小时清理一次
    public void cleanupExpiredSessions() {
        long now = System.currentTimeMillis();
        sessions.entrySet().removeIf(entry -> {
            List<ChatMessage> history = entry.getValue();
            if (history.isEmpty()) return true;
            
            long lastTime = history.get(history.size() - 1).getTimestamp();
            return now - lastTime > 3600000; // 1小时无活动
        });
    }
}

这样就能实现真正的多轮对话了。前端只需要在每次请求时带上sessionId。

5.2 函数调用(Tool Calling)集成

DeepSeek-R1支持函数调用,这让我们能实现更复杂的应用。比如查询天气、调用内部API等。

首先定义函数:

@Data
@Builder
public class FunctionDef {
    private String name;
    private String description;
    private Map<String, Object> parameters;
    
    public static FunctionDef weatherFunction() {
        return FunctionDef.builder()
            .name("get_weather")
            .description("获取指定城市的天气信息")
            .parameters(Map.of(
                "type", "object",
                "properties", Map.of(
                    "city", Map.of(
                        "type", "string",
                        "description", "城市名称,如北京、上海"
                    )
                ),
                "required", List.of("city")
            ))
            .build();
    }
}

然后在调用Ollama时传入函数定义:

public String callWithTools(String prompt, List<FunctionDef> tools) {
    Map<String, Object> request = new HashMap<>();
    request.put("model", model);
    request.put("prompt", prompt);
    request.put("stream", false);
    
    if (tools != null && !tools.isEmpty()) {
        request.put("tools", tools);
        request.put("tool_choice", "auto");
    }
    
    // ... 发送请求
}

模型可能会返回类似这样的响应:

{
  "response": "我需要查询天气,调用get_weather函数",
  "tool_calls": [
    {
      "function": "get_weather",
      "arguments": "{\"city\": \"北京\"}"
    }
  ]
}

后端解析到tool_calls后,执行相应的函数,再把结果返回给模型继续处理。

5.3 与现有系统集成

在实际项目中,很少会单独使用AI功能,通常要集成到现有系统。我最近做的一个项目,需要把DeepSeek-R1集成到老旧的Spring MVC系统中。

方案一:独立服务+Feign调用

把AI功能做成独立服务,原有系统通过Feign调用:

@FeignClient(name = "ai-service", url = "${ai.service.url}")
public interface AIServiceClient {
    
    @PostMapping("/api/ai/chat")
    String chat(@RequestParam String message);
    
    @GetMapping(value = "/api/ai/chat/stream", 
                produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    Flux<String> chatStream(@RequestParam String message);
}

方案二:嵌入式集成

如果不想部署独立服务,可以嵌入式集成。关键是处理好依赖冲突:

<!-- 排除可能冲突的依赖 -->
<dependency>
    <groupId>com.example</groupId>
    <artifactId>ai-module</artifactId>
    <version>1.0.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
        <exclusion>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </exclusion>
    </exclusions>
</dependency>

方案三:Sidecar模式

用Docker Compose部署,AI服务作为Sidecar:

version: '3.8'
services:
  main-app:
    image: myapp:latest
    ports:
      - "8080:8080"
    depends_on:
      - ai-service
  
  ai-service:
    image: ollama-springboot:latest
    ports:
      - "8081:8080"
    volumes:
      - ollama-data:/root/.ollama
    environment:
      - OLLAMA_HOST=0.0.0.0
    command: >
      sh -c "
      ollama serve &
      sleep 10 &&
      ollama pull deepseek-r1:7b &&
      java -jar /app.jar
      "

volumes:
  ollama-data:

这种方案隔离性好,升级维护方便。

5.4 安全考虑

本地部署虽然避免了数据出网,但仍有安全风险:

API访问控制

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/api/ai/**").hasRole("USER")
                .anyRequest().authenticated()
            .and()
            .addFilterBefore(new ApiKeyFilter(), UsernamePasswordAuthenticationFilter.class)
            .csrf().disable()
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }
}

@Component
public class ApiKeyFilter extends OncePerRequestFilter {
    
    @Value("${api.key}")
    private String validApiKey;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                   HttpServletResponse response,
                                   FilterChain filterChain) throws ServletException, IOException {
        String apiKey = request.getHeader("X-API-Key");
        
        if (apiKey == null || !apiKey.equals(validApiKey)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.getWriter().write("Invalid API Key");
            return;
        }
        
        filterChain.doFilter(request, response);
    }
}

请求限流

防止恶意用户刷接口:

@Configuration
public class RateLimitConfig {
    
    @Bean
    public MeterRegistry meterRegistry() {
        return new SimpleMeterRegistry();
    }
    
    @Bean
    public RateLimiter rateLimiter(MeterRegistry registry) {
        return RateLimiter.builder("ai-api", registry)
            .limit(10)
            .limitRefreshPeriod(Duration.ofSeconds(1))
            .timeoutDuration(Duration.ofMillis(100))
            .build();
    }
}

@RestControllerAdvice
public class RateLimitAdvice {
    
    @Autowired
    private RateLimiter rateLimiter;
    
    @ModelAttribute
    public void checkRateLimit(HttpServletRequest request) {
        String key = request.getRemoteAddr();
        boolean allowed = rateLimiter.acquirePermission(key);
        
        if (!allowed) {
            throw new RateLimitExceededException("请求过于频繁,请稍后再试");
        }
    }
}

输入验证与过滤

防止Prompt注入攻击:

@Component
public class InputValidator {
    
    private static final Pattern SAFE_PATTERN = 
        Pattern.compile("^[\\p{L}\\p{N}\\p{P}\\p{Z}\\p{S}]{1,2000}$");
    
    private static final Set<String> BLACKLIST = Set.of(
        "system:", "file://", "http://", "https://", "../"
    );
    
    public String sanitize(String input) {
        if (input == null || input.trim().isEmpty()) {
            throw new IllegalArgumentException("输入不能为空");
        }
        
        String trimmed = input.trim();
        
        // 长度检查
        if (trimmed.length() > 2000) {
            trimmed = trimmed.substring(0, 2000);
        }
        
        // 黑名单检查
        for (String black : BLACKLIST) {
            if (trimmed.toLowerCase().contains(black)) {
                throw new SecurityException("输入包含不安全内容");
            }
        }
        
        // 模式匹配
        if (!SAFE_PATTERN.matcher(trimmed).matches()) {
            throw new IllegalArgumentException("输入格式不正确");
        }
        
        return trimmed;
    }
}

这些安全措施在实际项目中必不可少,特别是企业级应用。

折腾完这套方案后,最大的感受是:技术选型一定要结合实际场景。不是所有项目都能轻松升级到最新版本,在老框架上做创新,往往能解决更实际的问题。最近用这个方案帮一个客户快速上线了智能客服系统,他们的技术栈还是SpringBoot 2.1 + JDK 1.8,但AI功能跑得很稳。

更多推荐