用 deepagents 最小化生成一个 RAG 系统

目录

  1. 这份教程能学到什么
  2. 先搞懂几个核心概念
  3. 系统总览:数据是怎么流动的
  4. 环境准备
  5. 核心代码详解:agent.py
  6. Web 服务详解:server.py
  7. 前端页面详解:static/index.html
  8. 运行与验证
  9. 一次完整请求的数据流追踪
  10. 如何定制成你自己的系统
  11. 常见问题排查
  12. 总结与进阶方向

1. 这份教程能学到什么

读完这份教程,你将理解并能动手改造一个完整可运行的 RAG 智能体系统,它包含:

  • 一个知识库问答智能体:基于 ragres.md(火山引擎 AI 音视频互动方案文档)回答问题
  • 一个Web 服务:用 FastAPI 提供 API
  • 一个聊天网页:浏览器里打字对话,流式输出回答

涉及的技术点:

技术 作用
RAG 让大模型基于你的文档回答,而不是瞎编
Embedding + FAISS 把文档变成向量,快速找到相关内容
deepagents LangChain 出品的智能体框架,给大模型装上"工具"
工具调用 (Function Calling) 让大模型自主决定"何时去检索知识库"
SSE 流式输出 回答一个字一个字地冒出来,体验更好

最终效果:浏览器打开 http://localhost:8000,问它「如何初始化 RTC 引擎?」,它会先去知识库里找相关段落,再组织出带代码、带表格的回答。


2. 先搞懂几个核心概念

如果你已经懂 RAG,可以跳过这一节。下面用大白话解释。

2.1 什么是 RAG

RAG = Retrieval-Augmented Generation(检索增强生成)。

打个比方 📖:大模型(LLM)就像一个考生,知识库就像一本课本。

  • 直接问大模型 → 闭卷考试,它只能凭记忆答题,可能会过时、会瞎编。
  • RAG → 开卷考试,答题前先翻课本找相关页码,再照着课本组织答案。

为什么需要 RAG?

  1. 大模型的训练数据有截止日期,不知道最新信息。
  2. 大模型不知道你的私有文档(公司内部资料、某个 SDK 的使用文档等)。
  3. RAG 让大模型能基于你提供的文档回答,既准确又可控。

2.2 什么是 Embedding(向量化)

Embedding = 把一段文字变成一串数字(向量)。

打个比方 🗺️:给每段文字一个"GPS 坐标",意思相近的文字,坐标也相近。

  • "如何初始化引擎" 和 "引擎初始化方法" → 坐标挨得很近
  • "如何初始化引擎" 和 "今天天气不错" → 坐标隔得很远

这样,当用户提问时,把问题也变成坐标,然后在文档里找坐标最近的几段,就是最相关的内容。

本项目用的向量模型是 Ollama 本地的 qwen3-embedding:0.6b,它会把任何文本变成 1024 个数字组成的向量。

2.3 什么是向量数据库(FAISS)

向量数据库 = 专门存"坐标"的仓库,能快速找到离某个坐标最近的那几个。

本项目用 FAISS(Facebook 开源的高效向量搜索库)。你把成千上万个文档向量存进去,提问时它能在毫秒级返回最相似的几个。

2.4 什么是文本切分(Chunking)

一整篇文档太长,大模型一次吃不下,也不方便精确检索。所以要把文档切成一小段一小段(叫 chunk),每段几百字。

打个比方 📇:把整本书拆成一张张小卡片,查找时只取相关的几张卡片给大模型看。

2.5 什么是 Agent 和工具调用

普通的大模型调用:你问一句,它答一句,它没法主动去查资料。

Agent(智能体):给大模型装上工具(tools)。大模型可以自己决定「这个问题我需要先调用 search_knowledge_base 工具查一下」,查完拿到结果,再组织回答。

用户提问
   ↓
Agent(大模型) 思考:要不要查资料?
   ↓ 调用工具
search_knowledge_base("初始化引擎") → 返回相关文档片段
   ↓
Agent 拿着片段组织最终回答
   ↓
返回给用户

deepagents 就是 LangChain 提供的智能体框架,几行代码就能创建一个带工具、带记忆的智能体。

2.6 什么是 SSE 流式输出

SSE(Server-Sent Events)= 服务器主动把数据一点一点推给浏览器。

如果不流式:大模型生成完一整段才一次性返回,用户要干等 10 秒。
如果流式:大模型每生成一个字就立刻推给浏览器,用户看到回答「打字机式」地冒出来,体验好很多。


3. 系统总览:数据是怎么流动的

3.1 架构图

┌─────────────────────────────────────────────────────────────┐
│  准备阶段(只在首次启动时做一次)                              │
│                                                               │
│  ragres.md ──切分──> 文本片段(48个)                           │
│                          │                                    │
│                     Ollama 向量化                              │
│                     (qwen3-embedding)                         │
│                          │                                    │
│                          ▼                                    │
│                    FAISS 向量库 ──存盘──> vector_store/        │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  运行阶段(每次提问)                                         │
│                                                               │
│  浏览器 (index.html)                                          │
│      │  用户输入"如何初始化RTC引擎?"                          │
│      │  POST /chat/stream                                     │
│      ▼                                                        │
│  FastAPI (server.py)                                          │
│      │  调用 agent.astream(...)                                │
│      ▼                                                        │
│  deepagents 智能体 (agent.py)                                 │
│      │  glm-5.2 决定调用工具                                   │
│      ▼                                                        │
│  search_knowledge_base 工具                                    │
│      │  FAISS 相似度搜索 → top4 片段                           │
│      ▼                                                        │
│  glm-5.2 拿着片段生成回答                                      │
│      │  逐 token 流式输出                                      │
│      ▼                                                        │
│  SSE 推送给浏览器 → 打字机效果显示                              │
└─────────────────────────────────────────────────────────────┘

3.2 文件结构

Study02/
├── agent.py            # 核心:向量库 + 检索工具 + 智能体
├── server.py           # FastAPI 服务端(SSE 流式)
├── static/
│   └── index.html      # 前端聊天网页
├── ragres.md           # 知识库(你要问答的文档)
├── .env                # 配置(API Key、模型名等)
├── requirements.txt    # Python 依赖
├── README.md
└── vector_store/       # FAISS 索引(首次运行自动生成)

4. 环境准备

4.1 你需要准备什么

  1. Python 3.10+(本项目用 3.12)
  2. Ollama(本地运行向量模型)—— 从 https://ollama.com 下载安装
  3. 一个对话大模型的 API(本项目用火山方舟的 glm-5.2)

4.2 安装 Ollama 并拉取向量模型

安装好 Ollama 后,在终端执行:

ollama pull qwen3-embedding:0.6b

这会下载一个约 600MB 的向量模型到本地。下载完后,Ollama 会在 http://localhost:11434 提供服务,并兼容 OpenAI 的接口格式(/v1)。

💡 为什么用本地 Ollama 而不是在线向量模型?
因为本地运行不花钱、不依赖网络、数据不外泄。对于学习和小项目非常合适。

4.3 安装 Python 依赖

在项目目录下执行:

pip install -r requirements.txt

requirements.txt 内容:

deepagents                 # 智能体框架
langchain-openai           # 连接 OpenAI 兼容的大模型和向量模型
langchain-community        # 提供 FAISS 向量库封装
langchain-text-splitters   # 文本切分工具
faiss-cpu                  # 向量搜索
python-dotenv              # 读取 .env 配置
fastapi                    # Web 框架
uvicorn>=0.30              # ASGI 服务器,跑 FastAPI

4.4 配置 .env

.env 存放各种配置和密钥,程序用 python-dotenv 读取:

# ===== 本地 Ollama 向量模型(embedding)=====
OLLAMA_BASE_URL=http://localhost:11434/v1
EMBEDDING_MODEL=qwen3-embedding:0.6b
OLLAMA_API_KEY=ollama

# ===== 对话大模型(glm-5.2,OpenAI 兼容接口)=====
GLM_BASE_URL=<你的 glm-5.2 服务地址>
GLM_API_KEY=<你的 API Key>
GLM_MODEL=glm-5.2

⚠️ GLM_BASE_URL 和 GLM_API_KEY 需要你填上自己申请的地址和密钥。
「OpenAI 兼容接口」的意思是:只要服务地址遵循 OpenAI 的 API 格式,就能用同一套代码调用,不管背后是 GPT、Claude 还是 glm。


5. 核心代码详解:agent.py

这是整个项目最重要的文件。它做三件事:

  1. 把 ragres.md 变成向量库
  2. 把"检索向量库"封装成一个工具
  3. 用 deepagents 组装成智能体

下面逐段讲解。

5.1 导入和全局配置

from __future__ import annotations

import os
from functools import lru_cache
from pathlib import Path

from dotenv import load_dotenv
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langgraph.checkpoint.memory import InMemorySaver

from deepagents import (
    GeneralPurposeSubagentProfile,
    HarnessProfile,
    create_deep_agent,
    register_harness_profile,
)

load_dotenv()  # 读取 .env 文件,把配置加载进环境变量

逐个认识这些导入:

导入 作用
FAISS 向量数据库
Document LangChain 里"一段文本"的标准容器,有 page_content 属性
tool 装饰器,把普通函数变成"智能体可调用的工具"
ChatOpenAI 对话大模型(用 OpenAI 兼容接口,所以能连 glm-5.2)
OpenAIEmbeddings 向量模型(同样用 OpenAI 兼容接口,所以能连本地 Ollama)
RecursiveCharacterTextSplitter 文本切分器
InMemorySaver 记忆检查点(让多轮对话能记住上下文)
create_deep_agent deepagents 的核心函数,创建智能体
HarnessProfile 智能体的"配置档案",用来屏蔽不需要的工具
BASE_DIR = Path(__file__).resolve().parent
KB_PATH = BASE_DIR / "ragres.md"          # 知识库文件路径
VECTOR_STORE_DIR = BASE_DIR / "vector_store"  # 向量库存放目录

OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "qwen3-embedding:0.6b")
CHAT_MODEL = os.getenv("GLM_MODEL", "glm-5.2")

os.getenv("名字", "默认值") 表示「从环境变量读,读不到就用默认值」。这样配置都走 .env,代码里不写死。

5.2 系统提示词(System Prompt)

SYSTEM_PROMPT = """你是「火山引擎 AI 音视频互动方案」技术问答助手。你的职责是基于知识库回答用户关于该 Android 集成方案的问题。

## 工作流程
1. 收到用户问题后,**先调用 `search_knowledge_base` 工具**检索相关知识片段;
2. 基于检索到的内容组织回答,保持准确、条理清晰;
3. 若一次检索信息不足,可调整查询词再次检索;
4. 如果知识库中确实没有相关信息,坦诚告知用户,不要编造内容。
...
"""

系统提示词是给大模型的"人设说明书"。它告诉模型:你是谁、该怎么工作、有什么约束。

注意第 1 条明确要求「先调用工具检索」——这能引导大模型养成"先查资料再回答"的习惯,而不是凭记忆瞎答。这是 RAG 质量的关键。

5.3 连接向量模型

def _get_embeddings() -> OpenAIEmbeddings:
    """连接本地 Ollama 的向量模型。"""
    return OpenAIEmbeddings(
        model=EMBEDDING_MODEL,
        base_url=OLLAMA_BASE_URL,
        api_key=os.getenv("OLLAMA_API_KEY", "ollama"),
        check_embedding_ctx_length=False,
    )
  • model:用哪个向量模型(qwen3-embedding:0.6b)
  • base_url:Ollama 的地址
  • api_key:Ollama 本地不校验密钥,随便填个 "ollama" 即可(但参数必须有)
  • check_embedding_ctx_length=False:重要。OpenAI 的向量模型默认会用 tiktoken 数 token 来分块,但 Ollama 不支持这个,必须关掉,否则会报错。

5.4 构建向量库(最关键的一段)

def _build_vector_store() -> FAISS:
    index_file = VECTOR_STORE_DIR / "index.faiss"
    if index_file.exists():                              # ① 已有索引就直接加载
        return FAISS.load_local(
            str(VECTOR_STORE_DIR),
            _get_embeddings(),
            allow_dangerous_deserialization=True,
        )

    text = KB_PATH.read_text(encoding="utf-8")           # ② 读取知识库原文

    splitter = RecursiveCharacterTextSplitter(           # ③ 切分
        chunk_size=800,
        chunk_overlap=150,
        separators=["\n## ", "\n### ", "\n```\n", "\n\n", "\n", " ", ""],
    )
    chunks = splitter.split_text(text)
    docs = [Document(page_content=c) for c in chunks]

    print(f"[RAG] 正在用 {EMBEDDING_MODEL} 向量化 {len(docs)} 个片段…")
    vector_store = FAISS.from_documents(docs, _get_embeddings())  # ④ 向量化+建库
    VECTOR_STORE_DIR.mkdir(parents=True, exist_ok=True)
    vector_store.save_local(str(VECTOR_STORE_DIR))       # ⑤ 存盘
    print(f"[RAG] 向量库构建完成,已保存到 {VECTOR_STORE_DIR}")
    return vector_store

逐步解释:

① 懒加载:向量库建好后会存到 vector_store/ 目录。下次启动如果发现已有索引,直接加载,省去重新向量化的几十秒。

② 读原文:把 ragres.md 整篇读成字符串。

③ 切分:这是 RAG 质量的灵魂。RecursiveCharacterTextSplitter 的参数:

  • chunk_size=800:每段最多 800 个字符
  • chunk_overlap=150:相邻段重叠 150 字符(避免把一句话切断在两段里,导致语义丢失)
  • separators:按优先级尝试这些分隔符来切。先按 ## 标题切,再按 ### 子标题切……这样能尽量沿着 markdown 的结构边界切,不会把一个代码块拦腰切断。

💡 初学者要点:切分质量直接决定 RAG 效果。切得太碎,每段没上下文;切得太长,一段里混了多个主题。chunk_size=800 / overlap=150 是个不错的起步值。

④ 建库:FAISS.from_documents(docs, embeddings) 一次性完成「把每段文本向量化 → 存进 FAISS 索引」。

⑤ 存盘:把索引写到磁盘,下次直接 load_local 加载。

@lru_cache(maxsize=1)
def get_vector_store() -> FAISS:
    """单例获取向量库。"""
    return _build_vector_store()

@lru_cache(maxsize=1) 是个装饰器,保证全局只建一次向量库(单例模式),避免重复构建。

5.5 把检索封装成工具

def _make_retriever_tool(vector_store: FAISS):
    retriever = vector_store.as_retriever(search_kwargs={"k": 4})

    @tool
    def search_knowledge_base(query: str) -> str:
        """检索「火山引擎 AI 音视频互动方案 Android 集成指南」知识库。

        当用户询问 RTC SDK 集成、StartVoiceChat / StopVoiceChat OpenAPI、
        ASR/LLM/TTS 配置、鉴权 Token、实时字幕、函数调用等任何与该方案
        相关的问题时,调用本工具获取相关文档片段。参数 query 为检索关键词
        或用户问题的精炼表达。
        """
        docs = retriever.invoke(query)
        if not docs:
            return "未检索到相关内容。"
        blocks = [
            f"【片段 {i + 1}】\n{d.page_content}" for i, d in enumerate(docs)
        ]
        return "\n\n---\n\n".join(blocks)

    return search_knowledge_base

这段是连接「向量库」和「智能体」的桥梁:

  • vector_store.as_retriever(search_kwargs={"k": 4}):把向量库变成一个"检索器",每次检索返回最相似的 4 段(k=4)。
  • @tool 装饰器:把下面的函数变成智能体可调用的工具。
  • 函数的 docstring(文档字符串)非常重要! 大模型就是靠读这段 docstring 来决定「该不该调用这个工具」「怎么传参数」。写得越清楚,模型调用越准确。
  • 函数体:执行检索,把 4 段结果拼成带编号的文本返回给大模型。

💡 关键理解:大模型不会直接碰向量库,它只看到"有个叫 search_knowledge_base 的工具,传入查询词就能拿到相关文档"。至于向量、相似度这些细节,都藏在工具内部。

5.6 配置大模型

def get_llm() -> ChatOpenAI:
    return ChatOpenAI(
        model=CHAT_MODEL,                                  # glm-5.2
        api_key=os.getenv("GLM_API_KEY"),
        base_url=os.getenv("GLM_BASE_URL"),
        temperature=0.3,                                    # 低温度=更稳定
        max_tokens=4096,
        extra_body={"thinking": {"type": "disabled"}},      # 关闭扩展思考
        timeout=60,
    )

几个要点:

  • temperature=0.3:温度越低,输出越确定、越保守。技术问答场景适合低温度,避免模型"发挥"。
  • extra_body={"thinking": {"type": "disabled"}}:这是本项目的一个关键坑。

⚠️ 关于关闭思考:glm-5.2(以及火山方舟上的 Claude)默认开启扩展思考模式,思考过程输出到 reasoning_content 字段。这会吃掉 max_tokens 的预算,导致真正返回给用户的 content 是空的。

用 extra_body={"thinking": {"type": "disabled"}} 关闭思考后,模型才会把 token 花在正式回答上。如果你换用其他模型,可能不需要这个参数(或参数名不同),需要自己测试。

5.7 屏蔽 deepagents 自带的危险工具

def _configure_profile() -> None:
    register_harness_profile(
        "openai",
        HarnessProfile(
            excluded_tools=frozenset(
                {"ls", "read_file", "write_file", "edit_file", "glob", "grep", "execute"}
            ),
            general_purpose_subagent=GeneralPurposeSubagentProfile(enabled=False),
        ),
    )

这段非常重要,但容易被初学者忽略。

deepagents 默认会给智能体装上一整套"文件系统 + 命令行"工具:read_file、write_file、execute(执行 shell 命令)等。这些工具在"AI 编程助手"场景很有用,但在我们这个面向网页的问答机器人里是个安全隐患——万一有人用精心构造的提问诱导智能体执行恶意命令呢?

所以这里通过 HarnessProfile:

  • excluded_tools=...:屏蔽掉所有文件系统和 shell 工具
  • general_purpose_subagent=...enabled=False:屏蔽掉 task(子智能体)工具

最终智能体只能调用我们自己提供的 search_knowledge_base 工具,安全可控。

💡 "openai" 是注册的"档案键"。因为我们用 ChatOpenAI 连接模型,deepagents 会按 "openai" 这个 provider 来匹配档案,从而应用上面的屏蔽规则。

5.8 组装智能体

@lru_cache(maxsize=1)
def build_agent():
    """构建并缓存 deepagents 智能体。"""
    _configure_profile()                          # 屏蔽危险工具
    vector_store = get_vector_store()             # 加载向量库
    tools = [_make_retriever_tool(vector_store)]  # 准备工具

    return create_deep_agent(
        model=get_llm(),                          # 大模型
        tools=tools,                              # 可用工具
        system_prompt=SYSTEM_PROMPT,              # 系统提示词
        checkpointer=InMemorySaver(),             # 记忆(多轮对话)
    )

create_deep_agent 是 deepagents 的核心。四个参数:

  • model:用什么大模型
  • tools:智能体能用哪些工具(这里只有检索工具)
  • system_prompt:人设说明
  • checkpointer:检查点保存器。InMemorySaver() 把对话状态存在内存里,配合 thread_id 实现多轮对话记忆(同一个 session_id 的连续提问,模型记得上文)。

返回值 agent 是一个 LangGraph 编译好的图(CompiledStateGraph),可以 .invoke() 同步调用,也可以 .astream() 异步流式调用。

🎉 到这里,agent.py 就讲完了。它产出了一个 agent 对象,这是整个系统的大脑。


6. Web 服务详解:server.py

agent.py 产出的是个 Python 对象,没法直接给浏览器用。server.py 用 FastAPI 把它包装成 HTTP 接口。

6.1 框架搭建

from fastapi import FastAPI
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from agent import build_agent

app = FastAPI(title="RAG 智能体服务")
app.mount("/static", StaticFiles(directory="static"), name="static")  # 暴露静态文件
agent = build_agent()  # 进程启动时就建好智能体(单例)
  • app.mount("/static", ...):把 static/ 目录挂到 /static 路径,浏览器能访问里面的 JS/CSS。
  • agent = build_agent():在服务启动时就建好智能体,之后所有请求共用这一个实例(智能体构建耗时,不能每次请求都重建)。

6.2 请求体定义

class ChatRequest(BaseModel):
    message: str              # 用户的问题
    session_id: str | None = None  # 会话 ID(可选,用于多轮记忆)

Pydantic 的 BaseModel 用来自动校验请求体格式。

6.3 非流式接口 /chat

@app.post("/chat")
def chat(req: ChatRequest) -> dict:
    session_id = req.session_id or str(uuid.uuid4())  # 没传就生成一个
    result = agent.invoke(
        {"messages": [{"role": "user", "content": req.message}]},
        config={"configurable": {"thread_id": session_id}},
    )
    reply = result["messages"][-1].content
    return {"session_id": session_id, "reply": reply}
  • agent.invoke(...):同步调用智能体,等它完全生成后返回。
  • 输入格式:{"messages": [...]},这是 LangGraph 智能体的标准输入。
  • config={"configurable": {"thread_id": session_id}}:用 session_id 作为线程标识,同一个 session_id 的多次调用会共享上下文(多轮记忆的关键)。
  • result["messages"][-1]:取最后一条消息(就是智能体的最终回答)。

💡 非流式接口简单但体验差(用户要干等)。前端实际用的是下面的流式接口。

6.4 流式接口 /chat/stream(前端实际用的)

@app.post("/chat/stream")
async def chat_stream(req: ChatRequest) -> StreamingResponse:
    session_id = req.session_id or str(uuid.uuid4())
    config = {"configurable": {"thread_id": session_id}}
    inputs = {"messages": [{"role": "user", "content": req.message}]}

返回类型是 StreamingResponse,会持续把数据推给浏览器。

核心是内部的 event_stream 生成器:

    async def event_stream() -> AsyncIterator[str]:
        searching_emitted = False
        try:
            async for chunk, _meta in agent.astream(
                inputs, config=config, stream_mode="messages"
            ):
                # 只处理 AI 消息,跳过工具返回结果
                if not hasattr(chunk, "tool_call_chunks"):
                    continue

                # 检测到工具调用 -> 告诉前端"正在检索"
                for tc in chunk.tool_call_chunks or []:
                    if tc.get("name"):
                        if not searching_emitted:
                            yield _sse({"status": "searching"})
                            searching_emitted = True
                        break

                # 流式输出文本
                content = getattr(chunk, "content", None)
                if isinstance(content, str) and content:
                    yield _sse({"content": content})

            yield _sse({"done": True, "session_id": session_id})
        except Exception as e:
            yield _sse({"error": str(e)})

逐段理解:

  • agent.astream(..., stream_mode="messages"):异步流式调用智能体。stream_mode="messages" 表示按"消息块"流式返回。每来一小段就 yield 一次。
  • if not hasattr(chunk, "tool_call_chunks"): continue:只处理 AI 消息。智能体运行中会产生多种消息(AI 消息、工具消息等),工具返回的结果(ToolMessage)不该显示给用户,所以跳过。
  • 检测工具调用:当 AI 决定调用工具时,chunk 里会带 tool_call_chunks。这时给前端发一个 {"status": "searching"} 事件,前端就显示"正在检索知识库…"的动画。
  • 流式输出文本:AI 回答的每个文本片段,包成 {"content": "..."} 事件发出去。
  • 最后发 {"done": true, ...} 表示结束。

SSE 事件格式

def _sse(data: dict) -> str:
    return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"

SSE 的格式很简单:每条事件是 data: <内容>\n\n(两个换行结尾)。ensure_ascii=False 保证中文不被转成 \uXXXX。

所以前端会收到这样一串:

data: {"status": "searching"}

data: {"content": "初始化"}

data: {"content": " RTC"}

data: {"content": " 引擎"}

data: {"done": true, "session_id": "abc123"}

6.5 启动入口

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("server:app", host="0.0.0.0", port=8000, reload=False)

python server.py 启动后,服务监听 8000 端口。


7. 前端页面详解:static/index.html

前端是一个单文件 HTML(HTML + CSS + JS 全在一个文件里),用原生 JS 实现,不依赖框架。

7.1 引入的外部库

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/github-dark.min.css" />
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.2/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/highlight.min.js"></script>
  • marked.js:把 Markdown 文本渲染成 HTML(因为大模型回答里常有代码块、表格、列表)
  • highlight.js:给代码块做语法高亮上色

7.2 页面结构(简化版)

<header>  顶部标题栏 + 状态徽章  </header>
<main id="chat">
  <div class="welcome">  欢迎屏 + 建议问题  </div>
  <!-- 消息气泡会动态插在这里 -->
</main>
<footer>
  <form class="input-wrap">
    <textarea id="input">  输入框  </textarea>
    <button class="send">  发送按钮  </button>
  </form>
</footer>

7.3 Markdown 渲染与代码高亮

if (window.marked) {
  marked.setOptions({ breaks: true, gfm: true });  // 支持换行和 GitHub 风格 markdown
}
function renderMd(text) {
  if (window.marked) {
    try { return marked.parse(text); } catch (_) { return escapeHtml(text); }
  }
  return escapeHtml(text);  // marked 没加载就转义成纯文本
}
function highlightCode(container) {
  if (!window.hljs) return;
  container.querySelectorAll("pre code").forEach(el => {
    if (el.dataset.highlighted) return;  // 避免重复高亮
    try { hljs.highlightElement(el); el.dataset.highlighted = "1"; } catch (_) {}
  });
}

renderMd 把 markdown 文本变成 HTML;highlightCode 给渲染出来的代码块上色。注意高亮只在回答结束时做一次(流式过程中频繁高亮会卡顿)。

7.4 发送消息 + 接收 SSE 流(最核心的 JS)

async function submit() {
  const message = inputEl.value.trim();
  if (!message || streaming) return;  // 防止重复发送

  streaming = true;
  sendBtn.disabled = true;

  addMsg("user", message);                    // 显示用户消息气泡
  const aiBubble = addMsg("ai", "");           // 先建一个空的 AI 气泡
  aiBubble.innerHTML = '<span class="cursor"></span>';  // 闪烁光标

  // 发起流式请求
  const resp = await fetch("/chat/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message, session_id: sessionId }),
  });

  const reader = resp.body.getReader();        // 拿到流式读取器
  const decoder = new TextDecoder("utf-8");
  let buffer = "";   // 缓冲区:存放"还没拼成完整事件"的碎片
  let acc = "";      // 累积的完整回答文本
  let searchRow = null;

  while (true) {
    const { done, value } = await reader.read();   // 读一块数据
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    // 按 SSE 事件边界(空行)切分
    const parts = buffer.split("\n\n");
    buffer = parts.pop();  // 最后一段可能不完整,留到下次
    for (const part of parts) {
      const line = part.trim();
      if (!line.startsWith("data:")) continue;
      const data = JSON.parse(line.slice(5).trim());

      if (data.status === "searching") {
        // 显示"正在检索知识库…"动画
        if (!searchRow) searchRow = addStatus("正在检索知识库…");
      } else if (data.content) {
        // 累积文本,实时刷新气泡内容(打字机效果)
        if (searchRow) { searchRow.remove(); searchRow = null; }
        acc += data.content;
        aiBubble.innerHTML = renderMd(acc) + '<span class="cursor"></span>';
      } else if (data.done) {
        sessionId = data.session_id;  // 记住会话 ID(多轮对话)
        aiBubble.innerHTML = renderMd(acc) || "<em>(无内容)</em>";
        highlightCode(aiBubble);      // 最后做一次代码高亮
      } else if (data.error) {
        aiBubble.innerHTML = renderMd("⚠️ 出错了:" + data.error);
      }
    }
  }
}

这段是前端的灵魂,几个要点:

  1. 用 fetch + getReader() 读取流式响应:这是浏览器接收 SSE 的原生方式。
  2. buffer 缓冲机制:网络数据是一块块来的,可能把一条 SSE 事件切成两半。所以用一个 buffer 累积,按 \n\n(事件边界)切分,最后一段(可能不完整)pop 出来留到下次。这是处理流式数据的经典套路。
  3. 打字机效果:每收到一个 content 事件,就 acc += data.content 然后重新渲染整个气泡。虽然每次都重渲染,但回答通常不长,性能没问题。
  4. 会话记忆:data.done 里带回 session_id,存到全局 sessionId,下次提问带上,服务端就能关联上下文。

7.5 键盘交互

formEl.addEventListener("submit", e => { e.preventDefault(); submit(); });
inputEl.addEventListener("input", autoGrow);  // 输入框自动增高
inputEl.addEventListener("keydown", e => {
  if (e.key === "Enter" && !e.shiftKey) {  // Enter 发送,Shift+Enter 换行
    e.preventDefault();
    submit();
  }
});

8. 运行与验证

8.1 启动服务

确保:

  1. Ollama 在跑(ollama list 能看到 qwen3-embedding:0.6b)
  2. .env 里 GLM_BASE_URL 和 GLM_API_KEY 已填好

然后:

python server.py

首次启动会看到:

[RAG] 正在用 qwen3-embedding:0.6b 向量化 48 个片段…
[RAG] 向量库构建完成,已保存到 vector_store
INFO:     Uvicorn running on http://0.0.0.0:8000

⏳ 首次构建向量库约需 30~40 秒(要把 48 段文本逐一向量化)。之后再启动只需几秒(直接加载磁盘索引)。

8.2 打开网页

浏览器访问 http://localhost:8000,看到一个聊天界面,点击建议问题或自己输入即可。

8.3 用命令行验证接口

不打开浏览器也能测:

# 健康检查
curl http://localhost:8000/health
# 返回: {"status":"ok"}

# 非流式问答
curl -X POST http://localhost:8000/chat ^
     -H "Content-Type: application/json" ^
     -d "{\"message\":\"StartVoiceChat 有哪些关键参数?\"}"

9. 一次完整请求的数据流追踪

让我们追踪一个问题「如何初始化 RTC 引擎?」从输入到显示的完整旅程:

1. 用户在浏览器输入「如何初始化 RTC 引擎?」按回车
   │
2. 前端 JS: fetch("/chat/stream", {body: {message: "如何初始化 RTC 引擎?", session_id: null}})
   │
3. server.py 收到请求
   │  调用 agent.astream({"messages":[{"role":"user","content":"如何初始化RTC引擎?"}]},
   │                     config={"configurable":{"thread_id":"随机UUID"}})
   │
4. deepagents 智能体启动,glm-5.2 读系统提示词,思考:
   │  "用户问 RTC 引擎初始化,我应该先调用 search_knowledge_base 工具"
   │  → 产出工具调用: search_knowledge_base(query="初始化 RTC 引擎")
   │
5. server.py 检测到工具调用,向前端推送: data: {"status":"searching"}
   │  前端显示「正在检索知识库…」动画
   │
6. search_knowledge_base 工具执行:
   │  - 把 "初始化 RTC 引擎" 用 Ollama 向量化成 1024 维向量
   │  - 在 FAISS 里找最相似的 4 段
   │  - 命中: §4.3 初始化 RTC 引擎、§4.2 权限配置 等片段
   │  - 拼成文本返回给智能体
   │
7. glm-5.2 拿到文档片段,开始组织回答,逐 token 输出:
   │  "## " → "初始化" → " RTC" → " 引擎" → ...
   │  每来一小段,server.py 推送: data: {"content":"初始化"} ...
   │
8. 前端 JS 每收到 content 事件,就 acc += 内容,重新渲染气泡
   │  用户看到回答像打字机一样冒出来
   │
9. 智能体输出结束,server.py 推送: data: {"done":true,"session_id":"..."}
   │  前端做代码高亮,保存 session_id(下次提问带上以保持上下文)
   │
10. 完成!用户看到一个带 Kotlin 代码块、参数表格的完整回答

10. 如何定制成你自己的系统

10.1 换成你自己的知识库

最简单的改动:把 ragres.md 换成你自己的文档。

  1. 准备一个 .md(或 .txt)文件,替换 ragres.md
  2. 删除 vector_store/ 目录(强制重建索引)
  3. 修改 agent.py 里的 KB_PATH 和 SYSTEM_PROMPT(把"火山引擎"相关描述改成你的领域)
  4. 重新 python server.py

💡 知识库可以是多个文件。如果你想加载多个文件,可以用 LangChain 的 DirectoryLoader 或 TextLoader 批量读取,把所有 Document 合并后再建库。

10.2 换个大模型

修改 .env:

GLM_BASE_URL=<新地址>
GLM_API_KEY=<新密钥>
GLM_MODEL=<新模型名>

只要是 OpenAI 兼容接口(GPT、Claude、通义千问、DeepSeek 等都兼容),代码不用改。

⚠️ 如果新模型也默认开"思考",可能需要调整 extra_body。如果模型不需要关思考,把 get_llm() 里的 extra_body=... 那行删掉即可。

10.3 换个向量模型

修改 .env:

EMBEDDING_MODEL=<ollama 里的其他向量模型>

比如 bge-m3、nomic-embed-text 等(用 ollama pull 先拉取)。换后记得删除 vector_store/ 重建(不同模型的向量维度不同,索引不通用)。

10.4 调整检索质量

在 _make_retriever_tool 里:

retriever = vector_store.as_retriever(search_kwargs={"k": 4})
#                                                ^^^
# 调大 k 会返回更多片段(信息更全但可能更噪),调小则更聚焦

在 _build_vector_store 里调整切分参数:

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,    # 调大:每段更长,上下文更全;调小:更精确
    chunk_overlap=150, # 调大:重叠更多,不易漏信息;但段数变多
    ...
)

10.5 增加更多工具

deepagents 智能体可以挂多个工具。比如你想让机器人能查天气:

@tool
def get_weather(city: str) -> str:
    """查询指定城市的天气。"""
    # ... 调用天气 API ...
    return f"{city}: 晴, 25°C"

tools = [_make_retriever_tool(vector_store), get_weather]

大模型会根据问题自主决定用哪个工具。


11. 常见问题排查

Q1:启动时报连接 Ollama 失败

现象:ConnectionError 或 http://localhost:11434 相关错误。

排查:

  • 确认 Ollama 在运行:终端执行 ollama list,能看到模型列表
  • 确认模型已拉取:列表里要有 qwen3-embedding:0.6b
  • 确认端口:浏览器访问 http://localhost:11434 应返回 Ollama is running

Q2:大模型回复是空的

现象:/chat 返回的 reply 是空字符串,或前端气泡一直空白。

排查:

  • 十有八九是思考模式没关。确认 get_llm() 里有 extra_body={"thinking": {"type": "disabled"}}
  • 检查 .env 的 GLM_API_KEY 和 GLM_BASE_URL 是否正确

Q3:大模型不调用检索工具,直接瞎答

排查:

  • 检查 SYSTEM_PROMPT 是否明确要求"先调用工具"
  • 检查工具的 docstring 是否清晰描述了"什么时候该用这个工具"
  • 可以临时调高 temperature(如 0.7)看模型是否更愿意探索工具

Q4:检索结果不相关

排查:

  • 检查切分参数:chunk_size 是否太大/太小
  • 尝试增加 k 值(返回更多片段)
  • 向量模型对中文支持好不好(qwen3-embedding 对中文不错)
  • 在 SYSTEM_PROMPT 里教模型"检索词要精炼",或者让它"换个词再检索一次"

Q5:换知识库后检索还是旧内容

原因:向量库还是旧的索引。

解决:删除 vector_store/ 目录后重启,强制重建。

Q6:多轮对话没有记忆

排查:

  • 确认前端每次请求都带上了 session_id(首次为 null,服务端返回后要保存)
  • 确认 agent.invoke/astream 的 config 里传了 thread_id
  • InMemorySaver() 的记忆只在进程内,服务重启就丢失。需要持久化可换成基于数据库的 checkpointer。

Q7:Windows 终端显示中文乱码

原因:Windows 控制台默认 GBK 编码,而程序输出 UTF-8。

解决:运行前设置 set PYTHONUTF8=1(PowerShell 用 $env:PYTHONUTF8=1)。这只影响终端显示,不影响网页显示(网页用 UTF-8,正常)。


12. 总结与进阶方向

12.1 你现在掌握了什么

回顾一下这套系统的核心套路,它其实就是一个最小化 RAG 智能体的标准结构:

文档 → 切分 → 向量化 → 向量库
                      ↓
                  检索工具 ← 智能体(deepagents) → 大模型
                                    ↓
                                Web 服务(FastAPI) → 前端网页

记住这个结构,换文档、换模型、换前端,套路都一样。

12.2 关键的几行代码

如果只能记住 5 件事:

  1. 切分:RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=150)
  2. 建库:FAISS.from_documents(docs, embeddings)
  3. 工具:@tool 装饰器 + 清晰的 docstring
  4. 智能体:create_deep_agent(model=..., tools=..., system_prompt=..., checkpointer=...)
  5. 流式:agent.astream(..., stream_mode="messages") + SSE

12.3 进阶方向

想继续深入,可以研究:

方向 做什么
持久化记忆 把 InMemorySaver 换成 SqliteSaver / PostgresSaver,重启不丢对话
多文档/多种格式 用 DirectoryLoader 加载整个目录,支持 PDF、Word 等
混合检索 向量检索 + 关键词检索(BM25)结合,提升召回率
重排序(Rerank) 检索出 top-20 后用 rerank 模型精排成 top-4
引用溯源 在回答里标注"这段来自文档第 X 节",方便核对
评估 准备一批问答对,自动评估 RAG 的准确率
deepagents 高级特性 子智能体、长期记忆、文件系统工具等(本教程为安全起见都关了)

12.4 项目文件速查表

文件 行数 一句话作用
agent.py ~180 建向量库、造检索工具、组装智能体
server.py ~120 FastAPI 服务,提供流式 API
static/index.html ~500 聊天网页,接收 SSE 流式显示
ragres.md — 知识库原文
.env — 配置和密钥
requirements.txt — 依赖清单

项目代码

https://gitee.com/itfitness/deepagent-small-rag

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容