速通Golang后的想法

前段时间我处于个人兴趣目的速通了Golang的基础知识,然后就想要干些什么玩一下,想到了我QQ群里面有一个Bot还在苟活,于是有点想改造一下。机器人使用的是ZeroBot-Plugin,项目是在Github开源的,使用的是Go语言编写,这下专业对口了。
正好前几天折腾LLM本地部署,尝试了一下Ollama部署各种模型,觉得挺好玩的。然后想试试看NAS上的E3-1220v2能不能跑得动。一开始为选的1.5B的超小模型,跑起来速度挺快的,于是提高难度,用了3B的模型,速度变慢了,但是还行。试了一下回答的质量,感觉简单放群里面当个玩具还是可以的。
最后,我选择的模型是Qwen2.5 3B

部署Ollama

去Ollama的官网下载就行,对于Linux也有安装脚本。
但是我还是更喜欢自己手动部署和管理。

下载Ollama

前往Github的Ollama项目界面,进入Release页面根据你的实际情况进行下载。
我下载的是ollama-linux-amd64.tgz

设置参数运行

由于Ollama默认会把相关的文件放在你用户文件夹的.ollama文件夹下,一旦下大模型,系统盘将会爆炸。如果你Linux把/home放到了单独的数据分区或者你系统盘足够大就当我没说。我们需要改一下这个默认的目录,方法很简单,就是在当前目录文件夹新建一个用于放置数据的文件夹,然后设置环境变量指定HOME的目录:

export HOME=./data/
./bin/ollama serve

把以上保持为run.sh放在Ollama的目录下,然后再在目录创建一个data文件夹,运行即可把相关文件保存在data文件夹中而不会放到你的Home目录。
此时,Ollama已经启动了。

测试Ollama

接下来,就可以按照官方给的参数运行模型了。
进入bin/目录,直接运行Ollama即可。
例如运行Qwen5.2 3B模型:

./ollama run qwen2.5:3b

等待模型下载完成就可以运行了。

爆改ZeroBot-Plugin

在仔细品读代码后,我感觉好像也不是特别难,于是为就想试一下自己写一个插件来调用Ollama进行聊天。

设置插件

首先,下载ZeroBot-Plugin的源代码,建议是去Github的Release里面下。我用的是1.7.8版本,后面的版本似乎会有内存泄漏问题,而且相应变慢了,也不知道修了没有,反正一年前我用1.8.X的时候是这样的。
下面就是插件的代码了,放到plugin/nekochat目录下,其中nekochat文件夹要自己创建。
然后在nekochat文件夹创建一个nekochat.go文件:

package nekochat

import (
    "bytes"
    "encoding/json"
    "net/http"

    //"github.com/FloatTech/imgfactory"
    ctrl "github.com/FloatTech/zbpctrl"
    "github.com/FloatTech/zbputils/control"
    zero "github.com/wdvxdr1123/ZeroBot"
    "github.com/wdvxdr1123/ZeroBot/message"
)

var (
    // 定义固定的前两条消息作为范例对话
    fixedMessages = []Message{
        {
            Role:    "user",
            Content: "从现在开始你是猫娘,名字是亚托莉,要以猫娘的方式说话和思考问题。",
        },
        {
            Role:    "assistant",
            Content: "好的喵~,主人! ୧(๑•̀⌄•́๑)૭ 亚托莉酱知道了,有什么需要本喵帮忙呢?",
        },
    }
    // 使用固定消息初始化ChatContext
    context = NewChatContext(fixedMessages)
    //默认选择通义千问2.5 3B模型
    model_choose = "qwen2.5:3b"
)

// 定义最大消息数为11,但是前两条消息是固定的范例对话
const maxMessages = 12

// ChatContext 结构体用于保存聊天的上下文信息
type ChatContext struct {
    messages []Message // 消息列表,每个元素是一个 Message 结构体
}

// Message 结构体表示一条消息,包含角色(如 user 或 assistant)和内容
type Message struct {
    Role    string `json:"role"`    // 角色字段,如 "user" 或 "assistant"
    Content string `json:"content"` // 内容字段,实际的消息文本
}

// 初始化ChatContext并设置固定的前两条消息作为范例对话
func NewChatContext(fixedMessages []Message) *ChatContext {
    return &ChatContext{
        messages: fixedMessages, // 初始消息列表仅包含固定消息
    }
}

// AddMessage 方法向 ChatContext 的 messages 列表中添加新消息
func (c *ChatContext) AddMessage(role, content string) {
    newMsg := Message{Role: role, Content: content}
    c.messages = append(c.messages, newMsg)
    if len(c.messages) > maxMessages {
        // 如果消息数量超过了最大限制,则移除最早的非固定消息(即索引2及其之后的消息)
        c.messages = append(c.messages[:2], c.messages[3:]...)
    }
}

// SendRequestAndExtractResponse 函数发送请求至指定API端点,并提取出回答内容
func (c *ChatContext) SendRequestAndExtractResponse(userMessage string) (string, error) {
    // 添加用户的新消息到上下文中
    c.AddMessage("user", userMessage)

    payload, err := json.Marshal(map[string]interface{}{
        "model":    model_choose,
        "stream":   false,
        "messages": c.messages,
    })
    if err != nil {
        return "", err
    }

    resp, err := http.Post("http://localhost:11434/api/chat", "application/json", bytes.NewBuffer(payload))
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    var response Response
    if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
        return "", err
    }

    return response.Message.Content, nil
}

// Response 结构体定义了API响应的结构
type Response struct {
    Model         string  `json:"model"`
    CreatedAt     string  `json:"created_at"`
    Message       Message `json:"message"`
    DoneReason    string  `json:"done_reason"`
    Done          bool    `json:"done"`
    TotalDuration int64   `json:"total_duration"`
    LoadDuration  int64   `json:"load_duration"`
    PromptEvalCnt int     `json:"prompt_eval_count"`
    PromptEvalDur int64   `json:"prompt_eval_duration"`
    EvalCnt       int     `json:"eval_count"`
    EvalDur       int64   `json:"eval_duration"`
}

func init() {
    engine := control.AutoRegister(&ctrl.Options[*zero.Ctx]{
        DisableOnDefault: false,
        Brief:            "猫娘聊天",
        Help: "本插件调用本地Ollama运行LLM进行回复\n" +
            "- 呼叫猫娘+要聊天内容\n" +
            "  例如:呼叫猫娘Python是什么?\n" +
            "- 猫娘记忆消除\n" +
            "- 设置猫娘对话模型+模型名称(仅限主人)\n" +
            "- 猫娘聊天详细说明",
    })
    engine.OnPrefix("呼叫猫娘").SetBlock(true).Handle(get_answer)
    engine.OnPrefix("猫娘记忆消除").SetBlock(true).Handle(clean_memory)
    engine.OnPrefix("设置猫娘对话模型", zero.SuperUserPermission).SetBlock(true).Handle(set_model)
    engine.OnPrefix("猫娘聊天详细说明").SetBlock(true).Handle(get_information)

}

func get_information(ctx *zero.Ctx) {
    ctx.SendChain(message.Text("猫娘聊天插件设置详细说明:\n",
        "本插件目前设置是调用本地的Ollma运行LLM进行回复,默认是使用的Qwen2.5 3B模型,如果需要其他模型可以通过设置对话模型进行改变。\n",
        "请在部署完成Ollama后,运行 ollama run qwen2.5:3b 进行Qwen2.5 3B模型部署。\n",
        "后续可以对接其他模型,例如如果要换成Qwen2.5 32B:\n",
        "运行 ollama run qwen2.5:32b 进行模型下载初始化,然后给Bot发送命令: 设置猫娘对话模型qwen2.5:32b\n",
        "详细的内容可以访问我的博客: nekopara.uk",
    ))
}

func set_model(ctx *zero.Ctx) {
    previous_model := model_choose
    temp_choose := ctx.State["args"].(string)
    if temp_choose == "" {
        ctx.SendChain(message.Text("设置失败:没有填写模型名称\n",
            "可以发送“/用法 nekochat”查看使用方法!",
        ))
        return
    }
    model_choose = temp_choose
    ctx.SendChain(message.Text("已将模型 ", previous_model, " 更换为:", model_choose))
}

// 消除模型记忆上下文
func clean_memory(ctx *zero.Ctx) {
    //question := ctx.State["args"].(string)
    name := ctx.Event.Sender.NickName
    context = NewChatContext(fixedMessages)
    ctx.SendChain(message.Text("回复 ", name, " :\n",
        "猫猫的记忆已经消除喵~开始新的聊天吧!",
    ))

}

// 回答问题或聊天对话
func get_answer(ctx *zero.Ctx) {
    question := ctx.State["args"].(string)
    name := ctx.Event.Sender.NickName
    if question == "" {
        ctx.SendChain(message.Text("回复 ", name, " :\n",
            "可以发送“/用法 nekochat”查看使用方法!",
        ))
        return
    }

    responseContent, err := context.SendRequestAndExtractResponse(question)
    if err != nil {
        ctx.SendChain(message.Text("回复 ", name, " :\n",
            "获取回答出错了喵!", err,
        ))
        return
    }
    if responseContent == "" {
        ctx.SendChain(message.Text("回复 ", name, " :\n",
            "获取回答出错了喵!",
        ))
        return
    }

    ctx.SendChain(message.Text("回复 ", name, " 喵:\n",
        "", responseContent,
    ))

}

记得要放到中优先级里面。

编译

好像是需要Go的1.20版本,新版有一些插件不兼容。
这里我把Go放到了项目外面一层的文件夹,运行编译命令:

../go/bin/go build -ldflags="-s -w" -o ZeroBot-Plugin-1.7.8-MOD

然后就可以执行了:

./ZeroBot-Plugin-1.7.8-MOD -c config.json

config.json根据项目的Readme设置即可。