从零搭建一只会聊天、会查资料、能操作飞书文档的 QQ「大肥鱼」AI 机器人
一台 Ubuntu 服务器、两个 Docker 容器、五个自写插件,把 DeepSeek、本地语音、飞书 CLI 和一个 Agent 运行时串成一条完整链路。 本文是完整复盘,包含可复制的命令、踩过的坑和最终架构。
一、前言:我们到底在做什么
目标很朴素:在 QQ 里养一只 AI 宠物。
但"会聊天"只是起点,真正难的是让它:
- 记得住上下文、查得到实时资料
- 听得懂语音、用特定音色回语音
- 能读写飞书云文档
- 能执行复杂任务(跑命令、写文件、多步研究)
- 还得有权限控制,不能谁都能指挥它
最终落地成一条链路:
一句话概括技术栈:
| 层 | 选型 |
|---|---|
| 消息通道 | NapCat(OneBot v11) |
| 对话中枢 | AstrBot 4.29.3 |
| 模型 | DeepSeek deepseek-flash |
| 语音识别 | 本地 Whisper small(CPU) |
| 语音合成 | Microsoft Edge TTS zh-CN-XiaoyiNeural |
| Agent | DeepSeek Harness dsh --profile headless |
| 文档 | 飞书官方 CLI @larksuite/cli |
二、服务器与基础环境
2.1 服务器
Ubuntu 24.04.3 LTS / 4 vCPU / 15 GB RAM / 885 GB 磁盘
Docker 29.4.1 + Compose v5.5
机器上本来就跑着 10 个生产容器(kodbox、官网、后台 demo 等)。 原则:只新增容器、只绑内网 IP、不重启 Docker daemon、不动现有服务。
2.2 拉镜像的第一道坎:Docker Hub 不通
国内服务器直连 registry-1.docker.io 基本超时。两种解法:
方案 A:改 daemon.json 加镜像加速(适合新机器,但需要重启 Docker)
json
{
"registry-mirrors": ["https://mirror.ccs.tencentyun.com"]
}
方案 B:不动 daemon,用镜像源前缀拉取再 retag(适合生产机器,强烈推荐)
bash
docker pull docker.m.daocloud.io/soulter/astrbot:latest
docker tag docker.m.daocloud.io/soulter/astrbot:latest soulter/astrbot:latest
我在生产机上用的就是方案 B ------ 重启 Docker 会把现有容器全部重启,风险不可控。
三、部署 AstrBot + NapCat
3.1 创建专用网络
bash
docker network create astrbot-napcat
3.2 启动 AstrBot
bash
docker run -d --name astrbot --network astrbot-napcat --restart always \
-p 192.168.0.70:6185:6185 \
-p 192.168.0.70:6199:6199 \
-v /home/docmap/astrbot/data:/AstrBot/data \
soulter/astrbot:latest
6185:WebUI(管理后台)6199:OneBot v11 反向 WebSocket 端口- 绑定的是内网 IP,不是 0.0.0.0,公网访问不到
3.3 启动 NapCat
bash
docker run -d --name napcat --network astrbot-napcat --restart always \
-p 192.168.0.70:6099:6099 \
-e NAPCAT_UID=1000 -e NAPCAT_GID=1000 \
-v /home/docmap/napcat/config:/app/napcat/config \
-v /home/docmap/napcat/qq:/app/.config/QQ \
mlikiowa/napcat-docker:latest
3.4 扫码登录
NapCat 启动后会在日志里打印二维码和 WebUI Token:
ini
WebUi User Panel Url: http://127.0.0.1:6099/webui?token=xxxxxxxx
二维码解码URL: https://txz.qq.com/p?k=...
用手机 QQ 扫码并在手机上点确认即可。
注意:NapCat 每次重启都需要重新扫码,登录态不持久。 所以后面对 AstrBot 做任何改动都只重启 AstrBot,不碰 NapCat。
3.5 打通反向 WebSocket
在 NapCat 的配置里加一个 WS 客户端:
json
{
"network": {
"websocketClients": [
{
"name": "astrbot",
"enable": true,
"url": "ws://astrbot:6199/ws",
"messagePostFormat": "array",
"token": "",
"heartInterval": 30000,
"reconnectInterval": 5000
}
]
}
}
AstrBot 侧在 cmd_config.json 里加 OneBot 平台:
json
{
"platform": [
{
"id": "default",
"type": "aiocqhttp",
"enable": true,
"ws_reverse_host": "0.0.0.0",
"ws_reverse_port": 6199,
"ws_reverse_token": ""
}
]
}
连上后日志会出现:
scss
aiocqhttp(OneBot v11) 适配器已连接。
四、接入 DeepSeek:模型与搜索
4.1 模型 Provider
json
{
"id": "deepseek-flash",
"provider": "deepseek",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": true,
"key": ["sk-你的Key"],
"api_base": "https://api.deepseek.com/v1",
"model": "deepseek-flash",
"provider_source_id": "deepseek",
"modalities": ["text", "image", "tool_use"]
}
4.2 联网搜索
DeepSeek 自带搜索接口,直接复用同一个 Key:
bash
curl -X POST https://api.deepseek.com/v1/tools/search \
-H "Authorization: Bearer sk-你的Key" \
-H "Content-Type: application/json" \
-d '{"query":"今天的 AI 新闻","count":3,"need_content":false}'
在 AstrBot 里开启:
json
{
"provider_settings": {
"web_search": true,
"websearch_provider": "deepseek",
"websearch_deepseek_key": ["sk-你的Key"],
"web_search_link": true
}
}
4.3 大坑:modalities 不写会 422
这是本文最值得记录的问题之一。
现象:
typescript
422 UnprocessableEntityError:
Failed to deserialize the JSON body into the target type:
messages[45]: unknown variant `input_audio`,
expected one of `text`, `image_url`, `file`
原因:AstrBot 的 context 清洗逻辑是------
python
modalities = provider.provider_config.get("modalities", None)
if not modalities:
# Unconfigured defaults to support all modalities
return contexts
也就是说:provider 不声明能力边界时,AstrBot 默认它什么模态都支持,于是把语音音频原样塞进请求,DeepSeek 直接 422。
解法 :显式声明,只给 text / image / tool_use,不给 audio:
json
"modalities": ["text", "image", "tool_use"]
这样即使语音转写失败,音频也会被替换成 [Audio] 占位文本,而不是让整个请求挂掉。
五、群聊唤醒控制:谁可以不用 @
5.1 需求
- 主人(指定 QQ)在群里说话,不需要 @
- 其他人在群里,必须 @ 机器人 或 叫它名字 才会回复
5.2 原理
AstrBot 的唤醒逻辑写死在 waking_check/stage.py,只认 @、@全体、引用、唤醒词。配置层没有"按人区分"的开关。
但代码里有一行关键逻辑:插件注册的处理器会把 is_wake 置为 True。于是可以:
- 把
wake_prefix设为空字符串[""],让所有群消息都进入处理管线 - 写一个插件做"门控":非白名单且没 @ 没叫名字的消息,直接
should_call_llm(False)
5.3 插件实现
python
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star, register
from astrbot.core.message.components import At, Reply
WHITELIST = {"136988xxxxx"}
NAME_KEYWORDS = ("大肥鱼", "肥鱼", "邪恶大肥鱼")
@register("wake_by_user", "you", "仅指定QQ免@", "1.0.0")
class WakeByUser(Star):
def __init__(self, context: Context):
super().__init__(context)
@filter.event_message_type(filter.EventMessageType.GROUP_MESSAGE)
async def gate(self, event: AstrMessageEvent):
sender = str(event.get_sender_id())
if sender in WHITELIST:
return
text = (event.message_str or "").strip()
if any(k in text for k in NAME_KEYWORDS):
return
self_id = str(event.get_self_id())
for seg in event.get_messages():
if isinstance(seg, At) and str(getattr(seg, "qq", "")) == self_id:
return
if isinstance(seg, Reply) and str(getattr(seg, "sender_id", "")) == self_id:
return
event.should_call_llm(False)
5.4 上下文记忆
群里只记录 @ 它的消息?那当然读不懂上下文。需要额外打开:
json
{
"provider_ltm_settings": {
"group_message_history_enable": true,
"group_message_history_max_cnt": 700
}
}
六、语音链路:本地识别 + 少女音回复
这是整个项目最折腾的部分,分四段讲。
6.1 本地 STT:Whisper 的安装陷阱
AstrBot 的 Whisper(Local) provider 依赖 openai-whisper,而它又依赖 torch。
坑 1:PyPI 的 torch 是 CUDA 版,体积近 1GB,国内下载极慢(实测 40 分钟只下了 390MB)。
解法:用阿里云的 CPU-only wheel(约 200MB,4.8MB/s):
bash
pip install --target=/AstrBot/data/pylibs --no-deps \
"https://mirrors.aliyun.com/pytorch-wheels/cpu/torch-2.6.0%2Bcpu-cp312-cp312-linux_x86_64.whl"
坑 2 :直接 pip install openai-whisper 会重新解析依赖,可能又去拉 CUDA 版 torch。
解法 :--no-deps 分开装,依赖项逐个指定国内源:
bash
pip install --target=/AstrBot/data/pylibs --no-deps -i https://pypi.tuna.tsinghua.edu.cn/simple \
openai-whisper
pip install --target=/AstrBot/data/pylibs -i https://pypi.tuna.tsinghua.edu.cn/simple \
filelock typing-extensions sympy networkx jinja2 fsspec numpy numba tiktoken more-itertools tqdm
坑 3 :--target 装的包,主进程默认 import 不到。
解法 :往系统 site-packages 塞一个 .pth 文件(比重建容器省事):
bash
printf '/AstrBot/data/pylibs\n/AstrBot/data/pylibs-tts\n' \
> /usr/local/lib/python3.12/site-packages/astrbot-extra.pth
6.2 识别不准?两个真凶
真凶一:语言自动检测跑偏。
Whisper 不指定语言时会自动判断,低音量/模糊时把中文识别成英语甚至瑞典语:
vbnet
Speech-to-text result: You thinkер y
Speech-to-text result: Så är jag.
解法:给 provider 打补丁,强制中文 + 引导提示:
python
result = await loop.run_in_executor(
None,
partial(self.model.transcribe,
language="zh",
initial_prompt="以下是普通话日常对话。"),
str(audio.path),
)
真凶二:QQ 语音音量太低。
实测用户语音平均音量 -36.9 dB(正常说话约 -20 dB),Whisper 直接输出空字符串。
解法 :转写前做响度归一化(loudnorm):
python
proc = await asyncio.create_subprocess_exec(
"ffmpeg", "-y", "-loglevel", "error",
"-i", src,
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
norm,
)
await proc.communicate()
target = norm if proc.returncode == 0 else src
效果对比:
| 音频 | 修复前 | 修复后 |
|---|---|---|
| 正常音量 | 正确 | 正确 |
| 音量降到 12% | 变成外语 | 仍然正确 |
| 实际 -36dB 语音 | 空字符串 | "我们就回来啦" |
兜底:如果归一化后还是空,返回一句提示文本,避免音频继续流向不支持音频的模型:
python
text = result["text"].strip()
return text if text else "(语音没听清,请再说一遍)"
6.3 Edge TTS:免费但需要联网
json
{
"id": "edge_tts",
"type": "edge_tts",
"provider": "microsoft",
"provider_type": "text_to_speech",
"enable": true,
"edge-tts-voice": "zh-CN-XiaoyiNeural",
"rate": "+0%",
"volume": "+0%",
"pitch": "+0Hz"
}
zh-CN-XiaoyiNeural(晓伊):少女音,偏"呆萌"zh-CN-XiaoxiaoNeural(晓晓):通用女声,更稳- Edge TTS 免费、无需 Key,但依赖微软接口,需要联网
想要完全离线的卡通女声,Linux 上可选项(GPT-SoVITS、Breezy 等)要么要 GPU,要么模型几 GB,性价比不高,最终选了 Edge。
6.4 语音门控:只在私聊、只在发语音时回语音
需求很明确:群里永远文字;私聊发文字回文字;私聊发语音才回语音。
AstrBot 的 TTS 开关是按会话的,所以写个插件在每条消息时动态设置:
python
@filter.event_message_type(filter.EventMessageType.ALL)
async def gate(self, event: AstrMessageEvent):
sender = str(event.get_sender_id())
enable = bool(
sender == OWNER
and event.is_private_chat()
and _has_voice(event)
)
await SessionServiceManager.set_tts_status_for_session(
event.unified_msg_origin, enable
)
关键坑 :AstrBot 的 STT 阶段会把 Record 组件替换成 Plain 文本 ,所以在插件阶段 isinstance(m, Record) 永远为 False。
解法:同时检查原始事件:
python
def _has_voice(event) -> bool:
# 1) 当前链路(STT 之前)
if any(isinstance(m, Record) for m in event.get_messages()):
return True
# 2) 原始 OneBot 事件(兜底)
raw = getattr(event.message_obj, "raw_message", None)
msg = getattr(raw, "message", None) or (raw.get("message") if isinstance(raw, dict) else None)
if isinstance(msg, list):
for seg in msg:
t = seg.get("type") if isinstance(seg, dict) else getattr(seg, "type", None)
if t == "record" or "record" in str(seg).lower():
return True
# 3) CQ 字符串形式
if isinstance(getattr(raw, "raw_message", ""), str) and "[CQ:record" in raw.raw_message:
return True
return False
6.5 TTS 把符号读出来了
模型回复里带了 emoji 和 markdown(咕噜......本鱼**最爱**吃宵夜🐟🍜),TTS 会老老实实把它们念出来。
解法:合成前清洗文本:
python
_EMOJI_RE = re.compile(
r"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\u2190-\u21FF\u2B00-\u2BFF\uFE0F\u200D]+"
)
_MD_RE = re.compile(r"[*_`#>~\[\](){}|\\]+")
_ELLIPSIS_RE = re.compile(r"...+|\.{3,}")
def _clean_for_tts(text: str) -> str:
t = _EMOJI_RE.sub("", text or "")
t = _MD_RE.sub("", t)
t = _ELLIPSIS_RE.sub(",", t)
return re.sub(r",{2,}", ",", t).strip(" ,\n")
实测:
bash
原文: 咕噜......本鱼**最爱**吃宵夜🐟🍜!还有 #标签 和 [链接]
清洗后:咕噜,本鱼最爱吃宵夜!还有 标签 和 链接
七、让机器人调用 Agent:DSH 接入
7.1 为什么用 headless
DSH(DeepSeek Harness)有一次性执行模式:
bash
dsh --profile headless "帮我查一下今天的 AI 新闻并总结"
# 执行完把结果打印到 stdout 然后退出
这正好适合"QQ 消息 → 子进程 → 拿结果"的模型。
7.2 容器内怎么跑 Node
DSH 需要 Node 24。服务器上装好后(约 120MB),把它只读挂载进 AstrBot 容器:
bash
docker run -d --name astrbot ... \
-v /opt/node:/opt/node:ro \
soulter/astrbot:latest
容器内验证:
bash
docker exec astrbot /opt/node/bin/dsh --version
# 0.2.1-rc.2
用官方源装 npm 包:
npm install -g @deepseek-ai/dsh --registry=https://registry.npmmirror.com注意 npm 11 会拦截 install scripts,需要--allow-scripts。
7.3 插件设计:白名单 + 审批
python
OWNER = "1369xxxxx"
DSH_BIN = "/opt/node/bin/dsh"
WORKDIR = "/AstrBot/data/dsh-work"
TIMEOUT = 600
PENDING = {} # 审批队列
LAST = {} # 上次任务上下文
# 触发词:dsh / 工具 / 消息里提到飞书
# 主人:直接执行
# 其他人:生成编号,私聊发给主人审批
审批流:
路人:工具 帮我写个脚本
机器人:已提交管理员审批(编号 1)
主人(私聊):同意 1
机器人 → 路人:执行结果
机器人 → 主人:【1 完成】摘要
7.4 几个工程细节
① 上下文续接
Agent 任务完成后,用户下一句"不对,应该放到表格里"会落到普通聊天模型,它完全不知道 DSH 干过什么。
解法:把"修正类关键词"(不对/表格/改成/应该/重新/补漏)在 30 分钟内自动带回 DSH,并附带上次任务与结果:
python
task = (
f"上下文:上一次任务『{LAST['task'][:300]}』;"
f"执行结果摘要:{LAST['result'][:600]}。"
f"用户现在的要求:{text}。请基于上下文直接执行。"
)
上下文还会持久化到 last_task.json,重启不丢。
② 超时保护
python
out, err = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT)
超时后 kill 进程,并把部分输出截断返回,方便定位卡在哪一步。
③ 给 Agent 加约束
任务提示里明确写"直接执行,不要探索无关文件、不要读技能文档、不要上网查资料",否则它会在 5 分钟里跑 60 多步"研究"。
八、飞书文档能力
8.1 官方 CLI,别手搓 API
飞书官方出了 larksuite/cli,把云文档、消息、日历都封装成命令,而且自带 OAuth 设备码流程,非常适合服务器这种无浏览器环境。
bash
npm install -g @larksuite/cli
lark-cli --version
8.2 无浏览器授权
bash
# 1. 初始化应用(会打印一个链接,浏览器打开创建应用)
lark-cli config init --new
# 2. 设备码授权(打印 verification_url,手机/电脑打开确认)
lark-cli auth login --domain docs,drive,wiki --no-wait
# 拿到 device_code 和 verification_url
# 3. 用户确认后完成
lark-cli auth login --device-code <device_code>
# 4. 验证
lark-cli auth status
授权成功后能看到:
vbnet
User identity: ready
openId: ou_xxx
userName: 张三
scope: docx:document:readonly docs:document:export drive:file:download ...
8.3 让 DSH 调用它
在 dsh_bridge 插件的子进程环境里注入:
python
env["HOME"] = "/AstrBot/data/lark-home" # lark-cli 的配置和 token 在这里
env["PATH"] = "/opt/node/bin:" + env.get("PATH", "")
然后 QQ 里直接说:
arduino
帮我整理这篇飞书文档,给三条要点:https://xxx.feishu.cn/docx/xxxx
插件识别到"飞书"关键词就自动转给 DSH,DSH 自己会选 lark-cli docs +fetch / drive +inspect 之类的命令。
九、插件生态:表情包与"防串台"
从社区配置包 dafeiyu-config-pack 里挑了三个不碰 QQ 敏感接口的插件:
| 插件 | 作用 |
|---|---|
meme_manager |
表情包管理,AI 按情绪自动发图,带 WebUI 管理页 |
astrbot_plugin_dafeiyu_clean |
清理模型偶尔吐出的 <system_reminder>、DSML 等内部标签 |
LivingMemory |
长期记忆,需要 Embedding 模型,暂未启用 |
长期记忆的坑:它要求配置一个向量嵌入 Provider(如 Ollama + bge-m3)。服务器上没有 GPU,Ollama 也要再拉 1.5GB+,权衡后先停用。
十、踩坑清单(建议收藏)
| # | 现象 | 原因 | 解法 |
|---|---|---|---|
| 1 | docker pull 超时 |
Docker Hub 被墙 | 镜像源前缀 + retag,或 daemon mirror |
| 2 | pip 装 torch 极慢 | PyPI 是 CUDA 版 | 阿里云 CPU wheel |
| 3 | 主进程 import 不到 --target 装的包 |
不在 sys.path | 写 .pth 到 site-packages |
| 4 | 语音识别成外语 | Whisper 自动语言检测 | 强制 language="zh" + initial_prompt |
| 5 | 语音识别为空 | QQ 语音音量过低(-36dB) | ffmpeg loudnorm 归一化 |
| 6 | 422 input_audio |
provider 没声明 modalities,默认全支持 | modalities: ["text","image","tool_use"] |
| 7 | TTS 不触发 | STT 把 Record 换成了 Plain | 从 raw_message 检测 record 段 |
| 8 | 语音念出符号 | 没清洗 markdown/emoji | TTS 前正则清洗 |
| 9 | 群里只有 @ 才回 | 唤醒逻辑写死 | 空 wake_prefix + 插件门控 |
| 10 | 读不懂群聊上下文 | 群历史未记录 | group_message_history_enable: true |
| 11 | Agent 超时且无输出 | 任务太模糊,Agent 在"研究" | 给文档链接 + 约束提示 + 返回部分输出 |
| 12 | 任务追问丢上下文 | DSH 结果没进聊天上下文 | 修正类关键词带回 DSH + 持久化 |
| 13 | QQ 频繁被踢 | 账号在多端登录 | 专用小号,只用于机器人 |
| 14 | serverErrorCode: 168 |
腾讯风控判定异常 | 手机 QQ 走安全验证;接受周期性风险 |
| 15 | 重启 NapCat 要重新扫码 | 登录态不持久 | 只重启 AstrBot,不动 NapCat |
| 16 | 容器重建后补丁丢失 | 补丁打在容器文件系统里 | 记录补丁脚本,或用 -e PYTHONPATH 固化 |
| 17 | 飞书 scope 不够 | 只申请了 docs/drive/wiki | auth login --scope "space:document:retrieve" |
十一、安全与合规(必须看)
- QQ 自动化违反腾讯用户协议 ,有封号风险。本项目的账号就被风控限制过两次(
KickedOffLine、serverErrorCode: 168)。建议专用小号,控制发言频率,不要群发/加人。 - Agent 权限要收 :DSH 在容器内以 root 运行,能读写
/AstrBot/data(里面有配置文件)。白名单 + 审批流是底线;不要把审批权限放开给陌生人。 - Key 分离 :聊天/搜索用一个 Key,DSH 用另一个 Key(
DEEPSEEK_API_KEY注入),便于单独统计和止损。 - 端口只绑内网,WebUI 靠内网 IP 或 SSH 隧道访问,不要暴露公网。
- 飞书权限最小化:只申请 docs/drive/wiki,不做写操作就不加写权限。
十二、成本
| 项 | 费用 |
|---|---|
| DeepSeek 对话 | 每百万 token 几元,日常聊天可用很久 |
| DeepSeek 搜索 | 同上,按调用计费 |
| Edge TTS | 免费 |
| 飞书 API | 免费 |
| 服务器 | 已有机器,新增占用约 5GB 磁盘 / 1.5GB 内存 |
十三、总结
这套方案的核心不是"接一个大模型",而是把四个系统缝成一条可靠的链路:
- 消息层:NapCat 负责 QQ 协议
- 对话层:AstrBot 负责编排、上下文、插件
- 能力层:DeepSeek(模型+搜索)、Whisper(语音)、Edge TTS(语音)
- 执行层:DSH Agent + lark-cli 负责"动手"
真正花时间的不是写代码,而是排查那些沉默的失败:语言检测跑偏、音量太低、modalities 默认全开、STT 把语音段换掉......每一条都藏在日志的角落里。
如果这篇文档帮你少踩一个坑,就值了。
本文基于真实部署整理,涉及账号、Key、内网 IP 均已脱敏处理。