在这里插入图片描述

文章目录

一、项目背景与动机

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出题的质量,直接取决于知识库的完整性和结构化程度。我选择了纯文本格式,原因有三:

  1. 零学习成本:任何人都可以用记事本编辑
  2. 版本控制友好:可以用Git追踪每次修改
  3. 解析简单:不需要复杂的解析器

知识库文件 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 返回内容中没有找到题目数据。请重试。")
    ...

这个函数采用了渐进式容错策略:

  1. 先尝试直接解析
  2. 如果失败,尝试移除markdown标记
  3. 如果还失败,尝试从文本中提取JSON数组
  4. 最后,验证每个题目的字段完整性

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处理中文,是一个深坑。我遇到了两个问题:

  1. Tcl/Tk编码问题:tkinter底层使用Tcl/Tk,默认编码不是UTF-8
  2. 控制台编码问题: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

这段代码做了三件事:

  1. 设置环境变量,告诉Python使用UTF-8
  2. 调用Windows API设置控制台代码页
  3. 重新包装标准输出,确保所有输出都是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

这个处理函数做了三件事:

  1. 将异常信息写入调试日志
  2. 显示友好的错误提示框
  3. 调用原始的异常处理函数

五、Web应用开发:纯前端实现

5.1 架构选择:为什么不需要后端?

Web版本采用了纯前端架构,所有逻辑都在浏览器中执行。这听起来可能有些反直觉——通常Web应用需要后端服务器。但在这个场景下,纯前端架构有独特的优势:

  1. 零部署:用户只需要双击HTML文件即可使用
  2. 隐私安全:API密钥只存储在本地,不会上传到任何服务器
  3. 离线可用:除了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:8080localhost: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');
}

这个方法的原理是:

  1. 将文本内容包装成 Blob 对象
  2. 创建临时的 ObjectURL
  3. 创建一个隐藏的 <a> 标签并模拟点击
  4. 清理临时资源

六、错题本系统:从数据模型到持久化

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 自定义知识库

用户可以根据需要自定义知识库:

  1. knowledge_base 文件夹中创建新的 .txt 文件
  2. 按照 ncre_syllabus.txt 的格式编写知识点
  3. 重启程序,在下拉菜单中选择新的知识库文件

10.4 API密钥获取

  1. 访问 api.aigc.bar/register
  2. 注册账号
  3. 在控制台创建API密钥
  4. 将密钥填入程序的API密钥输入框

十一、性能优化与最佳实践

11.1 减少API调用次数

AI出题是成本最高的操作。为了减少API调用次数,可以:

  1. 一次生成多题:每次调用生成20-100道题,而不是逐题生成
  2. 缓存题目:将生成的题目缓存到本地,下次可以直接使用
  3. 复用题目:同一套题目可以多次练习

11.2 优化UI响应速度

tkinter的UI响应速度受限于主线程。为了保持界面流畅:

  1. 使用后台线程:所有网络操作和耗时计算都在后台线程执行
  2. 批量更新UI:不要在循环中频繁更新UI,而是批量更新
  3. 使用 after 方法:在后台线程中通过 root.after 调度UI更新

11.3 内存管理

对于100道题的试卷,内存占用大约在2-5MB,对于现代计算机来说完全可以接受。但需要注意:

  1. 及时清理:清空试卷时,要彻底销毁所有控件
  2. 避免内存泄漏:后台线程结束后,确保所有资源被释放
  3. 控制题目数量:建议单次出题不超过100道

十二、项目总结与展望

12.1 项目亮点

  1. 双端支持:同时提供桌面应用和Web应用,满足不同用户的需求
  2. 知识库可定制:用户可以根据自己的学习需求,自定义知识库内容
  3. 答案均衡:通过算法确保答案在A/B/C/D中均匀分布,提高练习效果
  4. 错题本系统:自动记录错题,支持错误次数统计,帮助用户针对性复习
  5. 多格式保存:支持TXT和JSON两种格式,方便打印和数据分析

12.2 技术难点与解决方案

难点 解决方案
Windows中文编码问题 强制UTF-8环境 + Tcl编码初始化 + 安全字符串函数
AI输出格式不稳定 渐进式JSON解析 + 字段完整性验证
答案分布不均 两轮筛选均衡算法
跨域请求限制 本地代理服务器 + 自适应API调用
UI线程安全 后台线程 + root.after 调度

12.3 未来改进方向

  1. 支持更多题型:目前只支持单选题,可以扩展到多选题、判断题、填空题
  2. 智能复习计划:根据错题本数据,自动生成复习计划
  3. 学习进度追踪:记录每次练习的成绩,生成学习曲线
  4. 多人协作:支持教师创建试卷,学生在线作答
  5. 离线题库:内置标准题库,在没有网络的情况下也能练习

12.4 写在最后

这个项目从构思到完成,经历了多次迭代。最初只是一个简单的Python脚本,后来逐渐演变成一个完整的考试系统。在这个过程中,我深刻体会到了AI技术在教育领域的潜力——它不仅能生成高质量的题目,还能根据每个学习者的表现提供个性化的反馈。

当然,AI出题也有其局限性。比如,AI生成的题目有时会存在歧义,解析也可能不够准确。因此,在使用过程中,建议结合官方教材和真题进行练习,将AI生成的题目作为辅助学习工具。

最后,如果你对这个项目感兴趣,欢迎访问GitHub仓库查看完整源码。如果你在使用过程中遇到任何问题,或者有好的建议,欢迎在评论区留言交流。


项目地址https://download.csdn.net/download/nmdbbzcl/93089593

API获取AIGC Bar

参考资料

  • NCRE二级公共基础知识考试大纲(2025年版)
  • OpenAI API文档
  • Python tkinter官方文档
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐