一个基于 Ncatbot 的 QQ 机器人插件,用 LangGraph 自建图管线把 QQ 对话接给 LLM,让 QQ 消息驱动模型思考、回复并调用工具。
- 版本:0.28.0
- 入口:
main.py(插件类MiaoLiBot) - 运行载体:Ncatbot 插件系统(NapCat/OneBot 协议)
- LLM 客户端:
langchain-openai的ChatOpenAI(base_url指向任意 OpenAI 兼容端点) - 阶段:插件化拆分完成,当前处于优化态 —— 现状与已知短板见项目状态
- 默认跳转:edgeless 拓扑下「下一个事件是谁」由处理器主动声明,
base_topology子插件提供默认顺序(一个事件一个节点文件,每个只返回一条Goto),并承担ON_AFTER_REQUEST的分支判断(末条有tool_calls→ 工具循环,否则 → 收尾),以及压缩三段的默认路径(ON_BEFORE_COMPACT默认不压缩直接去ON_BEFORE_REQUEST,ON_AFTER_COMPACT跳回ON_TURN_START)。它注册在最低优先级MINIMUM,任何子插件挂在任何更高档位都能抢在它之前改道或叫停
- 🧩 事件管线(edgeless 拓扑,已独立成包):
GraphPipeline由独立依赖miaoli_pipeline提供(泛型GraphPipeline[StateT, ContextT, InputT, OutputT],四个 TypeVar 取自langgraph.typing),本仓库只保留事件名与注册门面 —— 11 个图事件常量集中在consts/graph_events.py,节点用register(event, node, priority=0)显式挂载(同一事件可挂多个,按priority降序执行,priority大者先跑;档位常量是consts/node_priorities.py的九档等比阶梯MAXIMUM(9999) …NORMAL(0) …MINIMUM(-9999))。wire()不定义任何事件顺序,只挂事件节点 + 一条入口边(START → ON_AGENT_START);「下一个事件是谁」由处理器主动返回跳转指令决定:return {...}累积增量并继续本事件剩余处理器、Continue(updates=…)停止本事件剩余处理器、Goto(goto=…, updates=…)跳到指定事件、Abort(reason=…, updates=…)中止整轮图。谁挂在哪个事件上、谁跳向哪里,都由子插件注册时声明。 - 🔌 子插件系统:
core.PluginLoader扫描sub_plugin.load_from下带plugin.toml的子目录,把每个子目录注册成 Python 包后实例化入口类并调on_load();子插件通过core.Registry注册工具 / 解析器 / 图节点;卸载时on_close()并把整包从sys.modules抹掉,改完代码重载立即生效。 - 🧠 LLM 接入:
main.py只把event写进 state;miaoli_bot_adapter子插件的inject_model_info节点(ON_TURN_START,priority=NORMAL+1)从根配置取出base_url/api_key/model/max_tokens四个值写进 state,同事件的inject_soul(同优先级)把prompt_file的人设全文写进prompt的soul段,随后build_client节点(priority=NORMAL)用这四个值建ChatOpenAI写进runtime.context["client"](刻意不在建实例时绑工具 —— 工具表按身份每请求变化);call_llm节点用runtime.context["tools"](来自ToolRegistry)bind_tools后请求模型;工具循环由base_topology的on_after_request节点分流 —— 末条消息有tool_calls就跳ON_TOOL_CALLING执行工具、再回到ON_TURN_START重走一轮;没有就跳ON_TURN_END收尾。 - 🗜️ 上下文压缩(三段事件):压缩不再是藏在
on_before_request里的一个if,而是图上三个有名字的事件 ——ON_BEFORE_COMPACT挂守卫should_compact(context_compactor子插件,priority=NORMAL)算 token 判阈值,需要压就Goto(ON_COMPACT)、不需要就Goto(ON_BEFORE_REQUEST);ON_COMPACT挂压缩compact,把历史压成一条<abstract>摘要并用Overwrite整体替换;ON_AFTER_COMPACT的默认跳转回到ON_TURN_START,于是压缩完重新走一轮(这一轮守卫命中<abstract>标志后Abort,本轮结束)。阈值按模型真实context_window/max_tokens算(为输出预留空间),保留条数 / 提示词 / 计数方式(base粗估或tiktoken精确)全部配置化,提示词落在data/compact_prompt.md。 - 📝 提示词分段拼装:
prompt不是一整个字符串,而是Dict[str, str]—— 各子插件写各自的段({"identity": …}/{"style": …}),写入None表示删除该段。prompt_builder子插件的build_prompt节点(ON_BEFORE_REQUEST,priority=NORMAL+1)把全部段拼成<key>value</key>形式,用Overwrite放到messages[0];内容与现有首条SystemMessage完全相同时返回None,不白写一次Overwrite。 - 📜 状态与运行时分离:
models.GraphState(会话状态:messages用add_messages、prompt用SectionPatch,两者进 checkpoint;event/model/base_url/api_key/max_tokens/final_answer六键用Annotated[T, UntrackedValue]—— 单次ainvoke内可见、不落盘)与models.GraphRuntimeContext(不进 checkpoint 的运行时依赖:client/tools)分离 ——context不做浅拷贝、结果保持原引用,所以 LLM 客户端 / 锁这类不可序列化对象只能放context(放 state 会因 msgpack 无法序列化而当场崩)。 - 🎛️ 配置模型化(分层 + 更名):
PluginConfig含providers(List[Provider],每项用name字段标识 ——Provider有name/base_url/api_key/protocol/models,而models是List[LLM]、LLM有name/context_window/max_tokens/support_visions)、provider/model(两个名字,由model_validator校验确实存在于providers里)、account(bot_id/admin_id/bot_nickname/admin_nickname,四项全必填)、split、typing、checkpointer、sub_plugin与prompt_file/prompt。 - 🛠️ 工具调用闭环:工具统一经
ToolRegistry注册、由ToolNode执行,返回值统一是{"status": bool, ...}字典;主包不再内置任何工具,平台 / 系统 / meme / 联网搜索工具全部由内置子插件注册;tool_permission_manager子插件可在此基础上按身份做工具级权限控制(on_before_request摘runtime.context["tools"],未声明即拒绝)。 - 🔍 联网搜索:
web_search子插件注册一个web_search工具(工具名与后端无关),adapter选后端 ——langsearch调 langsearch.com 的POST /v1/web-search,deepseek调 DeepSeek 的POST /v1/tools/search,原始响应整体交给模型。adapter只影响工具的入参 schema 与返回数据形状,不影响工具名。实测英文检索质量明显优于中文(技术 / 包名类相关率约 65% vs 30%),工具 docstring 已要求模型优先用英文搜。 - 🖼️ 工具附件兜底:部分模型不接受
tool消息携带多模态块(无论块里是什么内容都会400或静默忽略),tool_attachment_fixer子插件的fix_tool_attachment节点(ON_TOOL_CALLING,priority=NORMAL-2)把非字符串的ToolMessage.content整体挪进紧跟其后的一条HumanMessage、原ToolMessage正文换成replace_text;只对models里声明的模型生效,无改动时返回None不写增量。 - 🧩 两级解析链:
EventParseChain(群 / 私聊事件元数据)与SegmentParseChain(文本 / AT / 图片 / 文件 / 引用消息段)双链分发,由ob11_adapter子插件的event_adapter节点调起;解析器完全吃字典,不 import ncatbot 任何类。 - 🖼️ 多类型消息段:
text→{text}、at→{at}、image→{image, size, sub_tp}、file→{file, size}、reply→{reply};解析器按段字典的type判别。 - 🔧 协议先行:
protocols/abc/定义编译期抽象(PluginProtocol/ChainProtocol/StoreProtocol/BaseCheckpointerSaverAdapter)。 - 🗄️ checkpointer 适配层:
checkpointer.database在memory/sqlite/postgresql之间切换 checkpoint 存储 ——adapters/check_pointers/的三个适配器把InMemorySaver/AsyncSqliteSaver/AsyncPostgresSaver收拢成同一门面(protocols.BaseCheckpointerSaverAdapter:1:1 转发BaseCheckpointSaver的同步 / 异步协议方法,connect(connect_to, **extra)建连、close()释放),main.py的CPA_MAPPING按database取类,on_load注入图、on_close统一关闭。 - 💾 共享存储:
stores/SHARE_STORE全局共享容器;core 的键在consts/share_store_keys.py(TOOL_REGISTRY/NCATBOT_API/PLUGIN_CONFIG/RAW_CONFIG/PLUGIN_DIR/WORKSPACE_DIR/GRAPH_PIPELINE),子插件用自带命名空间键(如ob11_adapter的EVENT_PARSER/SEGMENT_PARSER/REGISTRY)。 - 🚪
api/门面层:子插件不直接碰SHARE_STORE,统一从miaoli_bot.api取东西 —— 账号信息(get_admin_id等)、存储操作(store_recall/store_set/store_drop/store_update)、运行时句柄(get_ncatbot_api)、配置派生(get_model_info/get_prompt)、发送封装(easier_send等)。好处是把「键名 + 取值路径 + 类型」这些内部细节锁在门面层里,子插件只依赖函数签名。 - 🔔 事件优先级让位:
on_message以priority=-100注册,排在后处理的位置 —— 扩展插件可用更高优先级抢先接收并停止事件传播(如miaoli_like的赞我用priority=100),被上游截下的消息不会再进入 LLM。 - 🎭 角色扮演:内置猫娘「喵璃」人设提示词(
data/prompts/prompt_v1.7.md,另有 v1.0 ~ v1.6 历史版本);account_injector子插件的inject_account节点把人设与管理员 / 机器人的 QQ 号、昵称拼成提示词块,一起写进系统提示词。 - ⏱️ 输出切块与打字延迟:
on_message按根配置split(enable/delimiter)把回复切块,跳过空块;若typing.enable,每块按len(块) * uniform(base_speed ± speed_offset)sleep 后再发送,模拟真人在打字。发送由main.py负责(首块event.reply,其余easier_send)。 - 🧪 本地测试(待重写):
tests/目录(gitignore,不随仓库提交)里的用例针对的是 pi 桥接层,随 0.8.0 重构整体失效。
QQ / NapCat (OneBot)
│ ncatbot 事件
▼
main.py MiaoLiBot(NcatBotPlugin) ← config.yaml → models.PluginConfig(providers / provider / model / account / split / typing / sub_plugin / …)
│ ① event._data.model_dump() + concatenate_id → session_id(thread_id)
│ ② 群聊还要 @ 到 bot_id,否则跳过
▼
miaoli_pipeline.GraphPipeline.ainvoke({"event"}, thread_id=session_id, context=…)
│
│ START
▼
ON_AGENT_START ── event_adapter(ob11_adapter:解析 event → event_data,回填 state["event"],JSON 追加进 messages)
│ inject_account(账号信息块 → prompt 的 account_info 段)
▼
ON_TURN_START ── inject_model_info(根配置 → base_url / api_key / model / max_tokens)+ inject_soul(根配置 → prompt 的 soul 段)
│ build_client(用 state 里那四个值建 ChatOpenAI → context["client"])
▼
ON_BEFORE_COMPACT ── should_compact(算 token 判阈值)
│ ├─ 不需要压缩 ──────────────────────┐
│ └─ 需要压缩 → ON_COMPACT ── compact(压成 <abstract> 摘要,
│ Overwrite 替换 messages)
│ └─▶ ON_AFTER_COMPACT ── 跳回 ON_TURN_START
▼◀──────────────────────────────────────────────────┘
ON_BEFORE_REQUEST ── build_prompt(prompt 各段 → SystemMessage 放 messages[0])→ pick_tools(按身份裁 tools)
▼
ON_REQUEST ── call_llm ──▶ bind_tools(tools) → ChatOpenAI
│ │
│ ▼
ON_AFTER_REQUEST ── base_topology: on_after_request(末条有 tool_calls?)
│
├─ 有 tool_calls ──▶ ON_TOOL_CALLING ── invoke_tools(ToolNode → ToolMessage)
│ └── 回到 ON_TURN_START(重走一轮)
│
└─ 无 tool_calls ──▶ ON_TURN_END ──▶ ON_AGENT_END ── output_checker(校验末条是 AIMessage)
└── latest_to_answer(messages[-1] → final_answer)
▼
END
│ final_state["final_answer"](按 split.delimiter 切块 + typing 延迟)
▼
main.py event.reply / utils.easier_send(api, …) ──▶ QQ
miaoli_bot/
├── main.py # 插件入口:MiaoLiBot(建容器 + 建图 + 起子插件加载器)
├── manifest.toml # 插件清单(name / version / entry_class / pip_dependencies)
├── config.example.yaml # 配置模板(config.yaml 已 gitignore,内含密钥不提交)
├── __init__.py # 顶层只重导出 api / models 两个入口
├── adapters/ # 适配器
│ └── check_pointers/ # checkpointer 适配器:InMemory / SQLite / Postgresql
├── api/ # 子插件唯一入口(门面层,不直接暴露 SHARE_STORE)
│ ├── account_ops.py # get_admin_id / get_bot_id / get_admin_nickname / get_bot_nickname
│ ├── platform_ops.py # get_ncatbot_api(框架运行时句柄)
│ ├── store_ops.py # store_recall / store_drop / store_update / store_set / store_contains
│ └── sugar.py # get_model_info / get_provider_info / get_prompt(从根配置派生)
├── consts/ # 常量集中定义
│ ├── graph_events.py # 11 个图事件名(ON_AGENT_START / ON_TURN_START / ON_BEFORE_COMPACT / ON_COMPACT / ON_AFTER_COMPACT / …)
│ ├── node_priorities.py # 九档优先级阶梯(MAXIMUM 9999 … NORMAL 0 … MINIMUM -9999)
│ ├── id_prefix.py # 会话键前缀(group- / private-)
│ └── share_store_keys.py # SHARE_STORE 键名(含 TOOL_REGISTRY)
├── core/ # 子插件加载与注册门面(图管线本身已迁到 miaoli_pipeline)
│ ├── plugin_loader.py # PluginLoader:扫 plugin.toml → 注册成包 → 加载 / 卸载子插件
│ ├── registry.py # Registry:子插件的注册门面(工具 / 图节点),含全局单例 registry
│ └── tool_registry.py # ToolRegistry:按名字存工具(register / remove / tools)
├── data/prompts/ # 提示词资产(prompt_v1.0.md ~ prompt_v1.7.md)
├── errors/ # 异常定义(AgentAborted 已迁到 miaoli_pipeline.errors)
│ ├── base_bot_error.py # BaseBotError 基类
│ ├── pipeline_stop_dispatch.py # PipelineStopDispatch(已无消费方,待清理)
│ └── session_manager_closing_error.py # SessionManagerClosingError
├── models/ # 数据模型
│ ├── config/ # PluginConfig / Provider / LLM / Account / SplitConfig / TypingConfig / SubPluginConfig / CheckPointerConfig
│ ├── graph_state.py # GraphState(进 checkpoint 的是 messages 与 prompt;其余 6 键用 UntrackedValue 不落盘)
│ └── graph_runtime_context.py # GraphRuntimeContext(不进 checkpoint:client / tools)
├── plugins/ # 内置子插件(随仓库提交,加载目录由 sub_plugin.load_from 指定)
│ ├── account_injector/ # 账号注入节点(nodes/ + plugin.toml)
│ ├── base_nodes/ # 图节点(nodes/ + plugin.toml)
│ ├── base_system_tools/ # 系统工具(tools/ + models/ + consts/ + config.example.yaml + README)
│ ├── base_topology/ # 默认跳转(nodes/ 一个事件一个文件 + config.example.yaml)
│ ├── context_compactor/ # 上下文压缩(should_compact 守卫 + compact 压缩;nodes/ + utils/ + models/ + consts/ + data/ 提示词 + config.example.yaml + README)
│ ├── intent_recognition/ # 意图识别:按配置的规则匹配末条消息,命中就把对应提示词文件的内容拼成 intent_recognition 段(models/ + models/rules/ + protocols/ + nodes/ + utils/ + consts/ + config.example.yaml + README)
│ ├── mcp_adapter/ # MCP 适配:接入外部 MCP server 并把其工具注入工具表(models/transports + nodes/ + config.example.yaml + README)
│ ├── meme_ops/ # meme 工具 + 使用提示注入(tools/ + nodes/ + core/ + models/ + consts/ + config.example.yaml + README)
│ ├── miaoli_bot_adapter/ # 内部数据适配:根配置 → state 与 prompt(inject_model_info / inject_soul;nodes/ + models/ + consts/ + config.example.yaml + README)
│ ├── ob11_adapter/ # OB11 事件适配:两级解析器 + event_adapter(parsers/ + chains/ + api/ + nodes/ + models/ + consts/ + config.example.yaml + README)
│ ├── orphan_tool_fixer/ # 孤儿工具返回修复(nodes/ + utils/ + config.example.yaml)
│ ├── pending_tool_fixer/ # 悬空工具调用修复(nodes/ + utils/ + models/ + consts/ + config.example.yaml)
│ ├── prompt_builder/ # 提示词拼装:把 prompt 各段拼成 SystemMessage 放到 messages[0](nodes/ + README)
│ ├── qq_platform_tools/ # QQ 平台工具(tools/ + README)
│ ├── tool_attachment_fixer/ # 工具附件修复(nodes/ + utils/ + models/ + consts/ + config.example.yaml + README)
│ ├── tool_permission_manager/ # 工具权限控制(nodes/ + models/ + consts/ + config.example.yaml 权限表 + README)
│ └── web_search/ # 联网搜索(tools/ + models/ + consts/ + config.example.yaml + README)
├── protocols/ # 抽象协议
│ └── abc/ # 编译期抽象:PluginProtocol / ChainProtocol / StoreProtocol / BaseCheckpointerSaverAdapter
├── stores/ # 全局共享存储(SHARE_STORE)
├── tests/ # 本地 pytest 用例(gitignore,不提交;当前待重写)
└── utils/ # 工具函数
├── easier_sender.py # private / group 便捷发送封装
└── sugar.py # concatenate_id(会话键加 group- / private- 前缀)
- QQ 消息经 NapCat → ncatbot → 按注册优先级分发给各插件:本插件以
priority=-100排在后处理位置,上游插件若停下事件传播(如miaoli_like的赞我用priority=100)本插件就收不到;群聊消息还必须 @ 到plugin_cfg.account.bot_id。 on_message先算is_group/chat_id/session_id,再把event._data.model_dump()当作input["event"]传进图;解析在图内由ob11_adapter子插件的event_adapter完成(走EventParseChain/SegmentParseChain)。session_id = concatenate_id(target_id, is_group=is_group)(群 →group-<group_id>,私聊 →private-<user_id>),作为 LangGraph 的thread_id—— 同一会话共享同一个 checkpointer(由checkpointer.database选中的 memory / sqlite / postgresql 实现)里的 checkpoint,跨轮记忆不串台。graph_pipeline.ainvoke({"event": event._data.model_dump()}, thread_id=session_id, context={"client": None, "tools": tool_registry.tools})—— 入参只带event,模型相关的四个值在图内由inject_model_info从根配置取。ON_AGENT_START的event_adapter(ob11_adapter子插件,priority=HIGH)把state["event"](事件字典)交给解析链 →event_data,回填state["event"](供pick_tools读sender.user_id),再序列化成 JSON 包成HumanMessage追加进messages;同事件的inject_account(account_injector子插件)把账号信息块写进prompt的account_info段(SectionPatch通道:按 key 打补丁,各插件写各自的段、互不覆盖);ON_TURN_START先跑inject_model_info(miaoli_bot_adapter子插件,priority=NORMAL+1)把根配置里的base_url/api_key/model/max_tokens写进 state(同样是UntrackedValue、不落盘),再由build_client(priority=NORMAL)用这四个值建ChatOpenAI写进runtime.context["client"]—— 优先级保证inject_model_info一定先跑。ON_TURN_START之后进入压缩三段:ON_BEFORE_COMPACT的守卫should_compact(context_compactor子插件,priority=NORMAL)算 token 判阈值 —— 不需要压缩就Goto(ON_BEFORE_REQUEST)直接往下走;需要压缩就Goto(ON_COMPACT),compact节点把历史压成一条<abstract>摘要并返回{"messages": Overwrite([摘要, *保留窗口])}整体替换历史(Overwrite是 langgraph 官方的「绕过 reducer 直接写入」包装,merge_into_updates折叠多个处理器的产出时保留它、merge_into_state构建视图时消费它,因此「整体替换」与其它处理器的「追加 / 定向删除」可以任意顺序组合),再由ON_AFTER_COMPACT的默认跳转回到ON_TURN_START重走一轮 —— 这一轮守卫会命中messages里的<abstract>标志并Abort,本轮到此结束(AgentAborted冒到ainvoke调用处,由main.py转成一条提示回复)。ON_BEFORE_REQUEST批次(按 priority 降序)先跑build_prompt(priority=NORMAL+1,prompt_builder子插件)把prompt的各段拼成<key>value</key>并用Overwrite放到messages[0],再跑pick_tools(priority=1)按config.yaml权限表与event.sender.user_id摘掉无权使用的工具(未声明的工具默认拒绝并打 warning)、随后ON_REQUEST先跑pending_tool_fixer(priority=2)给悬空的tool_calls补上合成返回、orphan_tool_fixer(priority=2)删掉找不到发起者的ToolMessage(两者只在上一轮异常中断留下坏形状时才有产出,历史本就合法时直接返回None、不写任何增量),最后由call_llm直接把state["messages"](首条已是build_prompt放好的SystemMessage)交给模型,返回的AIMessage追加进messages。ON_AFTER_REQUEST由base_topology的on_after_request节点分流(它注册在最低优先级MINIMUM,因此任何插件都能抢在它之前改道):末条消息带tool_calls→ 跳ON_TOOL_CALLING,由invoke_tools用ToolNode执行工具、ToolMessage追加进messages,若工具结果是非字符串内容(多模态块列表),由tool_attachment_fixer子插件把它整体挪成一条HumanMessage(原ToolMessage正文换成replace_text,只对models里声明的模型生效),然后跳回ON_TURN_START重走一轮(重跑build_client/pick_tools/call_llm);无tool_calls→ 跳ON_TURN_END,再经on_turn_end跳ON_AGENT_END,最后on_agent_end返回Goto(END)收尾(END被 langgraph 过滤,于是没有下一跳)。ON_AGENT_END的latest_to_answer取messages[-1],是AIMessage就把content写进final_answer(否则回退成携带thread_id的提示文案)。on_message拿final_state的final_answer(用.get()兜底 —— 该键是UntrackedValue,本轮没走到on_agent_end时不存在),按根配置split(enable/delimiter)切块并跳过空块;若typing.enable,每块按len(块) * uniform(base_speed ± speed_offset)sleep;首块用event.reply,其余用utils.easier_send发回 QQ。- 插件卸载(
on_close):先显式drop掉已知键(含TOOL_REGISTRY/EVENT_PARSER/SEGMENT_PARSER/GRAPH_PIPELINE),再plugin_loader.unload_all()卸载子插件(逐个on_close()并从sys.modules抹掉整包),最后await self.cpa.close()显式关闭 checkpointer(sqlite 关aiosqlite连接、postgresql 关连接池、memory 无副作用),残留键只记 warning。
主包只保留 checkpointer 适配层、core.Registry 注册门面与子插件加载框架(图管线在独立包 miaoli_pipeline 里),业务能力由子插件提供。以下十七个内置子插件位于本仓库的 plugins/ 目录,plugin.toml 与配置模板 config.example.yaml 随仓库提交;各部署环境的真实 config.yaml 一律 gitignore(含根目录),部署时从模板复制一份再改:
| 子插件 | 提供 |
|---|---|
account_injector |
账号注入:inject_account 在 on_agent_start 把管理员 / 机器人的 QQ 号与昵称拼成 account_info 段写进 prompt |
base_nodes |
图节点:build_client / call_llm / invoke_tools / latest_to_answer / output_checker(on_agent_end 校验末条是 AIMessage) |
base_system_tools |
系统工具:bash / read_file / write / replace / read_image(自带 config.yaml 与 README.md)。read_image 按 support_image 名单过滤格式 —— 这道过滤是为了限制读取(不做的话读什么都会被 b64 成图片塞进上下文,导致 400),不是给模型过滤不同类型的图片 |
base_topology |
默认跳转(edgeless 拓扑的顺序来源):一个事件一个节点文件,11 个节点各返回一条 Goto,把旧 wire() 的静态边与 tools_condition 条件边在插件层复原;on_after_request 按 is_tool_calling(messages) 分流工具循环与收尾;压缩三段也在这里兜底(on_before_compact 默认不压缩 → ON_BEFORE_REQUEST,on_after_compact 跳回 ON_TURN_START)。注册在最低优先级 MINIMUM,可被任何子插件抢跑 |
context_compactor |
上下文压缩,拆成守卫 + 压缩两个节点:should_compact 挂 on_before_compact(priority=NORMAL)读模型真实 context_window / max_tokens 判阈值,返回 Goto(ON_COMPACT) 或 Goto(ON_BEFORE_REQUEST);compact 挂 on_compact,把历史压成一条 <abstract> 摘要并用 Overwrite 整体替换。保留条数 / 提示词 / 是否回填系统提示词 / 计数方式全部配置化(base 粗估或 tiktoken 精确),自带 data/compact_prompt.md 与 README.md |
intent_recognition |
意图识别:recognize_intent 挂 on_before_request(priority=NORMAL+2,必须早于 prompt_builder 的 NORMAL+1 —— 数值大者先跑),按配置的 matchers 逐条匹配末条消息的正文 —— 一个 matcher 的多条 rule 是**「与」关系**(all()),命中就按 encoding 读 file,以 name 为键;全部命中项最后合成一个 intent_recognition 段(name 只是段内子标签)。四种命中方式以 mode 为判别字段组成判别式联合:single_keyword(单关键字子串)/ any_keywords(任意一个命中)/ min_keywords(至少命中 count 个)/ regex(re.search)。多模态正文由 get_content 提取,只收带 str 型 text 键的字典块与裸字符串块,图片块 / tool_use 块跳过(避免 base64 data URL 进匹配文本)。单个 matcher 失败只失效自己并打 ERROR 日志;enable 默认 false。自带 README.md |
mcp_adapter |
MCP 适配:recall_mcp_tools 在 on_before_request(priority=MEDIUM)用 MultiServerMCPClient 拉取外部 MCP server 的工具,按名去重后合并进 runtime.context["tools"];必须排在 pick_tools(priority=1)之前,否则 MCP 工具会绕过权限过滤。支持 stdio / streamable_http 两种传输,每个 server 可选 reusable(工具列表稳定则首次拉取后进缓存,由 MCPClientRegistry 管理)。自带 README.md。未完成:只做去重不做下架 |
meme_ops |
meme 工具:归档 / 发送 / 按标签搜索 / 按 hash 删除;inject_doc 在 on_agent_start(priority=NORMAL)把 data/meme_helper.md 的内容写进 prompts 的 meme_helper 段,inject_memes 同事件把已归档表情包的 hash: description 列表写进 available_memes 段,模型无需再调工具列举;inject_doc 给出归档前的四条判据;enable: false 时工具与节点都不注册;自带 README.md 说明隐私风险 —— 模型可能把用户发的普通图片误归档为表情包 |
miaoli_bot_adapter |
内部数据适配:两个节点都挂在 on_turn_start(priority=NORMAL+1):inject_model_info 调 miaoli_bot.api 的 get_provider_info / get_model_info 把 base_url / api_key / model / max_tokens 写进 state,供 build_client 直接消费;inject_soul 把根配置 prompt_file 的人设全文写进 prompt 的 soul 段,供 prompt_builder 拼装。图节点因此不必知道根配置的 providers 是列表还是字典。必须排在 build_client(NORMAL)之前。自带 README.md |
orphan_tool_fixer |
坏历史修复:fix_orphan_tool_message 在 on_before_request(priority=2)删掉找不到发起 tool_calls 的 ToolMessage(只产出定向删除增量) |
pending_tool_fixer |
坏历史修复:fix_pending_tool_call 在 on_before_request(priority=3)给悬空的 tool_calls 补一条「工具未执行」的合成 ToolMessage(返回 Overwrite 整表替换) |
ob11_adapter |
OB11 事件适配(纯 adapter,只解析不发送):event_adapter 在 on_agent_start(priority=HIGH)把 ncatbot 事件字典解析成 event_data,回填 state["event"] 并把 JSON 追加进 messages;两级解析链(parsers/)完全吃字典、不 import ncatbot 任何类。发送由 main.py 负责。自带 config.yaml 与 README.md |
prompt_builder |
提示词拼装:build_prompt 在 on_before_request(priority=NORMAL+1)把 state["prompt"] 的各段拼成 <key>value</key>,用 Overwrite 放到 messages[0];内容与现有首条 SystemMessage 相同时返回 None 不写增量。自带 README.md |
qq_platform_tools |
QQ 平台工具:发消息 / 发文件 / 下载文件 / 查消息 ID / 撤回消息 / 戳一戳(send_poke,group_id 有值为群内戳、无值为私聊戳)。自带 README.md |
tool_attachment_fixer |
工具附件修复:fix_tool_attachment 在 on_tool_calling(priority=-2)把非字符串的 ToolMessage.content 挪进紧跟其后的一条 HumanMessage(原 ToolMessage 正文换成 replace_text),让不接受 tool 消息带多模态块的模型不再 400;只对 models 里声明的模型生效(留空 = 全部),无改动时返回 None。自带 README.md |
tool_permission_manager |
工具权限控制:按 config.yaml 的权限表(admin / white_list / anyone / passerby / nobody 五种),在 on_before_request(priority=1)把当前身份无权使用的工具从 runtime.context["tools"] 摘掉;未声明的工具默认拒绝并打 warning(debug: false 可关日志,不影响判定)。自带 README.md |
web_search |
联网搜索:注册一个 web_search 工具(工具名与后端无关),adapter 选后端 —— langsearch(POST /v1/web-search)或 deepseek(POST /v1/tools/search),原始响应整体交给模型。adapter 只影响工具入参 schema 与返回数据形状,不影响工具名。自带 README.md |
orphan_tool_fixer与pending_tool_fixer成对:前者治「有返回没请求」,后者治「有请求没返回」。两者互不依赖、各自独立判断,谁先跑都不影响结果(accumulate_data保证不同形态的增量可以任意顺序折叠);触发场景是上一轮工具执行中途异常/并发冲突导致消息形状坏掉,此后每轮请求模型都会400 insufficient tool messages following tool_calls。
子插件约定:目录下放 plugin.toml(enter_class / enter_file)与入库的配置模板 config.example.yaml(真实 config.yaml 不入库);入口类继承 PluginProtocol,在 on_load 里用 Registry 注册工具 / 解析器 / 图节点,在 on_close 里释放资源。
- 把本目录整体移动到 ncatbot 创建的
plugins/目录下(如/root/Ncatbot_QQ/plugins/miaoli_bot)。这是 ncatbot 插件而不是 Python 包,不走 pip 安装 —— ncatbot 启动时扫描plugins/,见到manifest.toml就加载。 - 确认运行环境已安装依赖(
manifest.toml的pip_dependencies会在plugin.auto_install_pip_deps打开时自动安装):ncatbot(>=5.5.8)langgraph(>=1.2.12)langchain-core(>=1.6.5):@tool工具定义 /BaseMessage/BaseChatModel等基础类型langchain-openai(>=1.6.6)langgraph-checkpoint-sqlite(>=3.1.1,database: sqlite时用到)langgraph-checkpoint-postgres(>=3.1.2,database: postgresql时用到)aiosqlite(>=0.22.1)/psycopg(>=3.3.6)/psycopg-pool(>=3.3.3):直接 import 的驱动与连接池(SQLiteAdapter/PostgresqlAdapter/MemeSqlite)aiofiles(>=24.1.0):base_system_tools的write/replace工具异步写盘aiohttp(>=3.13.4):web_search的 HTTP 客户端(ClientSession)pydantic(>=2.13.5):配置模型(models/config/)与各工具的参数 schemapyyaml(>=6.0.3):子插件config.yaml解析(core/plugin_loader.py的yaml.safe_load)filetype(>=1.2.0)pillow(>=12.3.0)tiktoken(>=0.14.0)langchain-mcp-adapters(>=0.3.2):mcp_adapter的MultiServerMCPClient(langchain_mcp_adapters.client)mcp(>=1.24.0):MCP 协议 SDK,由上面那个包带入,此处显式声明miaoli_pipeline不在上面这一串里:pip_dependencies只接受「包名 + PEP 440 约束」,而它没上 PyPI,装不了。它是个普通 Python 包,得自己从本地仓库目录装一次 —— 见下方依赖一节的注
- 插件配置:
cp config.example.yaml config.yaml后填真实api_key(config.yaml含密钥、已 gitignore)。插件目录的config.yaml为默认值,全局config.yaml的plugin.plugin_configs.miaoli_bot同键覆盖;on_load第一步即PluginConfig.model_validate(self.config),必填项缺失 / 路径不存在 / 类型不符都会抛ValidationError,本插件加载失败并在日志留下 traceback(机器人其余部分不受影响)。
配置键名与 models.PluginConfig 的字段一一对应:
| 键 | 默认 / 必填 | 用途 |
|---|---|---|
provider / model |
必填 | 要用哪个供应商、哪个模型 —— 两个名字,model_validator 会校验它们在 providers 里确实存在 |
providers |
必填 | List[Provider],至少一项;每项用 name 字段标识供应商名 |
providers[].name |
必填 | 供应商名(provider 字段的取值) |
providers[].base_url |
必填 | 请求地址(不会拼路径,填到 /v1 这一层) |
providers[].api_key |
必填 | 鉴权密钥 |
providers[].protocol |
必填 | openai-completions / openai-responses |
providers[].models |
必填 | List[LLM],至少一项;每项用 name 字段标识模型名 |
providers[].models[].name |
必填 | 模型名(model 字段的取值) |
providers[].models[].context_window |
128000 |
上下文窗口 |
providers[].models[].max_tokens |
4096 |
最大输出 |
providers[].models[].support_visions |
必填 | 模型支持的模态输入,至少一项:image / video / text / audio |
account |
必填 | bot_id / admin_id / bot_nickname / admin_nickname,四项全必填;account_injector 子插件的 inject_account 用它拼提示词块,admin_id 也是工具权限插件判定管理员的依据 |
prompt_file |
null |
系统提示词文件路径,prompt 属性在首次访问时按 UTF-8 懒加载 |
split |
SplitConfig() |
enable(默认 true)/ delimiter(默认 null,输出切块分隔符)—— enable 为真时 delimiter 必须非空,否则校验报错;on_message 用它把回复切块 |
typing |
TypingConfig() |
enable(默认 true)/ base_speed(默认 0,每字打字秒数)/ speed_offset(默认 0,随机偏移)—— on_message 按 len(块) * uniform(base_speed ± speed_offset) sleep |
checkpointer |
CheckPointerConfig() |
database(memory / sqlite / postgresql,默认 memory)/ connect_to(数据库地址,非 memory 必填)/ extra(额外参数,透传给适配器的 connect(**kwargs);postgresql 认 max_size 默认 8 / min_size 默认 4 / autocommit 默认 true / row_factory 默认 dict_row)—— on_load 按 database 从 CPA_MAPPING 取适配器并 connect(0.9.0 起已接线),on_close 调 close() |
sub_plugin |
SubPluginConfig() |
is_enable(默认 false,当前未消费)/ load_from(子插件目录,Path);PluginLoader 扫描该目录下带 plugin.toml 的子目录并加载 |
- 启动 bot,在 QQ 中私聊或群聊(群聊需 @ 机器人)即可与模型对话。
cd <插件父目录> # plugins/
<venv>/bin/python -m pytest miaoli_bot/tests/ -v注:
tests/里的用例仍是针对 pi 桥接层写的,随 0.8.0 重构整体失效,待重写。
| 包 | 用途 |
|---|---|
ncatbot(>=5.5.8) |
QQ 机器人框架 / 事件与消息 API |
langgraph(>=1.2.12) |
图管线(StateGraph / Command / Overwrite / ToolNode / checkpoint) |
miaoli_pipeline(>=1.0.2) |
事件管线本体:GraphPipeline(register / wire / compile / ainvoke / 优先级编排)、BaseNodeAction(Continue / Goto / Abort)、SectionPatch 通道。未发布到 PyPI,只能从本地仓库目录安装,见下方注 |
langchain-openai(>=1.6.6) |
ChatOpenAI(可指向任意 OpenAI 兼容端点) |
langchain-core(>=1.6.5) |
@tool 工具定义、BaseMessage / BaseChatModel 等基础类型(主包与子插件都用) |
langgraph-checkpoint-sqlite(>=3.1.1) |
AsyncSqliteSaver(checkpointer.database: sqlite),带 aiosqlite / sqlite-vec |
langgraph-checkpoint-postgres(>=3.1.2) |
AsyncPostgresSaver(checkpointer.database: postgresql),带 psycopg / psycopg-pool |
aiosqlite(>=0.22.1) |
SQLiteAdapter 与 meme_ops.MemeSqlite 共用的异步 sqlite 驱动(连接分别由 close() / __aexit__ 关) |
aiofiles(>=24.1.0) |
base_system_tools 的 write / replace 工具异步写盘(aiofiles.open + async with) |
aiohttp(>=3.13.4) |
web_search 的联网搜索请求(ClientSession.post);它也是 ncatbot5 的传递依赖,此处显式声明 |
psycopg(>=3.3.6)/ psycopg-pool(>=3.3.3) |
PostgresqlAdapter 直接 import 的驱动与连接池(AsyncConnectionPool) |
pydantic(>=2.13.5) |
配置模型(BaseModel / Field / model_validator)与工具参数 schema |
pyyaml(>=6.0.3) |
core/plugin_loader.py 读子插件 config.yaml 的 yaml.safe_load |
filetype(>=1.2.0) |
base_system_tools 的图片类型嗅探(read_image) |
pillow(>=12.3.0) |
context_compactor 的图片宽高解析(image_to_tokens,据此估算图片 token) |
tiktoken(>=0.14.0) |
context_compactor 的精确 token 计数(calculate.mode: tiktoken) |
langchain-mcp-adapters(>=0.3.2) |
mcp_adapter 的 MultiServerMCPClient(拉取 / 调用外部 MCP server 的工具) |
mcp(>=1.24.0) |
MCP 协议 SDK(由 langchain-mcp-adapters 带入,此处显式声明以免上游依赖树变动时导入期 ModuleNotFoundError) |
注:以上依赖由
manifest.toml的pip_dependencies声明 —— 但miaoli_pipeline不在其中:pip_dependencies只接受「包名 + PEP 440 约束」,而miaoli_pipeline没有发布到 PyPI,写包名装不上,写 git URL 又会自动安装失败。它是个能安装的普通 Python 包,只能从本地仓库目录装一次:pip install /path/to/miaoli_pipeline装好后
import miaoli_pipeline即可用;它的唯一依赖是langgraph>=1.2,<2(已在上面声明)。 注:0.7.0 及以前依赖pi_bridge与外部 pi Agent 进程,0.8.0 起已整体移除。
本节只描述现在是什么样,不随版本累积 —— 每一版改了什么见 CHANGELOG.md。
已有形态
- 主包只剩三件事:checkpointer 适配层、
core.Registry注册门面与子插件加载框架。图管线本身已迁到独立包miaoli_pipeline;它未上 PyPI,部署时从 GitHub 装 - 业务全部由 17 个子插件提供(见内置子插件)。每个子插件自带
README.md与入库的config.example.yaml,真实config.yaml一律 gitignore,部署时从模板复制 - 端到端链路已在服务器上长时间运行:真实 QQ 消息 → OB11 事件字典 → 图管线 → LLM → 工具循环 → 回复
- 上下文压缩、工具权限、MCP 接入、联网搜索、表情包、意图识别都已接入,各自能独立开关(
enable)
已知短板
- 工具只有「谁能用」,没有「能干什么」:进程以 root 身份运行,
tool_permission_manager管的是工具可见性;bash/write/replace一旦放行,就没有文件系统边界 mcp_adapter只做去重不做下架:MCP server 撤掉的工具不会从工具表里消失- 压缩摘要是「摘要的摘要」:第二次压缩吃的是第一次压缩的产物,压缩次数一多细节必然流失,会话越长越明显
intent_recognition是纯字符串匹配:没有语义理解,判据只拿到末条消息的正文。「上一轮是不是我发的」「这张图算不算表情包」这类需要上下文或工具返回的条件,当前的is_hit(data)签名装不下