从零开始做一个高校课程资料 AI Agent 问答系统(六)接入大模型

从零开始做一个高校课程资料 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 状态。