架构与设计(项目细节)
本文档整合 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 封装、消息段构造 |
| Agent | internal/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 API | internal/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 插件系统),不要改成plugin。docs/guidance.md拼成inferstructure是错的,真实路径是infrastructure/。
数据模型
共 31 个 GORM 表(见 internal/core/core.go::AutoMigrate)。
关键模型语义
ChatArea:私聊/群聊最小隔离单元,是 Session / Memory / ChatRecord / ACLRule 的父级。由首条消息自动GetOrCreate创建,无手动创建接口。ChatRecord:id为自增 int64(其他模型多为 UUID)。Session.AppendRecord写 Postgres 与短期记忆 Redis 写入解耦——前者为审计/检索,后者为 Agent 上下文窗口。- 单行配置:
Onebot11Adapter/WebhookConfig/T2IConfig/SandboxConfig固定id=1,首次访问 DB 不存在时InitConfig用OnConflict 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}经CronJobEventschannel 注入事件循环。SkillMemory:全局技能记忆单例(id="global"),存储从对话中提取的技能/知识/黑话。Compact 时由 LLM 自动更新,写回 Postgres。KnowledgeItem:SQL 知识库条目,存入时由 Agent 异步提取Keywords(keyword_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 运行时拓扑
HagoCenter(internal/agent/agent.go)是 Agent 运行时聚合体。Start 后并发起 2 个 goroutine:
EinoAgent(adk.ChatModelAgent):基于 Eino ADK 框架的 ChatModelAgent,工具调用在 ReAct 循环内同步执行,MaxIterations=20。ConcurrencyManager(concurrency.go):每 ChatArea 并发控制(默认 8 goroutine),使用 buffered channel 作为信号量,超限消息排队等待。CronJobManager(cronjob/cronjob.go):robfig/cron调度(秒级),命中后构造合成事件。- 事件循环 4 个 select 分支:
ctx.Done/Adapter.Events(断流自愈)/webhookEvents/CronJobEvents。
Agent 子包
| 子包 | 实现 | 说明 |
|---|---|---|
provider | provider.go | OpenAI 兼容 /v1/chat/completions(流式 SSE)、Vision(inline base64);ProviderGroup 同类型单 Active 管理 |
mcp | mcp.go | mark3labs/mcp-go SSE 客户端;MCPGroup 聚合连接 + ListTools/CallTool(MCP 可覆盖 builtin 同名工具) |
memory | memory.go + shortterm/longterm/skillmem | 四层记忆:短期(Redis 滑窗, 默认100条, 自动Compact) / 长期(Postgres + 内存 LRU HotArea) / 技能记忆(SkillMemory, Compact 时 LLM 自动提取) / 会话记录(Postgres 审计) |
prompt | prompt.go | PromptManager + 系统锁定提示词 EnsureSystemPrompt 幂等播种 + BuildFullContext(工具感知不拼入提示词,由 Eino tools 参数提供) |
session | session.go | SessionManager:GetOrCreate / AppendRecord(Postgres) / UpdateTokenUsage |
skill | skill.go | SkillEngine.Match(input) 按关键词 / 正则 / priority 匹配首个激活技能 |
tool | tool.go / builtin.go | ToolRegistry + 内置工具 RegisterBuiltinTools(OneBot11 / 沙箱 / T2I / vision 等),工具注册为 Eino ADK ToolNode |
cronjob | manager.go | robfig/cron 调度 + 合成事件 |
插件 API 分组(速查)
| 权限 | 全局表 / SDK 字段 | 函数 | 说明 |
|---|---|---|---|
| 始终 | log (jn.log) | 3 | info/warn/error → slog |
| 始终 | json (jn.json) | 2 | encode/decode |
onebot11 | onebot11 (jn.onebot11) | 23 | 消息发送(异步/同步)+ 群管理 + 信息查询 + 请求处理 + 登录/状态/版本 + read_file_base64 |
http | http (jn.http) | 4 | get/post(30s 超时)+ get_async/post_async(异步回调 on_http_response) |
database | database (jn.database) | 2 | query/exec(共享 DB,前缀桩未生效,⚠ 权限敏感) |
cache | cache (jn.cache) | 4 | get/set/del/exists(pluggin:<name>: 命名空间) |
t2i | t2i (jn.t2i) | 7 | generate/generate_url + generate_async/generate_url_async(异步回调 on_t2i_response)+ toggle/is_active/get_config |
sandbox | sandbox (jn.sandbox) | 11 | create/exec_shell/exec_python + create_async/exec_shell_async/exec_python_async(异步回调 on_sandbox_response)+ toggle/is_active/get_config/list/delete |
agent | agent (jn.agent) | 17 | 配置查询 + Provider/MCP/Tool 切换 + switch_provider + compact_memory + get_current_chat_area |
| 内置 | jn.command | 1 | register(path, handler, opts) 多级命令注册 |
详见 插件开发指南。
前端 SPA 静态服务
实现:internal/web/web.go::SPAHandler(filepath.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.events ← wsServer.events ← readLoop。事件入口 Adapter.Events() (adapter.go:124)。
OneBot11 API 调用(Agent 工具 / 插件 → WS)
normalizeMessage(api.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.runEventLoop(internal/agent/event.go:38):
| 来源 | 通道 | PostType | 备注 |
|---|---|---|---|
| OneBot11 反向 WS | h.Adapter.Events() | message/notice/request/meta_event | 由 internal/adapter/server.go::readLoop 推送 |
| Webhook | h.WebhookAdapter.Events() (webhookEvents) | webhook | 由 internal/adapter/webhook.go::handleRequest 推送;外部 HTTP POST 触发 |
| CronJob | h.CronJobEvents | cronjob | 由 agent/cronjob/manager.go::makeJobFunc 合成 |
EventLoop(2 个 goroutine)
HagoCenter.Start 启动两个并发 goroutine(agent.go:334-339):
runEventLoop 的 4 个 select 分支(event.go:38-77):
<-ctx.Done()→ 直接退出循环<-h.Adapter.Events():OneBot11 事件。若 channel 关闭(适配器Stop调用了close(events)),不会 panic——记日志、sleep 1s 重新获取Events()句柄并 continue(event.go:52)。这正是反向 WS 重启后事件循环自愈的关键。<-webhookEvents(仅WebhookAdapter != nil):调用processEvent。<-h.CronJobEvents:合成 cronjob 事件,Admins从 adapter 重新挂回后喂processEvent。
事件分发决策树(processEvent 三阶段)
processEvent(event.go:81-117)采用三阶段架构:Plugin 拦截 → 消息过滤 → 回复策略检查 → 异步派发 Agent。
isAtSelf(reply_strategy.go)做的是精确[CQ:at,qq=<self>]匹配;优先用当前Adapter.SelfID()而非缓存SelfQQ,支持机器人换号后立即生效。 relevance 判断优化管线(filterRelevant→relevanceBatchEvaluate):@/命令/提及名字 → 必回(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.Stop会close(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 透传到每条Event;handleMessage中isAdmin(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)
| 字段 | 类型 | 说明 |
|---|---|---|
ppid | string | 稳定 UUID(空时自动生成并写回) |
name | string | 插件名(=目录名,作为 id) |
version | string | 版本,默认 "1.0.0" |
author | string | 作者 |
description | string | 描述 |
entry | string | Lua 入口,默认 main.lua |
permissions | string[] | 申请的权限(onebot11/http/database/cache/t2i/sandbox/agent) |
system | bool | 系统插件(undeletable / unstoppable) |
enabled | bool | 是否启用(控制是否在 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)。
内置 /help 在 registerBuiltinCommands() 注册(plugin=system),列出所有顶级命令;/help <cmd> [sub...] 列出子命令与用法。
插件卸载时 UnregisterPlugin(name) 递归清理该插件注册的所有命令并修剪空叶子。
注入的 Lua 全局表
按 permissions 字段 gated,由 injectBaseAPI(pluggin.go:973)注入。完整签名见 插件开发指南。
| 全局表 | 权限 | 说明 |
|---|---|---|
log | 始终 | info/warn/error → slog [plugin:<name>] 前缀 |
json | 始终 | encode/decode |
onebot11 | onebot11 | 21 个 OneBot11 API(SendAdapter 接口桥接) |
http | http | get/post,30s 超时真实 HTTP |
database | database | query/exec(共享 DB;prefixSQL 桩未生效,⚠ 任意 SQL) |
cache | cache | get/set/del/exists(pluggin:<name>: 前缀命名空间) |
t2i | t2i | generate / generate_url + toggle/is_active/get_config |
sandbox | sandbox | create/exec_shell/exec_python/list/delete + toggle/is_active/get_config |
agent | agent | 配置查询 + Provider/MCP/Tool 切换 + switch_provider + compact_memory |
jn.command | 内置 | 命令注册 |
Lua SDK(jn.lua)
由 Go 二进制内嵌(//go:embed sdk/jn.lua,pluggin.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:前缀。 - Database:
database.query/exec跑在共享库上,prefixSQL桩当前未应用pluggin_<name>_前缀;请谨慎授database权限(⚠ 任意 SQL,可在插件侧加自己的表前缀)。 - 插件配置:
data/pluggins/<name>/pluggin.yaml在磁盘,不进 DB(除非 DBplugins表存元数据镜像)。
安全建议
- 仅对受信插件授予
database权限 - 对从社区上传的 ZIP 插件先审阅 Lua 源码再 Deploy
- 系统插件
system提供/system provider switch、/system memory compact等管理命令,需要 admin 操作(受 ACL 与 OneBot11 Adapters 的 Admins 双重保护)