系列文章目录
B站视频内容智能分析系统(二):Docker Compose 一键部署
B站视频内容智能分析系统(六):Text-to-SQL 结构化查询
B站视频内容智能分析系统(八):Router Agent 智能路由
B站视频内容智能分析系统(十):踩坑记录与性能优化
文章目录
- 系列文章目录
- 前言
- [一、Docker SDK Volume 命名陷阱](#一、Docker SDK Volume 命名陷阱)
- [1. 问题现象](#1. 问题现象)
- [2. 根本原因](#2. 根本原因)
- [3. 解决方案](#3. 解决方案)
- [二、/api/system_metrics 性能优化:23s → 2.2s](#二、/api/system_metrics 性能优化:23s → 2.2s)
- [1. 原始实现:串行采集](#1. 原始实现:串行采集)
- [2. 第一次优化:容器 stats 并行化](#2. 第一次优化:容器 stats 并行化)
- [3. 第二次优化:三路并行采集](#3. 第二次优化:三路并行采集)
- [4. 优化效果对比](#4. 优化效果对比)
- 三、增量扫描遗漏旧视频
- [1. 问题发现](#1. 问题发现)
- [2. 根因分析](#2. 根因分析)
- [3. 全量扫描按钮](#3. 全量扫描按钮)
- [四、API 配置集中化](#四、API 配置集中化)
- [1. 问题:硬编码散落各处](#1. 问题:硬编码散落各处)
- [2. 方案:去掉所有默认值](#2. 方案:去掉所有默认值)
- [3. docker-compose.yml 漏传变量](#3. docker-compose.yml 漏传变量)
- [五、Nginx 413 上传限制](#五、Nginx 413 上传限制)
- [六、NAS 8GB 内存下的 mem_limit 调优](#六、NAS 8GB 内存下的 mem_limit 调优)
- [1. 内存分配策略](#1. 内存分配策略)
- [2. 峰值管理](#2. 峰值管理)
- [七、LLM 分类器的 category 幻觉](#七、LLM 分类器的 category 幻觉)
- 八、其他小坑
- [1. DuckDB 跨容器锁冲突](#1. DuckDB 跨容器锁冲突)
- [2. faster-whisper 模型下载失败](#2. faster-whisper 模型下载失败)
- [3. B站 Cookie 风控 -352](#3. B站 Cookie 风控 -352)
- 总结
前言
前九篇把整个系统从头到尾讲了一遍。这最后一篇,专门记录开发过程中踩过的坑和做过的优化。
这些坑有的是 Docker 的隐藏机制,有的是 LLM 的行为特性,有的是性能问题。每一个都花了几个小时甚至几天才排查清楚。记录下来,希望能帮到遇到类似问题的朋友。
一、Docker SDK Volume 命名陷阱
1. 问题现象
前端触发采集时,bilibili-monitor 容器启动了,但数据写到了"错误的地方"------采集完后,DuckDB 和 ChromaDB 里的数据没有更新。查看容器内的数据目录,文件都在,但退出容器后这些数据就"消失"了。
2. 根本原因
问题出在 Docker SDK 和 Docker Compose 对 Named Volume 的命名方式不同。
Docker Compose 创建 Volume 时,会自动加上项目名前缀:
yaml
# docker-compose.yml
volumes:
duckdb-data: # 实际创建的卷名是 "content-analysis-system_duckdb-data"
driver: local
但用 Docker SDK(Python)创建容器时,如果直接写 Named Volume:
python
# Docker SDK 创建的容器
volumes = [
"duckdb-data:/app/data:rw", # 创建了一个新的卷 "duckdb-data"
]
这行代码不会使用 Compose 创建的 content-analysis-system_duckdb-data,而是创建了一个全新的 duckdb-data 卷。两个卷名字不同,数据自然不互通。
Compose 创建的卷:content-analysis-system_duckdb-data ← text-to-sql 读这个
SDK 创建的卷: duckdb-data ← bilibili-monitor 写这个
3. 解决方案
用完整的卷名(带项目前缀):
python
volumes = [
"content-analysis-system_bilibili-data:/app/downloads:rw",
"content-analysis-system_duckdb-data:/app/data:rw",
]
但这个前缀取决于 COMPOSE_PROJECT_NAME,所以更稳妥的做法是从环境变量读取:
python
project_name = os.getenv("COMPOSE_PROJECT_NAME", "content-analysis-system")
volumes = [
f"{project_name}_bilibili-data:/app/downloads:rw",
f"{project_name}_duckdb-data:/app/data:rw",
]
Bind Mount(直接映射宿主机路径)就没有这个问题,因为路径是显式指定的。但 Named Volume 在 Compose 和 SDK 之间的这个命名差异,确实坑了我好几天。
二、/api/system_metrics 性能优化:23s → 2.2s
1. 原始实现:串行采集
前端的服务监控页面调用 /api/system_metrics 获取系统状态。最初的实现是串行采集:
python
def get_system_metrics():
# 1. 采集容器 stats(6个容器,每个 ~1.5s)
for container in containers:
stats = container.stats(stream=False) # 阻塞 ~1.5s
metrics[container.name] = parse_stats(stats)
# 总计:~9s
# 2. 调用 RAG /api/stats(~2s)
rag_stats = requests.get(f"{rag_url}/api/stats").json()
# 3. 查询 DuckDB(~0.5s)
sql_stats = query_duckdb()
# 4. 查询统计(~0.1s)
query_stats = query_logger.get_stats()
return metrics
6 个容器的 stats() 调用串行执行,每个 ~1.5s,光容器采集就要 9 秒。加上 RAG 的 HTTP 调用和 DuckDB 查询,总耗时 23 秒。前端等半分钟才刷新,体验很差。
2. 第一次优化:容器 stats 并行化
container.stats() 是一个阻塞调用,但各容器之间互相独立,可以并行:
python
def _collect_one(container):
stats = container.stats(stream=False)
return container.name, parse_stats(stats)
with ThreadPoolExecutor(max_workers=len(containers)) as ex:
futures = {ex.submit(_collect_one, c): c for c in containers}
for fut in as_completed(futures):
name, info = fut.result()
metrics[name] = info
6 个容器并行采集,从 9s 降到 ~1.5s(受限于最慢的一个)。
3. 第二次优化:三路并行采集
容器采集、RAG 调用、DuckDB 查询这三步也是互相独立的,可以三路并行:
python
with ThreadPoolExecutor(max_workers=3) as ex:
f_containers = ex.submit(_collect_containers)
f_rag = ex.submit(_collect_rag)
f_sql = ex.submit(_collect_sql)
metrics["containers"] = f_containers.result()
metrics["rag_stats"] = f_rag.result()
metrics["sql_stats"] = f_sql.result()
4. 优化效果对比
| 阶段 | 容器采集 | RAG 调用 | DuckDB | 总计 |
|---|---|---|---|---|
| 原始(串行) | 9s | 2s | 0.5s | 23s |
| 第一次优化 | 1.5s | 2s | 0.5s | 4s |
| 第二次优化 | 1.5s | 2s | 0.5s(并行) | 2.2s |
从 23 秒降到 2.2 秒,快了 10 倍 。核心思路就是:能用并行的地方绝不用串行。
另一个优化是把数据库查询从 LLM 调用改成了直接查 DuckDB。最初的设计是用 LLM 生成 SQL 来统计数据,但一个简单的 SELECT COUNT(*) 完全不需要 LLM------直接查 DuckDB 只要 0.01 秒,用 LLM 要 2-3 秒。
三、增量扫描遗漏旧视频
1. 问题发现
UP主"老张"有 900 多个视频,但系统只处理了 500 多个。每次运行采集,都显示"无新视频"。
2. 根因分析
最初的增量逻辑是:当已处理视频数 ≥ 100 时,只拉最新 30 个视频。
python
# 旧逻辑
max_count = 9999 if len(done_bvid_set) < 100 else 30
老张已经处理了 500+ 个视频(> 100),所以每次只拉最新 30 个。但这 30 个都已经处理过了,所以永远显示"无新视频"。剩下的 400 多个老视频排在 API 的后面几页,永远不会被拉到。
3. 全量扫描按钮
保留了增量逻辑(日常采集用),新增 --full-scan 参数强制全量拉取:
python
# 新逻辑
max_count = 9999 if (args.full_scan or is_new_up or len(done_bvid_set) < 100) else 30
前端加了一个复选框"全量扫描(拉取所有历史视频)",勾选后传 full_scan: true,一路透传到 monitor.py。
对于老张这种情况,手动勾选一次全量扫描就能把剩下的 400 多个视频全部拉回来。日常采集还是走增量模式,只关注最新视频。
四、API 配置集中化
1. 问题:硬编码散落各处
最初 REFINE_API_URL 的默认值散落在 5 个 .py 文件里,而且各不相同:
| 文件 | 默认值 |
|---|---|
shared_config.py |
https://api.deepseek.com/v1/chat/completions |
refiner_domains.py |
https://api.deepseek.com/v1/chat/completions |
rag_engine.py |
https://api.deepseek.com/v1(少了 /chat/completions) |
refine_batch.py |
http://140.143.147.125:3300/...(旧内网 IP) |
切换 API 端点时需要逐个文件修改,很容易漏。
2. 方案:去掉所有默认值
所有 .py 文件的默认值改为空字符串,纯粹从 .env 读取:
python
# 之前
API_URL = os.getenv('REFINE_API_URL', 'https://api.deepseek.com/v1/chat/completions')
# 之后
API_URL = os.getenv('REFINE_API_URL', '')
这样切换 API 只需改 .env 三行,不需要动任何代码。如果 .env 没配置,启动时会因为空 URL 立即报错,比静默用旧 IP 好得多。
3. docker-compose.yml 漏传变量
还发现 docker-compose.yml 里三个服务都漏传了 REFINE_MODEL:
yaml
# 修复前
- REFINE_API_URL=${REFINE_API_URL}
- REFINE_API_KEY=${REFINE_API_KEY}
# REFINE_MODEL 没传!容器内用的是硬编码默认值
# 修复后
- REFINE_API_URL=${REFINE_API_URL}
- REFINE_API_KEY=${REFINE_API_KEY}
- REFINE_MODEL=${REFINE_MODEL:-deepseek-v4-flash} # 补上
五、Nginx 413 上传限制
UP主导入功能需要上传 ZIP 文件(可能几百 MB),但 Nginx 默认 client_max_body_size 是 1MB:
POST /api/up_info/import → 413 Request Entity Too Large
修复很简单,在 Nginx 配置里加一行:
nginx
location /api/ {
client_max_body_size 500m;
proxy_pass http://router-agent:8000;
}
500MB 的上限对于 UP主导入完全够用了。
六、NAS 8GB 内存下的 mem_limit 调优
1. 内存分配策略
NAS 只有 8GB 内存,7 个容器的内存分配需要精打细算:
| 容器 | mem_limit | 说明 |
|---|---|---|
| frontend | 256m | Nginx 很轻量 |
| router-agent | 1g | FastAPI + LLM SDK |
| text-to-sql | 2g | 需要加载 schema 和 LLM 响应 |
| rag | 2g | ChromaDB 客户端 + BM25 索引 |
| chromadb | 1g | 向量数据库 |
| bilibili-monitor | 4g | 按需启动,跑完释放 |
| bilibili-cron | 128m | crond + docker cli |
常驻服务(frontend + router + text-to-sql + rag + chromadb + cron)约 6.4G。
2. 峰值管理
bilibili-monitor 只在采集时启动,mem_limit 给了 4G。它和常驻服务不会同时占满内存------因为 bilibili-monitor 跑完会自动退出释放内存。
转写期间的峰值:常驻 6.4G + 转写 ~1G ≈ 7.5G,在 8G 范围内可以接受。
如果同时跑 bilibili-monitor(4G)+ 常驻服务(6.4G)= 10.4G,就会 OOM。所以 bilibili-monitor 跑完后必须退出,不能常驻。Docker Compose 里配置了 restart: "no" + command: ["echo", "..."],确保它不会自动重启。
七、LLM 分类器的 category 幻觉
Router Agent 的意图分类器有一个坑:LLM 会把话题关键词当分类输出。
比如用户问"博主们对冷暴力怎么看",LLM 可能输出:
json
{"filters": {"category": "冷暴力"}} // ❌ "冷暴力"不是有效分类!
31 个有效分类里根本没有"冷暴力","冷暴力"是话题关键词,应该放在 keywords 字段里。
修复方法是在 Prompt 里反复强调:
**重要:category 是目录分类名,不是话题关键词。
"冷暴力"是话题(用 keywords),不是分类。**
并在 Prompt 里列出所有 31 个有效分类名,让 LLM 只能从中选择。加上代码里的后处理校验(检查 category 是否在有效列表中),双重保险。
八、其他小坑
1. DuckDB 跨容器锁冲突
DuckDB 是嵌入式数据库,同一时间只能有一个写入者。bilibili-monitor 写入时,如果 text-to-sql 也在读,可能会遇到锁冲突。
解决方案:text-to-sql 用 read_only=True 连接:
python
conn = duckdb.connect(db_path, read_only=True)
只读连接不会加写锁,可以和写入者共存。
2. faster-whisper 模型下载失败
NAS 在国内网络下从 HuggingFace 下载 Whisper 模型经常超时。解决方案是设置 HF 镜像:
yaml
environment:
- HF_ENDPOINT=https://hf-mirror.com
第一次运行还是会下载模型(~500MB for small),之后会缓存在容器内。
3. B站 Cookie 风控 -352
B站的 Cookie 有效期大概 1-2 个月,过期后 API 返回 code=-352(风控校验失败)。
解决方案是每次采集前先测试 Cookie 有效性,失效时自动发 QQ 通知:
python
cookie_ok, cookie_msg = test_cookie(cookies)
if not cookie_ok:
send_qq_notify(f"Cookie 已失效: {cookie_msg}")
sys.exit(1)
前端管理面板也有 Cookie 测试按钮,可以随时手动检查。
总结
这个系列到这里就全部写完了。十篇文章从项目架构到 Docker 部署,从视频采集到语音转写,从 LLM 精炼到双通道查询,从智能路由到前端面板,最后以踩坑记录收尾。
回顾整个项目,最有价值的几个设计:
- 三级转写回退:让系统在任何环境下都能完成转写
- BM25 + 向量混合检索:比单一检索方式效果好很多
- UP主名称三层标准化:解决了简称匹配的全链路问题
- system_metrics 并行优化:从 23s 降到 2.2s
- 增量 + 全量扫描:兼顾效率和覆盖
如果对你有帮助,欢迎点赞收藏。项目代码在 GitHub:https://github.com/chaoge615-afk/content-analysis-system