从零搭建NCRE考试智能体:基于AI大模型的自适应出题系统实战

文章目录
一、项目背景与动机
1.1 为什么需要考试智能体?
全国计算机等级考试(NCRE)二级公共基础知识是每个计算机专业学生必须跨越的门槛。然而,传统的备考方式存在几个痛点:
- 题库陈旧:市面上的纸质题库更新缓慢,难以覆盖最新考纲
- 缺乏个性化:所有考生使用相同的题目,无法针对薄弱环节强化训练
- 解析质量参差不齐:很多教辅材料的解析过于简略,无法真正帮助理解
- 学习路径不清晰:考生不清楚自己的知识盲区在哪里
这些问题催生了一个想法:能否利用大语言模型(LLM)的能力,构建一个能够根据考纲自动出题、智能评分、记录错题的考试智能体?
1.2 技术选型与架构设计
在技术选型上,我考虑了三个关键因素:
| 维度 | 选择 | 理由 |
|---|---|---|
| AI模型 | DeepSeek-V4-Flash / GLM-5.2 | 免费模型可用,支持中文出题,API兼容OpenAI格式 |
| 桌面端 | Python tkinter | 无需安装额外依赖,Windows原生支持,快速原型 |
| Web端 | 纯HTML+JavaScript | 零部署成本,双击即可运行,跨平台 |
| 知识库 | 纯文本文件(.txt) | 易于编辑和维护,支持热加载 |
整个项目的架构可以用下图表示:
┌─────────────────────────────────────────────────────────────┐
│ NCRE Exam Agent │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 知识库管理 │ │ AI出题引擎 │ │ 答题与评分系统 │ │
│ │ (txt文件) │ │ (LLM API) │ │ (tkinter/HTML) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 错题本管理 │ │ 试卷保存 │ │ 配置持久化 │ │
│ │ (JSON文件) │ │ (txt/JSON) │ │ (JSON文件) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 运行方式: 桌面应用 (tkinter) / 浏览器 (HTML+本地服务器) │
└─────────────────────────────────────────────────────────────┘
二、知识库设计:让AI理解考试大纲
2.1 知识库的结构化设计
知识库是整个系统的基石。AI出题的质量,直接取决于知识库的完整性和结构化程度。我选择了纯文本格式,原因有三:
- 零学习成本:任何人都可以用记事本编辑
- 版本控制友好:可以用Git追踪每次修改
- 解析简单:不需要复杂的解析器
知识库文件 ncre_syllabus.txt 的结构如下:
================================================================================
全国计算机等级考试(NCRE)二级公共基础知识考试大纲(2025年版)
================================================================================
■ 基本要求
1. 掌握计算机系统的基本概念...
2. 掌握算法的基本概念...
...
================================================================================
一、计算机系统
================================================================================
1. 掌握计算机系统的结构。
2. 掌握计算机硬件系统结构...
...
【详细知识点】
◇ 计算机系统组成
- 计算机系统由硬件系统和软件系统两大部分组成。
- 硬件系统:运算器、控制器、存储器、输入设备、输出设备。
- 软件系统:系统软件(操作系统、编译系统等)和应用软件。
这种设计遵循了分层递进的原则:大纲层 → 章节层 → 知识点层 → 细节层。每一层都用不同的分隔符和标记来区分,既方便人类阅读,也方便AI理解知识的结构。
2.2 知识库的加载与热更新
在代码实现中,知识库的加载被设计为可配置的:
def load_knowledge_text(file_path=None) -> str:
"""加载知识库文本文档"""
path = file_path or DEFAULT_KNOWLEDGE_FILE
if not os.path.exists(path):
return "错误:知识库文件未找到,请确保 knowledge_base 文件夹中存在知识点文档。"
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"读取知识库文件失败:{str(e)}"
这里有一个容易被忽视但非常重要的细节:编码问题。Windows 系统默认使用 GBK 编码,而我们的知识库文件包含大量中文和特殊符号,必须显式指定 encoding="utf-8",否则会出现乱码。
知识库的热更新通过 list_knowledge_files() 函数实现:
def list_knowledge_files() -> list:
"""列出 knowledge_base 目录下所有 txt 文件"""
if not os.path.exists(KNOWLEDGE_DIR):
return []
files = [f for f in os.listdir(KNOWLEDGE_DIR) if f.endswith(".txt")]
return sorted(files)
这意味着用户可以在不重启程序的情况下,向 knowledge_base 文件夹添加新的知识点文档,然后在下拉菜单中选择切换。
2.3 知识库的覆盖范围
NCRE二级公共基础知识的考纲涵盖五个主要领域,每个领域又包含若干子知识点:
| 章节 | 核心知识点 | 题量占比 |
|---|---|---|
| 计算机系统 | CPU组成、存储器分层、操作系统、进程管理 | ~20% |
| 数据结构与算法 | 栈、队列、二叉树、排序、查找 | ~25% |
| 程序设计基础 | 结构化编程、面向对象、封装继承多态 | ~15% |
| 软件工程基础 | 生命周期、测试方法、DFD、模块化 | ~20% |
| 数据库设计基础 | E-R图、关系代数、范式、SQL | ~20% |
每个知识点都包含了定义、特性、分类、应用场景四个维度的描述,确保AI能够全面理解。
三、AI出题引擎:从Prompt Engineering到答案均衡
3.1 Prompt设计的艺术
AI出题的核心在于Prompt设计。一个好的Prompt需要告诉AI三件事:你是谁、要做什么、怎么做。
system_prompt = (
"你是一个计算机二级公共基础知识考试出题专家。\n\n"
f"请根据以下考纲知识点,生成 {qcount} 道单项选择题。\n\n"
"=== 考纲知识点 ===\n"
f"{kb_text}\n\n"
"=== 出题要求 ===\n"
"1. 每道题包含题干和 A、B、C、D 四个选项,以及正确答案和详细解析。\n"
f"2. 正确答案必须在 A、B、C、D 中均匀分布,每个选项占比约 {qcount // 4} 题。\n"
"3. 题目必须覆盖考纲的五个部分:计算机系统、数据结构与算法、"
"程序设计基础、软件工程基础、数据库设计基础。\n"
"4. 题目难度为二级考试真实水平,要有一定的区分度。\n"
"5. 解析必须详细,解释为什么选这个答案以及每个选项的考点。\n"
"6. 输出格式必须是严格的 JSON 数组,不要包含任何其他文字。\n\n"
"输出格式示例:\n"
'[\n'
' {\n'
' "question": "题干内容",\n'
' ...\n'
' }\n'
']\n\n'
f"注意:必须生成完整的 {qcount} 条题目,答案均匀分布。"
)
这个Prompt的设计有几个关键点:
角色设定:开头就告诉AI它是"出题专家",这比直接说"生成题目"效果要好得多。角色设定能激活AI在特定领域的知识。
知识注入:将完整的考纲文本嵌入到Prompt中,而不是让AI依赖自己的训练数据。这样做的好处是:
- 确保题目严格遵循最新考纲
- 不受AI训练数据截止时间的限制
- 用户可以自定义知识库内容
格式约束:明确要求JSON格式输出,并给出了示例。这大大降低了AI"自由发挥"的概率,使得解析代码更加稳定。
质量要求:要求"均匀分布"和"覆盖五个部分",这是对出题质量的关键约束。
3.2 流式调用与实时反馈
API调用采用了流式(stream)模式,而不是等待完整响应:
def chat_stream(self, messages, on_chunk, **kwargs):
client = self._get_client()
response = client.chat.completions.create(
model=self.model,
messages=messages,
stream=True,
stream_options={"include_usage": True},
**kwargs,
)
for chunk in response:
if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
on_chunk(chunk.choices[0].delta.content)
流式调用的优势在于:
- 用户体验更好:用户不需要等待全部内容生成完毕
- 内存占用更低:不需要在内存中缓存整个响应
- 可以实时处理:可以边接收边解析
但在实际使用中,我发现流式调用也带来了一些挑战。最典型的问题是网络不稳定导致连接中断。为此,我在客户端设置了120秒的超时时间:
with urllib.request.urlopen(req, timeout=120) as resp:
resp_data = resp.read()
3.3 JSON解析与容错处理
AI的输出并不总是完美的JSON。有时候它会添加markdown代码块标记(```json),有时候会在JSON前后添加额外文字。因此,解析函数需要具备一定的容错能力:
def _parse_questions(self, text: str, expected_count: int) -> list:
"""解析 AI 返回的 JSON 题目列表"""
text = text.strip()
# 移除 markdown 代码块标记
if text.startswith("```json"):
text = text[7:]
if text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
try:
questions = json.loads(text)
except json.JSONDecodeError:
# 尝试从文本中提取 JSON 数组
start = text.find("[")
end = text.rfind("]")
if start != -1 and end != -1:
try:
questions = json.loads(text[start:end + 1])
except json.JSONDecodeError:
raise ValueError("AI 返回格式不正确,无法解析为 JSON。请重试。")
else:
raise ValueError("AI 返回内容中没有找到题目数据。请重试。")
...
这个函数采用了渐进式容错策略:
- 先尝试直接解析
- 如果失败,尝试移除markdown标记
- 如果还失败,尝试从文本中提取JSON数组
- 最后,验证每个题目的字段完整性
3.4 答案均衡算法
AI生成的题目往往存在答案分布不均的问题——它倾向于选择"A"或"D"作为正确答案。为了解决这个问题,我实现了一个答案均衡算法:
def _balance_answers(self, questions: list, target_count: int) -> list:
"""确保答案在 A、B、C、D 中均匀分布"""
random.shuffle(questions)
result = []
temp_counts = {"A": 0, "B": 0, "C": 0, "D": 0}
limit = target_count // 4 + 1
# 第一轮:优先选择答案分布均匀的题目
for q in questions:
ans = q.get("answer", "")
if ans not in temp_counts:
continue
if temp_counts[ans] < limit:
result.append(q)
temp_counts[ans] += 1
if len(result) >= target_count:
break
# 第二轮:如果题目不足,补充题目并调整答案
if len(result) < target_count:
remaining = target_count - len(result)
options_needed = []
for k in sorted(temp_counts.keys()):
while temp_counts[k] < target_count // 4 and len(options_needed) < remaining:
options_needed.append(k)
temp_counts[k] += 1
while len(options_needed) < remaining:
for k in ["A", "B", "C", "D"]:
if len(options_needed) < remaining:
options_needed.append(k)
for i, ans in enumerate(options_needed):
q = questions[i % len(questions)].copy()
q["answer"] = ans
result.append(q)
return result[:target_count]
这个算法的核心思想是两轮筛选:
- 第一轮:从AI生成的题目中,优先选择答案分布均匀的
- 第二轮:如果题目不足,复制现有题目并修改答案,确保最终分布均匀
四、桌面应用开发:tkinter实战
4.1 为什么选择tkinter?
在桌面端技术选型时,我考虑了三个选项:
| 框架 | 优点 | 缺点 |
|---|---|---|
| tkinter | Python内置,无需安装,轻量 | 界面不够美观,功能有限 |
| PyQt/PySide | 功能强大,界面美观 | 安装包大,学习曲线陡 |
| Electron | 跨平台,界面现代 | 资源占用大,开发复杂 |
最终选择tkinter,核心原因是零依赖。用户只需要安装Python,不需要安装任何第三方GUI库。这对于一个面向普通考生的工具来说至关重要。
4.2 界面布局与交互设计
整个界面采用上下结构 + 左右分栏的布局:
┌─────────────────────────────────────────────────────────────┐
│ API密钥栏 [输入框] [获取密钥] [保存配置] [展开设置] │
├─────────────────────────────────────────────────────────────┤
│ 设置栏 (可折叠) │
│ 模型 | 温度 | 出题数 | Top-P | 知识库 │
├─────────────────────────────────────────────────────────────┤
│ 控制栏 │
│ [开始出题] [提交答卷] [保存试卷] [查看保存] [错题本] [清空] │
├──────────────────────────┬──────────────────────────────────┤
│ 题目区域 (可滚动) │ 答题统计 │
│ ├──────────────────────────────────┤
│ 第1题: xxxxx │ 评分结果 │
│ ○ A. xxxx ├──────────────────────────────────┤
│ ○ B. xxxx │ 错题本 (可折叠) │
│ ○ C. xxxx │ │
│ ○ D. xxxx │ │
│ │ │
│ 第2题: xxxxx │ │
│ ... │ │
├──────────────────────────┴──────────────────────────────────┤
│ 状态栏 │
└─────────────────────────────────────────────────────────────┘
这种布局的优点是:
- 信息层级清晰:从上到下依次是配置、操作、内容、状态
- 左右分工明确:左侧是主要操作区,右侧是信息反馈区
- 可折叠面板:设置栏和错题本可以折叠,节省空间
4.3 编码问题的终极解决方案
在Windows系统上使用tkinter处理中文,是一个深坑。我遇到了两个问题:
- Tcl/Tk编码问题:tkinter底层使用Tcl/Tk,默认编码不是UTF-8
- 控制台编码问题:Windows控制台默认使用GBK编码
解决方案如下:
# ========== 最优先:强制 UTF-8 环境 ==========
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["PYTHONUTF8"] = "1"
os.environ["LANG"] = "zh_CN.UTF-8"
# 设置 Windows 控制台代码页为 UTF-8
try:
ctypes.windll.kernel32.SetConsoleCP(65001)
ctypes.windll.kernel32.SetConsoleOutputCP(65001)
except Exception:
pass
# 重新包装 stdout/stderr
try:
if hasattr(sys.stdout, 'buffer'):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
except Exception:
pass
这段代码做了三件事:
- 设置环境变量,告诉Python使用UTF-8
- 调用Windows API设置控制台代码页
- 重新包装标准输出,确保所有输出都是UTF-8编码
但这还不够。Tcl/Tk的编码问题需要单独处理:
def _init_tcl_encoding(root: tk.Tk):
"""初始化 Tcl 编码为 UTF-8"""
global _TCL_ENCODING_SET
if _TCL_ENCODING_SET:
return
try:
root.tk.eval('encoding system utf-8')
_TCL_ENCODING_SET = True
except Exception as e:
_debug_log(f"Failed to set Tcl encoding: {e}")
这个函数必须在创建任何带中文的控件之前调用。我在 NCREExamAgent.__init__ 的第一行就调用了它。
4.4 线程管理与UI更新
AI API调用是耗时的网络操作,如果在主线程中执行,界面会卡死。解决方案是使用后台线程:
self.exam_started = True
Thread(
target=self._call_api_generate,
args=(api_key, model_id, kb_text, qcount, params),
daemon=True,
).start()
这里有一个关键点:tkinter不是线程安全的。所有UI更新必须在主线程中执行。因此,在后台线程中不能直接更新UI,而是使用 root.after() 方法:
# 在后台线程中:
questions = self._parse_questions(full_reply, qcount)
self.root.after(0, self._on_exam_generated, questions)
root.after(0, callback, args) 会在主线程的事件循环中调度 callback 的执行,从而避免了线程安全问题。
4.5 全局异常处理
网络请求、JSON解析、文件操作都可能抛出异常。为了不让程序崩溃,我实现了全局异常处理:
_original_excepthook = sys.excepthook
def _global_excepthook(exc_type, exc_value, exc_tb):
"""全局异常处理,记录到日志并显示友好提示"""
tb_text = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
_debug_log(f"UNHANDLED EXCEPTION:\n{tb_text}")
try:
ascii_msg = str(exc_value).encode('ascii', errors='replace').decode('ascii')
messagebox.showerror("Program Error", f"Error: {ascii_msg}\n\nDetails logged to ncre_debug.log")
except Exception:
pass
_original_excepthook(exc_type, exc_value, exc_tb)
sys.excepthook = _global_excepthook
这个处理函数做了三件事:
- 将异常信息写入调试日志
- 显示友好的错误提示框
- 调用原始的异常处理函数
五、Web应用开发:纯前端实现
5.1 架构选择:为什么不需要后端?
Web版本采用了纯前端架构,所有逻辑都在浏览器中执行。这听起来可能有些反直觉——通常Web应用需要后端服务器。但在这个场景下,纯前端架构有独特的优势:
- 零部署:用户只需要双击HTML文件即可使用
- 隐私安全:API密钥只存储在本地,不会上传到任何服务器
- 离线可用:除了AI出题需要网络,其他功能完全离线
当然,纯前端架构也有局限性:
- 跨域问题:直接从
file://协议打开时,浏览器会阻止跨域请求 - 无法保存文件到指定位置:只能通过下载方式保存
为了解决跨域问题,我提供了两种访问方式:
方式一:直接打开HTML文件
- 优点:最简单,双击即可
- 缺点:部分浏览器可能阻止跨域请求
方式二:通过本地服务器
- 优点:没有跨域问题,功能完整
- 缺点:需要运行
server.py
# server.py - 本地代理服务器
class ProxyHandler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
if self.path == "/api/proxy":
# 解析客户端请求
req_data = json.loads(body)
api_key = req_data.get("api_key", "")
api_path = req_data.get("path", "/chat/completions")
api_body = req_data.get("body", {})
# 转发到 AI API
url = API_BASE + api_path
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
req = urllib.request.Request(
url,
data=json.dumps(api_body).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=120) as resp:
resp_data = resp.read()
self.send_response(200)
self.wfile.write(resp_data)
这个代理服务器的作用是中转请求,浏览器将请求发送到本地服务器,本地服务器再转发到AI API。这样就避免了浏览器的跨域限制。
5.2 前端架构设计
HTML版本的前端架构可以分为三个层次:
┌─────────────────────────────────────────────────────────────┐
│ 视图层 (View) │
│ HTML模板 + CSS样式 │
│ 负责页面渲染和用户交互 │
├─────────────────────────────────────────────────────────────┤
│ 控制层 (Controller) │
│ JavaScript逻辑 │
│ 负责事件处理、API调用、数据管理 │
├─────────────────────────────────────────────────────────────┤
│ 数据层 (Data) │
│ localStorage + 内存变量 │
│ 负责配置持久化、错题本存储、试卷数据管理 │
└─────────────────────────────────────────────────────────────┘
数据持久化使用了 localStorage:
// 保存配置
function saveConfig() {
var cfg = {
apiKey: document.getElementById('apiKey').value,
qCount: document.getElementById('qCount').value,
temperature: document.getElementById('temperature').value,
topP: document.getElementById('topP').value,
model: document.getElementById('modelSelect').value,
};
localStorage.setItem('ncre_config', JSON.stringify(cfg));
showToast('配置已保存', 'success');
}
// 加载配置
(function loadSavedConfig() {
try {
var cfg = JSON.parse(localStorage.getItem('ncre_config') || '{}');
if (cfg.apiKey) document.getElementById('apiKey').value = cfg.apiKey;
if (cfg.qCount) document.getElementById('qCount').value = cfg.qCount;
// ...
} catch(e) {}
})();
5.3 自适应API调用
Web版本需要同时支持两种访问方式,因此API调用需要自适应:
async function generateExam() {
// ...
var useProxy = (window.location.port === '8080' || window.location.port === '8000');
var apiUrl, fetchOptions;
if (useProxy && window.location.hostname === 'localhost') {
// 通过本地代理服务器
apiUrl = '/api/proxy';
fetchOptions = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
path: '/chat/completions',
body: { /* ... */ }
})
};
} else {
// 直接调用 API
apiUrl = 'https://api.aigc.bar/v1/chat/completions';
fetchOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey
},
body: JSON.stringify({ /* ... */ })
};
}
var response = await fetch(apiUrl, fetchOptions);
// ...
}
判断逻辑很简单:如果当前页面是通过 localhost:8080 或 localhost:8000 访问的,就使用代理模式;否则直接调用API。
5.4 试卷下载功能
Web版本无法直接保存文件到用户指定的位置,但可以通过 Blob + URL.createObjectURL 实现下载:
function saveExam() {
// ... 构建试卷内容 ...
var blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('试卷已保存:' + filename, 'success');
}
这个方法的原理是:
- 将文本内容包装成
Blob对象 - 创建临时的
ObjectURL - 创建一个隐藏的
<a>标签并模拟点击 - 清理临时资源
六、错题本系统:从数据模型到持久化
6.1 数据模型设计
错题本的数据模型需要存储足够的信息,以便用户回顾和复习:
{
"question": "在并发程序执行过程中,进程调度负责分配()",
"option_a": "CPU",
"option_b": "CPU、打印机",
"option_c": "CPU、打印机、外存",
"option_d": "所有系统资源",
"answer": "A",
"correct_answer": "A",
"user_answer": "B",
"explanation": "进程调度负责分配CPU资源...",
"wrong_count": 3,
"last_wrong": "2026-07-08 14:30:00",
"added_at": "2026-06-29 20:35:00"
}
这个模型包含了:
- 题目信息:题干和四个选项
- 答案信息:正确答案和用户答案
- 统计信息:错误次数和最近错误时间
- 元数据:添加时间
6.2 错题本的CRUD操作
错题本的增删改查操作都封装在独立的函数中:
def add_wrong_question(question: dict):
"""添加一道错题到错题本"""
wrongs = load_wrong_questions()
# 检查是否已存在
for q in wrongs:
if q.get("question") == question.get("question"):
# 已存在,更新错误次数
q["wrong_count"] = q.get("wrong_count", 0) + 1
q["last_wrong"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
save_wrong_questions(wrongs)
return
# 不存在,添加新记录
question["wrong_count"] = 1
question["last_wrong"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
question["added_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
wrongs.append(question)
save_wrong_questions(wrongs)
这里有一个重要的设计决策:同题合并。如果同一道题错了多次,不会重复添加,而是累加错误次数。这样用户就能清楚地知道哪些题目是自己的"顽固错题"。
6.3 错题本的UI展示
在桌面端,错题本使用 Text 控件展示:
def _refresh_wrong_list(self):
"""刷新错题本显示"""
self.wrong_list.config(state=tk.NORMAL)
self.wrong_list.delete("1.0", tk.END)
wrongs = load_wrong_questions()
if not wrongs:
self.wrong_list.insert("1.0", "暂无错题,继续加油!\n\n做错的题目会自动记录到这里。")
else:
self.wrong_list.insert("1.0", f"共 {len(wrongs)} 道错题\n\n")
for i, q in enumerate(wrongs, 1):
_safe_widget_text(self.wrong_list, f"{i}. {q.get('question', '')}\n")
_safe_widget_text(self.wrong_list, f" 正确答案:{q.get('correct_answer', '')}\n")
_safe_widget_text(self.wrong_list, f" 解析:{q.get('explanation', '')}\n")
_safe_widget_text(self.wrong_list, f" 错 {q.get('wrong_count', 1)} 次 | {q.get('last_wrong', '')}\n")
_safe_widget_text(self.wrong_list, "-" * 30 + "\n\n")
self.wrong_list.config(state=tk.DISABLED)
注意这里使用了 _safe_widget_text 函数,而不是直接调用 insert。这是因为在Windows系统上,直接插入中文文本可能导致编码错误。
七、试卷保存系统:多种格式支持
7.1 保存格式设计
试卷保存支持两种格式:
| 格式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| TXT | 打印、分享 | 通用性强,任何设备都能打开 | 不支持结构化数据 |
| JSON | 程序处理、数据分析 | 结构化,易于解析 | 可读性差 |
TXT格式的试卷内容如下:
============================================================
计算机二级公共基础知识模拟试卷
生成时间:2026-07-08 14:30:00
题目数量:20 题
============================================================
第 1 题:在并发程序执行过程中,进程调度负责分配()
A. CPU
B. CPU、打印机
C. CPU、打印机、外存
D. 所有系统资源
你的答案:A
正确答案:A
解析:进程调度是操作系统核心功能之一...
----------------------------------------
7.2 文件保存的实现
桌面端使用 filedialog.asksaveasfilename 让用户选择保存位置:
file_path = filedialog.asksaveasfilename(
title="保存试卷",
defaultextension=".txt",
initialfile=default_name,
filetypes=[("文本文件", "*.txt"), ("JSON文件", "*.json"), ("所有文件", "*.*")],
)
根据文件扩展名决定保存格式:
ext = os.path.splitext(file_path)[1].lower()
if ext == ".json":
# JSON格式
paper_data = {
"title": "计算机二级公共基础知识模拟试卷",
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"questions": self.current_questions,
"correct_answers": self.current_answers,
}
with open(file_path, "w", encoding="utf-8") as f:
json.dump(paper_data, f, ensure_ascii=False, indent=2)
else:
# TXT格式
with open(file_path, "w", encoding="utf-8") as f:
f.write("=" * 60 + "\n")
f.write(" 计算机二级公共基础知识模拟试卷\n")
# ...
八、配置管理系统:持久化用户偏好
8.1 配置文件的存储位置
配置文件存储在用户目录下的 .ncre_agent 文件夹中:
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".ncre_agent")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
选择这个位置的原因是:
- 跨用户隔离:每个用户有自己的配置
- 不易丢失:用户目录通常不会被清理
- 符合惯例:很多应用都使用
~/.appname的命名方式
8.2 配置项设计
DEFAULT_CONFIG = {
"api_key": "",
"model": "deepseek-v4-flash",
"temperature": 0.7,
"max_tokens": 8192,
"top_p": 0.9,
"question_count": 20,
}
每个配置项都有默认值,确保即使配置文件损坏或不存在,程序也能正常运行。
8.3 配置的加载与保存
def load_config():
"""从本地文件加载配置"""
if not os.path.exists(CONFIG_FILE):
return dict(DEFAULT_CONFIG)
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
cfg = dict(DEFAULT_CONFIG)
cfg.update(data)
return cfg
except Exception:
return dict(DEFAULT_CONFIG)
这里使用了 dict(DEFAULT_CONFIG) 创建副本,然后 update 合并,确保即使配置文件中缺少某些字段,也能使用默认值。
九、调试与日志系统
9.1 日志设计
调试日志记录了程序运行的关键信息:
DEBUG_LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ncre_debug.log")
def _debug_log(msg: str):
"""写入调试日志"""
try:
with open(DEBUG_LOG, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n")
except Exception:
pass
日志文件记录了:
- 程序启动时间
- Tcl编码设置状态
- API调用错误详情
- 未捕获的异常
9.2 从日志中定位问题
从 ncre_debug.log 中可以看到一个典型的错误:
[20:33:12] API call error: Traceback (most recent call last):
...
UnicodeEncodeError: 'ascii' codec can't encode characters in position 7-36:
ordinal not in range(128)
这个错误的原因是:OpenAI Python 客户端在构建HTTP请求头时,使用了ASCII编码,但请求头中包含了中文字符。解决方案是在创建客户端时,确保所有中文字符都被正确处理。
十、部署与使用指南
10.1 环境要求
| 组件 | 要求 |
|---|---|
| Python | 3.8+ |
| 依赖包 | openai, tkinter (内置) |
| 操作系统 | Windows 10/11 (推荐), macOS, Linux |
| 网络 | 需要访问 api.aigc.bar |
| API密钥 | 从 api.aigc.bar 获取 |
10.2 安装步骤
# 1. 克隆项目
git clone https://github.com/yourusername/ncre_agent.git
cd ncre_agent
# 2. 安装依赖
pip install openai
# 3. 运行桌面应用
python ncre_exam_agent.py
# 4. 或者运行Web服务器
python server.py
# 然后在浏览器访问 http://localhost:8080
10.3 自定义知识库
用户可以根据需要自定义知识库:
- 在
knowledge_base文件夹中创建新的.txt文件 - 按照
ncre_syllabus.txt的格式编写知识点 - 重启程序,在下拉菜单中选择新的知识库文件
10.4 API密钥获取
- 访问 api.aigc.bar/register
- 注册账号
- 在控制台创建API密钥
- 将密钥填入程序的API密钥输入框
十一、性能优化与最佳实践
11.1 减少API调用次数
AI出题是成本最高的操作。为了减少API调用次数,可以:
- 一次生成多题:每次调用生成20-100道题,而不是逐题生成
- 缓存题目:将生成的题目缓存到本地,下次可以直接使用
- 复用题目:同一套题目可以多次练习
11.2 优化UI响应速度
tkinter的UI响应速度受限于主线程。为了保持界面流畅:
- 使用后台线程:所有网络操作和耗时计算都在后台线程执行
- 批量更新UI:不要在循环中频繁更新UI,而是批量更新
- 使用
after方法:在后台线程中通过root.after调度UI更新
11.3 内存管理
对于100道题的试卷,内存占用大约在2-5MB,对于现代计算机来说完全可以接受。但需要注意:
- 及时清理:清空试卷时,要彻底销毁所有控件
- 避免内存泄漏:后台线程结束后,确保所有资源被释放
- 控制题目数量:建议单次出题不超过100道
十二、项目总结与展望
12.1 项目亮点
- 双端支持:同时提供桌面应用和Web应用,满足不同用户的需求
- 知识库可定制:用户可以根据自己的学习需求,自定义知识库内容
- 答案均衡:通过算法确保答案在A/B/C/D中均匀分布,提高练习效果
- 错题本系统:自动记录错题,支持错误次数统计,帮助用户针对性复习
- 多格式保存:支持TXT和JSON两种格式,方便打印和数据分析
12.2 技术难点与解决方案
| 难点 | 解决方案 |
|---|---|
| Windows中文编码问题 | 强制UTF-8环境 + Tcl编码初始化 + 安全字符串函数 |
| AI输出格式不稳定 | 渐进式JSON解析 + 字段完整性验证 |
| 答案分布不均 | 两轮筛选均衡算法 |
| 跨域请求限制 | 本地代理服务器 + 自适应API调用 |
| UI线程安全 | 后台线程 + root.after 调度 |
12.3 未来改进方向
- 支持更多题型:目前只支持单选题,可以扩展到多选题、判断题、填空题
- 智能复习计划:根据错题本数据,自动生成复习计划
- 学习进度追踪:记录每次练习的成绩,生成学习曲线
- 多人协作:支持教师创建试卷,学生在线作答
- 离线题库:内置标准题库,在没有网络的情况下也能练习
12.4 写在最后
这个项目从构思到完成,经历了多次迭代。最初只是一个简单的Python脚本,后来逐渐演变成一个完整的考试系统。在这个过程中,我深刻体会到了AI技术在教育领域的潜力——它不仅能生成高质量的题目,还能根据每个学习者的表现提供个性化的反馈。
当然,AI出题也有其局限性。比如,AI生成的题目有时会存在歧义,解析也可能不够准确。因此,在使用过程中,建议结合官方教材和真题进行练习,将AI生成的题目作为辅助学习工具。
最后,如果你对这个项目感兴趣,欢迎访问GitHub仓库查看完整源码。如果你在使用过程中遇到任何问题,或者有好的建议,欢迎在评论区留言交流。
项目地址:https://download.csdn.net/download/nmdbbzcl/93089593
API获取:AIGC Bar
参考资料:
- NCRE二级公共基础知识考试大纲(2025年版)
- OpenAI API文档
- Python tkinter官方文档
更多推荐




所有评论(0)