从零搭建 AI 智能体陪练系统:基于 FastAPI + DeepSeek-V3 的题库生成 MVP 全记录
一个周末,一行代码都不浪费——用 OpenAI 兼容接口调用国产大模型,打造可落地的 AI 出题引擎。
一、项目背景与定位
在 CTF(Capture The Flag)安全竞赛培训场景中,有一个持续存在的痛点:出题成本极高。一道高质量的 CTF 题目,从构思环境、编写 Dockerfile、埋设 Flag 到撰写题解,往往需要数小时甚至数天。对于培训方而言,手动出题根本跟不上学员的练习节奏。
于是就有了这个项目的核心命题:能不能让 AI 来出题?
但这里有一个现实约束——很多开发者没有 OpenAI 的 API Key(信用卡门槛、地区限制、费用顾虑),却拥有国产模型平台的 API 额度(硅基流动、DeepSeek 官方、阿里百炼等)。于是技术方案聚焦为一个验证性问题:
用 OpenAI SDK + 国产大模型 API(硅基流动 / DeepSeek-V3),能不能搭出一套可用的 AI 出题系统?
答案是:完全可以,而且比想象中简单得多。
本文将完整记录tiku_mvp从零搭建的全过程,包括后端架构、前端交互、模型选型、部署配置,以及开发过程中踩过的坑和解决方案。
二、系统架构总览
┌─────────────────────────────────────────────────────┐ │ Frontend (5501) │ │ vanilla HTML/CSS/JS │ │ http://127.0.0.1:5501/index.html │ └────────────────────┬────────────────────────────────┘ │ HTTP REST (CORS) ▼ ┌─────────────────────────────────────────────────────┐ │ Backend (8000) │ │ FastAPI + Uvicorn │ │ │ │ POST /generate ──▶ _call_openai_json() ──▶ LLM │ │ GET /history ──▶ _read_history_raw() │ │ GET /history/{id} │ └────────────────────┬────────────────────────────────┘ │ OpenAI SDK (base_url override) ▼ ┌─────────────────────────────────────────────────────┐ │ SiliconFlow API (OpenAI 兼容) │ │ Model: deepseek-ai/DeepSeek-V3 │ └─────────────────────────────────────────────────────┘核心设计原则:
- 前端零框架依赖:纯 HTML/CSS/JS,一个
index.html搞定,无需 npm/webpack - 后端极简:FastAPI + 3 个 REST 端点 + 本地 JSON 文件持久化
- 模型可替换:通过
base_url+ 环境变量,任何 OpenAI 兼容接口都能即插即用
三、技术栈详解
3.1 后端:FastAPI + OpenAI SDK
# backend/main.py 核心依赖fromfastapiimportFastAPIfromopenaiimportOpenAIfromdotenvimportload_dotenvimportuvicorn选择 FastAPI 的理由:
- 原生异步支持,与 Uvicorn 搭配性能优秀
- 自动生成 Swagger 文档(
/docs),调试体验极佳 - Pydantic 数据校验,请求/响应格式一目了然
3.2 大模型:DeepSeek-V3(通过硅基流动)
# backend/.envOPENAI_API_KEY=sk-xxxxxxxx# 硅基流动 API KeyOPENAI_MODEL=deepseek-ai/DeepSeek-V3# main.py 第 28 行client=OpenAI(api_key=OPENAI_API_KEY,base_url="https://api.siliconflow.cn/v1"# 关键:覆写 base_url)为什么选择硅基流动?
- 完全兼容 OpenAI SDK,一行
base_url替换即可 - 提供 DeepSeek-V3 等国产模型,无需 OpenAI 账号
- 国内网络直连,延迟低
3.3 前端:原生 HTML + JavaScript
<!-- frontend/index.html — 单文件应用 --><script>constAPI_BASE="http://127.0.0.1:8000";// 表单提交 → fetch /generate → 渲染题目 → fetch /history → 历史列表</script>前端采用 SPA(单页应用)思路,没有任何构建工具:
- 学科选择+难度选择(easy/medium/hard)
- 逐题作答:一次生成 5 题,每次展示 1 题,答完切换下一题
- 历史题库:左侧卡片列表,点击可回顾任意历史题集
四、核心代码走读
4.1 Prompt 工程(_build_prompt)
def_build_prompt(subject:str,difficulty:str)->List[dict]:sys={"role":"system","content":("You are a test item writer. Return exactly 5 multiple-choice questions ""as strict JSON (utf-8), with keys: ""items:[{id,subject,difficulty,stem,choices:[{key,text}],answer,explanation}]. ""Choices must be A,B,C,D; answer must be one of A-D. ""Keep stems concise; explanations correct.")}user={"role":"user","content":(f"Subject:{subject}\nDifficulty:{difficulty}\n""Write 5 diverse multiple-choice questions covering the core knowledge points.")}return[sys,user]设计要点:
- 强约束 JSON 输出:通过
response_format={"type": "json_object"}确保结构化 - 选项规范化:强制 4 选项 A-D,前端 UI 依赖此约定
- 解释必填:每道题都要求
explanation,提升学习价值
4.2 调用 & 容错(_call_openai_json)
def_call_openai_json(subject:str,difficulty:str)->List[QAItem]:resp=client.chat.completions.create(model=OPENAI_MODEL,messages=messages,response_format={"type":"json_object"},temperature=0.7,)content=resp.choices[0].message.content data=json.loads(content)items=data.get("items",[])# 兜底:不足 4 选项自动补全foritinitems[:5]:choices=it.get("choices",[])iflen(choices)!=4:keys=["A","B","C","D"]fixed=[]fori,kinenumerate(keys):ifi<len(choices)andisinstance(choices[i],dict):fixed.append(choices[i])else:fixed.append({"key":k,"text":f"Option{k}"})choices=fixed...容错策略:
- 选项数量不匹配→ 自动用 A/B/C/D 补全
- 不足 5 题→ 抛出 RuntimeError 提示重试
- JSON 解析失败→ 透传异常给前端展示
4.3 REST API 设计
| 端点 | 方法 | 功能 |
|---|---|---|
/generate | POST | 接收{subject, difficulty},返回 5 道选择题 + 存入 history.json |
/history | GET | 返回所有历史题集摘要(学科、难度、题数、前 3 题预览) |
/history/{batch_id} | GET | 返回指定题集的完整内容(含答案和解析) |
4.4 数据持久化
HISTORY_PATH=DATA_DIR/"history.json"def_write_history_raw(data:List[dict]):HISTORY_PATH.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding="utf-8")MVP 阶段使用本地 JSON 文件持久化,每次/generate调用后insert(0, record)写入文件头部(最新题集在前)。对于单用户本地使用场景完全够用,未来可平滑迁移到 SQLite 或 PostgreSQL。
五、前端交互设计
5.1 逐题作答流
[选择学科+难度] → [点击生成] → [展示第1题] ↓ [选择选项A/B/C/D] ↓ [提交答案] → [揭晓答案+解析] ↓ [下一题] → [展示第2题] ↓ ... 重复 ... ↓ [第5题完成] → [显示总分 5/5]核心 JS 逻辑:
// mountQuestion(i) — 每次只渲染 1 题functionmountQuestion(i){constitem=currentBatch.items[i];currentIndex=i;selectedKey=null;nextBtn.style.display='none';submitBtn.style.display='inline-block';stemEl.textContent=item.stem;choicesEl.innerHTML='';item.choices.forEach(ch=>{constdiv=document.createElement('div');div.className='choice';div.textContent=`${ch.key}.${ch.text}`;div.dataset.key=ch.key;div.addEventListener('click',()=>{selectedKey=ch.key;[...choicesEl.children].forEach(n=>n.classList.remove('selected'));div.classList.add('selected');});choicesEl.appendChild(div);});}5.2 答案标色逻辑
functionrevealAnswer(){constitem=currentBatch.items[currentIndex];[...choicesEl.children].forEach(n=>{constk=n.dataset.key;if(k===item.answer){n.classList.add('correct');// 正确答案 → 绿色}if(selectedKey&&k===selectedKey&&selectedKey!==item.answer){n.classList.add('wrong');// 选错 → 红色}});}视觉效果:正确答案绿色高亮,如果用户选错则红色标记自己的选择,对比一目了然。
六、开发踩坑实录
6.1 CORS 跨域问题(最耗时)
现象:前端http://127.0.0.1:5501访问后端http://127.0.0.1:8000时,浏览器报错:
Access to fetch at 'http://127.0.0.1:8000/history' from origin 'http://127.0.0.1:5501' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.排查过程:
- 第一反应:检查
main.py是否有 CORS 中间件 → 有,allow_origins=["*"] - 怀疑规范冲突:
allow_credentials=True+allow_origins=["*"]在 CORS 规范中不被允许 → 改为显式["http://127.0.0.1:5501", "http://localhost:5501"] - 改了还是不行→ 用
curl直接测试,发现 CORS 头正常返回 - 最终根因:
lsof -i :8000发现/tmp/ctf-agent-server进程抢占 8000 端口,所有请求被它拦截
解决方案:
kill61235# 停掉 ctf-agent-serverkill98899895# 停掉旧的 main.py 进程python main.py# 重新启动教训:CORS 中间件配置正确但仍然报错时,优先排查端口是否被其他进程抢占,而非反复改中间件配置。lsof -i :PORT是排查利器。
6.2 浏览器缓存导致问题残留
现象:CORS 修好后,无痕模式正常,正常模式依然报错。
原因:浏览器缓存了之前失败的 OPTIONS 预检响应。
解决:Cmd + Shift + Delete清除浏览器缓存,或 DevTools → Network → Disable cache。
6.3 模型替换:从 DeepSeek API 到硅基流动
项目原本base_url指向https://api.deepseek.com,需要 DeepSeek 官方的 API Key。迁移到硅基流动只需改动两处:
- base_url="https://api.deepseek.com" + base_url="https://api.siliconflow.cn/v1" - OPENAI_MODEL=deepseek-chat + OPENAI_MODEL=deepseek-ai/DeepSeek-V3模型名称前缀规则:{提供商}/{模型名}。硅基流动支持的模型列表可在其控制台查看。
6.4 依赖安装注意
如果使用.venv虚拟环境,务必先source .venv/bin/activate,然后在 venv 内执行pip install -r requirements.txt。全局安装的包不会被 venv 内的 Python 解释器识别。
七、部署指南
7.1 环境准备
# 1. 克隆项目cdtiku_mvp# 2. 创建虚拟环境(推荐)python-mvenv tiku_venvsourcetiku_venv/bin/activate# macOS# tiku_venv\Scripts\activate # Windows# 3. 安装依赖pipinstall-rrequirements.txt7.2 配置 API Key
编辑backend/.env:
OPENAI_API_KEY=sk-your-siliconflow-api-key OPENAI_MODEL=deepseek-ai/DeepSeek-V37.3 启动服务
# 终端 1:启动后端cdbackend python main.py# → http://127.0.0.1:8000/docs 可查看 API 文档# 终端 2:启动前端cdfrontend python-mhttp.server5501# → http://127.0.0.1:5501/index.html7.4 验证
打开http://127.0.0.1:5501/index.html,选择学科和难度,点击「生成题库」,应该能看到 AI 生成的 5 道选择题。
八、项目结构与文件清单
tiku_mvp/ ├── backend/ │ ├── main.py # FastAPI 后端(242 行) │ ├── .env # API Key 和模型配置 │ └── data/ │ └── history.json # 题库历史记录(自动生成) ├── frontend/ │ └── index.html # 前端单页应用(289 行) ├── requirements.txt # Python 依赖 └── readme.txt # 部署说明总代码量约 530 行,非常适合作为 AI 应用开发的入门参考项目。
九、系统截图
9.1 首页
包含学科/难度选择表单、生成按钮、历史题库卡片列表。
9.2 答题界面
AI 生成的题目,4 个选项可点击选择,顶部进度条显示当前进度。
9.3 答案揭晓
提交后显示正确答案(绿色)和详细解析,如果选错会红色标记。
9.4 API 文档
FastAPI 自动生成的 Swagger UI,可直接在线调试接口。
9.5 项目目录
极简的目录结构,核心文件仅 4 个。
十、总结与展望
10.1 核心收获
- OpenAI 兼容接口的生态价值:国产大模型平台普遍提供 OpenAI 兼容 API,这意味着换个
base_url就能复用整个 OpenAI SDK 生态,迁移成本几乎为零。 - FastAPI 的极致开发体验:自动生成的
/docs页面让前后端联调变得轻松,Pydantic 的类型校验省去了大量参数检查代码。 - MVP 思维:本地 JSON 文件做持久化、单文件 HTML 做前端——这些"简陋"的选择在原型验证阶段反而是最高效的。
10.2 未来扩展方向
- 数据库迁移:从 JSON 文件升级到 SQLite,支持多用户、题目搜索、统计分析
- 出题质量优化:引入 Few-shot 示例 + 难度校准,减少模型生成"水题"的概率
- 题型扩展:支持判断题、填空题、简答题
- 用户系统:接入 OAuth,支持多用户各自维护题库进度
- CTF 专项出题:从通用题库转向 CTF 场景,支持 Web/Misc/Crypto/Reverse/Pwn 五个方向的定向出题
技术栈:FastAPI + SiliconFlow API (DeepSeek-V3) + Vanilla JS