从零开始做一个高校课程资料 AI Agent 问答系统
本系列博客将带你从零开始,使用 Python + FastAPI + RAG + AI Agent 搭建一个面向 Java Web 课程资料问答与智能学习辅助 的后端系统,涵盖 课程资料上传、文档解析、文本切块、本地检索、问答生成、Agent 工具调用、执行链路追踪、运行记录回放 等实战场景。无论你是 AI 应用初学者,还是希望掌握 RAG 与 Agent 工程化落地的开发者,都能通过本教程理解一个教学场景 AI Agent 系统的规划、开发、测试与升级过程。
内容主要包括:
✅ 基础架构搭建:FastAPI 后端项目初始化、接口路由设计、Pydantic Schema、SQLAlchemy 数据模型、SQLite 本地数据库配置。
✅ 课程资料知识库构建:支持 Markdown、TXT、代码文件、PDF、Word、PPTX 等资料上传,完成文档解析、文本切块、来源元数据保存与资料入库。
✅ RAG 问答流程实现:基于课程资料 chunk 进行本地检索,构造 grounded prompt,生成带引用来源的回答,并在资料不足时安全拒答,降低大模型幻觉。
✅ LLM 接入与兜底机制:支持 OpenAI-compatible 接口,可对接 OpenAI、Ollama、本地大模型等,同时保留 stub fallback,保证系统在无模型环境下也能稳定测试。
✅ AI Agent 能力升级:从简单 Agent Harness 逐步升级为具备 Planner、Executor、Tool Registry、Memory、Verifier 的课程学习 Agent,实现任务规划、工具调用、短期记忆、结果校验和执行追踪。
✅ Agent 执行记录与回放:新增 AgentRun、AgentStep、AgentToolCall 数据模型,保存每次 Agent 的执行计划、工具调用、引用来源、校验结果,并提供运行历史查询接口。
✅ 接口调试与测试验证:通过 Swagger UI 手动测试文档上传、问答、Agent 运行、工具列表和运行记录接口,并使用 pytest 编写自动化测试,覆盖 RAG、Agent、工具、记忆和校验流程。
✅ 系统演进与工程实践:从 MVP 到 v1.2.0 Agent 升级,逐步讲解如何控制范围、拆分模块、保持接口兼容、设计可测试代码,并为后续前端 Trace 面板、向量检索、LangGraph 工作流和 SaaS 化扩展打基础。
提问时系统内部发生了什么
当前项目的提问入口是:
text
POST /api/chat/ask
核心链路涉及这些文件:
- 你的路径\agent\backend\app\api\chat.py
- 你的路径\agent\backend\app\services\chat.py
- 你的路径\agent\backend\app\services\retrieval.py
- 你的路径\agent\backend\app\services\prompt.py
- 你的路径\agent\backend\app\models\chat.py
- 你的路径\agent\backend\app\schemas\chat.py
1. 用户发起提问请求
接口定义在:
python
router = APIRouter(prefix="/api/chat", tags=["chat"])
具体接口是:
python
@router.post("/ask", response_model=ChatAskResponse)
def ask(request: ChatAskRequest) -> dict:
所以完整地址是:
text
POST /api/chat/ask
请求体结构来自 ChatAskRequest:
python
class ChatAskRequest(BaseModel):
question: str = Field(min_length=1, max_length=2000)
question_type: str = Field(default="lab_steps")
session_id: int | None = None
也就是说,前端可以传:
json
{
"question": "LoginServlet 的作用是什么?",
"question_type": "code_explanation",
"session_id": null
}
字段含义:
| 字段 | 含义 |
|---|---|
question |
用户问题,不能为空,最长 2000 字符 |
question_type |
问题类型,默认是 lab_steps |
session_id |
会话 ID;如果为空,则创建新会话 |
2. FastAPI 先做请求校验
因为用了 Pydantic:
python
question: str = Field(min_length=1, max_length=2000)
所以如果用户传空问题:
json
{
"question": ""
}
接口会直接校验失败,不会进入业务逻辑。
如果问题超过 2000 字符,也会被拦截。
3. 初始化数据库表
接口进入后第一步:
python
init_db()
这会确保数据库表存在。
当前和提问相关的表主要是:
text
chat_sessions
chat_messages
chunks
documents
其中:
chunks:上传资料解析后的文本切片chat_sessions:聊天会话chat_messages:用户问题和助手回答
4. 调用核心问答函数 answer_question
接口接着调用:
python
return answer_question(
question=request.question,
question_type=request.question_type,
session_id=request.session_id,
)
核心逻辑在:
python
app/services/chat.py
入口函数是:
python
def answer_question(question: str, question_type: str = "lab_steps", session_id: int | None = None) -> dict:
5. 先从资料 chunks 中检索相关内容
answer_question() 的第一步是:
python
chunks = retrieve_chunks(question, top_k=5)
也就是说,系统会根据用户问题,从之前上传资料生成的 chunks 表里找最相关的 5 个片段。
注意:当前项目没有调用大模型做语义检索,也没有 embedding,也没有向量数据库。
当前检索方式是:
text
关键词/字符匹配检索
6. 检索逻辑:把问题切成 tokens
检索逻辑在:
python
app/services/retrieval.py
里面定义了一个正则:
python
TOKEN_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]+", re.UNICODE)
它会识别两类内容:
- 英文、数字、下划线组成的词
例如:
text
LoginServlet
UserService
doPost
- 连续中文
例如:
text
登录代码是什么意思
7. 中文会被拆成单字和双字词
tokenize() 里有一段逻辑:
python
if re.fullmatch(r"[\u4e00-\u9fff]+", token):
tokens.update(token)
tokens.update(token[index : index + 2] for index in range(len(token) - 1))
假设用户问:
text
登录
会得到:
text
登
录
登录
假设用户问:
text
登录代码
会得到:
text
登
录
代
码
登录
录代
代码
这样做的目的是:让中文问题即使没有专业分词库,也能做一些粗略匹配。
缺点是:它不是语义理解,只是字符/短词匹配。
8. 英文和代码标识符会转成小写
对于英文、数字、下划线 token:
python
tokens.add(token.lower())
例如:
text
LoginServlet
会变成:
text
loginservlet
这样用户问 loginservlet 或 LoginServlet,都能匹配到。
9. 从数据库读取所有 chunks
检索函数里:
python
with SessionLocal() as session:
chunks = list(session.scalars(select(Chunk)).all())
这一步会把 chunks 表里的所有资料片段都取出来。
当前实现没有做数据库层过滤,也没有全文索引。
也就是说:
text
每次提问都会扫描全部 chunks
数据量小时没问题;资料很多时性能会下降。
10. 对每个 chunk 做匹配打分
对每个 chunk,系统会分别提取:
python
content_tokens = tokenize(chunk.content)
source_tokens = tokenize(chunk.source_path or "")
也就是:
- chunk 内容里的 tokens
- 文件路径里的 tokens
然后计算和问题的交集:
python
overlap = query_tokens & (content_tokens | source_tokens)
如果没有任何重叠:
python
if not overlap:
continue
这个 chunk 就被跳过。
如果有重叠,就计算分数:
python
score = len(overlap) / math.sqrt(max(len(content_tokens), 1))
这个分数大概意思是:
text
匹配到的 token 越多,分数越高;
chunk 本身越长,分数会被适当压低。
例如用户问:
text
LoginServlet 登录代码是什么意思
如果某个 chunk 里包含:
text
LoginServlet
登录
doPost
request
response
它就可能被排到前面。
11. 返回得分最高的 5 个 chunks
检索结束后:
python
return sorted(scored, key=lambda item: item.score, reverse=True)[:top_k]
默认 top_k=5。
也就是说,最多返回 5 个相关资料片段。
每个返回结果是:
python
@dataclass(frozen=True)
class RetrievedChunk:
chunk_id: int
document_id: int
content: str
source_title: str | None
source_path: str | None
source_page: int | None
language: str | None
score: float
字段含义:
| 字段 | 含义 |
|---|---|
chunk_id |
chunk 表里的 ID |
document_id |
来源资料 ID |
content |
匹配到的资料片段内容 |
source_title |
来源文件名 |
source_path |
来源文件路径 |
source_page |
PDF/PPT 页码 |
language |
如果是代码文件,记录语言 |
score |
匹配分数 |
12. 如果没有检索到资料
如果:
python
if not chunks:
系统会返回固定回答:
python
INSUFFICIENT_CONTEXT_ANSWER = "课程资料中未找到足够依据,暂时无法基于已导入资料回答这个问题。"
也就是说,如果资料库里没有匹配内容,回答是:
text
课程资料中未找到足够依据,暂时无法基于已导入资料回答这个问题。
然后仍然会保存这次问答记录。
13. 如果检索到了资料,构造 contexts
如果找到了相关 chunks,会先构造上下文:
python
contexts = _build_contexts(chunks)
对应函数:
python
def _build_contexts(chunks: list[RetrievedChunk]) -> list[dict[str, str]]:
return [
{
"source": chunk.source_path or chunk.source_title or f"chunk:{chunk.chunk_id}",
"content": chunk.content,
}
for chunk in chunks
]
生成结果大概是:
json
[
{
"source": "uploads/xxx-login-lab.md",
"content": "LoginServlet 用于处理登录请求..."
},
{
"source": "uploads/xxx-user-service.java",
"content": "public class UserService ..."
}
]
14. 根据问题类型构造 prompt
然后调用:
python
prompt = build_prompt(question=question, question_type=question_type, contexts=contexts)
这个函数在:
python
app/services/prompt.py
它会根据 question_type 决定回答结构。
支持这些类型:
python
QUESTION_FORMATS = {
"lab_steps": "...",
"code_explanation": "...",
"error_debugging": "...",
"exam_prediction": "...",
}
15. 不同 question_type 对应不同输出结构
lab_steps
用于实验步骤类问题:
text
1. 实验目标
2. 前置条件
3. 操作步骤
4. 常见错误
5. 引用来源
code_explanation
用于代码解释类问题:
text
1. 代码整体作用
2. 关键语句解释
3. 在项目中的位置
4. 可能的错误点
5. 引用来源
error_debugging
用于报错排查类问题:
text
1. 可能原因
2. 排查顺序
3. 解决建议
4. 资料不足说明
5. 引用来源
exam_prediction
用于复习/考试预测类问题:
text
1. 重点知识点
2. 可能题型
3. 示例问题
4. 复习建议
5. 引用来源
如果传入未知类型,会默认使用:
text
lab_steps
16. prompt 内容是什么
build_prompt() 返回的 prompt 大致是:
text
你是 Java Web 开发课程的实验助教。
你只能基于给定课程资料回答。
如果资料中没有足够依据,必须明确说明"课程资料中未找到足够依据"。
不要编造教材、页码、实验步骤或代码含义。
输出结构:
...
课程资料:
[来源: xxx]
资料片段内容...
学生问题:xxx
这个 prompt 设计上是给大模型用的,目的是约束回答:
text
只能基于课程资料回答,不允许编造。
但是要注意一个关键点:当前项目构造了 prompt,但没有真正调用大模型。
17. 当前回答是"占位式生成",不是 LLM 生成
回答生成函数是:
python
answer = generate_grounded_stub_answer(question, question_type, chunks, prompt)
函数名里有 stub,说明它是一个占位实现。
核心逻辑是:
python
primary = chunks[0]
source = primary.source_path or primary.source_title or "课程资料"
它只取检索结果里的第一个 chunk,也就是分数最高的 chunk。
18. 代码解释类问题的回答方式
如果:
python
question_type == "code_explanation"
返回:
python
return (
f"根据课程资料中的 {source},这段代码的核心含义是:{primary.content} "
f"因此,针对"{question}",可以理解为它围绕该 Java Web 代码片段说明请求处理、业务调用或相关流程。"
)
也就是说,它会把最相关 chunk 的原文塞进回答里,然后加一句通用解释。
例如可能返回:
text
根据课程资料中的 uploads/xxx-LoginServlet.java,这段代码的核心含义是:...
因此,针对"LoginServlet 是干嘛的?",可以理解为它围绕该 Java Web 代码片段说明请求处理、业务调用或相关流程。
19. 其他类型问题的回答方式
如果不是 code_explanation,返回:
python
return f"根据课程资料中的 {source},可回答:{primary.content}"
也就是说,当前系统并不会真正总结、推理、改写成完整自然语言答案。
它主要是:
text
找到最相关资料片段,然后把片段作为答案返回。
20. 构造 citations 引用来源
系统会把所有检索到的 chunks 转成引用信息:
python
citations = [_citation_from_chunk(chunk) for chunk in chunks]
每条引用包含:
python
{
"chunk_id": chunk.chunk_id,
"document_id": chunk.document_id,
"source_title": chunk.source_title,
"source_path": chunk.source_path,
"source_page": chunk.source_page,
"score": chunk.score,
}
返回给前端后,前端可以显示:
- 答案来自哪个文件
- 来自哪个 chunk
- 如果是 PDF/PPT,来自第几页
- 匹配分数是多少
21. 创建或复用聊天会话
回答生成后,会进入:
python
_persist_answer(session_id, question, question_type, answer, citations)
里面第一步是:
python
resolved_session_id = _get_or_create_session_id(session_id, question)
逻辑是:
python
if session_id is not None:
existing = session.scalar(select(ChatSession).where(ChatSession.id == session_id))
if existing is not None:
return existing.id
也就是说:
- 如果前端传了有效的
session_id,就继续使用这个会话 - 如果没传,或者传了不存在的 ID,就创建新会话
创建新会话时:
python
chat_session = ChatSession(title=question[:80] or "新会话")
会话标题取用户问题前 80 个字符。
22. 保存用户问题和助手回答
当前项目会保存两条消息。
第一条是用户消息:
python
ChatMessage(
session_id=resolved_session_id,
role="user",
question_type=question_type,
content=question,
citations=[],
)
第二条是助手消息:
python
ChatMessage(
session_id=resolved_session_id,
role="assistant",
question_type=question_type,
content=answer,
citations=citations,
)
也就是说,每次提问会往 chat_messages 表里插入两行:
text
一行 user
一行 assistant
23. 聊天相关数据库表
chat_sessions
模型是:
python
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: int
user_id: int | None
title: str
created_at: datetime
updated_at: datetime
用于记录一个对话会话。
当前 user_id 可以为空,说明用户系统还没有真正接入。
chat_messages
模型是:
python
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: int
session_id: int
role: str
question_type: str | None
content: str
citations: list[dict]
created_at: datetime
用于保存每一条聊天消息。
其中:
role="user"表示用户问题role="assistant"表示系统回答citations保存引用来源 JSON
24. 接口最终返回什么
返回结构由 ChatAskResponse 定义:
python
class ChatAskResponse(BaseModel):
session_id: int
answer: str
citations: list[CitationRead]
所以接口最终返回类似:
json
{
"session_id": 1,
"answer": "根据课程资料中的 uploads/xxx-login-lab.md,可回答:LoginServlet 用于处理登录请求...",
"citations": [
{
"chunk_id": 3,
"document_id": 1,
"source_title": "login-lab.md",
"source_path": "uploads/xxx-login-lab.md",
"source_page": null,
"score": 0.57
}
]
}
25. 完整流程图
text
用户提问
|
v
POST /api/chat/ask
|
v
Pydantic 校验请求
|
|-- question 不能为空
|-- question 最长 2000
|-- question_type 默认 lab_steps
|-- session_id 可选
v
init_db()
|
v
answer_question()
|
v
retrieve_chunks(question, top_k=5)
|
|-- 对问题分词
|-- 从 chunks 表读取所有资料片段
|-- 对每个 chunk 分词
|-- 计算问题 tokens 和 chunk tokens 的重叠
|-- 按 score 排序
|-- 返回前 5 个 chunks
v
是否找到相关 chunks?
|
|-- 否
| |
| v
| 返回"课程资料中未找到足够依据..."
|
|-- 是
|
v
_build_contexts()
|
v
build_prompt()
|
v
generate_grounded_stub_answer()
|
|-- 当前不调用大模型
|-- 只使用分数最高的 chunk 生成占位式答案
v
构造 citations
|
v
创建或复用 chat_session
|
v
保存 user 消息
|
v
保存 assistant 消息
|
v
返回 session_id、answer、citations
26. 当前实现的本质
当前项目提问流程本质是:
text
用户问题
-> 关键词检索 chunks
-> 找到最相关资料片段
-> 用模板拼接答案
-> 保存问答记录
-> 返回答案和引用
它还不是完整的 LLM RAG。
更准确地说,当前是:
text
轻量级资料检索问答原型
不是:
text
向量检索 + 大模型生成答案
27. 当前实现的几个限制
1. 没有真正调用大模型
虽然代码构造了 prompt:
python
prompt = build_prompt(...)
但这个 prompt 没有发送给 OpenAI、Ollama、通义、DeepSeek 或其他模型。
所以回答质量取决于 chunk 原文,不会真正总结推理。
2. 没有 embedding 和向量数据库
当前不是语义检索。
例如:
text
"登录功能怎么实现"
和:
text
"用户认证流程"
语义上相关,但如果关键词重叠很少,当前系统可能检索不到。
3. 每次查询扫描全部 chunks
这段代码:
python
chunks = list(session.scalars(select(Chunk)).all())
会读取所有 chunks。
资料少时可以接受;资料多后需要改成:
- SQLite FTS 全文索引
- PostgreSQL 全文检索
- 向量数据库
- BM25 检索
- 混合检索
4. 只用第一个 chunk 生成答案
虽然检索返回最多 5 个 chunks,但当前回答主要使用:
python
primary = chunks[0]
也就是分数最高的一个 chunk。
其他 chunks 只进入 citations,没有真正参与答案生成。
5. prompt 构造目前没有实际效果
build_prompt() 写得像是为 LLM 准备的,但当前:
python
generate_grounded_stub_answer(..., prompt)
里面没有使用 prompt 内容。
也就是说 prompt 当前更多是未来接入大模型时的预留代码。
28. 简化总结
当前项目提问时,系统内部做了这些事:
text
1. 接收用户问题
2. 校验 question / question_type / session_id
3. 初始化数据库表
4. 从 chunks 表中检索相关资料片段
5. 如果没找到,返回"课程资料中未找到足够依据"
6. 如果找到,基于最高分 chunk 拼接一个模板答案
7. 生成 citations 引用来源
8. 创建或复用聊天会话
9. 保存用户问题和助手回答到 chat_messages
10. 返回 answer、session_id、citations
关键判断:
text
当前系统是"基于关键词检索的资料问答原型",不是完整的大模型 RAG。