我给博客装了一个 RAG 智能体:Astro + FastAPI + ChromaDB 全景复盘
你现在看到的这个博客,右下角有一个悬浮的聊天窗。问它「Docker 网络模式怎么选」,它会先检索站内文章,再把检索结果喂给大模型,逐字流出回答,末尾附上相关文章卡片。
这不是接的第三方插件,是一套自己写的 RAG 系统:Astro 静态站做基座,FastAPI + LangChain + ChromaDB 做智能体后端,全部跑在一台 99 元一年的阿里云 ECS 上。从 PRD 到上线用了两天,这篇文章把关键取舍全部摊开。
为什么不上 Dify
第一个岔路口是:用现成的 LLM 应用平台,还是自己写。
我认真评估过 Dify(仓库里现在还留着《Dify部署方案.md》和《非Dify替代方案.md》两份调研)。结论是不适合这个场景,三个原因:
- 资源账算不过来。Dify 完整部署要拖起 web、api、worker、postgres、redis、weaviate 一堆容器,2 核 2G 的小机器直接喘不过气。
- 定制反而更麻烦。我要的东西很具体:一个嵌入静态站的聊天窗、一套自己的 SSE 协议、按二级标题分块的检索策略。在 Dify 里做这些,得绕过它的抽象层,比自己写还绕。
- 我想把每一层都摸一遍。这个博客的定位本身就是技术记录,黑盒接入没有成文价值。
最后的替代方案轻得多:FastAPI 一个进程,ChromaDB 本地持久化向量库,Embedding 和 LLM 调云端 API。整个后端常驻内存不到 400MB。
架构:全站静态,只留一座岛屿
前端是 Astro 4。选型理由一句话:博客 99% 的内容是静态的,JS 应该为零。Astro 默认不把任何 JS 发到浏览器,32 个页面构建出来全是纯 HTML。
唯一的例外是聊天窗。它作为一个 React 岛屿(island)存在,用 client:idle 水合——浏览器空闲时才加载那 14KB 的 JS。不点开聊天窗的访客,几乎感受不到它的存在。
后端的职责边界划得很窄:
浏览器 ──POST /api/chat──> Nginx ──反代──> FastAPI (127.0.0.1:8000)
│
├─ 限流(单 IP 10 次/分钟)
├─ RAG 检索(ChromaDB 本地向量库)
├─ 组装 Prompt
└─ 调 DeepSeek API,SSE 逐字推回
向量库选 ChromaDB 而不是云端服务,是因为文章总共就几十篇、几百个 chunk,本地持久化完全够,还省掉一个网络依赖。
SSE 协议:先定契约,再写代码
前后端并行开发,最怕的是联调时扯皮。所以动手前先把 SSE 协议定稿,写进了开发文档:
| 字段 | 类型 | 说明 |
|---|---|---|
content | string | 本次推送的文本增量 |
done | boolean | 是否最后一帧 |
sources | array | 相关文章,[{title, url}],仅最后一帧携带 |
degraded | boolean | 降级标记,前端据此显示提示条 |
协议里有一个容易被忽略的设计:sources 只在最后一帧出现。前端逐字渲染时不用操心卡片闪烁,收到 done: true 再一次性渲染「相关文章」。
还有个经验:EventSource 不支持 POST,所以前端是用 fetch + ReadableStream 手动解析 data: 帧的。这个解析器后来成了全项目最坑的一个 bug——服务端用 CRLF 分帧,我用 \n\n 切,两天才查到。这是下一篇文章的主角,这里先不展开。
RAG 管道:分块策略比模型重要
检索质量的大头不在模型,在分块。我实测过三种方式,最后定稿的策略是:按 Markdown 二级标题切分,单块不超过 500 字,检索时取 Top-N 并带上文章标题作为上下文前缀。
为什么是按标题而不是按固定字数硬切?博客文章天然有结构,一个二级标题下的内容语义是完整的;硬切会把一句话拦腰斩断,向量检索出来的片段上下文残缺,大模型只能瞎猜。
文章入库是一条独立管道:scripts/index_docs.py --rebuild 扫描 data/docs/ 下的 Markdown,分块、算 Embedding、写入 ChromaDB。发新文章 = 同步文件 + 重建索引,智能体就「读过」新文章了。
上线:两天里踩掉的七个坑
部署本身不复杂:静态站 scp 到 /var/www/blog,后端 systemd 托管,Nginx 反代 /api/。真正的成本全在环境差异上,两天里实际解决了七个 bug,每一个都值得单独成文:
crypto.randomUUID在 HTTP 裸 IP 站点上不存在,聊天岛屿直接白屏- Alibaba Cloud Linux 自带 sqlite3 版本太低,ChromaDB 拒绝启动
- LangChain 旧版 Chroma 封装悄悄不落盘,重启后索引清零
- DashScope 兼容模式不收 token 数组,Embedding 请求 400
- DeepSeek V4 默认开启思考模式,首字延迟超 15 秒触发前端降级
- Nginx 反代后所有访客共享一个限流桶,自己测试把真实用户挤出去
- SSE 的 CRLF 分帧与前端解析器不匹配,curl 正常但浏览器全灭
它们有一个共同点:本地开发环境全部无法复现。这也促使我把部署过程写成了逐命令的操作文档和上线检查清单,以后换机器照着抄就行。
这套架构的边界
说缺点才算诚实复盘:
- 限流器在内存里,多实例部署就失效,重启计数清零。目前单实例够用,将来要换 Redis。
- 知识库更新是手动的,发文章要记得重建索引。后续可以加文件监听或 GitHub Actions 钩子。
- 检索精度靠分块策略硬撑,没有重排序(rerank)。文章量上到三位数后可能需要加。
写在最后
这个项目最有意思的地方在于它的自洽:博客上写的文章,就是智能体的知识来源;智能体的开发过程,又成了新的文章素材。接下来会有一组排障实战文章,把上面那七个坑一个个讲透,最先把 SSE 换行符那篇写出来——毕竟调试两天最后败给一个 \r,这种故事不讲可惜了。
对实现细节感兴趣的,可以直接问右下角的 AI 助手,它读过这个项目的所有文章。包括这篇。