核桃派2B运行deepseek-r1大模型,并将用户文本转化为AT指令输出给单片机

前言

Deepseek在寒假可是火出圈了,为了不让自己虚度寒假光阴,作者(本科在读机械生)打算制作一个能基于deepseek-r1将用户文本(或者语音转文本)转化为AT指令,并通过串口将指令传给ESP32S3单片机去执行一些操作的小作品。然而家中没有性能强大的Linux单板机,作者最开始是部署在笔记本电脑上的。花了将近一下午的时间去学习部署模型、给提示词、代码调用、添加语音转文本,编写ESP32S3处理AT指令代码。令人欣慰的是时间没有被浪费,整体效果还是不错的。

在坐高铁去学校的路上,发现心心念念的高性价比T527开发板核桃派2B开售了,果断出手买下了它。现在我将代码功能简化,方便大家理解和修改,并为大家制作了一篇简单明了的教程。

正文

1.安装Ollama

首先我们在自己的笔记本电脑或是台式电脑上进行准备操作。

  • 先确保你的核桃派正常工作且和你的电脑处于同一局域网。

  • 打开浏览器,输入网址https://ollama.com/download/ollama-linux-arm64.tgz去下载适合ARM64设备的ollama安装包。(你也可以通过curl指令直接在核桃派上下载,我这么做是因为我电脑当时下载速度差不多是30MB/s,如果不能访问就需要科学上网工具或者多次尝试)。

  • 打开下载到的压缩包文件所在的文件夹,右键,选择**“在终端打开”。在终端中输入 scp .\ollama-linux-arm64.tgz pi@xxx.xxx.xxx.xxx:/home/pi 来将我们下载的其中xxx.xxx.xxx.xxx是你的核桃派IP地址。**

接下来的操作在核桃派上执行

  • 登陆上核桃派,输入cd /home/pi前往我们给核桃派传输安装包的位置。使用指令sudo tar -C /usr -xvzf ollama-linux-arm64.tgz将安装包解压到/usr目录。

  • 解压完毕后再分别执行sudo useradd -r -s /bin/false -U -m -d /usr/share/ollama ollamasudo usermod -a -G ollama $(whoami)来为 Ollama 创建用户和组。

  • 接下来执行cd /etc/systemd/system/,然后执行sudo nano ollama.service来编写文件,在文件中输入下述内容:

    [Unit]
    Description=Ollama Service
    After=network-online.target
    
    [Service]
    ExecStart=/usr/bin/ollama serve
    User=ollama
    Group=ollama
    Restart=always
    RestartSec=3
    Environment="PATH=$PATH"
    
    [Install]
    WantedBy=default.target
    
  • 然后按Ctrl+X组合键,按照屏幕下方的提示退出并保存。

  • 在终端中分别执行sudo systemctl daemon-reloadsudo systemctl enable ollama来启动服务。

  • 最后指令sudo reboot重启设备,到此Ollama就安装好了。

2.安装模型

  • 重启设备后在终端执行ollama run 模型名:参数就可以下载安装大模型了,这里举两个例子,更多模型下载安装请移步ollama官网

    例一:  ollama run deepseek-r1:1.5b
    例二:  ollama run qwen2.5:1.5b
    

其中例一是下载安装deepseek-r1模型,参数为1.5个亿,例二是下载安装阿里的通义千问2.5模型,参数也是1.5个亿。这些指令只有在第一次执行时是下载安装模型,当已经存在模型时则会直接调用模型。若想查看当前安装有哪些模型可执行指令ollama list,若想删除模型则执行ollama rm 模型:参数。若想退出当前和模型的交互可以使用组合键Ctrl+D或是输入**\bye**

如果你仅是想在派上边使用模型,那么看到这里就可以了。执行ollama run 模型:参数之后就可以使用大模型了。

3.通过Python调用模型

首先是给pip永久换成国内源:

pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/

接着安装ollama的python包和pyserial:

pip install ollama pyserial

若是安装过程中出现了如下报错:

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

可以通过执行sudo rm /usr/lib/python3.*/EXTERNALLY-MANAGED来解决,然后再执行一遍安装指令就可以了

然后我们还需要去打开核桃派2B上的串口,参考官网教程就可以了:https://walnutpi.com/docs/walnutpi_2/python/gpio/uart

最后用自己喜欢的代码编辑器连接到核桃派2B,编写如下的python

# -*- coding: utf-8 -*-
import ollama
import re
from typing import Generator
import serial
# import speech_recognition as sr # 这个是调用语音转文本的库

ser = serial.Serial("/dev/ttyS2", baudrate=115200, timeout=1)

GPIO_prompt = """
# 角色定义
你是一个多模态硬件控制指令转换助手,支持GPIO和PWM指令生成
专门将自然语言转换为标准AT指令格式

# 指令生成规范
## GPIO模式
参数要求:
- 引脚号:1-100
- 状态:0(关闭)/1(开启)
关键词:开/关/高电平/低电平

## PWM模式
参数要求:
- 引脚号:1-100(需支持PWM)
- 占空比:0-100(百分比)
关键词:占空比/亮度/百分比

# 参数转换表
| 参数类型 | 自然语言表达          | 转换规则          |
|---------|----------------------|-------------------|
| GPIO状态 | 开/高电平/启动        | 1                 |
| {state} | 关/低电平/停止        | 0                 |

| 占空比   | 数字+% (如"30%")      | 直接取值 ,千万不能带%号 |
|  {duty}  | 分数 (如"三分之1")    | 转换为百分比      |
|          | 模糊描述 (如"最大")   | 映射为100         |

# 输出模板
GPIO:<AT>AT+GPIO={pin},{state}</AT>
PWM:<AT>AT+PWM={pin},{duty}</AT>

# 规则
1. 占空比自动修正到0-100范围
2. 禁止同一引脚同时设置GPIO/PWM
3. GPIO状态只能是0或1
4. 当用户文本中包含‘占空比’三个字时,务必要使用PWM指令,不能使用GPIO指令
5. 必须使用<AT>和</AT>标签包裹指令

# 典型示例
用户: 帮我点亮48号引脚的LED
你: <AT>AT+GPIO=48,1</AT>

用户: 打开48
你: <AT>AT+GPIO=48,1</AT>

用户: 给48设置高电平
你: <AT>AT+GPIO=48,1</AT>

用户: 给48设置低电瓶
你: <AT>AT+GPIO=48,0</AT>

用户: 给48设置占空比20
你: <AT>AT+PWM=48,20</AT>

用户: 48设置占空比100
你: <AT>AT+PWM=48,100</AT>

用户: 设置PWM5为40%
你: <AT>AT+PWM=5,40</AT>

用户: 把3号PWM调到七成
你: <AT>AT+PWM=3,70</AT>
"""

class AiChat:
    def __init__(self, system_prompt: str):
        self.history = [{
            "role": "system",
            "content": f"{system_prompt}"
        }]

    def _extract_at_commands(self, text: str) -> list:
        pattern = r'<AT>(AT\+\w+=\d+(?:,\d+)?)</AT>'
        return re.findall(pattern, text)

    def chat(self, message: str, stream: bool = False) -> Generator[str, None, None]:
        self.history.append({"role": "user", "content": message})

        response = ollama.chat(
            model="deepseek-r1:1.5b",  # 可以按照自己的下载来选择启动模型,我下载了deepseek-r1:1.5b和qwen2.5:1.5b
            messages=self.history, # 建议小伙伴们都尝试一下体验效果
            stream=stream,
            options={
                "temperature": 0.1,
                "max_tokens": 2000,
                "top_p": 0.85
            }
        ) 

        full_response = ""
        if stream:
            for chunk in response:
                content = chunk["message"]["content"]
                full_response += content
                yield content
        else:
            full_response = response["message"]["content"]
            yield full_response

        self.history.append({"role": "assistant", "content": full_response})

def main():
    # r = sr.Recognizer()
    GPIObot = AiChat(GPIO_prompt)

    while True:
        # # 使用默认麦克风作为音频来源
        # with sr.Microphone() as source:
        #     print("请说些什么吧...")
        #     audio = r.listen(source, phrase_time_limit=10)

        #     try:
        #         # 使用Google Web Speech API进行识别
        #         print("Google 语音识别认为你说的是:")
        #         text = r.recognize_google(audio, language='zh-CN')
        #         print(text)
        #     except sr.UnknownValueError:
        #         print("Google 语音识别无法理解音频")
        #         continue
        #     except sr.RequestError as e:
        #         print(f"无法从Google 语音识别服务请求结果; {e}")
        #         continue

        all_chunks = []
        # 可以自由替换文本哦
        for chunk in GPIObot.chat("48设置占空比1", stream=True): # for chunk in GPIObot.chat(text, stream=True):
            print(chunk, end="", flush=True)
            all_chunks.append(chunk)

        full_response = ''.join(all_chunks)
        commands = GPIObot._extract_at_commands(full_response)
        for cmd in commands:
            print(f"\n[找到] {cmd}")
            if ser.is_open:
                print("\n串口打开")
                try:
                    ser.write((cmd + '\r\n').encode('utf-8'))  # 将字符串转换为字节
                except Exception as e:
                    print(f"串口写入失败: {e}")
                    print("猜测是Ai输出指令不匹配")
        # 询问用户是否继续
        user_input = input("\n是否继续?(y/n): ").strip().lower()
        if user_input != 'y':
            ser.close()
            print("串口关闭")
            break

if __name__ == "__main__":
    main()

4.ESP32S3的代码编写

作者使用Arduino固件来进行开发,代码如下

#include <Arduino.h>

void parseATCommand(String command);
void setup()
{
	Serial1.begin(115200, SERIAL_8N1, 8, 7);  // 初始化串口通信,波特率为 115200
	Serial.begin(115200, SERIAL_8N1, 38, 39); // 用于调试信息输出
    delay(3000);
}

void loop()
{
	if (Serial1.available())
	{
		String cmd = Serial1.readStringUntil('\n');
		if (cmd.endsWith("\r"))
			cmd.trim();
		parseATCommand(cmd);
	}
	delay(50);
}
/*
	解析AT指令并执行操作
*/
void parseATCommand(String command)
{
	if (command.startsWith("AT+GPIO="))
	{
		int pin, state;
		if (sscanf(command.c_str() + 8, "%d,%d", &pin, &state))
		{
			// 确保引脚号和状态是有效的
			if (pin >= 0 && (state == 0 || state == 1))
			{
				pinMode(pin, OUTPUT);
				digitalWrite(pin, state);
				Serial.print("设置引脚 ");
				Serial.print(pin);
				Serial.print("为");
				Serial.println(state == 1 ? "高电平" : "低电平");
			}
			else
				Serial.println("无效的引脚号或状态");
		}
		else
			Serial.println("无法解析引脚号和状态");
	}
	else if (command.startsWith("AT+PWM="))
	{
		// 提取引脚号和占空比
		int equalIndex = command.indexOf('=');
		int commaIndex = command.indexOf(',');
		if (equalIndex != -1 && commaIndex != -1)
		{
			String pinStr = command.substring(equalIndex + 1, commaIndex);
			String dutyStr = command.substring(commaIndex + 1);

			int pin = pinStr.toInt();
			int duty = dutyStr.toInt();
			// 确保引脚号和占空比是有效的
			if (pin >= 0 && duty >= 0 && duty <= 100)
			{
				pinMode(pin, OUTPUT);
				int analogValue = map(duty, 0, 100, 0, 255);
				analogWrite(pin, analogValue);
				Serial.print("设置引脚");
				Serial.print(pin);
				Serial.print("的PWM占空比为");
				Serial.print(duty);
				Serial.println("%");
			}
			else
				Serial.println("无效的引脚号或占空比");
		}
		else
			Serial.println("无法解析引脚号和占空比");
	}
	else
		Serial.println("无效的AT指令");
}

最后附上效果图

核桃派的终端输出:

pi@WalnutPi:~$ /bin/python3.11 /home/pi/Py-projects/ATcmdAI/main.py
<AT>AT+PWM=48,1</AT>
[找到] AT+PWM=48,1

串口打开

是否继续?(y/n):  y
<AT>AT+PWM=48,1</AT>
[找到] AT+PWM=48,1

串口打开

是否继续?(y/n):  n
串口关闭

ESP32上的板载LED灯确实亮了(是个低电平点亮的LED)

2 个赞

哈哈哈,1.5B的B是billion,十亿,是15亿喔~

强强强

欸(๑ó﹏ò๑),是十亿