.NET 调用本地 Deepseek 模型

147 阅读14分钟

前言

本篇咱们来聊一聊怎么在 .NET 代码中使用本地部署的 Deepseek 语言模型。大伙伴们不必要紧张,很简单的,你不需要学习新知识,只要你知道 .NET 如何访问 HTTP 和 JSON 的序列化相关就够了。

先说说如何弄本地模型,有伙伴会问:直接用在线的不好?其实,本地部署更实用,也更符合安全要求。其实,AI 真正用于生产环境反而不需要那么"强大",能有行业化定制模型会更好,这样在专业领域的预测算法更准确;只有用于娱乐产业才需要"面面俱到"。

网上关于本地化部署的教程太多了,所以老周只是简单描述一下。而且,老周也相信大伙伴们的能力,连代码都会写了,怎么会搞不定这些活儿呢?

准备工作

1、安装 Ollama(噢,喇嘛),官网:Ollama。各位注意,这年头下载软件一定要看清楚是不是官网,现在国内很多冒充官网骗钱的,所以,看好域名很重要(比如,OBS、VS Code 等也有很多假域名)。

Ollama 就是专门用于调用 LLM 的工具,它会启动本地服务器(Web),通过 Web API 方式交互。

该工具是跨平台的,使用 C 和 Go 语言开发,性能上不用担心,它不是用某脚本语言开发的。这个下载后直接安装就完事了,无难度。

2、先不急着下载模型,看看你的 C 盘空间够不够下载模型,不够的话,请配置一下 OLLAMA_MODELS 环境变量,指向你要存放模型的目录。这个都会配置了,不用老周说了吧,基于用户和基于机器的环境变量都可以。

Ollama 默认启动 Web 服务器的本地地址是 http://127.0.0.1:11434,如果端口有冲突,可以用 OLLAMA_HOST 环境变量自己配置一下。没其他要求,就按默认就行了,不用配置。

配置时要写上完整的 HTTP 地址,如 http://192.168.1.15:8819。这个你看看源代码就知道为什么要写完整 URL 了。

func Host() *url.URL {
    defaultPort := "11434"

    s := strings.TrimSpace(Var("OLLAMA_HOST"))
    scheme, hostport, ok := strings.Cut(s, "://")
    switch {
    case !ok:
        scheme, hostport = "http", s
    case scheme == "http":
        defaultPort = "80"
    case scheme == "https":
        defaultPort = "443"
    }

    hostport, path, _ := strings.Cut(hostport, "/")
    host, port, err := net.SplitHostPort(hostport)
    if err != nil {
        host, port = "127.0.0.1", defaultPort
        if ip := net.ParseIP(strings.Trim(hostport, "[]")); ip != nil {
            host = ip.String()
        } else if hostport != "" {
            host = hostport
        }
    }

    if n, err := strconv.ParseInt(port, 10, 32); err != nil || n > 65535 || n < 0 {
        slog.Warn("invalid port, using default", "port", port, "default", defaultPort)
        port = defaultPort
    }

    return &url.URL{
        Scheme: scheme,
        Host:   net.JoinHostPort(host, port),
        Path:   path,
    }
}

3、第一次启动大语言模型需要下载,在 Ollama 官网进入"Models"页面,你基本不用找了,现在最热门的就是它了。

点击模型链接,进入详细页。下拉列表能看到模型大小,视你的 CPU 或 GPU 配置来选吧。

老周的机器 CPU 是 i5-11400F,跑 8B 问题不大(显卡是 4G 显存),回答问题一般要二三十秒,能接受。如果你不确定,可以从 7B 开始测试。页面向下滚动会告诉你命令行怎么用,比如,要下载7B的模型,执行命令 **ollama run deepseek-r1:7b**。

模型名称后面有个冒号,之后是模型的大小。执行后就是坐和等待。下载时间取决于网速和运气。

4、下载完后,你就可以问 Deepseek 问题了,输入问题,回车就行了。你关闭了控制台,手动启动的方法就是上面下载模型的命令(只想下载,不运行,可以将 run 替换为 pull),ollama run XXXX:7B,但这次它不会再下载了,而是直接启动。

正文

好了,准备工作结束,下面正片开始。

和调用一般 Web API 一样,HTTP 协议,JSON 格式。要和模型交互,会用到两个API。

1、单次对话(你问,它回答,类似搜索,这种较常用)。

POST /api/generate

要提交的JSON一般只需要三个参数(其他你可以看文档,其实有些参数老周也没看懂):

model:LLM 模型的名称,毕竟 Ollama 可以下载多种模型,所以要指定模型,如 deepseek-r1:7b;

prompt:你要问它的问题,比如"何不食肉糜?";

stream:是否启用流式传输。如果是 false,你发出请求后,要等到所有回答内容生成后,一次性返回。如果是 true,可以分块返回,不必等到全部生成你就可以读了。

返回的 JSON 对象中,response 字段就是 LLM 回答你的内容,如果是流式返回,最后一段回复的 done 字段会为 true,其他片段为 false。

2、聊天模式

POST /api/chat

请求的 JSON 常用的字段和上面单次对话一样,但 prompt 字段换成 messages 字段。此字段是数组类型,包含多个对象,代表聊天记录。

其中,role 代表角色,你是 user,AI是 assistant。content 代表聊天消息内容。在调用时,可以把前面的聊天记录放进 messages 数组。

{
    "model": "deepseek-r1:32b",
    "stream": false,
    "messages": [
        {
              "role": "user",
              "content": "你好啊"
         },
         {
               "role": "assistant",
               "content": "我不好"
          },          {               "role": "user",               "content": "为什么不好?"          }
    ]
}

返回的 JOSN 对象中,message 字段就是新的聊天记录(一般是 AI 回复你的)。

好了,知识就介绍到这儿,下面咱们要实际操作了。先声明一下:Ollama API 的调用是有专门的 Nuget 包的,都封装好的,你不需要自己干活。不过,为了让大伙伴们好理解,老周下面的演示暂不使用 Nuget 包。

先来个简单的,关闭流模式。

internal class Program
{
    // 请求URL
    const string BS_URL = "http://127.0.0.1:11434";
    // API路径
    const string API_GEN = "/api/generate";

    static async Task Main(string[] args)
    {
        using HttpClient client = new();
        // 设置基址
        client.BaseAddress = new Uri(BS_URL);
        // 请求数据
        string senddata = """
            {
                "model": "deepseek-r1:8b",
                "prompt": "黄河有多长?",
                "stream": false
            }
            """;
        StringContent content = new(senddata, Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
        HttpRequestMessage msg = new HttpRequestMessage()
        {
            Method = HttpMethod.Post,
            Content = content
        };
        // 设置相对的URL即可
        msg.RequestUri = new Uri(API_GEN, UriKind.Relative);
        // 发送请求,并读取响应消息
        HttpResponseMessage respmsg = await client.SendAsync(msg);
        if (respmsg.IsSuccessStatusCode)
        {
            string json_back = await respmsg.Content.ReadAsStringAsync();
            Console.WriteLine("-------------- 返回的JSON ----------------\n");
            Console.WriteLine(json_back);
        }
    }
}

代码运行后,需要等待一段时间。请求返回后,将得到以下 JSON:

{
    "model": "deepseek-r1:8b",
    "created_at": "2025-02-23T05:12:46.9156624Z",
    "response": "\u003cthink\u003e\n嗯,用户问"黄河有多长?"首先,我得回想一下黄河的基本信息。黄河是中国的一条重要河流,发源于青藏高原,流经华北平原,注入渤海湾。\n\n接下来, 我需要确定黄河的长度。我记得它被称为"九曲黄河",这可能是因为它有很多弯曲的河道。根据我所知道的资料,黄河的总长大约在5463公里左右。不过,这个数字好像有点问题,因为有些资料会提到水系更长的情况,比如包括支流在内。\n\n另外,我还需要考虑用户为什么会问这个问题。也许他们是在做研究、写作业,或者只是出于好奇。我应该确认一下数据的准确性,避免误导用户。如果有时间的话,最好核实一下最新的资料,但目前我只能依据已有的信息回答。\n\n在解释时,我会提到黄河的重要性,比如它对中国历史、文化的影响,以及作为水资源的重要性。这不仅能满足用户的基本问题,还能提供更多背景信息,让回答更丰富。\n\n最后,考虑到用户可能需要进一步了解,可以建议他们查阅最新的地理资料或相关文献,以确保得到准确的数据和详细的信息。\n\u003c/think\u003e\n\n黄河的长度约为5,463公里(3,455英里),它是中国最长的河流之一,从青藏高原的玛旁雍措开始,经过九曲,最终注入渤海湾。",
    "done": true,
    "done_reason": "stop",
    "context": [...],
    "total_duration": 46687936200,
    "load_duration": 6910762400,
    "prompt_eval_count": 9,
    "prompt_eval_duration": 1643000000,
    "eval_count": 347,
    "eval_duration": 38128000000
}

其中,response 字段就是模型所回答的内容。done: true 表示所有回复已完成。

当流模式关闭时,响应消息是一次性返回的。下面咱们开启流模式,看会发生什么。

internal class Program
{
    // 请求URL
    const string BS_URL = "http://127.0.0.1:11434";
    // API路径
    const string API_GEN = "/api/generate";

    static async Task Main(string[] args)
    {
        using HttpClient client = new();
        // 设置基址
        client.BaseAddress = new Uri(BS_URL);
        // 请求数据
        string senddata = """
            {
                "model": "deepseek-r1:8b",
                "prompt": "人为什么不能有两个头?",
                "stream": true
            }
            """;
        StringContent content = new(senddata, Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
        HttpRequestMessage msg = new HttpRequestMessage()
        {
            Method = HttpMethod.Post,
            Content = content
        };
        // 设置相对的URL即可
        msg.RequestUri = new Uri(API_GEN, UriKind.Relative);
        // 发送请求,并读取响应消息
        HttpResponseMessage respmsg = await client.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead);
        if (respmsg.IsSuccessStatusCode)
        {
            StreamReader reader = new(await respmsg.Content.ReadAsStreamAsync());
            string? line;
            while( (line = reader.ReadLine()) != null )
            {
                Console.WriteLine(line);
            }
        }
    }
}

使用流模式后,有几个地方要改:

1、POST 的内容中,stream 字段要设置为 true;

2、调用 HttpClient.SendAsync 方法时,要指定 HttpCompletionOption 枚举值 ResponseHeadersRead,它表示:客户端不需要等到所有响应都完成,只要读到 Header 就可以返回;

3、以流的方式读取,所以为了方便一行一行地读,需要创建一个 StreamReader 实例。因为分区返回的 JSON 文本之间会有换行符,所以,咱们可以一行一行地读。

运行后你会发现,响应消息是几个字几个字地返回,这样模拟它的思考过程,即返回多个 JSON 对象。

最后一个 JSON 对象的 done 字段为 true,表示是最后一个消息分块。context 字段中的数字是用于对话上下文的,即下一次你向 LLM 发问时,可以把上次返回的 context 放到请求数据中,这样形成基于上下文的推理。

不过这个 context 字段在官方文档中已标记为"过时",以后可能不使用了。所以咱们可以不理会,因为可以使用聊天模式 API(请看上文)。

通过上面的简单演示,相信大伙伴都会用了。不过为了方便,咱们可以把请求数据封装一下,通过 JSON 序列化来调用,会更方便。

A、请求消息,主要使用 model、prompt 和 stream 字段。

/// <summary>
/// 请求数据
/// </summary>
public class ModelRequest
{
    /// <summary>
    /// 模型名称
    /// </summary>
    public string? Model { get; set; }
    /// <summary>
    /// 提问
    /// </summary>
    public string? Prompt { get; set; }
    /// <summary>
    /// 是否使用流模式
    /// </summary>
    public bool Stream { get; set; }
}

B、返回的JSON封装,用到 model、response、done 字段。

/// <summary>
/// 返回的消息
/// </summary>
public class ModelResponse
{
    /// <summary>
    /// 模型名
    /// </summary>
    public string? Model { get; set; }
    /// <summary>
    /// 回复内容
    /// </summary>
    public string? Response { get; set; }
    /// <summary>
    /// 是否为最后一个分块
    /// </summary>
    public bool Done {  get; set; } = false;
}

咱们测试一下流模式。

internal class Program
{
    // 根URL
    const string BASE_URL = "http://127.0.0.1:11434";
    // API 路径
    const string GEN_API = "/api/generate";

    static async Task Main(string[] args)
    {
        using HttpClient client = new HttpClient();
        // 设置基址
        client.BaseAddress = new Uri(BASE_URL);
        // 准备请求数据
        ModelRequest rqdata = new()
        {
            Model = "deepseek-r1:8b",
            Stream = true,
            Prompt = "25的15次方是多少?"
        };
        // 发送请求
        var reqmsg = new HttpRequestMessage(HttpMethod.Post, GEN_API);
        // 请求正文
        JsonContent data = JsonContent.Create(rqdata);
        reqmsg.Content = data;
        var responsemsg = await client.SendAsync(reqmsg, HttpCompletionOption.ResponseHeadersRead);
        // 处理响应消息
        if (responsemsg.IsSuccessStatusCode)
        {
            using StreamReader reader = new(await responsemsg.Content.ReadAsStreamAsync());
            // 属性名不区分大小写,在反序列化时如果不启用该选项
            // 将无法读到字段值
            JsonSerializerOptions options = new() { PropertyNameCaseInsensitive = true };
            var line = await reader.ReadLineAsync();
            while (line != null)
            {
                ModelResponse? mdresp = JsonSerializer.Deserialize<ModelResponse>(line, options);
                if(mdresp != null)
                {
                    Console.Write(mdresp.Response);
                    // 是否为最后一块
                    if(mdresp.Done == true)
                    {
                        Console.Write("\n\n(已完毕)\n");
                        break;
                    }
                }
                line = await reader.ReadLineAsync();
            }
        }
    }
}

这个和上一个例子差不多,只是将数据封装了。

需要注意的是,由于流是不断地返回 JSON 对象,而不是一个单独的JSON数组,所以不应该直接返序列化为 ModelResponse 数组,而是和前面一样,读一行出来,用 JsonSerializer.Deserialize 方法进行反序列化。

这里要用到 JsonSerializerOptions 类设置一个 PropertyNameCaseInsensitive 属性,这是因为返回的 JSON 的字段名全是小写的,而咱们定义的 ModelResponse 类的属性是大写字母开头的,默认处理是严格区分大小写的(反序列化的时候,序列化时可以忽略),设置该选项是让其能够赋值。如果你嫌麻烦,也可以把 ModelResponse 类的属性名称全定义为小写。

使用流模式调用,可以得到这样的效果:

不过,不得不说一句,AI 做数学运算不如直接让 CPU 进行运算,秒出结果。这厮一直在瞎推理,推来推去,总算得出答案。

所以说,AI 是有其适用范围的,真不是任何场合都合适。不要听网上那些外行人和卖课的胡说八道,他们整天把 AI 吹的比人还历害。

骗三个月小孩呢,机器永远是机器,只能用在机器所擅长的领域,而我们人也应当做人该做的事,不可能啥都推给机器做,在那里无所事事。如何正确处理人和 AI 的关系,建议观赏一下 CLAMP 大妈的漫话《人形电脑天使心》。

下面咱们使用一下 nuget 包,搜索 OllamaSharp 就能找到。

如何添加 nuget 包,此处省略 1851 字。

安装好包后,需要引入以下命名空间:

using OllamaSharp;
using OllamaSharp.Models;

然后,实例化 OllamaApiClient。

IOllamaApiClient client = new OllamaApiClient(
        "http://127.0.0.1:11434",
        "deepseek-r1:8b"
);

构造函数的第二个参数是设置一个默认模型名称,后面在调用 API 时就不用再设置了。

请求时要提交一个 GenerateRequest 对象,和前面咱们自己封装的一样,需要用到 Model、Prompt 等属性。

// 准备数据
GenerateRequest req = new();
// 如果在构造函数配置过模型名称,可以省略
//req.Model = "deepseek-r1:8b";
req.Stream = true;
req.Prompt = "唐太宗是唐朝第几位皇帝?";

由于咱们设置了默认模型,这里 Model 属性可以不赋值。

发出请求,返回一个 IAsyncEnumerable< T> 对象,里面包含返回的 GenerateResponseStream 对象列表。这个支持异步枚举。

var resp = client.GenerateAsync(req);
await foreach(GenerateResponseStream? stream in resp)
{
    if(stream != null)
    {
        Console.Write(stream.Response);
        // 如果已结束
        if(stream.Done)
        {
            Console.Write("\n\n会话结束\n");
        }
    }
}

其实和咱们刚才自己封装的差不多。运行结果如下:

虽然答案是对的,但推理过程纯属胡说八道,大伙伴且当娱乐。

聊天功能

下面咱们用一下聊天功能。

这是一个 WinForm 项目,窗口上方是一个 RichTextBox,显示AI回复的内容,下面的文本框用来输入。

往项目添加 OllamaSharp 包,然后在窗口类的代码文件中引入以下命名空间:

using OllamaSharp;
using OllamaSharp.Models.Chat;

聊天模式也是先创建 OllamaApiClient 实例,然后把此 OllamaApiClient 实例传递给 Chat 类的构造函数,进而创建 Chat 实例。接着,调用 Chat 实例的 SendAsAsync 或 SendAsync 方法发送消息。

方法返回 AI 回答你的内容。

 // 声明类级别的私有字段
 OllamaApiClient? client;
 Chat? theChat;

 public Form1()
 {
     InitializeComponent();
     // 调用这个方法初始化 Ollama 客户端
     InitOllamClient();
     btnSend.Click += BtnSend_Click;
 }

 private void InitOllamClient()
 {
     client = new OllamaApiClient("http://127.0.0.1:11434", "deepseek-r1:8b");
     theChat = new Chat(client);
 }

处理一下"发送"按钮的 Click 事件,发送消息并把AI响应的消息追加到 RichTextBox 中。

private async void BtnSend_Click(object? sender, EventArgs e)
{
    if (txtMessage.Text == string.Empty) return;

    // 发起请求
    IAsyncEnumerable<string> history = theChat!.SendAsAsync(ChatRole.User, txtMessage.Text);
    await foreach(string s in history)
    {
        // 跳过不需要的字符
        if(s.Equals("<think>") 
            || s.Equals("</think>")
            || s.Equals("\n\n")
            || s.Equals("\n"))
        {
            continue;
        }
        // 追加文本
        rtbMessages.AppendText(s);
    }
    // 一条消息后加一个换行符
    rtbMessages.AppendText("\n");
    txtMessage.Clear();             // 清除文本
}

SendAsAsync 方法有N多重载,此处使用的是以下版本:

public IAsyncEnumerable<string> SendAsAsync(ChatRole role, string message, CancellationToken cancellationToken = default(CancellationToken))

第一个参数是 role 表示角色,你说的话要用 User 角色;第二个参数是消息内容;第三个可选,一般默认就行,除非你需要取消调用。

返回的内容是字符串列表,这个列表包含模型预测的短语列表,要把整个列表串联起来才是完整的消息(看前文第一个例子,就是流模式那样)。

效果如下图所示:

好了,本文内容基本介绍完了。老周可不允许你学不会!如果这也学不会,那就罚自己饿两个月吧。

ASP.NET Core 上的用法也一样的,你可以让 Ollama 和应用程序在同一个服务器,本地调用,然后返回给客户端,一般不用直接对外暴露 URL。

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!

优秀是一种习惯,欢迎大家留言学习!

作者:东邪独孤

出处:cnblogs.com/tcjiaan/p/18731997

声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!