← 返回文章列表
021 — AI · #RAG #Astro #FastAPI #ChromaDB #架构 · 2026-08-17 · 6 MIN READ

我给博客装了一个 RAG 智能体:Astro + FastAPI + ChromaDB 全景复盘

你现在看到的这个博客,右下角有一个悬浮的聊天窗。问它「Docker 网络模式怎么选」,它会先检索站内文章,再把检索结果喂给大模型,逐字流出回答,末尾附上相关文章卡片。

这不是接的第三方插件,是一套自己写的 RAG 系统:Astro 静态站做基座,FastAPI + LangChain + ChromaDB 做智能体后端,全部跑在一台 99 元一年的阿里云 ECS 上。从 PRD 到上线用了两天,这篇文章把关键取舍全部摊开。

为什么不上 Dify

第一个岔路口是:用现成的 LLM 应用平台,还是自己写。

我认真评估过 Dify(仓库里现在还留着《Dify部署方案.md》和《非Dify替代方案.md》两份调研)。结论是不适合这个场景,三个原因:

  1. 资源账算不过来。Dify 完整部署要拖起 web、api、worker、postgres、redis、weaviate 一堆容器,2 核 2G 的小机器直接喘不过气。
  2. 定制反而更麻烦。我要的东西很具体:一个嵌入静态站的聊天窗、一套自己的 SSE 协议、按二级标题分块的检索策略。在 Dify 里做这些,得绕过它的抽象层,比自己写还绕。
  3. 我想把每一层都摸一遍。这个博客的定位本身就是技术记录,黑盒接入没有成文价值。

最后的替代方案轻得多:FastAPI 一个进程,ChromaDB 本地持久化向量库,Embedding 和 LLM 调云端 API。整个后端常驻内存不到 400MB。

架构:全站静态,只留一座岛屿

前端是 Astro 4。选型理由一句话:博客 99% 的内容是静态的,JS 应该为零。Astro 默认不把任何 JS 发到浏览器,32 个页面构建出来全是纯 HTML。

唯一的例外是聊天窗。它作为一个 React 岛屿(island)存在,用 client:idle 水合——浏览器空闲时才加载那 14KB 的 JS。不点开聊天窗的访客,几乎感受不到它的存在。

后端的职责边界划得很窄:

plaintext
浏览器 ──POST /api/chat──> Nginx ──反代──> FastAPI (127.0.0.1:8000)

                                              ├─ 限流(单 IP 10 次/分钟)
                                              ├─ RAG 检索(ChromaDB 本地向量库)
                                              ├─ 组装 Prompt
                                              └─ 调 DeepSeek API,SSE 逐字推回

向量库选 ChromaDB 而不是云端服务,是因为文章总共就几十篇、几百个 chunk,本地持久化完全够,还省掉一个网络依赖。

SSE 协议:先定契约,再写代码

前后端并行开发,最怕的是联调时扯皮。所以动手前先把 SSE 协议定稿,写进了开发文档:

字段类型说明
contentstring本次推送的文本增量
doneboolean是否最后一帧
sourcesarray相关文章,[{title, url}],仅最后一帧携带
degradedboolean降级标记,前端据此显示提示条

协议里有一个容易被忽略的设计: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,每一个都值得单独成文:

  1. crypto.randomUUID 在 HTTP 裸 IP 站点上不存在,聊天岛屿直接白屏
  2. Alibaba Cloud Linux 自带 sqlite3 版本太低,ChromaDB 拒绝启动
  3. LangChain 旧版 Chroma 封装悄悄不落盘,重启后索引清零
  4. DashScope 兼容模式不收 token 数组,Embedding 请求 400
  5. DeepSeek V4 默认开启思考模式,首字延迟超 15 秒触发前端降级
  6. Nginx 反代后所有访客共享一个限流桶,自己测试把真实用户挤出去
  7. SSE 的 CRLF 分帧与前端解析器不匹配,curl 正常但浏览器全灭

它们有一个共同点:本地开发环境全部无法复现。这也促使我把部署过程写成了逐命令的操作文档和上线检查清单,以后换机器照着抄就行。

这套架构的边界

说缺点才算诚实复盘:

  • 限流器在内存里,多实例部署就失效,重启计数清零。目前单实例够用,将来要换 Redis。
  • 知识库更新是手动的,发文章要记得重建索引。后续可以加文件监听或 GitHub Actions 钩子。
  • 检索精度靠分块策略硬撑,没有重排序(rerank)。文章量上到三位数后可能需要加。

写在最后

这个项目最有意思的地方在于它的自洽:博客上写的文章,就是智能体的知识来源;智能体的开发过程,又成了新的文章素材。接下来会有一组排障实战文章,把上面那七个坑一个个讲透,最先把 SSE 换行符那篇写出来——毕竟调试两天最后败给一个 \r,这种故事不讲可惜了。

对实现细节感兴趣的,可以直接问右下角的 AI 助手,它读过这个项目的所有文章。包括这篇。

Comments