欢迎光临
我们一直在努力

.NET + AI 跨平台实战系列(四):本地化部署——使用Ollama运行开源多模态模型

.NET + AI 跨平台实战系列(四):本地化部署——使用Ollama运行开源多模态模型

隐私优先、零成本、离线可用:将大模型装进你的开发环境

引言:为什么需要本地模型?

在上一篇文章中,我们成功接入了GPT-4V,体验了云端AI的强大能力。但生产环境中的开发者往往面临三个现实问题:

  • 隐私风险:用户照片上传到云端,涉及敏感数据合规性

  • API成本:GPT-4V按Token收费,高频使用成本可观

  • 网络依赖:无网络环境下功能完全不可用

  • 2026年初,Ollama发布了2.0版本,支持LLaVA、BakLLaVA等视觉语言模型的一键部署,推理速度提升了40%

    。更重要的是,完全本地运行,数据不出设备。

    本文的目标:在本地开发机上搭建Ollama服务,并通过MAUI调用,实现与云端API无缝切换的架构。

    一、Ollama与LLaVA简介

    1.1 Ollama是什么?

    Ollama是一个开源的本地大模型运行工具,支持macOS、Linux、Windows。它将复杂的模型部署简化为几条命令,并提供HTTP API供应用调用。

    1.2 LLaVA:开源多模态模型

    LLaVA(Large Language and Vision Assistant)是当前最流行的开源视觉语言模型之一,由威斯康星大学麦迪逊分校和微软研究院联合开发。2026年1月发布的LLaVA-1.6版本在多项基准测试中接近GPT-4V水平

    模型参数量显存需求特点
    llava:7b 7B 8GB 速度最快,适合开发测试
    llava:13b 13B 16GB 精度更高,接近GPT-4V
    bakllava 7B 8GB LLaVA变体,优化OCR能力

    二、Ollama安装与配置

    2.1 安装Ollama

    macOS/Linux

    bash

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

    Windows

    下载安装包:https://ollama.com/download/windows

    安装完成后,启动服务:

    bash

    ollama serve

    2.2 下载LLaVA模型

    bash

    # 下载7B模型(推荐开发测试)
    ollama pull llava:7b

    # 或下载13B模型(需16GB显存)
    ollama pull llava:13b

    查看已下载模型:

    bash

    ollama list

    2.3 测试模型

    命令行直接测试:

    bash

    ollama run llava:7b "请描述一张海滩的照片"

    也可以通过API测试:

    bash

    curl http://localhost:11434/api/generate -d '{
    "model": "llava:7b",
    "prompt": "描述一张海滩的照片",
    "stream": false
    }'

    三、MAUI中调用Ollama API

    3.1 创建Ollama服务接口

    在Services文件夹下创建IOllamaService.cs:

    csharp

    using SmartPhotoAlbum.Models;

    namespace SmartPhotoAlbum.Services;

    public interface IOllamaService
    {
    Task<ImageAnalysisResult> AnalyzeImageAsync(byte[] imageData, string prompt = null);
    Task<bool> CheckServiceHealthAsync();
    string GetServiceUrl();
    void SetServiceUrl(string url);
    }

    3.2 实现Ollama服务

    创建OllamaService.cs:

    csharp

    using System.Text;
    using System.Text.Json;

    namespace SmartPhotoAlbum.Services;

    public class OllamaService : IOllamaService
    {
    private readonly IApiService _apiService;
    private readonly IImagePickerService _imagePickerService;
    private string _serviceUrl = "http://localhost:11434";
    private const string DefaultModel = "llava:7b";

    public OllamaService(IApiService apiService, IImagePickerService imagePickerService)
    {
    _apiService = apiService;
    _imagePickerService = imagePickerService;
    }

    public string GetServiceUrl() => _serviceUrl;

    public void SetServiceUrl(string url)
    {
    _serviceUrl = url.TrimEnd('/');
    }

    public async Task<bool> CheckServiceHealthAsync()
    {
    try
    {
    using var client = new HttpClient();
    client.Timeout = TimeSpan.FromSeconds(3);
    var response = await client.GetAsync($"{_serviceUrl}/api/tags");
    return response.IsSuccessStatusCode;
    }
    catch
    {
    return false;
    }
    }

    public async Task<ImageAnalysisResult> AnalyzeImageAsync(byte[] imageData, string prompt = null)
    {
    try
    {
    // 压缩图片(Ollama对图片大小有限制)
    var resizedImage = await _imagePickerService.ResizeImageAsync(imageData, 768, 768);
    var base64Image = Convert.ToBase64String(resizedImage);

    // 构造请求体
    var requestBody = new
    {
    model = DefaultModel,
    prompt = prompt ?? "请详细描述这张图片中的内容,包括物体、场景、颜色、人物活动等。然后用逗号分隔的列表形式输出图片中的主要物体。",
    images = new[] { base64Image },
    stream = false,
    options = new
    {
    temperature = 0.7,
    num_predict = 500
    }
    };

    var json = JsonSerializer.Serialize(requestBody);
    using var content = new StringContent(json, Encoding.UTF8, "application/json");

    using var client = new HttpClient();
    client.Timeout = TimeSpan.FromSeconds(60); // 本地模型可能较慢

    var response = await client.PostAsync($"{_serviceUrl}/api/generate", content);
    response.EnsureSuccessStatusCode();

    var responseJson = await response.Content.ReadAsStringAsync();
    var result = JsonSerializer.Deserialize<OllamaResponse>(responseJson);

    return ParseResponse(result, prompt);
    }
    catch (Exception ex)
    {
    throw new Exception($"本地模型分析失败: {ex.Message}", ex);
    }
    }

    private ImageAnalysisResult ParseResponse(OllamaResponse response, string originalPrompt)
    {
    var result = new ImageAnalysisResult
    {
    RawResponse = response.Response,
    Tags = new List<string>()
    };

    if (string.IsNullOrEmpty(result.RawResponse))
    return result;

    result.Description = result.RawResponse.Trim();

    // 尝试提取标签(基于常见输出格式)
    var lines = result.RawResponse.Split('\\n');
    foreach (var line in lines)
    {
    if (line.Contains("物体:") || line.Contains("Objects:") || line.Contains("标签:"))
    {
    var tagsPart = line.Substring(line.IndexOf(':') + 1);
    var tags = tagsPart.Split(new[] { ',', ',', '、' }, StringSplitOptions.RemoveEmptyEntries);
    foreach (var tag in tags)
    {
    result.Tags.Add(tag.Trim());
    }
    break;
    }
    }

    // 如果没有找到标签行,取最后一行作为标签
    if (result.Tags.Count == 0 && lines.Length > 1)
    {
    var lastLine = lines.Last().Trim();
    if (lastLine.Contains(','))
    {
    var tags = lastLine.Split(new[] { ',', ',' }, StringSplitOptions.RemoveEmptyEntries);
    foreach (var tag in tags)
    {
    result.Tags.Add(tag.Trim());
    }
    }
    }

    return result;
    }
    }

    public class OllamaResponse
    {
    public string Model { get; set; }
    public string Response { get; set; }
    public bool Done { get; set; }
    public int PromptEvalCount { get; set; }
    public int EvalCount { get; set; }
    }

    3.3 注册Ollama服务

    在MauiProgram.cs中添加:

    csharp

    builder.Services.AddSingleton<IOllamaService, OllamaService>();

    四、云端与本地双轨策略

    4.1 统一的服务接口

    为了实现无缝切换,我们创建一个统一的AI服务,根据配置自动选择云端或本地:

    创建IAIService.cs:

    csharp

    namespace SmartPhotoAlbum.Services;

    public interface IAIService
    {
    Task<ImageAnalysisResult> AnalyzeImageAsync(byte[] imageData, string prompt = null);
    bool IsAvailable();
    string GetCurrentProvider();
    Task SwitchProvider(string provider); // "cloud" 或 "local"
    }

    实现AIService.cs:

    csharp

    namespace SmartPhotoAlbum.Services;

    public class AIService : IAIService
    {
    private readonly IOpenAIService _openAIService;
    private readonly IOllamaService _ollamaService;
    private readonly IConfigurationService _configService;
    private string _currentProvider = "auto"; // auto, cloud, local

    public AIService(
    IOpenAIService openAIService,
    IOllamaService ollamaService,
    IConfigurationService configService)
    {
    _openAIService = openAIService;
    _ollamaService = ollamaService;
    _configService = configService;

    // 从配置加载上次选择
    Task.Run(async () =>
    {
    var saved = await _configService.GetApiKeyAsync("ai_provider");
    if (!string.IsNullOrEmpty(saved))
    _currentProvider = saved;
    });
    }

    public async Task<ImageAnalysisResult> AnalyzeImageAsync(byte[] imageData, string prompt = null)
    {
    // 自动模式:本地可用则用本地,否则用云端
    if (_currentProvider == "auto")
    {
    var localAvailable = await _ollamaService.CheckServiceHealthAsync();
    if (localAvailable)
    {
    try
    {
    return await _ollamaService.AnalyzeImageAsync(imageData, prompt);
    }
    catch
    {
    // 本地失败,降级到云端
    return await _openAIService.AnalyzeImageAsync(imageData, prompt);
    }
    }
    else
    {
    return await _openAIService.AnalyzeImageAsync(imageData, prompt);
    }
    }
    else if (_currentProvider == "local")
    {
    return await _ollamaService.AnalyzeImageAsync(imageData, prompt);
    }
    else
    {
    return await _openAIService.AnalyzeImageAsync(imageData, prompt);
    }
    }

    public bool IsAvailable()
    {
    if (_currentProvider == "local")
    {
    return _ollamaService.CheckServiceHealthAsync().GetAwaiter().GetResult();
    }
    else
    {
    return _openAIService.IsConfigured();
    }
    }

    public string GetCurrentProvider() => _currentProvider;

    public async Task SwitchProvider(string provider)
    {
    if (new[] { "auto", "cloud", "local" }.Contains(provider))
    {
    _currentProvider = provider;
    await _configService.SaveApiKeyAsync("ai_provider", provider);
    }
    }
    }

    4.2 创建提供者选择页面

    在Views文件夹下创建ProviderSelectionPage.xaml:

    xml

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    x:Class="SmartPhotoAlbum.Views.ProviderSelectionPage"
    Title="AI服务选择">

    <VerticalStackLayout Spacing="20" Padding="20">

    <Label Text="选择AI识别服务"
    FontSize="24"
    FontAttributes="Bold"
    HorizontalOptions="Center"/>

    <Label Text="云端:使用OpenAI GPT-4V,精度高但需网络和费用"
    FontSize="14"
    TextColor="Gray"/>

    <Label Text="本地:使用Ollama + LLaVA,免费离线但需本地部署"
    FontSize="14"
    TextColor="Gray"/>

    <Frame CornerRadius="10"
    Padding="15"
    BackgroundColor="{OnPlatform iOS=#F2F2F7, Android=#F5F5F5}">

    <VerticalStackLayout Spacing="15">

    <RadioButton x:Name="AutoRadio"
    Content="自动模式(优先本地,不可用则用云端)"
    IsChecked="True"
    CheckedChanged="OnProviderChanged"/>

    <RadioButton x:Name="CloudRadio"
    Content="仅云端(OpenAI)"
    CheckedChanged="OnProviderChanged"/>

    <RadioButton x:Name="LocalRadio"
    Content="仅本地(Ollama)"
    CheckedChanged="OnProviderChanged"/>

    <BoxView HeightRequest="1" Color="LightGray" Margin="0,10"/>

    <Label Text="本地服务状态"
    FontAttributes="Bold"/>

    <HorizontalStackLayout Spacing="10">
    <Label x:Name="LocalStatusLabel"
    Text="检查中…"
    VerticalOptions="Center"/>
    <Button Text="刷新"
    Clicked="OnRefreshLocalStatus"
    HorizontalOptions="End"/>
    </HorizontalStackLayout>

    <Label Text="服务地址:"
    FontSize="12"
    TextColor="Gray"/>

    <Entry x:Name="LocalUrlEntry"
    Text="http://localhost:11434"
    Completed="OnLocalUrlChanged"
    IsEnabled="{Binding Source={x:Reference LocalRadio}, Path=IsChecked}"/>

    </VerticalStackLayout>
    </Frame>

    <Button Text="保存选择"
    Clicked="OnSaveClicked"
    BackgroundColor="#007AFF"
    TextColor="White"/>

    </VerticalStackLayout>
    </ContentPage>

    实现代码:

    csharp

    using SmartPhotoAlbum.Services;

    namespace SmartPhotoAlbum.Views;

    public partial class ProviderSelectionPage : ContentPage
    {
    private readonly IAIService _aiService;
    private readonly IOllamaService _ollamaService;

    public ProviderSelectionPage(IAIService aiService, IOllamaService ollamaService)
    {
    InitializeComponent();
    _aiService = aiService;
    _ollamaService = ollamaService;

    LoadCurrentProvider();
    CheckLocalStatus();
    }

    private void LoadCurrentProvider()
    {
    var provider = _aiService.GetCurrentProvider();
    switch (provider)
    {
    case "auto":
    AutoRadio.IsChecked = true;
    break;
    case "cloud":
    CloudRadio.IsChecked = true;
    break;
    case "local":
    LocalRadio.IsChecked = true;
    break;
    }
    }

    private async void CheckLocalStatus()
    {
    var isHealthy = await _ollamaService.CheckServiceHealthAsync();
    LocalStatusLabel.Text = isHealthy ? "✅ 正常运行" : "❌ 未连接";
    LocalStatusLabel.TextColor = isHealthy ? Colors.Green : Colors.Red;
    }

    private async void OnRefreshLocalStatus(object sender, EventArgs e)
    {
    LocalStatusLabel.Text = "检查中…";
    await Task.Delay(500);
    CheckLocalStatus();
    }

    private void OnLocalUrlChanged(object sender, EventArgs e)
    {
    _ollamaService.SetServiceUrl(LocalUrlEntry.Text);
    }

    private void OnProviderChanged(object sender, CheckedChangedEventArgs e)
    {
    // 实时更新UI状态
    LocalUrlEntry.IsEnabled = LocalRadio.IsChecked;
    }

    private async void OnSaveClicked(object sender, EventArgs e)
    {
    string provider = "auto";
    if (CloudRadio.IsChecked)
    provider = "cloud";
    else if (LocalRadio.IsChecked)
    provider = "local";

    await _aiService.SwitchProvider(provider);
    await DisplayAlert("成功", "服务选择已保存", "确定");
    await Navigation.PopAsync();
    }
    }

    五、性能对比与调优

    5.1 云端 vs 本地实测数据

    基于2026年1月的测试数据:

    指标GPT-4V (云端)LLaVA:7b (本地)LLaVA:13b (本地)
    首次响应时间 2-3秒 5-8秒 10-15秒
    连续识别 1-2秒 3-5秒 6-10秒
    识别精度 95% 85% 90%
    成本 按Token计费 免费 免费
    隐私 数据上传 本地处理 本地处理
    硬件要求 8GB显存 16GB显存

    5.2 本地模型优化技巧

  • 量化版本:使用llava:7b-q4减少显存占用

  • 批处理:多张图片依次发送,避免并发

  • 超时设置:本地模型响应较慢,设置60秒超时

  • 预热:首次调用前发送空请求加载模型

  • csharp

    // 预热模型
    public async Task WarmupModelAsync()
    {
    try
    {
    var dummy = new byte[1024]; // 1KB空白图片
    await AnalyzeImageAsync(dummy, "hello");
    }
    catch { }
    }

    六、常见问题排查

    6.1 Ollama服务无法访问

    现象:CheckServiceHealthAsync返回false 排查步骤:

  • 确认Ollama服务运行:ollama list

  • 检查端口:netstat -an | findstr 11434

  • 防火墙设置:允许本地11434端口

  • Android模拟器需使用10.0.2.2代替localhost

  • 6.2 内存不足

    现象:分析时报错或Ollama崩溃 解决方案:

    • 使用7B量化版:ollama pull llava:7b-q4

    • 关闭其他占用显存的应用

    • 增加交换空间

    6.3 识别结果不准确

    原因:LLaVA对中文支持不如GPT-4V 优化:使用英文提示词,再翻译结果

    csharp

    public async Task<ImageAnalysisResult> AnalyzeWithEnglishPrompt(byte[] imageData)
    {
    var result = await AnalyzeImageAsync(imageData, "Describe this image in detail, then list objects with commas.");
    // 可调用翻译API将result.Description转中文
    return result;
    }

    七、小结与下期预告

    至此,我们已经实现了完整的双轨AI架构:

    能力云端方案本地方案
    模型 GPT-4V LLaVA
    调用方式 HTTP API Ollama API
    成本 按量付费 免费
    隐私 需上传 本地处理
    网络要求 必须在线 可离线

    用户可以自由选择,应用自动适配。

    下一篇文章,我们将进入智能相册的核心功能开发——批量处理、本地缓存、标签搜索,让这个应用真正具备生产力。


    本文代码基于 .NET 10 + MAUI 8.0 + Ollama 2.0 验证。 如遇Ollama版本更新,请参考官方文档。

    赞(0)
    未经允许不得转载:171主机测评 » .NET + AI 跨平台实战系列(四):本地化部署——使用Ollama运行开源多模态模型
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址