跳到主要内容

架构与设计(项目细节)

本文档整合 JuanNiang-Neo 的架构、调用栈、EventLoop 与插件系统,作为二次开发与运维理解的核心参考。

一、架构

概述

JuanNiang-Neo 是基于 OneBot11 协议的 LLM QQ 聊天 Agent 系统(红岩网校吉祥物"卷娘")。核心由 LLM 驱动的对话 Agent(HagoCenter,聚合 Provider / MCP / Memory / Prompt / Session / Skill / Tool)与 OneBot11 反向 WebSocket 适配器组成,基于 Eino ADK 框架构建 ChatModelAgent,工具调用在 ReAct 循环内同步执行。事件流经三阶段管线:Plugin 拦截 → 回复策略检查 → 异步派发 Agent,每个聊天区域由 ConcurrencyManager 控制最多 8 个 Agent goroutine 并发。项目还包含 Lua 插件引擎、Vue 3 管理面板,以及 Postgres + Redis + Sandbox + T2I 等可插拔基础设施。所有持久化状态落 Postgres + Redis,配置与运行时状态均可在 Web 面板热切换。

分层架构

模块职责

模块包路径职责
入口cmd/server/main.go组装所有模块、启动服务、反向优雅退出(带 15s watchdog)
适配器internal/adapter/OneBot11 反向 WS 服务端 + Webhook HTTP 服务端:事件解析、API 封装、消息段构造
Agentinternal/agent/Agent 核心:HagoCenter 聚合 Provider/MCP/Memory/Prompt/Session/Skill/Tool/ACL + Eino ADK ChatModelAgent (ReAct loop) + ConcurrencyManager (每 ChatArea 8 goroutine),事件循环、CronJob、回复策略
核心库internal/core/数据模型 (GORM)、DAO Bundle、Redis 缓存、ACL
Web APIinternal/api/Hertz Web 引擎、JWT 中间件、路由、Service(121 个 handler + 根路径 /health
插件internal/pluggin/gopher-lua 引擎:生命周期、Lua API 暴露、命令树、事件拦截
基础设施infrastructure/postgres、redis、sandbox、t2i 客户端(每个含 handler 子包,功能选项风格)
前端服务internal/web/SPAHandler 通过 Hertz NoRoute 兜底服务 web/dist
日志internal/logging/fatih/color 彩色输出 + JSON 格式化 + 调用栈 + Hub(SSE)

术语陷阱internal/adapter.Provider=OneBot11 反向 WS 适配器;internal/agent/provider.ProviderGroup=LLM Provider 组。pluggin 是有意拼写(Lua 插件系统),不要改成 plugindocs/guidance.md 拼成 inferstructure 是错的,真实路径是 infrastructure/

数据模型

共 31 个 GORM 表(见 internal/core/core.go::AutoMigrate)。

关键模型语义

  • ChatArea:私聊/群聊最小隔离单元,是 Session / Memory / ChatRecord / ACLRule 的父级。由首条消息自动 GetOrCreate 创建,无手动创建接口。
  • ChatRecordid 为自增 int64(其他模型多为 UUID)。Session.AppendRecord 写 Postgres 与短期记忆 Redis 写入解耦——前者为审计/检索,后者为 Agent 上下文窗口。
  • 单行配置Onebot11Adapter/WebhookConfig/T2IConfig/SandboxConfig 固定 id=1,首次访问 DB 不存在时 InitConfigOnConflict DoNothing 创建默认行。
  • ReplyStrategyConfig:无 DeletedAt 的单例,默认 strategy=always, relevance_threshold=0.5, judge_fail_policy=drop
  • Prompt IsSystem:启动时 EnsureSystemPrompt 幂等播种 __system_locked__,强制拼接(顺序 SystemLocked → system → personality → custom)。
  • Plugin Manifest.System:系统插件三层守卫(Manifest.System + PluginEngine.IsSystem() + Service 层 Toggle/Delete)禁删/禁停。
  • CronJob:不与 ChatArea 建外键;触发时由 cronjob.Manager 构造合成 adapter.Event{PostType:"cronjob", IsCronJob:true}CronJobEvents channel 注入事件循环。
  • SkillMemory:全局技能记忆单例(id="global"),存储从对话中提取的技能/知识/黑话。Compact 时由 LLM 自动更新,写回 Postgres。
  • KnowledgeItem:SQL 知识库条目,存入时由 Agent 异步提取 Keywordskeyword_status: pending→ready/failed);对话前 buildKnowledgeContext 按关键词命中 + 内容 ILIKE 匹配,命中结果注入系统提示词(LRU 50 条缓存)。

状态管理

  • 持久化状态 → Postgres(23 张表)
  • 缓存状态 → Redis(短期记忆滑动窗口 shortterm:msgs:<areaID>、PubSub 任务结果通知、插件/Agent 任意 KV/Hash)
  • 插件数据隔离 → Cache 键以 pluggin:<name>: 前缀命名空间隔离(注意:database.query 当前未真正应用前缀,是 prefixSQL 桩)
  • 例外 → Lua 插件配置由 data/pluggins/<name>/pluggin.yaml 管理(非 DB,便于 bind-mount 跨镜像保留)
  • 可插拔服务 → T2I / Sandbox 未配置时自动返回未启用提示;启用时由 API 层 OnUpdateT2I/OnUpdateSandbox 回调热注入 HagoCenter 与 Service 共享的 *Client 指针
  • 原则 → 内存中有状态模块(Agent / Memory / Skill)最终与 DB 同步;不引入纯内存状态

HagoCenter 运行时拓扑

HagoCenterinternal/agent/agent.go)是 Agent 运行时聚合体。Start 后并发起 2 个 goroutine:

  • EinoAgentadk.ChatModelAgent):基于 Eino ADK 框架的 ChatModelAgent,工具调用在 ReAct 循环内同步执行,MaxIterations=20。
  • ConcurrencyManagerconcurrency.go):每 ChatArea 并发控制(默认 8 goroutine),使用 buffered channel 作为信号量,超限消息排队等待。
  • CronJobManagercronjob/cronjob.go):robfig/cron 调度(秒级),命中后构造合成事件。
  • 事件循环 4 个 select 分支ctx.Done / Adapter.Events(断流自愈)/ webhookEvents / CronJobEvents

Agent 子包

子包实现说明
providerprovider.goOpenAI 兼容 /v1/chat/completions(流式 SSE)、Vision(inline base64);ProviderGroup 同类型单 Active 管理
mcpmcp.gomark3labs/mcp-go SSE 客户端;MCPGroup 聚合连接 + ListTools/CallTool(MCP 可覆盖 builtin 同名工具)
memorymemory.go + shortterm/longterm/skillmem四层记忆:短期(Redis 滑窗, 默认100条, 自动Compact) / 长期(Postgres + 内存 LRU HotArea) / 技能记忆(SkillMemory, Compact 时 LLM 自动提取) / 会话记录(Postgres 审计)
promptprompt.goPromptManager + 系统锁定提示词 EnsureSystemPrompt 幂等播种 + BuildFullContext(工具感知不拼入提示词,由 Eino tools 参数提供)
sessionsession.goSessionManagerGetOrCreate / AppendRecord(Postgres) / UpdateTokenUsage
skillskill.goSkillEngine.Match(input) 按关键词 / 正则 / priority 匹配首个激活技能
tooltool.go / builtin.goToolRegistry + 内置工具 RegisterBuiltinTools(OneBot11 / 沙箱 / T2I / vision 等),工具注册为 Eino ADK ToolNode
cronjobmanager.gorobfig/cron 调度 + 合成事件

插件 API 分组(速查)

权限全局表 / SDK 字段函数说明
始终log (jn.log)3info/warn/error → slog
始终json (jn.json)2encode/decode
onebot11onebot11 (jn.onebot11)23消息发送(异步/同步)+ 群管理 + 信息查询 + 请求处理 + 登录/状态/版本 + read_file_base64
httphttp (jn.http)4get/post(30s 超时)+ get_async/post_async(异步回调 on_http_response
databasedatabase (jn.database)2query/exec(共享 DB,前缀桩未生效,⚠ 权限敏感)
cachecache (jn.cache)4get/set/del/exists(pluggin:<name>: 命名空间)
t2it2i (jn.t2i)7generate/generate_url + generate_async/generate_url_async(异步回调 on_t2i_response)+ toggle/is_active/get_config
sandboxsandbox (jn.sandbox)11create/exec_shell/exec_python + create_async/exec_shell_async/exec_python_async(异步回调 on_sandbox_response)+ toggle/is_active/get_config/list/delete
agentagent (jn.agent)17配置查询 + Provider/MCP/Tool 切换 + switch_provider + compact_memory + get_current_chat_area
内置jn.command1register(path, handler, opts) 多级命令注册

详见 插件开发指南

前端 SPA 静态服务

实现:internal/web/web.go::SPAHandlerfilepath.Rel 路径穿越防护);在 internal/api/engine/engine.go 通过 h.NoRoute(...) 注册。不嵌入二进制web/dist 是磁盘文件,便于只换前端不重编 Go)。开发期 Vite :3000 代理 /api:8090,Go fallback 不触发。


二、调用栈

调用栈图以 mermaid 流程图呈现(节点含文件:行号标注),支持缩放/拖动查看。

启动流程

优雅退出(cmd/server/main.go:287 shutdown

OneBot11 反向 WS 事件接收到解析

触发链路:Adapter.eventswsServer.eventsreadLoop。事件入口 Adapter.Events() (adapter.go:124)。

OneBot11 API 调用(Agent 工具 / 插件 → WS)

normalizeMessageapi.go:324)兼容 string / Segment / []Segment / *MessageBuilder,含 CQ 码时重新解析。

Agent 事件循环(核心路径)

runEventLoop agent/event.go:39
└─ select {
case <-ctx.Done(): stop
case ev := <-h.Adapter.Events(): event.go:50
若 channel 关闭(适配器重启): sleep 1s 重新取 Events() (event.go:52)
ev.Admins = h.Adapter.Admins()
h.processEvent(ctx, ev)
case ev := <-webhookEvents: event.go:63 (WebhookAdapter != nil 时)
h.processEvent(ctx, ev)
case ev := <-h.CronJobEvents: event.go:69
ev.Admins = h.Adapter.Admins()
h.processEvent(ctx, ev)
}

processEvent(三阶段架构)

handleMessage(Eino ADK 对话主流程)

工具调用(Eino ADK ReAct 循环内同步执行)

工具调用完全由 Eino ADK ChatModelAgent 的 ReAct 循环管理,所有工具同步执行:

已移除:BgTaskExecutor 和 DrainerAgent 已完全移除。所有工具调用(包括长时间运行的操作)均在 Eino ADK 的 ReAct 循环内同步完成。

CronJob 调度

Web API 请求 → handler

日志系统(自定义彩色日志)

基于 github.com/fatih/color 的自定义日志系统,替代 log/slog。功能:彩色输出、JSON 自动格式化、WARN+ 调用栈、模块日志器、GORM SQL 日志集成。

GET /api/v1/logs/stream → svc.StreamLogs service.go:1634
├─ sse.NewWriter(c)
├─ 阶段1: LogHub.Recent() 250 条按序 WriteEvent("log")
├─ 阶段2: subscribe() 实时 WriteEvent("log", entry)
└─ 每 15s WriteKeepAlive 心跳(兼测死连)

三、EventLoop 与事件流

事件来源

JuanNiang-Neo 有三类外部事件源,最终都汇入 HagoCenter.runEventLoopinternal/agent/event.go:38):

来源通道PostType备注
OneBot11 反向 WSh.Adapter.Events()message/notice/request/meta_eventinternal/adapter/server.go::readLoop 推送
Webhookh.WebhookAdapter.Events() (webhookEvents)webhookinternal/adapter/webhook.go::handleRequest 推送;外部 HTTP POST 触发
CronJobh.CronJobEventscronjobagent/cronjob/manager.go::makeJobFunc 合成

EventLoop(2 个 goroutine)

HagoCenter.Start 启动两个并发 goroutine(agent.go:334-339):

runEventLoop 的 4 个 select 分支(event.go:38-77):

  1. <-ctx.Done() → 直接退出循环
  2. <-h.Adapter.Events():OneBot11 事件。若 channel 关闭(适配器 Stop 调用了 close(events)),不会 panic——记日志、sleep 1s 重新获取 Events() 句柄并 continue(event.go:52)。这正是反向 WS 重启后事件循环自愈的关键。
  3. <-webhookEvents(仅 WebhookAdapter != nil):调用 processEvent
  4. <-h.CronJobEvents:合成 cronjob 事件,Admins 从 adapter 重新挂回后喂 processEvent

事件分发决策树(processEvent 三阶段)

processEventevent.go:81-117)采用三阶段架构:Plugin 拦截 → 消息过滤 → 回复策略检查 → 异步派发 Agent。

isAtSelfreply_strategy.go)做的是精确 [CQ:at,qq=<self>] 匹配;优先用当前 Adapter.SelfID() 而非缓存 SelfQQ,支持机器人换号后立即生效。 relevance 判断优化管线(filterRelevantrelevanceBatchEvaluate):@/命令/提及名字 → 必回(0 次 LLM);噪音消息(纯表情/过短/仅 URL)→ 规则丢弃;其余候选合并为一次 LLM 批量判断(含图消息标注 [图片],单条候选走原分数判断)。判断结果写 Redis(related=15s 对话轮次放宽 / unrelated=30s 冷却),判断并发全局上限 4、超时可配置(reply_strategy 的 relevance_timeout,默认 10s),失败按 judge_fail_policy(drop/reply)降级;群聊刷屏(1s≥5 条)时批窗口拉长到 3s 并降级为只回必回消息。

一条消息的全程(OneBot11 → 回执)

CronJob 注入流

CronJob 事件只派发给 Lua 插件(on_cronjob 回调),不进入 LLM Agent、不经过回复策略与 ACL。CronJob 的 message/message_type/target_id 字段仅作为 event.raw_message 等上下文透传给插件。

API 侧增删改后 Manager.Reload() 同步调度器(service.go:1733/1768/1808/1822),无需重启进程。详见 webhook-cronjob.md

Webhook 注入流

Webhook 不走 Agent LLM 路径,是对外暴露给 Lua 插件的事件钩子(如 GitHub push 通知触发群发)。详见 webhook-cronjob.md

关键不变量

  • Adapter 重启不会击穿事件循环Adapter.Stopclose(events) 并置 nil,Start 时若 events==nil 重建(adapter.go:36-62);EventLoop 分支2 检测关闭后 sleep 1s 重新取句柄(event.go:52)。
  • Redis 与 Postgres 解耦:短期记忆写 Redis 是为了 LLM 上下文窗口,Session.AppendRecord 写 Postgres 是为了审计检索;任一失败不影响另一路。
  • Admins 绕过 ACL:Admins 列表(来自 Onebot11Adapter.AdminQQNumbers)从 adapter 透传到每条 EventhandleMessageisAdmin(userID, admins) || ACL.CheckChat(...) 决定消息是否进入 Agent。ACL 现仅管理聊天黑名单(仅 deny 规则生效,allow 规则不再生效)。
  • __NO_REPLY__ 静默:LLM 可主动输出 __NO_REPLY__ 让系统不发任何 QQ 消息(避免群聊噪音)。
  • SystemLocked 强制拼接:每次对话系统提示词必含 __system_locked__ 内容,前端不能停用,保证 LLM 知道能用 T2I 富文本、分消息段、权限层级等行为约束。
  • 工具调用全同步:所有工具调用(包括长时间运行的操作)均在 Eino ADK ReAct 循环内同步完成,无后台任务分流。BgTaskExecutor 和 DrainerAgent 已完全移除。
  • 每 ChatArea 并发控制ConcurrencyManager 通过 buffered channel 信号量控制每个 ChatArea 最多 8 个 Agent goroutine 并发(默认值可配置),超限消息排队等待。

四、插件系统

概述

JuanNiang-Neo 的 Lua 插件系统基于 gopher-lua(Go-Lua 绑定),允许用户通过 Lua 脚本扩展机器人功能。插件可以:

  • 拦截 OneBot11 消息事件 / Webhook 事件
  • 注册多级斜杠命令(如 /system provider switch
  • 调用 OneBot11 协议接口、HTTP、数据库、Redis 缓存、T2I、Sandbox、Agent 操作接口
  • 通过内嵌 Lua SDK(jn.lua,带 LuaCATS 注解)获得 IDE 类型提示

拼写约定pluggin(双 g 单 n)是有意拼写:模块路径 internal/pluggin、配置文件 pluggin.yaml、插件目录 data/pluggins。请勿"修正"为 plugin。开发完整指南见 插件开发指南

组件结构

生命周期

代码位置:

系统插件三层守卫(Manifest.System + PluginEngine.IsSystem() + Service 层 Toggle/Delete),确保 system 插件不可删/停。

Manifest(pluggin.yaml

字段类型说明
ppidstring稳定 UUID(空时自动生成并写回)
namestring插件名(=目录名,作为 id
versionstring版本,默认 "1.0.0"
authorstring作者
descriptionstring描述
entrystringLua 入口,默认 main.lua
permissionsstring[]申请的权限(onebot11/http/database/cache/t2i/sandbox/agent
systembool系统插件(undeletable / unstoppable)
enabledbool是否启用(控制是否在 LoadAll 时加载)

示例(系统插件 internal/pluggin/systemplugin/pluggin.yaml):

ppid: 6563c9c3-1072-4168-8bb3-62db4c11990b
name: system
version: "1.0.0"
author: JuanNiang-Neo
description: "系统插件,封装 Agent/Provider/MCP/Tool/T2I/Sandbox/Session 管理命令"
entry: main.lua
system: true
enabled: true
permissions:
- onebot11
- agent
- t2i
- sandbox

事件回调

插件通过两个全局 Lua 函数拦截事件(PCall,2 返回值 (consumed bool, reply string)):

回调触发权限过滤
on_message(event)收到 OneBot11 / 开头走 commands.Dispatch;否则对每条有 onebot11 权限的插件调用onebot11
on_webhook(event)Webhook 事件到达(不走 LLM Agent)webhook

EventData 结构(传给 Lua 的 event table):

type EventData struct {
PostType string
MessageType string
UserID int64
GroupID int64
RawMessage string
Admins []string
Webhook map[string]any
}

OnMessage 决策(pluggin.go:397-441):

命令树(CommandRegistry)

internal/pluggin/command.go 实现多级命令派发:

CommandNode {Name, Opts{Description,Usage}, Handler, PluginName, Children map}
长前缀匹配, 最长匹配节点带 Handler 时执行
未命中 Handler 但停在非 root → 返回该节点子命令列表
Dispatch(raw, event): 按 "/" 分词遍历, 取最后带 handler 的节点, 调用 handler(剩余 args, event)

命令 handler 签名(Go 侧):

type CommandHandler = func(args []string, event EventData) (consumed bool, reply string, err error)

Lua 侧通过 SDK jn.command.register(path, handlerFn, opts) 注册,path 可为 string 或 table(多级),handler 接收 (argsTable, eventTable) 返回 (consumedBool, replyString)

内置 /helpregisterBuiltinCommands() 注册(plugin=system),列出所有顶级命令;/help <cmd> [sub...] 列出子命令与用法。

插件卸载时 UnregisterPlugin(name) 递归清理该插件注册的所有命令并修剪空叶子。

注入的 Lua 全局表

permissions 字段 gated,由 injectBaseAPIpluggin.go:973)注入。完整签名见 插件开发指南

全局表权限说明
log始终info/warn/error → slog [plugin:<name>] 前缀
json始终encode/decode
onebot11onebot1121 个 OneBot11 API(SendAdapter 接口桥接)
httphttpget/post,30s 超时真实 HTTP
databasedatabasequery/exec(共享 DB;prefixSQL 桩未生效,⚠ 任意 SQL)
cachecacheget/set/del/exists(pluggin:<name>: 前缀命名空间)
t2it2igenerate / generate_url + toggle/is_active/get_config
sandboxsandboxcreate/exec_shell/exec_python/list/delete + toggle/is_active/get_config
agentagent配置查询 + Provider/MCP/Tool 切换 + switch_provider + compact_memory
jn.command内置命令注册

Lua SDK(jn.lua

由 Go 二进制内嵌(//go:embed sdk/jn.luapluggin.go:1543),启动时 ensureEmbeddedAssets 落盘到 data/pluggins/sdk/jn.lua(每次覆盖以匹配二进制版本)。injectSDK<basePath>/sdk/?.lua 追加到 LState 的 package.path,使 require("jn") 可用。

SDK 仅捕获 Go 注入的全局表作为模块字段(jn.log = log 等),不引入额外行为;带 LuaCATS 注解,sumneko lua-language-server 可提供完整代码提示。

local jn = require("jn")
jn.log.info("插件启动")
local id, err = jn.t2i.generate("<h1>Hello</h1>")

数据隔离

  • Cache:所有 cache.* 操作自动加 pluggin:<name>: 前缀,插件间键不冲突,且无法读写 Agent 的 session:/shortterm: 前缀。
  • Databasedatabase.query/exec 跑在共享库上,prefixSQL 桩当前未应用 pluggin_<name>_ 前缀;请谨慎授 database 权限(⚠ 任意 SQL,可在插件侧加自己的表前缀)。
  • 插件配置data/pluggins/<name>/pluggin.yaml 在磁盘,不进 DB(除非 DB plugins 表存元数据镜像)。

安全建议

  • 仅对受信插件授予 database 权限
  • 对从社区上传的 ZIP 插件先审阅 Lua 源码再 Deploy
  • 系统插件 system 提供 /system provider switch/system memory compact 等管理命令,需要 admin 操作(受 ACL 与 OneBot11 Adapters 的 Admins 双重保护)