QQ Bot KIM:从零造一个人格化的 LLM Agent
NapCatQQ + OneBot V11 + Agent Loop + 多引擎搜索 + 6 层安全 + 3 层记忆——把一个 QQ 机器人变成有记忆有人格的 LLM Agent 的完整技术记录。
开头:一个让人抓狂的现状
我在的某个技术群里有个 bot。每次有人问问题,它要么沉默,要么丢一段牛头不对马嘴的回复——像是从某个 SEO 垃圾站随机抓了一段话拼上去的。
更让人崩溃的是,它没有记忆。你上一秒才告诉它"别刷屏",下一秒它又连发三条。完全没有上下文的概念。
我当时的想法很简单:一个群聊 bot,起码得像个人吧?
不是那种"您好,我是智能助手,请问有什么可以帮您的"的假人感。而是一个真的能在群里待着、该说话时说话、该闭嘴时闭嘴、还能记住前后文的"人"。
于是有了 KIM。
为什么叫 KIM
名字取自《极乐迪斯科》的金·曷城。
玩过这游戏的应该懂——金不是话痨,不刷存在感,从不抢你的风头。但他永远在你需要的时候给出精准的判断。他沉默、观察、在恰当的时候说恰当的话。
这就是我对 KIM 这个 bot 的人格设定。
不是那种"嗨大家好我是你们的智能助手"的油腻感。而是:群里有人问了个问题,它刚好知道答案,就平静地说一句。没人理它,它也不在意。它能记住你上一轮说过什么,自然地把对话延续下去。
人格不是加在系统上的装饰。人格是系统架构的产物。 这一点后面细说。
技术选型:为什么是 NapCatQQ + OneBot
不是 go-cqhttp
坦白讲,第一反应去看的是 go-cqhttp。生态成熟,文档多,社区活跃。但有个致命问题——它基于 QQ 协议的模拟登录,而腾讯对协议机器人的打击越来越严。go-cqhttp 在 2023 年底就已经停止维护了。拿一个停更的项目做基座,哪天协议一变,全线瘫痪。
不是 Mirai
Mirai 是 Kotlin 写的,协议层更底层,灵活性确实强。但我这个项目的定位是轻量群聊 bot,不是给 Mirai 再套一层 HTTP adapter。Kotlin 生态对我来说也是额外负担——我不想为了一个 bot 再维护一个 JVM 进程。
选了 NapCatQQ
NapCatQQ 的思路不一样:它不走协议模拟,而是基于 QQ 官方客户端的逆向装饰器。你装一个 QQ 3.2.21(NT 架构),NapCat 以插件形式注入,暴露 OneBot V11 标准的 WebSocket + HTTP 接口。
好处很明显:
- 协议层跟随官方更新——QQ 升级,NapCat 社区跟进,你不用动自己的 bot 代码。
- OneBot V11 是标准接口——接口定义稳定,社区工具链完善。换后端只需要换 adapter,业务代码基本不动。
- 部署简单——一个 npm 包 + 一个配置文件就搞定。
代价也有:你得跑一个完整的 QQ 客户端进程,占用明显比 go-cqhttp 重。但对我来说——22MB 内存,可以接受。
NapCatQQ → WS 6701 → bot.py (750 行)
架构拆解:750 行的 Agent Loop
整个 bot 的核心代码只有 750 行 Python。不是炫技,是真的不需要更多。
Agent Loop
核心逻辑是一个 agent_loop,配置参数极简:
MAX_TURNS=5, MAX_TOOLS=2
什么意思呢?每次用户消息进来,agent 最多思考 5 轮(思考→工具调用→观察结果→再思考),每轮最多调用 2 个工具。超过这个限制就收拢回复——防止 bot 在某个问题上无限递归。
说实话,这个限幅在实践中太重要了。不加限制的 agent loop 就是定时炸弹。 我们遇到过 bot 搜索一条天气信息然后搜上瘾了连着调 10 次搜索 API 的情况。
多引擎搜索
KIM 接了三类搜索/工具:
- DuckDuckGo:通用搜索,不依赖 API key
- get_weather:天气查询,轻量实用
- get_stock:股票查询,群友高频需求
技术实现上没啥好炫的——每个工具就是一个异步函数,注册到 agent 的工具箱,loop 自己去调度。
但有一个取舍值得聊:为什么选择 DuckDuckGo 而不是更"强"的搜索引擎?
答案很现实:因为没有 API key 的开销。 这个 bot 是我自己维护的,我不想再挂一个 SerpAPI 的账单。DDG 在中文搜索结果上确实不如百度,但对于群聊场景——答疑、解释概念、查天气——足够了。够用,不比完美差。
连续对话机制
群聊 bot 最大的技术难点不是"回答",而是**"什么时候该接话"**。
KIM 的触发逻辑是分层的:
- @KIM 100% 触发——点名了当然要回。
- 关键词匹配 3 倍覆盖权重——群里聊到代码、AI、某个特定话题时,KIM 自动介入。
- 分层概率触发:HIGH 50%, MED 35%, LOW 20%, 基础 30%。
- 自适应退避:短时间内多次触发会降权——防止 bot 跟人抢话。
连续对话更讲究:
- bot 回复后 2 分钟内,同一人再说话,bot 自动延续上下文。
- 跨人延续 30 秒窗口——A 问完 B 追问,bot 理解这是同一话题。
- QQ 的 reply 关系直接映射为对话上下文延续。
这些逻辑加起来大概 150 行代码。 但它是 KIM 显得"有智商"的关键。一个 bot 如果不能理解对话的连续性,那它就是失忆症患者——每句话都是新的开始。
打一句就独立成段
记忆系统。这是 LLM agent 的分水岭。
没有记忆的 bot 是金鱼。每句话都是这辈子第一次见你。
KIM 做了三层记忆:
-
Token 预算裁剪:context window 不是无限的。每次对话前,系统自动计算当前上下文的 token 数,超了就裁剪早期消息。这层是保命层——没有它,长对话会直接撑爆 context window。
-
Deque 短期缓存:最近 N 条消息保持在内存里,用 Python 的 deque 实现。群聊场景下消息量很大,不能什么都往长期记忆里写。短期缓存是缓冲层,只有真正重要的才落盘。
-
LLM 凝练长期记忆:每隔 1 小时,系统把 deque 中的关键信息交给 LLM 自己做一次凝练——用 max_tokens=4000 的预算,把过去一小时的内容压缩成几条核心记忆,写入 SQLite。
SQLite WAL 模式。零外部数据库。一个文件搞定所有持久化。
坦率地讲,这个设计不是最优的——真正的生产系统会用向量数据库做语义检索。但我的约束条件是零外部依赖。一个 pip install 就能部署的东西,不比微服务架构香?
所以呢——设计决策永远是为约束服务,而不是为完美服务。
6 层安全防御
群聊 bot 放在公网上,安全不是选项,是底线。
KIM 做了 6 层防护,从外到内:
| 层 | 措施 |
|---|---|
| 网络层 | ufw 防火墙,只放行 127.0.0.1 |
| 进程层 | systemd 沙盒 (ProtectSystem, NoNewPrivileges) |
| 认证层 | OneBot access token |
| 输入层 | 注入检测 (safety.py) |
| 工具层 | 搜索消毒 + 超时 10s |
| 输出层 | 去 markdown + 纯文本 |
网络层和进程层
这两个是基础设施级别的。ufw 配置 5 行,systemd 配置 10 行,但它们是第一道防线。没有这两层,后面再强的安全措施也白搭。
KIM 通过 systemd 的 ProtectSystem=strict 和 NoNewPrivileges=yes 限制了进程权限。即使 bot 被攻破,攻击者也拿不到系统权限。
输入层
injection_detection.py 是一个专门的模块。群聊是开放环境,任何人往群里发一条消息都可能带着 prompt injection。KIM 对所有输入都做了过滤——不是简单的关键词匹配,而是基于语义的模式检测。
说实话,这个模块还不够成熟。prompt injection 是一个持续的攻防竞赛,不是写一个函数就能一劳永逸的。
工具层
每个工具调用都有 10 秒超时。搜索 API 的返回结果会经过消毒——去除潜在的恶意内容。这一层的设计哲学是:宁可让 bot 说"我不知道",也不要让它说一句不该说的话。
输出层
去 markdown。纯文本。原因是 QQ 的渲染引擎对 markdown 支持很差,而且 markdown 注入本身也是一个攻击面。纯文本安全且兼容。
踩坑全记录
这部分我觉得是最有价值的。LLM 和 bot 相关的文章大多只讲"怎么搭",不讲"怎么炸"。
包名抢注:duckduckgo_search v9.14.4 是假的
(感受一下这个愤怒。)
pip install duckduckgo_search 后跑了一整天报错。查了半天,发现 v9.14.4 是一个恶意抢注的 metasearch 包,跟 duckduckgo 没有任何关系。正确的包是 duckduckgo_search>=8.1。
这个事儿给我上了一课:Python 包名不是标识符,是信任凭证。 PyPI 上同名抢注太常见了,装包之前看一眼版本号和发布时间是最基本的自卫。
WS 连上但收不到消息
NapCatQQ 的 WebSocket 连接显示成功了,但 bot 就是收不到任何群消息事件。
一夜排查后找到原因:ONEBOT_ACCESS_TOKEN 配置缺失。NapCat 的 WebSocket 接口在 access token 配置不一致时会静默拒绝——连接能建立(TCP 握手成功),但不会推送任何事件。
静默失败是最恶心的 bug 类型。 没有错误日志、没有 4xx/5xx、什么都没有。就是安静地什么都不做。
QQ 3.2.21 不支持 JSON 卡片
写代码的时候用了 build_link_card 来生成漂亮的链接卡片。上线后所有卡片都是空白。
QQ NT 架构(3.2.21)砍掉了自定义 JSON 卡片能力。这个在旧版 QQ 上能用的功能,新版直接废弃。
解决方案:全部砍掉,改回纯文本 + 链接。
Provider API 超时
LLM 的 provider API 在群聊场景下超时率很高。原因很直接——LLM 推理不是瞬时的,群聊高峰期多个请求排队。
默认的 25 秒超时太紧了,改到 60 秒。
后来又加了一层:如果 LLM 返回超时,bot 直接回复"系统繁忙,稍后再试",而不是僵在那里等。
部署:systemd 伺候
整个 bot 的生产部署就两个文件:一个 systemd unit 文件,一个 ufw 配置。
[Service]
ExecStart=/usr/bin/python3 /home/bot/kim/bot.py
Restart=always
RestartSec=10
ProtectSystem=strict
NoNewPrivileges=yes
内存占用 ~22MB。零外部数据库。一个 cron 每天凌晨 4 点重启一次——不是为了防内存泄漏,只是防"不知道什么东西跑久了会出事"。
NapCatQQ 配置了 NAPCAT_QUICK_PASSWORD_MD5 实现密码自动登录。bot 重启后 QQ 自动上线,不需要人工介入。
升维:给你 ChatGPT 的 API,不等于你能做个好 bot
这是最想说的。
技术栈不重要。Agent loop、记忆系统、安全防御——这些都是可以抄作业的。真正的分水岭是:你有没有想清楚你的 bot 在什么场景下、以什么身份、用什么方式存在。
很多人做 bot 的思路是"我有一个强大的 LLM,所以我做一个 bot 让它回答所有问题"。这是能力导向的思维,不是场景导向的思维。
KIM 的能力不是"回答一切"。KIM 的能力是"在群聊里做一个有用的人"。
两句话不一样。
当你把定位从"回答问题"变成"做一个有用的人"时,很多设计决策就自然浮现了:什么时候说话、什么时候不说话的判断,比"回答得多准确"更重要。安全比功能重要——一个说错话的 bot 比一个不说话的 bot 更有害。有记忆的 bot 不是功能增强,是身份认同的基础。
说到底,你造的不是一个 bot。你造的是一个数字人格。
而人格,从来不是技术问题。