ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

轻量模型在算法复习中的“盲盒抽题”设计:动态调整题目难度与知识点

轻量模型在算法复习中的“盲盒抽题”设计:动态调整题目难度与知识点

很多刷过两三百道 LeetCode 的同学都会陷入同一种倦怠:
按照“Hot 100”顺序从前往后刷,看到题目第一眼就能联想起官方题解里的关键变量名,甚至连双指针的left < right还是left <= right都能肌肉记忆背出来;可一旦关掉题解,或者换了个业务场景包装,大脑立刻陷入一片空白。

这种现象在认知心理学上叫作“假性掌握”。机械刷题制造的虚假熟练度,在面对大厂面试官临时变体题目时会瞬间现出原形。

更尴尬的是,很多同学想用大模型辅助复习,但在线商业大模型往往有两个毛病:要么问一下就一股脑把完整代码和题解全盘托出,剥夺了独立思考的过程;要么受限于网络延迟与 API 调用额度,在高铁通勤或断网离线时根本无法使用。

近几个月,端侧 3B 级别小模型(如 Qwen2.5-3B-Instruct、Llama-3.2-3B 等量化版本)在单核 CPU 或轻量 GPU 上的推理速度已经能轻松突破 60 tokens/s。我利用本地部署的 3B 小模型,为自己搭建了一套“算法盲盒抽题与自适应陪练引擎”:系统会记录我的做题画像与遗忘曲线,动态调整难度,自动生成具有隐蔽特征的算法变体题,并在我卡壳时提供三段递进式启发,绝不剧透代码。


动态自适应抽题模型:Elo 评分与遗忘衰减的结合

为了让盲盒真正具备“懂我薄弱点”的能力,系统不能纯随机投喂,而是引入了两个核心算法引擎:

1. 知识点掌握度的动态 Elo 评分

我们把“用户能力”与“题目难度”映射到同一套类似国际象棋的 Elo 积分体系中(初始基准分为 1500 分)。
将算法划分为 8 个核心维度:[二叉树与图, 动态规划, 贪心与回溯, 滑动窗口与双指针, 单调栈/单调队列, 位运算与数学, 并查集, 字典树]。

每次练习结束,系统根据答题结果(AC/WA/请求了提示的层级)动态修正该知识点下的能力分:

$$R_{new} = R_{old} + K \cdot (S - E)$$

其中 $S$ 为实际得分(一次性 AC 记为 1.0,看了一级提示记为 0.7,看了两级提示记为 0.4,最终未解出记为 0.0);$E$ 为根据双方当前分值差计算出的期望胜率:

$$E = \frac{1}{1 + 10^{(R_{problem} - R_{user}) / 400}}$$

2. 基于艾宾浩斯遗忘因子的加权采样

一个知识点哪怕得分很高,如果已经连续 14 天未曾涉猎,其在抽题池中的唤醒权重就会指数级上升。遗忘衰减权重公式如下:

$$W_i = (1 - R_{user, i}^{norm}) \cdot 0.6 + \left(1 - e^{-\frac{\Delta t_i}{\tau}}\right) \cdot 0.4$$

系统根据权重 $W$ 构建轮盘赌选择器,优先挑出“既薄弱又快被遗忘”的知识点领域。


本地 3B 模型的工程调用与 JSON 严格约束

小参数模型最让人头疼的是指令遵循能力不如 70B 模型稳定。如果不加限制,小模型极其容易天马行空地写出一篇带剧透的万字长文。

为了让 3B 模型稳定充当“出题考官”,我们必须做好两件事:

  1. 采用严苛的 JSON Schema 结构化约束;
  2. 锁死温度系数(Temperature = 0.3),消除模型的过度发散。

以下是调用本地 Ollama 实例生成变种盲盒题目的 Python 核心调度代码:

import time import json import math import requests from dataclasses import dataclass, field from typing import List, Dict, Optional @dataclass class TopicProfile: topic_name: str elo_rating: float = 1500.0 last_practiced_timestamp: float = field(default_factory=time.time) class BlindBoxEngine: def __init__(self, profiles: Dict[str, TopicProfile]): self.profiles = profiles def select_next_topic(self) -> str: now = time.time() weights = {} tau = 7 * 86400 # 衰减半衰期设定为 7 天 for name, p in self.profiles.items(): # 归一化评分 (1200 ~ 2000 映射到 0 ~ 1) norm_elo = min(1.0, max(0.0, (p.elo_rating - 1200) / 800.0)) delta_t = now - p.last_practiced_timestamp forgetting_factor = 1.0 - math.exp(-delta_t / tau) # 综合薄弱度与遗忘度 weight = (1.0 - norm_elo) * 0.6 + forgetting_factor * 0.4 weights[name] = max(0.05, weight) # 轮盘赌采样 total_w = sum(weights.values()) rand_val = sum(weights.values()) * 0.5 # 示例固定取中位数,生产中使用 random.uniform acc = 0.0 for name, w in weights.items(): acc += w if acc >= rand_val: return name return list(self.profiles.keys())[0] def generate_problem(self, topic: str, target_difficulty: str) -> dict: prompt = f""" 你是一位顶级大厂技术面试官。请基于【{topic}】知识点,生成一道原创且具有实战业务背景的算法盲盒题。 难度要求:{target_difficulty}。 【输出规则】: 必须且仅能输出一个标准 JSON 对象,禁止包含 ```json 标签以外的任何客套废话。 JSON 字段必须完整包含: 1. "title": 题目简短名称 2. "scenario": 真实工程背景描述(如消息队列去重、微服务负载拓扑等) 3. "input_output_format": 明确输入输出约束 4. "examples": 包含至少两组测试用例与详细解释 5. "hint_level_1": 思维模型启发(只提大方向,绝不提具体数据结构) 6. "hint_level_2": 关键数据结构与时间复杂度建议 7. "verification_answer": 仅包含该题目的最优解法核心公式或思路(供系统后台校验) """ response = requests.post( "http://127.0.0.1:11434/api/generate", json={ "model": "qwen2.5:3b-instruct", "prompt": prompt, "stream": False, "format": "json", "options": { "temperature": 0.3, "num_predict": 1200 } }, timeout=30 ) return json.loads(response.json()["response"])

实战变体:从“打家劫舍”到“分布式限流窗口”

测试中,当系统检测到我在“动态规划”维度的分数略有停滞(1420 分),并且距上次复习已经过去 9 天时,盲盒引擎精准锁定了该知识点,并指挥本地 3B 模型吐出了一道令人眼前一亮的业务变形题:

本地小模型生成的盲盒题目片段

{ "title": "集群节点突发流量的错峰调度", "scenario": "在分布式服务网格中,有 N 个呈环形排列的异构计算节点。每个节点在第 t 秒可以承受并处理 c[i] 个请求。为防止相邻节点之间的网络交换机过热,调度中心规定:严禁同时激活任意两个相邻节点的突发加速模式。特别地,由于节点呈环形拓扑部署,第 0 号节点与第 N-1 号节点同样属于相邻节点。请计算在单次调度周期中,能够安全处理的最大突发请求总量。", "input_output_format": "输入一个正整数数组 capacity,表示环形节点的突发容量;输出一个整数,表示最大处理量。", "examples": [ { "input": "capacity = [2, 3, 2]", "output": "3", "explanation": "不能同时选择 0 号与 2 号,因为它们与 1 号相邻且在环上互为相邻;最优选择是只激活 1 号节点,获得容量 3。" }, { "input": "capacity = [1, 2, 3, 1]", "output": "4", "explanation": "选择 0 号节点(1)与 2 号节点(3),总容量为 4。" } ], "hint_level_1": "这是一个典型的环形拓扑约束问题。思考一下:如何通过两次线性状态的求解,把首尾相连的环解构成你所熟悉的线性序列?", "hint_level_2": "分别考虑【不选第 0 号节点】与【不选最后 1 号节点】两种互斥场景,在两段区间 [1, N-1] 与 [0, N-2] 上分别运行线性打家劫舍的经典 DP,最后取二者的最大值。" }

这道题虽然底层骨架是“环形打家劫舍”(LeetCode 213),但经过业务场景重新包裹与边界设卡,完全脱离了直接套用题号背代码的舒适圈。

如果我在解题过程中卡住,界面不会一股脑把题解弹出来,而是点击“获取提示 1”。如果提示 1 足够点醒“拆环为链”,系统只扣除少量 Elo 积分;只有在连提示 2 都用掉依然无法推导出状态转移时,系统才会判定该题未掌握,并在三日后的抽题轮盘中提高该类题目的重现概率。


本地端侧运行的实际收益与调优体会

这套基于端侧 3B 小模型的系统,在日常使用中展现出几项传统云端大模型无法替代的优势:

  1. 零成本与隐私安全:所有的做题历史、思考笔记和代码草稿全保存在本地 SQLite 中,没有 Token 计费焦虑,也完全不需要连接外网。
  2. 端到端极速响应:基于 Apple Silicon 统一内存或常规轻薄本(如搭载 NPU / RTX 4060 Laptop),Qwen2.5-3B 的 Prefill 延迟不到 80ms,题目生成与交互几乎零卡顿。
  3. 摆脱背题惯性:通过小模型低成本对经典 LeetCode 题目进行工程化“伪装”,强迫自己每次都必须从题目的输入约束、不变性原理(Invariants)和状态转移推导开始,真正锻炼把复杂现实场景抽象为数据结构的高阶工程能力。

把小模型当成随身携带的算法陪练搭子,你会发现算法复习不再是痛苦的题海苦役,而是一场充满博弈与确定性反馈的闯关升级。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进