从零开始做一个高校课程资料 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 化扩展打基础。


提问时系统内部发生了什么

当前项目的提问入口是:

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)

它会识别两类内容:

  1. 英文、数字、下划线组成的词
    例如:
text 复制代码
LoginServlet
UserService
doPost
  1. 连续中文
    例如:
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

这样用户问 loginservletLoginServlet,都能匹配到。


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。