目录
- 这份教程能学到什么
- 先搞懂几个核心概念
- 系统总览:数据是怎么流动的
- 环境准备
- 核心代码详解:
agent.py - Web 服务详解:
server.py - 前端页面详解:
static/index.html - 运行与验证
- 一次完整请求的数据流追踪
- 如何定制成你自己的系统
- 常见问题排查
- 总结与进阶方向
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?
- 大模型的训练数据有截止日期,不知道最新信息。
- 大模型不知道你的私有文档(公司内部资料、某个 SDK 的使用文档等)。
- 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 你需要准备什么
- Python 3.10+(本项目用 3.12)
- Ollama(本地运行向量模型)—— 从 https://ollama.com 下载安装
- 一个对话大模型的 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
这是整个项目最重要的文件。它做三件事:
- 把
ragres.md变成向量库 - 把"检索向量库"封装成一个工具
- 用 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);
}
}
}
}
这段是前端的灵魂,几个要点:
-
用
fetch+getReader()读取流式响应:这是浏览器接收 SSE 的原生方式。 -
buffer缓冲机制:网络数据是一块块来的,可能把一条 SSE 事件切成两半。所以用一个buffer累积,按\n\n(事件边界)切分,最后一段(可能不完整)pop出来留到下次。这是处理流式数据的经典套路。 -
打字机效果:每收到一个
content事件,就acc += data.content然后重新渲染整个气泡。虽然每次都重渲染,但回答通常不长,性能没问题。 -
会话记忆:
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 启动服务
确保:
- Ollama 在跑(
ollama list能看到qwen3-embedding:0.6b) -
.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 换成你自己的文档。
- 准备一个
.md(或.txt)文件,替换ragres.md - 删除
vector_store/目录(强制重建索引) - 修改
agent.py里的KB_PATH和SYSTEM_PROMPT(把"火山引擎"相关描述改成你的领域) - 重新
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 件事:
-
切分:
RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=150) -
建库:
FAISS.from_documents(docs, embeddings) -
工具:
@tool装饰器 + 清晰的 docstring -
智能体:
create_deep_agent(model=..., tools=..., system_prompt=..., checkpointer=...) -
流式:
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 |
— | 依赖清单 |