系列文章目录
B站视频内容智能分析系统(一):项目介绍与架构设计
B站视频内容智能分析系统(二):Docker Compose 一键部署
B站视频内容智能分析系统(三):B站视频自动采集
B站视频内容智能分析系统(四):语音转写三级回退
B站视频内容智能分析系统(五):LLM 内容精炼与多域分类
B站视频内容智能分析系统(六):Text-to-SQL 结构化查询
B站视频内容智能分析系统(七):RAG 语义检索
B站视频内容智能分析系统(八):Router Agent 智能路由
B站视频内容智能分析系统(九):React 前端与管理面板
B站视频内容智能分析系统(十):踩坑记录与性能优化
文章目录
- 系列文章目录
- 前言
- 一、Docker SDK Volume 命名陷阱
- 1. 问题现象
- 2. 根本原因
- 3. 解决方案
- 二、/api/system_metrics 性能优化:23s → 2.2s
- 1. 原始实现:串行采集
- 2. 第一次优化:容器 stats 并行化
- 3. 第二次优化:三路并行采集
- 4. 优化效果对比
- 三、增量扫描遗漏旧视频
- 1. 问题发现
- 2. 根因分析
- 3. 全量扫描按钮
- 四、API 配置集中化
- 1. 问题:硬编码散落各处
- 2. 方案:去掉所有默认值
- 3. docker-compose.yml 漏传变量
- 五、Nginx 413 上传限制
- 六、NAS 8GB 内存下的 mem_limit 调优
- 1. 内存分配策略
- 2. 峰值管理
- 七、LLM 分类器的 category 幻觉
- 八、其他小坑
- 1. DuckDB 跨容器锁冲突
- 2. faster-whisper 模型下载失败
- 3. B站 Cookie 风控 -352
- 总结
前言
前九篇把整个系统从头到尾讲了一遍。这最后一篇,专门记录开发过程中踩过的坑和做过的优化。
这些坑有的是 Docker 的隐藏机制,有的是 LLM 的行为特性,有的是性能问题。每一个都花了几个小时甚至几天才排查清楚。记录下来,希望能帮到遇到类似问题的朋友。
一、Docker SDK Volume 命名陷阱
1. 问题现象
前端触发采集时,bilibili-monitor 容器启动了,但数据写到了"错误的地方"——采集完后,DuckDB 和 ChromaDB 里的数据没有更新。查看容器内的数据目录,文件都在,但退出容器后这些数据就"消失"了。
2. 根本原因
问题出在Docker SDK 和 Docker Compose 对 Named Volume 的命名方式不同。
Docker Compose 创建 Volume 时,会自动加上项目名前缀:
# docker-compose.ymlvolumes:duckdb-data:# 实际创建的卷名是 "content-analysis-system_duckdb-data"driver:local但用 Docker SDK(Python)创建容器时,如果直接写 Named Volume:
# 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. 解决方案
用完整的卷名(带项目前缀):
volumes=["content-analysis-system_bilibili-data:/app/downloads:rw","content-analysis-system_duckdb-data:/app/data:rw",]但这个前缀取决于COMPOSE_PROJECT_NAME,所以更稳妥的做法是从环境变量读取:
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获取系统状态。最初的实现是串行采集:
defget_system_metrics():# 1. 采集容器 stats(6个容器,每个 ~1.5s)forcontainerincontainers:stats=container.stats(stream=False)# 阻塞 ~1.5smetrics[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()returnmetrics6 个容器的stats()调用串行执行,每个 ~1.5s,光容器采集就要 9 秒。加上 RAG 的 HTTP 调用和 DuckDB 查询,总耗时 23 秒。前端等半分钟才刷新,体验很差。
2. 第一次优化:容器 stats 并行化
container.stats()是一个阻塞调用,但各容器之间互相独立,可以并行:
def_collect_one(container):stats=container.stats(stream=False)returncontainer.name,parse_stats(stats)withThreadPoolExecutor(max_workers=len(containers))asex:futures={ex.submit(_collect_one,c):cforcincontainers}forfutinas_completed(futures):name,info=fut.result()metrics[name]=info6 个容器并行采集,从 9s 降到 ~1.5s(受限于最慢的一个)。
3. 第二次优化:三路并行采集
容器采集、RAG 调用、DuckDB 查询这三步也是互相独立的,可以三路并行:
withThreadPoolExecutor(max_workers=3)asex: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 个视频。
# 旧逻辑max_count=9999iflen(done_bvid_set)<100else30老张已经处理了 500+ 个视频(> 100),所以每次只拉最新 30 个。但这 30 个都已经处理过了,所以永远显示"无新视频"。剩下的 400 多个老视频排在 API 的后面几页,永远不会被拉到。
3. 全量扫描按钮
保留了增量逻辑(日常采集用),新增--full-scan参数强制全量拉取:
# 新逻辑max_count=9999if(args.full_scanoris_new_uporlen(done_bvid_set)<100)else30前端加了一个复选框"全量扫描(拉取所有历史视频)",勾选后传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读取:
# 之前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:
# 修复前-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 配置里加一行:
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 可能输出:
{"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连接:
conn=duckdb.connect(db_path,read_only=True)只读连接不会加写锁,可以和写入者共存。
2. faster-whisper 模型下载失败
NAS 在国内网络下从 HuggingFace 下载 Whisper 模型经常超时。解决方案是设置 HF 镜像:
environment:-HF_ENDPOINT=https://hf-mirror.com第一次运行还是会下载模型(~500MB for small),之后会缓存在容器内。
3. B站 Cookie 风控 -352
B站的 Cookie 有效期大概 1-2 个月,过期后 API 返回code=-352(风控校验失败)。
解决方案是每次采集前先测试 Cookie 有效性,失效时自动发 QQ 通知:
cookie_ok,cookie_msg=test_cookie(cookies)ifnotcookie_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