从零开始做一个高校课程资料 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 化扩展打基础。
接入真实大模型 Provider
前面我们已经将第一版 MVP 跑通了形成了完整 RAG 初步闭环:
text
上传资料
-> 解析资料
-> 文本切块
-> 本地检索
-> 生成模板化回答
-> 返回引用来源
但是第一版还有一个明显限制:回答不是由真实大模型生成的,而是由 grounded stub 生成的。
grounded stub 可以理解成一个本地兜底回答器。它不会调用外部模型,只会把检索到的课程片段拼成一个模板化回答。
第二阶段升级的目标是:
text
在保留原来 stub 兜底能力的基础上,增加真实大模型调用能力。
也就是说,升级后的流程变成:
text
学生提问
-> 检索课程资料 chunk
-> 构造 Prompt
-> 如果配置了真实 LLM,就调用 LLM 生成回答
-> 如果没有配置 LLM,或者 LLM 调用失败,就回退到 stub
-> 返回 answer 和 citations
这样做的好处是:
- 本地开发时不需要 API Key,也能继续测试。
- 配置 API Key 后,可以得到更自然的回答。
- 真实模型失败时,系统不会直接崩掉。
- 仍然保留 citations,回答来源可追踪。
1. LLM Provider 抽象模型接口
如果直接在 chat.py 里写死某一个模型接口,后面会遇到几个问题:
- 换模型麻烦。
- 测试时容易真的调用外网。
- API Key 容易散落在代码里。
- 模型调用失败时不好统一处理。
- 后续想支持 DeepSeek、通义千问、Kimi、OpenAI 兼容接口时会很乱。
所以我们新增了一个 LLM Provider 层。
可以这样理解:
text
chat.py 不直接关心调用哪家模型。
chat.py 只问 provider:请根据 prompt 生成回答。
provider 负责具体怎么调用模型。
类比 Java:
java
public interface LLMProvider {
String generate(String prompt);
}
然后可以有不同实现:
text
StubLLMProvider
OpenAICompatibleLLMProvider
DeepSeekLLMProvider
QwenLLMProvider
当前第二阶段先实现两个:
text
StubLLMProvider
OpenAICompatibleLLMProvider
2. 本次升核心文件
这次升级主要涉及这些文件:
text
backend/app/core/config.py
backend/app/services/llm.py
backend/app/services/chat.py
backend/tests/test_chat_service.py
backend/.env.example
backend/README.md
.gitignore
2.1 config.py
文件路径:
text
backend/app/core/config.py
作用:
text
集中读取项目配置。
这次新增了 LLM 相关配置:
python
llm_provider: str = "stub"
llm_api_key: str | None = None
llm_base_url: str = "https://api.openai.com/v1/chat/completions"
llm_model: str = "gpt-4o-mini"
llm_timeout_seconds: float = 30.0
这些配置会从 .env 文件读取。
默认值是:
text
LLM_PROVIDER=stub
意思是默认不调用真实大模型,继续使用本地 stub。
2.2 llm.py
文件路径:
text
backend/app/services/llm.py
这是这次新增的核心文件。
它负责:
- 定义 LLMProvider 协议。
- 定义 LLMProviderError 异常。
- 实现 StubLLMProvider。
- 实现 OpenAICompatibleLLMProvider。
- 根据配置返回对应 provider。
核心结构如下:
python
class LLMProvider(Protocol):
def generate(self, prompt: str) -> str:
raise NotImplementedError
这表示任何 provider 只要实现 generate(prompt),就可以被 chat 服务调用。
本地 stub provider:
python
class StubLLMProvider:
def generate(self, prompt: str) -> str:
return ""
它返回空字符串。chat 服务看到空字符串后,会自动回退到原来的 grounded stub。
真实 provider:
python
@dataclass(frozen=True)
class OpenAICompatibleLLMProvider:
api_key: str
base_url: str
model: str
timeout_seconds: float = 30.0
它会向 OpenAI 兼容的 Chat Completions 接口发送请求。
为什么叫 OpenAI 兼容?
因为很多模型服务都兼容类似接口格式:
text
POST /v1/chat/completions
Authorization: Bearer API_KEY
model
messages
temperature
不一定只能用 OpenAI,也可以接其他兼容服务,只要 base_url、model 和 api_key 配对正确。
2.3 chat.py
文件路径:
text
backend/app/services/chat.py
第一版逻辑是:
text
检索 chunks
-> 构造 prompt
-> generate_grounded_stub_answer
-> 保存问答
升级后逻辑变成:
text
检索 chunks
-> 构造 prompt
-> 尝试 get_llm_provider().generate(prompt)
-> 如果 LLM 返回内容,用 LLM 内容作为 answer
-> 如果 LLM 失败或返回空,回退 generate_grounded_stub_answer
-> 保存问答
关键代码逻辑:
python
try:
answer = get_llm_provider().generate(prompt)
except LLMProviderError:
answer = ""
if not answer:
answer = generate_grounded_stub_answer(question, question_type, chunks, prompt)
这个设计很重要。
它让系统具备容错能力:
- 没配置 API Key,不影响本地使用。
- 模型服务挂了,不影响基本问答。
- 网络失败,不会导致整个接口崩溃。
2.4 test_chat_service.py
文件路径:
text
backend/tests/test_chat_service.py
这次新增了两个测试。
第一个测试:配置了 LLM provider 时,使用 provider 的回答。
核心思路:
text
用 monkeypatch 替换 retrieve_chunks,模拟已经检索到课程资料。
用 FakeLLMProvider 替换真实 provider。
调用 answer_question。
断言 answer 等于 FakeLLMProvider 返回的内容。
第二个测试:LLM 返回空字符串时,回退到 stub。
核心思路:
text
模拟检索到 chunk。
模拟 EmptyLLMProvider 返回空字符串。
调用 answer_question。
断言回答里仍然包含 LoginServlet。
这两个测试都不触网,不需要真实 API Key。
2.5 .env.example
文件路径:
text
backend/.env.example
新增配置:
env
LLM_PROVIDER=stub
LLM_API_KEY=
LLM_BASE_URL=https://api.openai.com/v1/chat/completions
LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT_SECONDS=30
本地开发默认使用 stub。
如果要启用真实模型,复制 .env.example 到 .env 后,把配置改成:
env
LLM_PROVIDER=openai_compatible
LLM_API_KEY=你的API_KEY
LLM_BASE_URL=https://api.openai.com/v1/chat/completions
LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT_SECONDS=30
2.6 README.md
文件路径:
text
backend/README.md
这次补充了 LLM Provider 使用说明。
README 里说明:
text
默认使用本地 grounded stub。
如果要调用真实 OpenAI 兼容 Chat Completions API,需要配置 LLM_PROVIDER、LLM_API_KEY、LLM_BASE_URL、LLM_MODEL。
2.7 .gitignore
文件路径:
text
.gitignore
这次补充了 pytest 临时目录忽略规则,避免运行测试后产生的临时目录进入 Git 状态。