大语言模型的发展日新月异,记得在去年这个时候,函数调用还是gpt-4的专属。到今年本地运行的大模型无论是推理能力还是文本的输出质量都已经非常接近gpt-4了。而在去年gpt-4尚未发布函数调用时,智能体框架的开发者们依赖构建精巧的提示词实现了gpt-3.5的函数调用。目前在本机运行的大模型,基于这一套逻辑也可以实现函数式调用,今天我们就是用本地运行的大模型来实现这个需求。从测试的效果来看,本地大模型对于简单的函数调用成功率已经非常高了,但是受限于本地机器的性能,调用的时间还是比较长。如果有NVIDIA显卡的CUDA环境,质量应该会好很多,今天就以大家都比较熟悉的LLAMA生态作为起点,基于阿里云开源的千问7B模型的量化版作为基座通过C#和SemanticKernel来实现函数调用的功能。

基本调用逻辑参考这张图:

首先我们需要在本机(windows系统)安装Ollama作为LLM的API后端。访问https://ollama.com/,选择Download。选择你需要的版本即可,windows用户请选择Download for Windows。下载完成后,无脑点击下一步下一步即可安装完毕。

安装完毕后,打开我们的PowerShell即可运行大模型,第一次加载会下载模型文件到本地磁盘,会比较慢。运行起来后就可以通过控制台和模型进行简单的对话,这里我们以阿里发布的千问2:7b为例。执行以下命令即可运行起来:

ollama run qwen2:7b

接着我们使用ctrl+D退出对话框,并执行ollama serve,看看服务器是否运行起来了,正常情况下会看到11434这个端口已经运行起来了。接下来我们就可以进入到编码阶段

首先我们创建一个.net8.0的的控制台,接着我们引入三个必要的包

dotnet add package Microsoft.SemanticKernel --version 1.15.0
dotnet add package Newtonsoft.Json --version 13.0.3
dotnet add package OllamaSharp --version 2.0.1

SemanticKernel是我们主要的代理运行框架,OllamaSharp是一个简单的面向Ollama本地API服务的请求封装。避免我们手写httpclient来与本地服务器交互。我这里安装了Newtonsoft.Json来替代system.text.json,主要是用于后期需要一些序列化模型回调来使用,因为模型的回调json可能不是特别标准,使用system.text.json容易导致转义失败。

接下来就是编码阶段,首先我们定义一个函数,这个函数是后面LLM会用到的函数,简单的定义如下:

public class FunctionTest
{
[KernelFunction, Description("获取城市的天气状况")]
public object GetWeather([Description("城市名称")] string CityName, [Description("查询时段,值可以是[白天,夜晚]")] string DayPart)
{
return new { CityName, DayPart, CurrentCondition = "多云", LaterCondition = "阴", MinTemperature = 19, MaxTemperature = 23 };
}
}

这里的KernelFunction和Description特性都是必要的,用于SemanticKernel查询到对应的函数并封装处对应的元数据。

接着我们需要自定义一个继承自接口IChatCompletionService的实现,因为SemanticKernel是基于openai的gpt系列设计的框架,所以要和本地模型调用,我们需要设置独立的ChatCompletionService来让SemanticKernel和本机模型API交互。这里我们主要需要实现的函数是GetChatMessageContentsAsync。因为函数调用我们需要接收到模型完整的回调用于转换json,所以流式传输这里用不上。

public class CustomChatCompletionService : IChatCompletionService
{
public IReadOnlyDictionary<string, object?> Attributes => throw new NotImplementedException(); public Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
} public IAsyncEnumerable<StreamingChatMessageContent> GetStreamingChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}

接下来我们需要定义一个SemanticKernel的实例,这个实例会伴随本次调用贯穿全程。SemanticKernel使用了简单的链式构建。基本代码如下:

var builder = Kernel.CreateBuilder();
//这里我们需要增加刚才我们定义的实例CustomChatCompletionService,有点类似IOC的设计
builder.Services.AddKeyedSingleton<IChatCompletionService>("ollamaChat", new CustomChatCompletionService());
//这里我们需要插入之前定义的插件
builder.Plugins.AddFromType<FunctionTest>();
var kernel = builder.Build();

可以看到基本的构建链式调用代码部分还是比较简单的,接下来就是调用的部分,这里主要的部分就是将LLM可用的函数插入到系统提示词,来引导LLM去调用特定函数:

//定义一个对话历史
ChatHistory history = [];
//获取刚才定义的插件函数的元数据,用于后续创建prompt
var plugins = kernel.Plugins.GetFunctionsMetadata();
//生成函数调用提示词,引导模型根据用户请求去调用函数
var functionsPrompt = CreateFunctionsMetaObject(plugins);
//创建系统提示词,插入刚才生成的提示词
var prompt = $"""
You have access to the following functions. Use them if required:
{functionsPrompt}
If function calls are used, ensure the output is in JSON format; otherwise, output should be in text format.
""";
//添加系统提示词
history.AddSystemMessage(prompt);
//创建一个对话服务实例
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
//添加用户的提问
history.AddUserMessage(question);
//链式执行kernel
var result = await chatCompletionService.GetChatMessageContentAsync(
history,
executionSettings: null,
kernel: kernel);
//打印回调内容
Console.WriteLine($"Assistant> {result}");

在这里我们可以debug看看生成的系统提示词细节:

当代码执行到GetChatMessageContentAsync这里时,就会跳转到我们的CustomChatCompletionService的GetChatMessageContentsAsync函数,在这里我们需要进行ollama的调用来达成目的。

这里比较核心的部分就是将LLM回调的内容使用JSON序列化来检测是否涉及到函数调用,简单来讲由于类似qwen这样没有专门针对function calling专项微调过的(glm-4-9b原生支持function calling)模型,其function calling并不是每次都能准确的回调,所以这里我们需要对回调的内容进行反序列化和信息抽取,确保模型的调用符合回调函数的格式标准。具体代码如下

public async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
{
GetDicSearchResult(kernel);
var prompt = HistoryToText(chatHistory);
var ollama = new OllamaApiClient("http://127.0.0.1:11434", "qwen2:7b");
var chat = new Chat(ollama, _ => { });
sw.Start();
var history = (await chat.Send(prompt, CancellationToken.None)).ToArray();
sw.Stop();
Console.WriteLine($"调用耗时:{Math.Round(sw.Elapsed.TotalSeconds,2)}秒");
var last = history.Last();
var chatResponse = last.Content;
try
{
JToken jToken = JToken.Parse(chatResponse);
jToken = ConvertStringToJson(jToken);
var searchs = DicSearchResult.Values.ToList();
if (TryFindValues(jToken, ref searchs))
{
var firstFunc = searchs.First();
var funcCallResult = await firstFunc.KernelFunction.InvokeAsync(kernel, firstFunc.FunctionParams);
chatHistory.AddMessage(AuthorRole.Assistant, chatResponse);
chatHistory.AddMessage(AuthorRole.Tool, funcCallResult.ToString());
return await GetChatMessageContentsAsync(chatHistory, kernel: kernel);
}
else
{ }
}
catch(Exception e)
{ }
return new List<ChatMessageContent> { new ChatMessageContent(AuthorRole.Assistant, chatResponse) };
}

这里我们首先使用SemanticKernel的kernel的函数元数据通过GetDicSearchResult构建了一个字典,这部分代码如下:

public static Dictionary<string, SearchResult> DicSearchResult = new Dictionary<string, SearchResult>();
public static void GetDicSearchResult(Kernel kernel)
{
DicSearchResult = new Dictionary<string, SearchResult>();
foreach (var functionMetaData in kernel.Plugins.GetFunctionsMetadata())
{
string functionName = functionMetaData.Name;
if (DicSearchResult.ContainsKey(functionName))
continue;
var searchResult = new SearchResult
{
FunctionName = functionName,
KernelFunction = kernel.Plugins.GetFunction(null, functionName)
};
functionMetaData.Parameters.ToList().ForEach(x => searchResult.FunctionParams.Add(x.Name, null));
DicSearchResult.Add(functionName, searchResult);
}
}

接着使用HistoryToText将历史对话信息组装成一个单一的prompt发送给模型,大概会组装成如下内容,其实就是系统提示词+用户提示词组合成一个单一文本:

接着我们使用OllamaSharp的SDK提供的OllamaApiClient发送信息给模型,等待模型回调后,从模型回调的内容中抽取chatResponse,接着我们需要通过一个try catch来处理,当chatResponse可以被正确的解析成标准JToken后,说明模型的回调是一段json,否则会抛出异常,代表模型输出的是一段文本。如果是文本,我们就直接返回模型输出的内容,如果是json则继续向下处理,通过一个TryFindValues函数从模型中抽取我们所需要的回调函数名、参数,并赋值到一个临时变量中。最后通过SemanticKernel的KernelFunction的InvokeAsync进行真正的函数调用,获取到函数的回调内容,接着我们需要将模型的原始输出和回调内容一同添加到chatHistory后,再度递归发起GetChatMessageContentsAsync调用,这一次模型就会拿到前一次回调的城市天气内容来进行回答了。

第二次回调前的prompt如下,可以看到模型的输出虽然是json,但是并没有规范的格式,不过使用我们的抽取函数还是获取到了需要的信息,从而正确的构建了底部的回调:

通过这一轮回调再次喂给llm,llm就可以正确的输出结果了:

以上就是整个文章的内容了,可以看到在这个过程中我们主要做的工作就是通过系统提示词诱导模型输出回调函数json,解析json获取参数,调用本地的函数后再次回调给模型,这个过程其实有点类似的RAG,只不过RAG是通过用户的提示词直接进行近似度搜索获取到近似度相关的文本组合到系统提示词,而函数调用给了模型更大的自由度,可以让模型自行决策是否调用函数,从而使本地Agent代理可以实现诸如帮你操控电脑,打印文件,编写邮件等等助手性质的功能。

下面是核心部分的代码,请大家自取

program.cs:

using ConsoleApp4;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel;
using Microsoft.Extensions.DependencyInjection;
using System.ComponentModel;
using Newtonsoft.Json.Linq; await Ollama("我想知道西安今天晚上的天气情况"); async Task Ollama(string question)
{
Console.WriteLine($"User> {question}");
var builder = Kernel.CreateBuilder();
//这里我们需要增加刚才我们定义的实例CustomChatCompletionService,有点类似IOC的设计
builder.Services.AddKeyedSingleton<IChatCompletionService>("ollamaChat", new CustomChatCompletionService());
//这里我们需要插入之前定义的插件
builder.Plugins.AddFromType<FunctionTest>();
var kernel = builder.Build();
//定义一个对话历史
ChatHistory history = [];
//获取刚才定义的插件函数的元数据,用于后续创建prompt
var plugins = kernel.Plugins.GetFunctionsMetadata();
//生成函数调用提示词,引导模型根据用户请求去调用函数
var functionsPrompt = CreateFunctionsMetaObject(plugins);
//创建系统提示词,插入刚才生成的提示词
var prompt = $"""
You have access to the following functions. Use them if required:
{functionsPrompt}
If function calls are used, ensure the output is in JSON format; otherwise, output should be in text format.
""";
//添加系统提示词
history.AddSystemMessage(prompt);
//创建一个对话服务实例
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
//添加用户的提问
history.AddUserMessage(question);
//链式执行kernel
var result = await chatCompletionService.GetChatMessageContentAsync(
history,
executionSettings: null,
kernel: kernel);
//打印回调内容
Console.WriteLine($"Assistant> {result}");
}
static JToken? CreateFunctionsMetaObject(IList<KernelFunctionMetadata> plugins)
{
if (plugins.Count < 1) return null;
if (plugins.Count == 1) return CreateFunctionMetaObject(plugins[0]); JArray promptFunctions = [];
foreach (var plugin in plugins)
{
var pluginFunctionWrapper = CreateFunctionMetaObject(plugin);
promptFunctions.Add(pluginFunctionWrapper);
} return promptFunctions;
}
static JObject CreateFunctionMetaObject(KernelFunctionMetadata plugin)
{
var pluginFunctionWrapper = new JObject()
{
{ "type", "function" },
}; var pluginFunction = new JObject()
{
{ "name", plugin.Name },
{ "description", plugin.Description },
}; var pluginFunctionParameters = new JObject()
{
{ "type", "object" },
};
var pluginProperties = new JObject();
foreach (var parameter in plugin.Parameters)
{
var property = new JObject()
{
{ "type", parameter.ParameterType?.ToString() },
{ "description", parameter.Description },
}; pluginProperties.Add(parameter.Name, property);
} pluginFunctionParameters.Add("properties", pluginProperties);
pluginFunction.Add("parameters", pluginFunctionParameters);
pluginFunctionWrapper.Add("function", pluginFunction); return pluginFunctionWrapper;
}
public class FunctionTest
{
[KernelFunction, Description("获取城市的天气状况")]
public object GetWeather([Description("城市名称")] string CityName, [Description("查询时段,值可以是[白天,夜晚]")] string DayPart)
{
return new { CityName, DayPart, CurrentCondition = "多云", LaterCondition = "阴", MinTemperature = 19, MaxTemperature = 23 };
}
}

CustomChatCompletionService.cs:

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Newtonsoft.Json.Linq;
using OllamaSharp;
using System.Diagnostics;
using System.Text; namespace ConsoleApp4
{
public class CustomChatCompletionService : IChatCompletionService
{
public static Dictionary<string, SearchResult> DicSearchResult = new Dictionary<string, SearchResult>();
public static void GetDicSearchResult(Kernel kernel)
{
DicSearchResult = new Dictionary<string, SearchResult>();
foreach (var functionMetaData in kernel.Plugins.GetFunctionsMetadata())
{
string functionName = functionMetaData.Name;
if (DicSearchResult.ContainsKey(functionName))
continue;
var searchResult = new SearchResult
{
FunctionName = functionName,
KernelFunction = kernel.Plugins.GetFunction(null, functionName)
};
functionMetaData.Parameters.ToList().ForEach(x => searchResult.FunctionParams.Add(x.Name, null));
DicSearchResult.Add(functionName, searchResult);
}
}
public IReadOnlyDictionary<string, object?> Attributes => throw new NotImplementedException();
static Stopwatch sw = new Stopwatch();
public async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
{
GetDicSearchResult(kernel);
var prompt = HistoryToText(chatHistory);
var ollama = new OllamaApiClient("http://127.0.0.1:11434", "qwen2:7b");
var chat = new Chat(ollama, _ => { });
sw.Start();
var history = (await chat.Send(prompt, CancellationToken.None)).ToArray();
sw.Stop();
Console.WriteLine($"调用耗时:{Math.Round(sw.Elapsed.TotalSeconds,2)}秒");
var last = history.Last();
var chatResponse = last.Content;
try
{
JToken jToken = JToken.Parse(chatResponse);
jToken = ConvertStringToJson(jToken);
var searchs = DicSearchResult.Values.ToList();
if (TryFindValues(jToken, ref searchs))
{
var firstFunc = searchs.First();
var funcCallResult = await firstFunc.KernelFunction.InvokeAsync(kernel, firstFunc.FunctionParams);
chatHistory.AddMessage(AuthorRole.Assistant, chatResponse);
chatHistory.AddMessage(AuthorRole.Tool, funcCallResult.ToString());
return await GetChatMessageContentsAsync(chatHistory, kernel: kernel);
}
else
{ }
}
catch(Exception e)
{ }
return new List<ChatMessageContent> { new ChatMessageContent(AuthorRole.Assistant, chatResponse) };
}
JToken ConvertStringToJson(JToken token)
{
if (token.Type == JTokenType.Object)
{
// 遍历对象的每个属性
JObject obj = new JObject();
foreach (JProperty prop in token.Children<JProperty>())
{
obj.Add(prop.Name, ConvertStringToJson(prop.Value));
}
return obj;
}
else if (token.Type == JTokenType.Array)
{
// 遍历数组的每个元素
JArray array = new JArray();
foreach (JToken item in token.Children())
{
array.Add(ConvertStringToJson(item));
}
return array;
}
else if (token.Type == JTokenType.String)
{
// 尝试将字符串解析为 JSON
string value = token.ToString();
try
{
return JToken.Parse(value);
}
catch (Exception)
{
// 解析失败时返回原始字符串
return token;
}
}
else
{
// 其他类型直接返回
return token;
}
}
bool TryFindValues(JToken token, ref List<SearchResult> searches)
{
if (token.Type == JTokenType.Object)
{
foreach (var child in token.Children<JProperty>())
{
foreach (var search in searches)
{
if (child.Value.ToString().ToLower().Equals(search.FunctionName.ToLower()) && search.SearchFunctionNameSucc != true)
search.SearchFunctionNameSucc = true;
foreach (var par in search.FunctionParams)
{
if (child.Name.ToLower().Equals(par.Key.ToLower()) && par.Value == null)
search.FunctionParams[par.Key] = child.Value.ToString().ToLower();
}
}
if (searches.Any(x => x.SearchFunctionNameSucc == false || x.FunctionParams.Any(x => x.Value == null)))
TryFindValues(child.Value, ref searches);
}
}
else if (token.Type == JTokenType.Array)
{
foreach (var item in token.Children())
{
if (searches.Any(x => x.SearchFunctionNameSucc == false || x.FunctionParams.Any(x => x.Value == null)))
TryFindValues(item, ref searches);
}
}
return searches.Any(x => x.SearchFunctionNameSucc && x.FunctionParams.All(x => x.Value != null));
}
public virtual string HistoryToText(ChatHistory history)
{
StringBuilder sb = new();
foreach (var message in history)
{
if (message.Role == AuthorRole.User)
{
sb.AppendLine($"User: {message.Content}");
}
else if (message.Role == AuthorRole.System)
{
sb.AppendLine($"System: {message.Content}");
}
else if (message.Role == AuthorRole.Assistant)
{
sb.AppendLine($"Assistant: {message.Content}");
}
else if (message.Role == AuthorRole.Tool)
{
sb.AppendLine($"Tool: {message.Content}");
}
}
return sb.ToString();
}
public IAsyncEnumerable<StreamingChatMessageContent> GetStreamingChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings? executionSettings = null, Kernel? kernel = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
}
public class SearchResult
{
public string FunctionName { get; set; }
public bool SearchFunctionNameSucc { get; set; }
public KernelArguments FunctionParams { get; set; } = new KernelArguments();
public KernelFunction KernelFunction { get; set; }
}
}

使用Microsoft.SemanticKernel基于本地运行的Ollama大语言模型实现Agent调用函数的更多相关文章

  1. 基于Docker本地运行Kubernetes

    基于Docker本地运行Kubernetes 概览 下面的指引将高速你如何通过Docker创建一个单机.单节点的Kubernetes集群. 下图是最终的结果: 先决条件 \1. 你必须拥有一台安装有D ...

  2. 本地推理,单机运行,MacM1芯片系统基于大语言模型C++版本LLaMA部署“本地版”的ChatGPT

    OpenAI公司基于GPT模型的ChatGPT风光无两,眼看它起朱楼,眼看它宴宾客,FaceBook终于坐不住了,发布了同样基于LLM的人工智能大语言模型LLaMA,号称包含70亿.130亿.330亿 ...

  3. Atitit.hybrid混合型应用 浏览器插件,控件的实现方式 浏览器运行本地程序的解决方案大的总结---提升用户体验and开发效率..

    Atitit.hybrid混合型应用 浏览器插件,控件的实现方式 浏览器运行本地程序的解决方案大的总结---提升用户体验and开发效率.. 1. hybrid App 1 1.1. Hybrid Ap ...

  4. 开发函数计算的正确姿势 —— 使用 Fun Local 本地运行与调试

    前言 首先介绍下在本文出现的几个比较重要的概念: 函数计算(Function Compute): 函数计算是一个事件驱动的服务,通过函数计算,用户无需管理服务器等运行情况,只需编写代码并上传.函数计算 ...

  5. jQuery的JS库在本地运行项目时提示无法加载

    最近公司有个项目在我本地运行时引用本地的jquery.js,浏览器提示无法加载 <script src="/js/newperson/jquery-1.11.3.min.js" ...

  6. Spark程序本地运行

    Spark程序本地运行   本次安装是在JDK安装完成的基础上进行的!  SPARK版本和hadoop版本必须对应!!! spark是基于hadoop运算的,两者有依赖关系,见下图: 前言: 1.环境 ...

  7. CentOS 6.8 ftp服务安装配置 基于本地用户和虚拟用户

    CentOS 6.8 ftp服务安装配置 基于本地用户和虚拟用户 一.安装ftp服务 1.检查是否已经安装 # rpm -qa | grep ftp ftp-0.17-54.el6.x86_64 vs ...

  8. [How to]基于本地镜像的yum镜像源搭建

    1.简介 本文介绍如何在封闭环境(无外网)下安装离线安装本地镜像与基于本地镜像的yum镜像源. 2.环境版本交代: OS:CentOS-6.7-x86_64-minimal yum: yum-3.2. ...

  9. VScode配置CMD本地运行环境(2.0)

    VScode配置CMD本地运行环境(2.0) 官方Task.json说明 完整的Task.json配置信息 Task.json预定义变量 看了很多网上的教程都说需要下载VScode的python插件, ...

  10. 在本地运行Kubernetes的3种主流方式

    作者简介 Chris Tozzi,曾担任记者和Linux管理员.对开源技术.敏捷基础架构以及网络问题兴趣浓厚.目前担任高级内容编辑,并且是Fixate IO的DevOps分析师. 原文链接: http ...

随机推荐

  1. [公链观点] BTC 1.0, ETH 2.0, EOS 3.0, Dapp, WASM, DOT, ADA, VNT

    Dapp 发展史 WASM 兼容Web的编码方式 Cardano(ADA 艾达币) 权益挖矿 VNT chain 解决联盟链和公链的跨链基础项目 跨链项目 Polkadot (DOT 波卡币) 是不是 ...

  2. VisualStudio 使用 FastTunnel 辅助搭建远程调试环境

    有时候需要远程调试一些用户问题,期望能使用本机的 Visual Studio 开发环境,调试远程的用户的设备上的应用.这时会遇到的一个问题是如何让本机的 Visual Studio 可以连接上远程的用 ...

  3. dotnet 在 UOS 国产系统上使用 MonoDevelop 进行拖控件开发 GTK 应用

    先从一个 Hello World 应用开始,试试和古老的 WinForms 一样的拖控件式开发 在创建完成一个 GTK# 2.0 应用之后,咱可以试试开始拖控件的开发,当然这个开发方式开发出来的应用界 ...

  4. 🎉 Socket.D v2.4.12 发布(新增 python 实现)

    Socket.D 协议? Socket.D 是一个网络应用协议.在微服务.移动应用.物联网等场景,可替代 http.websocket 等.协议详情参考<官网介绍>. 支持: tcp, u ...

  5. JAVA基础-流程控制、字符串

    一.java基础 1.java主类结构 package com.study.again001; 包名public class helloword { 类名 static String s1 = &qu ...

  6. Git:如何撤销已经提交的代码

    日常操作流程 本地工作区(尚未暂存) ---> add . 到暂存区 ---> commit 到本地仓库 ---> pull拉取关联远程仓库分支合并到本地的分支---> pus ...

  7. C语言简答题

    C语言的历史: c语言是在20世纪70年代初美国贝尔实验室开发的一种高级编程语言,由B语言发展来,最初是为了Unix操作系统开发的.在80年代中期,由ISO和ANSI C对它进行了一系列的标准化, 9 ...

  8. cesium基础知识汇总PPT版

    以上教程来自火星科技,原视频教程地址如下: https://ke.qq.com/course/468292/3985600802137412#term_id=100560563

  9. python ddddocr图片验证码详解

    安装 下载地址:https://pypi.tuna.tsinghua.edu.cn/simple/ddddocr/ 安装命令: pip install D:\ChromeCoreDownloads\d ...

  10. 上位机开发福利!快速掌握.NET中的Modbus通信

    安装nuget包 Wesky.Net.OpenTools  1.0.8或以上版本.支持.net framework 4.6以上版本,以及所有.net core以及以上版本引用. 开发一个简单的Winf ...