我从零手写了 RAG 知识库系统 + ReAct Agent(非调 LangChain)——附线上 Demo

0 阅读15分钟

我从零手写了 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.139lifespan + APIRouter + Depends,SSE 流式友好
业务存储SQLite(本地)/ PostgreSQL 16(Docker)DATABASE_URL 一行切换
ORM/迁移SQLAlchemy 2.0 + Alembic3 个迁移文件,docker 启动自动 upgrade
向量存储ChromaDB(PersistentClient)按 kb_id 隔离 collection
Embedding硅基流动 BGE-M3 API1024 维,多语言,免本地模型(原因见第 2 站)
LLMDeepSeek V4 Flashopenai 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

三个诚实声明:

  1. 扫描件 PDF 提取出来是空文本——extract_text() 对没有文字层的 PDF 返回空。我的处理是把空页跳过(if p.extract_text()),等于静默忽略。这不算解决了,算是绕过了——OCR 是明确没做的事,写进改进清单。
  2. 非 PDF 文件会抛异常,我还没优雅处理。上传一个 .txt 伪装成 .pdf,PdfReader 会抛 PdfReadError,端点直接 500。测试断言了 422(取决于异常类型如何被 FastAPI 捕获),但这个分支我确实没写好——诚实地说,这是我项目里最糙的一个角落。
  3. 切片参数是"选"出来的,不是"验证"出来的: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 强。

两个收获:

  1. 本地模型在免费云环境不是"省钱"——是根本不可行。部署前先算内存账,再选技术方案。
  2. 选型框架应该在决策前用,而不是决策后写。我最初写的选型理由是"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

三个真实教训:

  1. 512MB 内存跑不动本地 Embedding 模型(第 2 站的 OOM)——免费 tier 部署,先算内存账再选技术方案。
  2. 模型文件别 COPY 进镜像:500MB+ 的模型文件会让 docker build 超时,镜像 2GB。换远程 API 后镜像 300MB。
  3. 部署后要真测:本地跑通 ≠ 云端跑通。Render 上我踩过环境变量没传全、CORS 配置不对两个坑——都是部署后立刻暴露的。

踩坑合集(速查表)

症状根因现状
扫描件 PDF提取文本为空无文字层静默跳过(绕过未解决)
容器反复崩溃Render 日志 OOMPyTorch + 模型吃满 512MBEmbedding 换 BGE-M3 API,镜像 2GB→300MB
.env 进 Git密钥泄露忘了 .gitignorefilter-branch 清理 + 更换密钥
Alembic 首次迁移"表已存在"已有 SQLite 库文件alembic stamp head
流式连接占线程用户一多接口全堵def + 同步 generator全量切 async def + AsyncOpenAI
非 PDF 上传接口 500/422未做异常处理没修,列入改进

改进路线图(诚实地标注:以下大多我都没做)

  • 已完成:Redis 缓存 + 限流(优雅降级)、tenacity 重试、全量异步化、Alembic 迁移、Docker 双容器
  • 搭了骨架未完成:Celery 异步任务(app/tasks.pyindex_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 试试,有问题评论区或私信都行。