我从零手写了 RAG 知识库系统 + ReAct Agent(非调 LangChain)——附线上 Demo
别人教你怎么调 LangChain,我教你怎么理解 LangChain 在封装什么。
用 FastAPI + ChromaDB + DeepSeek 从零搭了一套 AI 文档问答后端:PDF 上传 → 切片 → 向量化 → 语义检索 → LLM 流式回答,外加一个手写的 ReAct Agent(不用 LangChain),能自动调工具查数据库。JWT 认证、Redis 缓存与限流、13 条 pytest、Docker 一键部署,公网 Demo 跑在 Render 上。
🌐 线上 Demo(免登录):capstone-ai-kb.onrender.com/docs 📂 GitHub:github.com/dropsccene/… 📝 姊妹篇:《手写 RAG 管线的 5 个设计决策——以及教练是怎么把它们挨个拆穿的》
为什么写这篇文章
掘金上关于 RAG 的教程很多,但九成是「LangChain 版」:vectorstore = Chroma.from_documents(...),三行代码跑通。跑通之后呢?读者对「发生了什么」依然一无所知——Embedding 是什么形状的?切片切坏了什么?Agent 循环到底转了几圈?
这篇文章反过来写。我全程没有用 LangChain——RAG 管线自己拼,Agent 循环自己写。不是跟框架过不去,而是因为一个朴素的道理:
没手写过一遍的东西,出 bug 时你连排查的入口都找不到。
文中所有代码都来自 GitHub 真实仓库(我做了简化注释),所有参数都是我真实选过的,所有坑都是我真实踩过的——包括那些我到现在也没解决的。
系统全景
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户 │ │ FastAPI │ │ PostgreSQL │ │ Redis │
│ (Swagger) │──▶│ + 4 routers │──▶│ 用户/文档/块 │◀──│ 缓存+限流 │
└─────────────┘ └──────┬──────┘ └─────────────┘ └─────────────┘
│
┌──────────┼──────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ ChromaDB │◀──嵌入───│ BGE-M3 │
│ 向量存储 │ │ (硅基流动)│
└──────────┘ └──────────┘
│
▼
┌───────────────────────────┐
│ LLM (DeepSeek V4 Flash) │
│ RAG 问答 / ReAct Agent │
└───────────────────────────┘
技术栈
| 层 | 选型 | 说明 |
|---|---|---|
| Web 框架 | FastAPI 0.139 | lifespan + APIRouter + Depends,SSE 流式友好 |
| 业务存储 | SQLite(本地)/ PostgreSQL 16(Docker) | DATABASE_URL 一行切换 |
| ORM/迁移 | SQLAlchemy 2.0 + Alembic | 3 个迁移文件,docker 启动自动 upgrade |
| 向量存储 | ChromaDB(PersistentClient) | 按 kb_id 隔离 collection |
| Embedding | 硅基流动 BGE-M3 API | 1024 维,多语言,免本地模型(原因见第 2 站) |
| LLM | DeepSeek V4 Flash | openai SDK,异步 + 流式 |
| 缓存/限流 | Redis | 回答缓存 1h + IP 滑动窗口限流 |
| 认证 | bcrypt + JWT (HS256) | OAuth2PasswordBearer |
核心数据流:上传 PDF → 提取文本 → 切片 → 向量化 → 入库;提问 → 向量化 → 检索 top_k → 拼 prompt → 流式生成 → 推给前端。
第 1 站:文档上传与解析
上传接口就是 FastAPI 的 UploadFile,真正值得说的是解析和切片:
async def extract_pdf_text(file: UploadFile):
raw = await file.read()
pdf = PdfReader(io.BytesIO(raw))
# 注意:跳过提取结果为空白的页(扫描页)
text = "\n".join(p.extract_text() for p in pdf.pages if p.extract_text())
return text
def chunk_by_char(text: str, chunk_size: int = 300, overlap: int = 30):
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start += chunk_size - overlap
return chunks
三个诚实声明:
- 扫描件 PDF 提取出来是空文本——
extract_text()对没有文字层的 PDF 返回空。我的处理是把空页跳过(if p.extract_text()),等于静默忽略。这不算解决了,算是绕过了——OCR 是明确没做的事,写进改进清单。 - 非 PDF 文件会抛异常,我还没优雅处理。上传一个 .txt 伪装成 .pdf,
PdfReader会抛PdfReadError,端点直接 500。测试断言了 422(取决于异常类型如何被 FastAPI 捕获),但这个分支我确实没写好——诚实地说,这是我项目里最糙的一个角落。 - 切片参数是"选"出来的,不是"验证"出来的:chunk_size=300 是 RAG 社区 200-500 常见范围的中间值,overlap=30 大概是一个中文句子的长度。我没有做过 benchmark。这两个数字上生产前必须验证——固定 50 条真实查询,对比 150×6 / 300×3 / 500×2 三组配置下最终答案质量,用 LLM 盲评打分。
切片切坏的真实案例(如果你也是 300/30 党):假设用户问"为什么不用 LangChain Agent?",原文是 "LangChain Agent 是黑盒——报错不知道内部哪一步挂了。自己写 tool_map + call_tool + run 循环,每步可调试。"——chunk 太小会把这句话切成两半,前半被召回但丢了解法,后半有解法但没提 LangChain,LLM 拿到一个残缺片段。这就是语义断裂:embedding 质量再好也救不了。
第 2 站:向量化——OOM 逼我换掉了 Embedding 方案
我最初的方案是本地模型:
model = SentenceTransformer("all-MiniLM-L6-v2", local_files_only=True)
embedding = model.encode(chunk) # 384 维
local_files_only=True 强制从本地缓存加载,Docker 构建可复现。听起来很完美——直到部署那天。
Render 免费 tier 只有 512MB 内存。PyTorch + SentenceTransformers 加载模型,容器起来又崩、起来又崩。日志:OOM。
我被逼到墙角,把 Embedding 换成了硅基流动的 BGE-M3 远程 API:
client = AsyncOpenAI(api_key=os.getenv("SILICONFLOW_API_KEY"), base_url="https://api.siliconflow.cn/v1")
async def get_embedding(text: str) -> list[float]:
response = await client.embeddings.create(
model="Pro/BAAI/bge-m3",
input=text,
)
return response.data[0].embedding
这笔账算下来是双赢:Docker 镜像从 2GB 降到 300MB(不用装 PyTorch),运行时内存从 512MB 降到 150MB,API 延迟 ~200ms 和本地加载差不多——而且 BGE-M3(1024 维、多语言、支持 8192 token)本身就比 MiniLM 强。
两个收获:
- 本地模型在免费云环境不是"省钱"——是根本不可行。部署前先算内存账,再选技术方案。
- 选型框架应该在决策前用,而不是决策后写。我最初写的选型理由是"MiniLM 轻量、够用",被部署环境打脸后才换。真实驱动力是环境约束,不是模型优劣。
另外提一句:所有 LLM/Embedding API 调用都套了 tenacity 的 @retry 指数退避——API 抖动时自动重试,这是线上稳定性的第一道防线,成本几乎为零。
第 3 站:检索与问答——Redis 缓存和限流是后加的
@router.post("/ask")
async def ask_question(kb_id: int, body: AskRequest, request: Request):
# 1. IP 滑动窗口限流:60 秒内最多 5 次
if not check_rate_limit(request.client.host, max_req=5, window_sec=60):
raise HTTPException(status_code=429, detail="访问次数过多,请稍后访问")
# 2. Redis 回答缓存:相同问题 1 小时内直接命中
cache_key = f"ask:{kb_id}:{body.question}"
r = get_redis()
if r:
cached = r.get(cache_key)
if cached:
return {"answer": cached, "sources": []}
# 3. 检索 + 组装 prompt + 调用 LLM
docs = await VectorStore(f"kb_{kb_id}").query(body.question)
context = "\n".join(docs)
prompt = f"请根据下面资料回答问题,如果资料中没有相关信息,请回答“抱歉,我无法回答这个问题。”\n\n资料:\n{context}\n\n问题:\n{body.question}\n\n回答:"
answer = await call_llm(prompt)
# 4. 写缓存
if r:
r.set(cache_key, answer, ex=3600)
return {"answer": answer, "sources": docs}
几个值得展开的点:
限流用了 Redis 的 ZSET 实现滑动窗口:每次请求把当前时间戳 zadd 进 key,先 zremrangebyscore 清掉窗口外的旧记录,再数 zcard 超没超。比固定窗口(每分钟重置)准确得多。
Redis 挂了不会拖垮服务:get_redis() 连接失败会返回 None,限流直接放行、缓存直接跳过——优雅降级。代价是 Redis 挂的时候没有限流保护,但换来的是"缓存组件不能成为单点故障"。这个设计我挺满意。
"资料里没有就说不知道"是 prompt 指令,不是保障:LLM 可能在检索结果不相关时依然强行回答(幻觉)。生产级做法是检索后加相似度阈值过滤——top-3 相似度都低于 0.5 就直接返回"未找到",连 LLM 都不调。这个我还没实现(ChromaDB 的 distances 字段摆在那里,就差两行代码),列入改进清单。
/ask-stream 流式端点没有限流和缓存——只给同步端点加了。诚实说:这是偷懒,流式端点同样会被刷。改进清单+1。
流式回答为什么必须用 async def
@router.post("/ask-stream")
async def ask_question_stream(kb_id: int, body: AskRequest):
docs = await VectorStore(f"kb_{kb_id}").query(body.question)
context = "\n".join(docs)
prompt = f"请根据下面资料回答问题,如果资料中没有相关信息,请回答“抱歉,我无法回答这个问题。”\n\n资料:{context}\n\n问题:{body.question}\n\n回答:"
async def event_stream():
async for chunk in call_llm_stream(prompt):
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
这里有个真实演进故事:我的 README 里至今还留着最早的设计决策文档,写着"端点用 def 不用 async def,LLM 走同步 SDK"——那是第一版。后来我把整个 LLM/Embedding 调用换成 AsyncOpenAI,端点全部切成 async def。为什么?
一个流式连接可能持续几秒到几十秒。def + 同步 generator 能跑(Starlette 会把它扔进线程池),但每个流式连接占一个线程池线程——10 个用户同时流式,线程池满了,注册、登录、上传全在排队。async def + async for 让流式连接只占一个 event loop task,谁也不堵谁。这不算优化,是正确性要求。如果你的项目只有非流式端点,def + 线程池完全够用,别为了 async 而 async。
第 4 站:手写 ReAct Agent——本文章标题的由来
很多 RAG 系统会接一个"自然语言查数据库"的功能。常规做法:pip install langchain,调 create_sql_agent。我选择自己写,真实代码长这样(略去类型标注):
class ReActAgent:
def __init__(self, tools, client):
self.tools = tools # OpenAI Function Calling 格式的工具定义
self.client = client # AsyncOpenAI 实例
self.tool_map = {"query_database": execute_select_only}
async def run(self, messages, user_query, max_rounds=10):
messages.append({"role": "user", "content": user_query})
for _ in range(max_rounds):
response = await self.client.chat.completions.create(
model="deepseek-v4-flash",
messages=messages,
tools=self.tools
)
msg = response.choices[0].message
if msg.tool_calls:
messages.append(msg) # 记录这次工具调用
for tool_call in msg.tool_calls:
body = json.loads(tool_call.function.arguments)
result = self.tool_map[tool_call.function.name](**body)
messages.append({"role": "tool",
"tool_call_id": tool_call.id,
"content": result})
continue
return msg.content # 没有工具调用了,这就是最终答案
return {"error": "达到最大轮数,未能得到最终答案"}
这就是 Thought → Action → Observation 的完整循环,不到 40 行。三件事值得说:
1. 为什么手写而不是调 LangChain:框架把循环封装成了黑盒——工具参数解析错了、prompt 被截断了、模型返回格式不合法,你看到的都是顶层异常。手写版本里每轮 messages 都在我手里,任何一步都能打印出来看。我的建议不是"别用 LangChain",而是"先手写一个 40 行版本,再决定要不要上框架"——上框架时你才知道框架替你做了什么,出了问题才知道去哪查。
2. system prompt 是整个 Agent 的灵魂,我的真实版本:
你是一个数据库查询助手。数据库是 SQLite。只生成SQLite兼容的SQL语句。
先查表结构,再回答用户问题。如果查询结果为空,直接说数据为空。
"先查表结构"这一句是关键——它让 Agent 变成"边看边做":第一轮先 SELECT name FROM sqlite_master 看有哪些表,看清楚再写精准 SQL。这就是 ReAct 和"直接让 LLM 生成 SQL"的本质区别:直接生成是一次猜对的赌博,猜错就错了;ReAct 是带观察的迭代——错了还能看到错误信息,下一轮修正。
3. 两个诚实的缺陷:
- 没有异常处理:
self.tool_map[...](**body)执行时如果 SQL 抛错,会直接向上抛,接口 500。改进方向是try/except捕获后把错误信息作为 tool 返回内容传回给 LLM,让它自己改 SQL 再试——self-correction 机制,我还没做。 - 安全防护只是"很粗糙的壳":
def execute_select_only(sql: str):
if not sql.strip().lower().startswith("select"):
return "只允许执行 select 语句"
return query_database(sql)
只查了前缀——注释可以绕过(SELECT * FROM users; DROP TABLE users--)。生产级做法是解析 SQL AST 判断只读,或数据库层面用只读账号。我的态度:MVP 阶段的防护要够用,但文档里必须写明它的局限——你骗得了自己,骗不了审计代码的人。
另外,Agent 比直接生成贵:多轮 = 多次 API 调用。简单查询直接生成 SQL 就够了,复杂查询(先看表结构再写)才值得多花这几轮的钱。
第 5 站:认证与安全——以及我还没保护的端点
JWT 认证是标准流程:bcrypt 哈希存库 → 登录签发 jwt.encode({"sub": user_id, "exp": ...}, SECRET_KEY, HS256) → OAuth2PasswordBearer 提取校验。两个点值得展开:
bcrypt 为什么不是 SHA256:SHA256 是快哈希,设计目标就是快;bcrypt 是慢哈希,自带 salt 和 cost factor——攻击者 GPU 每秒算几十亿次 SHA256,但 bcrypt 的 cost factor 每加 1,破解时间翻倍。存密码用快哈希等于把用户密码做成公开字典。
一个诚实的现状:JWT 目前只保护了注册/登录//me 这套认证链路——RAG 问答端点和 Agent 端点都没加鉴权。Demo 为了免登录体验故意开放,但生产上这是明确缺口:任何人拿到你部署的 URL 都能传文档、调 LLM 烧钱。改进清单第一位。
还有一条血的教训(建议所有人引以为戒):我有一次不小心把 .env commit 进了 Git 历史。.gitignore 后来才加上。最后用 git filter-branch 清理——但GitHub 上泄露过的密钥相当于公开了,正确做法是直接更换密钥,而不是只清理历史。
第 6 站:测试与工程化——13 条 pytest,秒级跑完零费用
Mock 策略是灵魂:
- LLM 调用 → Mock 掉,返回预设假回答
- VectorStore → Mock 掉,返回预设假检索结果
- 效果:0 网络请求、0 API 费用、13 条测试秒级跑完
分布:认证 7 条(注册/重复/弱密码/错密码/登录/me/未授权)+ 上传 3 条(PDF/非 PDF/chunk 计数)+ Agent 2 条(正常查询/缺参数 422)+ 问答 1 条(Mock 全链路)+ 限流逻辑。
conftest.py 里有个我挺喜欢的细节——测试环境用 create_all 建表而不是 Alembic 迁移:
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
每个测试自动建表、测完删表,快且干净。生产环境走 Alembic 版本化迁移,测试环境走 create_all——两个环境用两套策略,各取所需。数据库版本管理切 Alembic 的原因:create_all() 只能建表不能改表,加个字段就得手写 SQL,版本不可追溯。3 个迁移文件:初始表 → 用户表 → 文档表加字段。坑:已有 SQLite 库文件时跑第一次迁移会报"表已存在",解法是删库重建或 alembic stamp head。
Mock 测试验证的是"逻辑正确",不是"真实 LLM 表现"——这个差距我用 Swagger /docs 手动端到端验证来弥补。理想情况是 CI 定期跑真实 API 的 E2E 测试,但成本高,MVP 阶段不值得每次 commit 都跑。
第 7 站:Docker 部署——免费 tier 的血泪教训
docker-compose.yml 编排 PostgreSQL 16 + FastAPI 双容器,docker-entrypoint.sh 启动时自动跑 alembic upgrade head,一键 docker compose up。
三个真实教训:
- 512MB 内存跑不动本地 Embedding 模型(第 2 站的 OOM)——免费 tier 部署,先算内存账再选技术方案。
- 模型文件别 COPY 进镜像:500MB+ 的模型文件会让
docker build超时,镜像 2GB。换远程 API 后镜像 300MB。 - 部署后要真测:本地跑通 ≠ 云端跑通。Render 上我踩过环境变量没传全、CORS 配置不对两个坑——都是部署后立刻暴露的。
踩坑合集(速查表)
| 坑 | 症状 | 根因 | 现状 |
|---|---|---|---|
| 扫描件 PDF | 提取文本为空 | 无文字层 | 静默跳过(绕过未解决) |
| 容器反复崩溃 | Render 日志 OOM | PyTorch + 模型吃满 512MB | Embedding 换 BGE-M3 API,镜像 2GB→300MB |
.env 进 Git | 密钥泄露 | 忘了 .gitignore | filter-branch 清理 + 更换密钥 |
| Alembic 首次迁移 | "表已存在" | 已有 SQLite 库文件 | alembic stamp head |
| 流式连接占线程 | 用户一多接口全堵 | def + 同步 generator | 全量切 async def + AsyncOpenAI |
| 非 PDF 上传 | 接口 500/422 | 未做异常处理 | 没修,列入改进 |
改进路线图(诚实地标注:以下大多我都没做)
- 已完成:Redis 缓存 + 限流(优雅降级)、tenacity 重试、全量异步化、Alembic 迁移、Docker 双容器
- 搭了骨架未完成:Celery 异步任务(
app/tasks.py有index_document/ask_question任务定义和进度上报,但核心函数还是占位符,上传仍走同步路径) - 未做(按优先级):① 问答/Agent 端点加 JWT 鉴权 ② 检索相似度阈值过滤(防幻觉)③ 工具调用 self-correction ④ Embedding 并发化(现在逐 chunk 串行 await,可
asyncio.gather)⑤ reranker 重排(先召回 10 再精排 3)⑥ SQL AST 只读校验 ⑦ Word/多模态上传 ⑧ 流式端点补限流
路线图的意义不在"我全都会",而在"我知道下一步该做什么"——面试官问"系统哪里最弱",能说出具体改进路径,比说"都很完善"可信十倍。
写到最后
第一句给读者:如果这篇文章对你有用,记住一件事——参数选型要诚实,代码边界要写明。chunk_size 是不是 benchmark 出来的、防护是不是能绕过、哪些端点没加鉴权——这些东西读者一眼能看穿,不如自己先摊开。我在姊妹篇里被教练追问了 50 个问题,3 个"设计决策"当场被问出原型——那篇文章比这篇更坦白,建议一起看。
第二句给我自己:这是一篇"边做边记录"的文章,不是"做完才补"的说明书。所以它没有"选择的最佳理由",只有"真实的决策过程"——包括被 OOM 逼着换方案、README 里还留着过时的设计决策文档、以及那些我明知该修却没修的地方。
代码已在 GitHub 开源(完整 commit 历史):github.com/dropsccene/… 公网 Demo 免登录体验:capstone-ai-kb.onrender.com/docs 姊妹篇:《手写 RAG 管线的 5 个设计决策——以及教练是怎么把它们挨个拆穿的》
我是袁仕杰,2026 届专科应届生,正在找 Python 后端 / AI 应用开发方向的工作,可立即到岗。如果你团队在做 RAG、Agent 或 AI 工具落地相关的事,欢迎联系我聊聊——也可以直接打开 Demo 试试,有问题评论区或私信都行。