讓遊戲記住你哪裡不熟:FSRS 間隔重複的簡單實作

讓遊戲記住你哪裡不熟:FSRS 間隔重複的簡單實作

你有 80 個日文單字要學,每個單字有 5 種變化形。隨機出題的話,你已經會的「食べます」和你老是搞混的「食べさせられる」出現的機率一樣。你的時間有一半浪費在已經會的東西上。

FSRS(Free Spaced Repetition Scheduler)解決的就是這個問題:讓你不熟的東西多出現,熟的東西少出現。我在 ConjuGate 裡面實作了一個簡化版的 FSRS,這篇用最簡單的方式解釋它怎麼運作。

先從一張卡片開始想

假設你正在學「飲む」的 ます形。你第一次看到它,答對了。下一次什麼時候該再出現?

如果太快再出,浪費時間。如果太晚再出,你已經忘了。FSRS 的核心就是找到這個「快要忘記但還沒忘」的甜蜜點。

做法很直覺:每張卡片有一個 interval(間隔),代表「隔多久再出一次」。答對了,interval 變長。答錯了,interval 縮短。

mutating func recordCorrect(turn: Int) {
    correctCount += 1
    lastSeen = turn
    interval = interval * easeFactor   // 間隔拉長
    easeFactor = min(3.0, easeFactor + 0.1)
}

mutating func recordWrong(turn: Int) {
    wrongCount += 1
    lastSeen = turn
    interval = max(1.0, interval * 0.5) // 間隔砍半
    easeFactor = max(1.3, easeFactor - 0.2)
}

easeFactor:每張卡片的「難度記憶」

interval 拉長的幅度不是固定的,由 easeFactor 決定。easeFactor 從 2.5 開始,答對一次加 0.1,答錯一次減 0.2。範圍鎖在 1.3 到 3.0 之間。

你一直答對的卡片,easeFactor 會慢慢爬到 3.0,interval 每次乘以 3,間隔越拉越長。你常答錯的卡片,easeFactor 掉到 1.3,interval 每次只乘 1.3,幾乎沒在拉長。

換句話說,系統會自動區分「你覺得簡單的卡片」和「你覺得難的卡片」,不用你手動標記。

怎麼決定下一題出什麼

有了 interval 和 easeFactor,下一步是決定 80 個單字、幾百種 word+form 組合裡面,下一題該出哪一個。

ConjuGate 用 weighted random 來選。每個 word+form 組合有一個 weight,weight 越高越容易被選到:

var weight: Double {
    let wrongBoost = Double(wrongCount) * 0.5
    let recency = 1.0 / (interval + 1.0)
    return max(0.1, 1.0 + wrongBoost + recency)
}

weight 由兩個因素組成:

  • wrongBoost:答錯越多次,weight 越高。答錯 4 次的卡片 weight 比從沒錯過的高 2.0。
  • recency:interval 越短(代表還不熟),weight 越高。interval 是 1 的卡片 recency 是 0.5,interval 是 99 的卡片 recency 趨近 0。

選的時候還有一個 due boost:如果你距離上次看到這張卡片的回合數已經超過 interval,代表它「到期了」,weight 再乘 1.5。

let turnsSinceSeen = Double(turnCount - (record?.lastSeen ?? 0))
let dueBoost = turnsSinceSeen >= interval ? 1.5 : 1.0
return base * dueBoost

Round Pool:避免短期重複

光靠 weight 還不夠。如果某張卡片 weight 很高,它可能連續出現三次,玩家會煩。

ConjuGate 用 round pool 解決:每一輪把所有 word+form 組合丟進池子裡,選過的就撈出來。等池子空了,重新裝滿,開始下一輪。

func nextQuestion() -> Question {
    turnCount += 1
    if currentRoundPool.isEmpty {
        rebuildPool()
    }
    let picked = pickWeighted()
    currentRoundPool.removeAll { $0 == picked }
    usedThisRound.insert(picked)
    // ...
}

這保證每個 word+form 在同一輪裡最多出現一次。weight 高的卡片會更早被選到,但不會霸占整輪。

跨 Session 記憶:SwiftData

FSRS 的學習記錄如果每次關掉 app 就消失,等於沒有。玩家上週已經把「飲む」的 ます形練到 interval 50 了,今天打開 app 又從頭開始,那 spaced repetition 就沒意義。

ConjuGate 用 SwiftData 把每個 word+form 的 ReviewRecord 存進本地資料庫:

@Model
final class ReviewRecordEntity {
    @Attribute(.unique) var key: String   // "wordID::formRawValue"
    var correctCount: Int = 0
    var wrongCount: Int = 0
    var interval: Double = 1.0
    var easeFactor: Double = 2.5
    var lastReviewDate: Date?
}

每次答題,QuizScheduler 立刻把結果寫回 DataStore。下次打開 app,loadRecords() 把所有記錄讀回來,FSRS 從上次的狀態繼續。

func recordResult(wordID: String, form: FormID, correct: Bool) {
    let key = WordFormKey(wordID: wordID, form: form)
    var record = records[key] ?? ReviewRecord()
    if correct {
        record.recordCorrect(turn: turnCount)
    } else {
        record.recordWrong(turn: turnCount)
    }
    records[key] = record
    dataStore?.saveRecord(key: key, record: record)
}

跟隨機出題比,差在哪

隨機出題FSRS
已經會的單字跟不會的出現頻率一樣interval 拉長,自動淡出
一直答錯的單字跟其他的出現頻率一樣weight 提高,更頻繁出現
關掉 app 再打開一切重來從上次的學習狀態繼續
80 個單字 × 5 種變化400 種組合平均分配時間時間集中在你不熟的組合上

實作上的取捨

ConjuGate 的 FSRS 是簡化版。完整的 FSRS(像 Anki 用的)有更複雜的記憶模型,會根據答題的反應時間、連續答對次數、遺忘曲線斜率來調整參數。

我選擇簡化版有兩個原因:

  • ConjuGate 是遊戲,不是單字卡 app。玩家在閃避敵人的同時回答問題,反應時間沒有參考價值。
  • QuizScheduler 整個檔案 150 行。加上 DataStore 的持久化,總共不到 350 行。複雜度和效果之間,這個平衡點夠用。

如果你在做學習類的 app,不需要一開始就搬完整的 FSRS。interval × easeFactor 這個核心公式,加上 wrong weight 和 due boost,已經比隨機出題好太多了。先用這個跑,等有了真實的用戶數據再決定要不要升級。


ConjuGate 是一個用 SpriteKit 寫的 iOS 遊戲,在打怪的過程中學日文動詞變化。這篇是「費曼的實習生」系列,把學到的東西用最簡單的方式講出來。