前言
本篇咱们来聊一聊怎么在 .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
声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!