xiaoge_distill · 技術解說

57 集教學影片,蒸餾成 637 條影片線規則

影片裡的知識是線性的、口語的、散落的。要把它變成能查、能比對、能追溯原話與畫面的規則庫,難的不是呼叫 LLM——是如何不相信 LLM,以及如何讓一個項目失敗時不連坐整批。

本頁只講影片線。全專案併入教材、課程、PPA 與外部筆記後,目前共 1,024 條規則,見 知識來源與產出物盤點。

Whisper 雙引擎 Gemini 3.8 Flash ffmpeg 逐 30 秒擷圖 3,082 項測試
0

一張圖看懂全貌

五個階段、一份狀態正本、一條平行支線

整條管線的骨架只有一句話:每一集影片獨立地穿過五個階段,狀態全部記在同一份 manifest.json 裡。階段之間不傳遞記憶體物件、不共用進程——中斷後重跑,未完成的項目自己接續。

最容易誤解的一點在圖的下半部:擷圖不在狀態鏈上。它只寫 frames_dir 欄位,刻意與轉錄同時進行,因為兩者一個吃網路頻寬、一個吃 GPU,串起來跑會白白浪費一半的時間。

① 轉錄 groq / 地端 GPU ③ 圖文編織 幀圖嵌入逐字稿 ④ 規則萃取 唯一呼叫 LLM 之處 ⑤ 彙整手冊 無 LLM · 確定性 637 條規則 ② 擷圖 每 30 秒一張 平行進行 manifest.json 唯一狀態正本 · 跨 process 檔案鎖 · 中斷可續跑 pending → transcribed → woven → extracted 終態:failed / extract_failed / members_only

每個階段都只跟 manifest 對話,彼此不直接耦合——這是「中斷後能續跑」的來源,也是雙 worker 能同時跑的前提

產出數量說明
已萃取集數57 / 57其中 17 集是 YouTube 頻道會員限定,2026-09-25 以會員 cookie 補齊(沒有 cookie 時標 members_only)
規則卡769 張逐集萃取,每張含條件、動作、理由、原話、時間戳、截圖
唯一規則637 條同一 rule_id 跨集合併後的結果(只算影片線;併入教材、課程、外部筆記後的主手冊是 764 條)
跨集重複84 條最高一條講過 6 集——這就是重要度訊號
幀圖2,628 張每 30 秒一張,供規則卡回溯當下畫面
quote 驗證通過97%引文與逐字稿相似度 ≥ 0.8 才算已驗證(2026-09-25 改良比對方法後由 60% 提升)
A

管線與併發

雙 worker 平行,鎖只包住毫秒級操作

轉錄有兩條引擎:雲端快但有限速、地端慢但免費且無限。兩者同時跑、共用同一個佇列,誰先空下來誰認領下一集。這使得限速不再是阻塞——雲端撞限速時該集退回佇列,地端自然接手。

雲端groq whisper-large-v3

  • 約 20 秒/集
  • 有每日限速,撞到即退回佇列
  • 限速不計入 retry_count——不是這集的錯

地端GPU whisper large-v3

  • 約 5 分鐘/集
  • 無限量,適合長時間掛著
  • 必須用 tf_3.0 環境的 python

五個階段的職責與產出:

  1. 轉錄run_transcribe_queue.py

    下載音訊 → Whisper → 帶 ## MM:SS 時間戳標題的 Markdown。字數少於「分鐘數 × 100」視為空殼,直接判失敗。

  2. 擷圖capture_frames.py

    下載影片 → 每 30 秒擷一張(取區間中點 15 秒處,避開轉場)。與轉錄平行,不等它。

  3. 圖文編織weave_frames.py

    把幀圖依時間戳插進逐字稿對應位置。冪等——重跑不會重複插入。

  4. 規則萃取extract_rules.py

    整份圖文逐字稿餵給 Gemini,回傳規則卡陣列。四道護欄過濾 + 引文模糊比對驗證。

  5. 彙整手冊consolidate_notes.py

    依 rule_id 跨集合併、累計提及次數、依重要度排序,輸出五篇 Markdown。不呼叫 LLM,同樣輸入必得同樣輸出。

關鍵片段 1 · 鎖該包住什麼,不該包住什麼

雙 worker 同時寫一份 JSON,最直覺的寫法是「整個處理流程包在鎖裡」。那會壞得很隱蔽:鎖有 60 秒過期機制,而轉錄要跑好幾分鐘——鎖會在轉錄途中被另一個 worker 判定過期而搶走,變成兩邊都以為自己持鎖。

scripts/xiaoge/run_transcribe_queue.pyPYTHON
# 轉錄呼叫留在鎖外執行(數分鐘等級),讓雙 worker 真正平行
outcome = run_transcription(vid, args.worker, _real_transcribe)

# 回寫走加鎖入口:鎖內重新 load 最新版 → 套用結果 → save,
# 避免整份覆寫舊快照蓋掉另一個 worker 同時完成的別集成果
try:
    final_data = mf.update_episode_and_save(
        MANIFEST, vid,
        lambda d, v: apply_outcome(d, v, args.worker, outcome))
except mf.LockTimeoutError:
    # 轉錄已經跑完(錢已經花了/GPU 已經算完)——結果絕不能憑空遺失
    _save_pending_outcome(vid, args.worker, outcome)
註解為什麼重要:網路呼叫與資料變更被刻意切成兩半——run_transcription 在鎖外(慢、可能失敗),apply_outcome 是純函式在鎖內(快、只動記憶體)。另外注意 except LockTimeoutError 那段:錢都花完了才拿不到鎖,結果先落地成備援檔,寧可留下一個要人工處理的檔案,也不讓花掉的成本憑空蒸發。

關鍵片段 2 · 為什麼回寫不能用呼叫端的快照

這支函式簽名上有個刻意的缺席:它不接受呼叫端手上的 data,只收一個 mutate_fn。這不是設計潔癖,是修過的 race。

scripts/xiaoge/manifest.pyPYTHON
def update_episode_and_save(path, vid, mutate_fn, lock_timeout=30.0,
                             stale_seconds=60.0):
    lock_path = path.with_name(path.name + ".lock")
    with file_lock(lock_path, timeout=lock_timeout, stale_seconds=stale_seconds):
        data = load(path)      # ← 鎖內重新讀,不用呼叫端的舊快照
        mutate_fn(data, vid)   # ← 只動這一集,純記憶體操作
        save(path, data)
    return data
註解為什麼重要:worker A 在認領時讀了快照,轉錄五分鐘;期間 worker B 完成了另一集並存檔。A 若拿自己那份五分鐘前的快照整份寫回,B 的成果就消失了(last-write-wins)。解法是在鎖內重新讀最新版,只套用自己那一集的變更。專案裡另有一條靜態測試掃描原始碼,禁止任何人改回 mf.save(MANIFEST, ...) 這種整份覆寫寫法。
B

證據鏈與守衛

哪些事實不能問 LLM,以及單點失敗如何被擋在原地

每張規則卡都附「出處」:原話、時間戳、畫面截圖。這些欄位只有原話是 LLM 給的,其餘全部由我們自己算——因為 LLM 對時間的自報從根本上不可靠,它沒有能力知道某句話出現在影片第幾分鐘。

實際踩過的事故:LLM 宣稱某句話在 29:30,但它在逐字稿中實際落在 ## 30:00 標題底下,影片裡真正的時點是 30:13——卡片因此指向一張不存在的截圖。

LLM 回傳 原話 quote + 自報時間戳 模糊比對定位 在逐字稿中找出 quote 的真實位置 往前找最近標題 ## MM:SS 轉錄時寫入的既有事實 可查證出處 時間戳 + 確實存在 的最近幀槽 定位不到 → 不採用 LLM 的猜測值頂替 該卡因缺 provenance.timestamp 被護欄丟棄,寧缺勿假 LLM 自報的時間戳 全程不被採用

唯一向 LLM 索取的是「哪句原話」;時間與畫面都由我們從既有事實推導,因此可被任何人重新驗證

關鍵片段 3 · 時間戳自己推導

逐字稿在轉錄階段就寫入了 ## MM:SS 標題,那是機器產生的既有事實。既然如此,時間戳就沒有理由去問 LLM。

scripts/xiaoge/extract_rules.pyPYTHON
def derive_timestamp_from_quote(transcript, position):
    # position 為 None(quote 完全定位不到)→ 回傳 None,
    # 呼叫端不得用 LLM 的猜測值頂替,讓該卡被護欄丟棄
    if position is None:
        return None
    seconds = None
    for offset, total_seconds in _iter_heading_seconds(transcript):
        if offset <= position:
            seconds = total_seconds      # 持續往後推進到最接近的標題
        else:
            break
    if seconds is None:
        return None
    return f"{seconds // 60:02d}:{seconds % 60:02d}"
註解為什麼重要:注意兩個 return None——它們代表「寧可沒有時間戳,也不要一個看起來合理但錯誤的時間戳」。缺 timestamp 的卡會被護欄丟棄,這是刻意的:一張指向錯誤畫面的卡片,比一張不存在的卡片更有害,因為使用者會相信它。

關鍵片段 4 · 讓單點失敗不連坐整批

這是本專案一天內在四個不同階段各犯一次的錯誤模式:某個共通原因讓每一項都失敗,而逐項容錯的迴圈忠實地把每一項都記成失敗、燒光重試額度。下面這段的設計重點不在「擋下」,而在擋下的方式。

scripts/xiaoge/llm_config.py & extract_rules.pyPYTHON
class DailyQuotaExceeded(Exception):
    """刻意不繼承 RuntimeError——呼叫端的逐集容錯通常寫
    except Exception/except RuntimeError,額度用盡必須能被
    單獨攔在前面,乾淨結束整批。"""

# ── extract_rules.main() 的迴圈裡 ──
except llm_config.DailyQuotaExceeded as e:
    # 額度用盡不是這一集的錯——絕不可落到下面的逐集容錯
    print(f"[規則卡萃取] {e}")
    break                        # ← 本集維持原狀,明日原地續跑
except Exception as e:      # noqa: BLE001 — 逐集容錯
    _save_failed(vid, f"規則卡萃取失敗:{e}")   # 燒 retry_count
註解為什麼重要:兩個 except 的順序就是防線本身,位置寫反等於防線不存在。反向驗證確認過:把上面那條分流拿掉,測試中 5 集全部被標成失敗。同型防線還有三道——環境故障連續失敗即停機、不為永遠拿不到的集數下載 200MB 影片、尾段餘數不足 3 秒不補幀(曾因最後一秒取不到畫面,害同集已成功的 35 張全部作廢)。

存在 ≠ 完整是貫穿全專案的判準:問「處理過了嗎」一律看內容,不看旗標或檔案存在,讓空的項目下次自動重來。

檢查點判準擋下過什麼
逐字稿字數 ≥ 分鐘數 × 10040 分鐘影片只轉出 129 字的空殼
幀圖逐槽驗存在且非 0 byte殘檔被當成已完成
萃取有產出但全被丟棄 → 退回重跑0 張卡被標成「已完成」而永久消失
手冊規則數縮水 >10% 即擋下資料源出錯時靜默產出縮水手冊

萃取那條特別微妙:「LLM 回空陣列」是合法的(整集都是行情點評,本來就沒有規則可萃),但「LLM 有產出卻全被護欄丟棄」代表這次輸出劣化,必須重跑。實測中後者發生過一次,重跑後同一集 39 張卡全數通過。

注意事項 · 踩過的坑

🐍兩個 Python 環境不可混用:地端轉錄只能用 D:/Anaconda_202412/envs/tf_3.0/python.exe(只有它有 GPU whisper),其餘一律用專案 .venv。地端 worker 還要把 tf_3.0/Scripts 加進 PATH——yt-dlp 在那裡,只用絕對路徑呼叫 python 會找不到,症狀是每一集都以「找不到 yt-dlp」失敗。
🎬ffmpeg 一律用 .tools/ffmpeg.exe:那是 imageio 的靜態 binary。conda 環境自帶的 ffmpeg 跨環境呼叫會 DLL 崩潰——判別特徵是 returncode 0xC0000139/0xC0000135 且 stderr 全空。看到這個組合直接換靜態 binary,不要再調 PATH 試錯。
⏱️長批次要起成獨立行程:用 PowerShell Start-Process。Claude Code 的 Bash 背景任務有 10 分鐘上限,地端轉錄一集就要 5 分鐘,會被半途砍掉。
🔑兩把金鑰、同一套解析規則:GROQ_API_KEY(轉錄)與 GOOGLE_API_KEY(萃取)都是環境變數優先 → 專案根 .env → 缺就 fail fast。搬遷初期 Gemini 那把沒有 .env 後備,寫進 .env 會被靜默忽略,已修。
🔀擷圖不要改成「要求已有逐字稿」:它刻意與轉錄平行跑。改成等轉錄完成會把平行變序列,白白拉長一倍時間。它排除的只有永遠不可能有逐字稿的終態集數(members_only/failed)。
🤖手冊裡混了三代模型的產出:現有 650 張卡是 gemini-2.5-flash 產的,2026-08-02 起改用 gemini-3.1-flash-lite,2026-09-30 起改用 gemini-3.8-flash 並要求逐條列出、名詞解釋做成知識卡。日後重跑既有集數做比較時,要意識到這條分界——品質特性不同,數字不能直接對照。
📊量測數字用 Python,不要用 wc:CJK 字元與欄位內嵌換行都會讓 wc 給出假數字。核對資料完整性時同時比檔數與總位元組——只比檔數對「檔案還在但內容被清空」完全盲目。