
🔗 相關連結
- 💻 GitHub:yazelin/annuli
- 📦 PR:#… wave-2 refactor (squash merge 0e6cec1)
- 📔 前篇:Annuli 誕生(MVP)
一個月後:MVP 撐不住了
2026-04-09 我寫了 Annuli MVP——222 行 app.py,樹輪隱喻、/sleep 反思、單一全域 persona。當時就知道有些地方撐不了多久:單一使用者、沒有意圖分類、沒有 keyword 搜尋、/sleep 是 destructive 操作。
接下來一個月我一直加功能:
- 4 輪深度研究(
/learn) - KOL 風格分析(
/study) - 自主選題與內容草稿(
/explore、/post) - 圖片生成(nano-banana API)
- Facebook 發佈與互動同步
- APScheduler 排程
- 知識庫管理
- Flask admin web UI
到 2026-05 中,engine.py 從 222 行膨脹到 2489 行。admin.py 也跟著漲到 1468 行。adapters/cli.py 391 行。
打開檔案找一個 function 都要捲三次。改一個 bug 怕踩到別的 feature。這已經不是 monolithic 的問題,是 monolithic mud ball 的問題。
2026-05-13 我決定做 Wave 2 重構。
問題:兩種完全不同的工作纏在一起
仔細看 engine.py,其實是兩個責任不同的系統被硬塞在同一個檔案:
| 記憶 / 反思(核心) | 內容生產 / 社群(副業) |
|---|---|
| persona / memory / rings | drafts、貼文排程、FB sync |
/sleep 反思 |
/learn /study /post 命令 |
| chat_history 儲存 | 圖像生成 API |
| 對話 intent 分類 | explore pool 管理 |
| - | knowledge 庫 |
為什麼這是問題:
- Release cycle 完全不同
- 記憶設計是「年穩」——一旦定了我希望它穩定數個月
- FB API 是「季變」——Meta 改規格時我得馬上跟
- 但現在它們綁在同一個 daemon,FB 改 API 我得整個 daemon 重啟,連對話記憶都斷
- User 需求被綁架
- 有人想要 Annuli 只當記憶系統、不要 FB 發文功能
- 但目前架構沒辦法選擇性安裝
- 2489 行單檔無法維護 — IDE 都跑得慢
設計目標
Wave 2 的目標是 modular monolith:
- 拆成兩個邏輯模組
annuli.core跟annuli.creator - 兩個各自一個 Flask server(port 5000 / 5001)
- 依賴方向:
creator → core(creator 讀 core 的記憶,但 core 不需要 creator) - 不改一行業務邏輯——這是純粹的「搬位置」
- 舊程式碼透過
engine.pyshim re-export,任何 import engine 的 callsite 不破
「不改邏輯」這條很重要——這次重構唯一的成功標準是「拆完整體還能跑」,不要混進新功能。
17 步機械式搬遷
整個重構是 17 個獨立 commit,每一步只做一件事。
| Step | 目標模組 | 來源 | 內容 |
|---|---|---|---|
| 1 | core/config.py |
engine.py:18-85 | 路徑常數 + config 加載 |
| 2 | core/utils.py |
engine.py 散落各處 | 純工具(JSON I/O、keyword 抽取) |
| 3 | core/llm_backend.py |
engine.py LLM 呼叫 | call_ai / call_fast_ai(spinner + multi-backend) |
| 4 | core/memory.py |
engine.py UserContext + I/O | persona / memory / recall |
| 5 | core/chat.py |
engine.py 對話 pipeline | intent 分類、smart recall、build_system_prompt、process_message |
| 6 | core/rings.py |
engine.py 反思層 | archive_ring、do_reflect、get_rings_list、reset |
| 7 | creator/learn.py |
engine.py 4 輪研究 + 影片字幕 | _extract_urls / _extract_video_transcripts / _chunk_text / do_learn |
| 8 | creator/{explore,study}.py |
engine.py KOL 研究 | do_explore、do_study、refine_writing_style |
| 9 | creator/{post,images}.py |
engine.py post + 圖 | generate_post、list_drafts、generate_draft_images |
| 10 | creator/{facebook,sync_engagement}.py |
engine.py FB | publish_to_fb、fetch_fb_comments、sync_post_engagement |
| 11 | creator/knowledge.py |
engine.py 知識管理 | load_explore_pool、search_knowledge、list_knowledge |
| 12 | creator/scheduler.py |
admin.py:14-238 APScheduler | 5 個 cron task |
| 13 | {core,creator}/server.py |
admin.py 35 個 Flask routes | 11 + 22 routes |
| 14 | core/adapters/cli.py |
adapters/cli.py | 10 個 core 命令 |
| 15 | main.py 重寫 |
dispatcher | chat / admin / creator-admin 子命令 |
| 16 | engine.py 變 shim |
71 行 re-export | 40 個 symbol re-export |
| 17 | 刪舊檔 | 收尾 | admin.py / adapters/cli.py / 殘留 |
每一步:
- 搬位置不改邏輯 — 函式內容 1:1 複製
- 每個新檔案開頭寫來源 — 標註 pre-Wave-2 行號、依賴、Wave 2 vs Wave 3 變動
- 每步驗證 engine.X re-export 仍然有效 — 舊 callsite 不破
新架構
src/annuli/
├── __init__.py (v0.2.0)
├── core/ ← 記憶 + 反思(主體)
│ ├── config.py 路徑/常數
│ ├── utils.py 純工具
│ ├── llm_backend.py LLM 呼叫 + spinner
│ ├── memory.py @dataclass UserContext / I/O
│ ├── chat.py intent 分類 / smart recall / 對話主迴
│ ├── rings.py 年輪歸檔 / do_reflect / 重置
│ ├── events.py [Wave 3 stub]
│ ├── digest.py [Wave 3 stub]
│ ├── curator.py [Wave 3 stub]
│ ├── scheduler.py core 排程
│ ├── server.py 11 個 Flask routes(persona / users / rings / config)
│ ├── adapters/cli.py CLI 入口(10 個命令)
│ └── bootstrap.py 初始化
│
├── creator/ ← 內容生產(副業)
│ ├── learn.py 4 輪研究 + yt-dlp + Whisper 字幕
│ ├── explore.py 自主選題 + editorial direction
│ ├── study.py KOL 分析 + 風格蒸餾
│ ├── post.py draft 生成 / CRUD
│ ├── images.py nano-banana API + 圖片 CRUD
│ ├── facebook.py FB Graph v22.0 + publish / comment
│ ├── sync_engagement.py FB 按讚 / 留言同步
│ ├── knowledge.py explore_pool I/O + 關鍵詞搜尋
│ ├── scheduler.py 5 個 cron(探索 / 學習 / 研究 / 發文 / 同步)
│ └── server.py 22 個 Flask routes(drafts / knowledge / schedule)
│
└── engine.py ← 71 行 shim,re-export 40 個 symbol
main.py ← dispatcher
admin.py ← [廢棄]
adapters/cli.py ← [廢棄]
兩個 server 跑在不同 port:
python main.py admin --port 5000→ core server (記憶 / persona / rings)python main.py creator-admin --port 5001→ creator server (drafts / FB / 知識庫)
依賴方向:
creator → core (creator 讀 core 的記憶,做為內容素材)
core ⊥ creator (core 不知道 creator 存在)
理論上以後可以把 creator/ 完全拆出去變獨立 repo annuli-creator,core 不用動。
同一段程式碼,拆前拆後
舉個例子。recall_relevant_memories 這個函式 Wave 2 前後一字未改:
拆前(engine.py:125-146):
def recall_relevant_memories(user_ctx, query):
"""依關鍵詞從 events / imprints / shared_experiences 抽相關記憶"""
keywords = _extract_keywords(query)
memories = []
for key in keywords:
try:
with open(user_ctx.memory_file, 'r', encoding='utf-8') as f:
mem = json.load(f)
if key in mem.get('events', []):
memories.append(mem['events'][key])
except (FileNotFoundError, json.JSONDecodeError):
pass
return memories[:5]
拆後(src/annuli/core/memory.py:75-96):
def recall_relevant_memories(user_ctx, query):
"""依關鍵詞從 events / imprints / shared_experiences 抽相關記憶"""
keywords = _extract_keywords(query)
memories = []
for key in keywords:
try:
with open(user_ctx.memory_file, 'r', encoding='utf-8') as f:
mem = json.load(f)
if key in mem.get('events', []):
memories.append(mem['events'][key])
except (FileNotFoundError, json.JSONDecodeError):
pass
return memories[:5]
完全一樣。
「Wave 2 機械式重構,不改邏輯」就是這個意思。
每個新檔案開頭都標註來源
這是讓 Wave 2 變得「人類可審 + 之後可改」的關鍵。例如 src/annuli/creator/learn.py 的檔頭:
"""annuli.creator.learn — 深度研究(4 輪)+ 影片字幕擷取
來源 engine.py(pre-Wave-2 行號):
- _extract_urls (78-80)
- _is_video_url (83-92)
- _extract_video_transcripts (95-123)
- _chunk_text (126-145)
- _condense_transcript (148-185)
- do_learn (265-507) ← 主函式
依賴:annuli.core.config / utils / llm_backend / memory
+ annuli.creator.knowledge (Step 11 後)
Wave 2 stopgap:do_learn 需要的 list_knowledge / search_knowledge
還在 engine.py(Step 11 才搬到 creator/knowledge),這裡用 lazy import 從 engine 拿。
Wave 2 機械式搬位置,不改邏輯。
"""
每個檔案開頭都這樣:
- 來源 — pre-Wave-2 哪幾行
- 依賴 — 靠了哪些其他模組
- Wave 2 vs Wave 3 — 哪些是 stopgap、哪些是最終形態
兩個月後我自己回來看這個 repo,這些檔頭比任何 commit message 都有用。
engine.py 變 71 行 shim
最後一個關鍵 commit:engine.py 不刪掉,改成 re-export shim。
# engine.py (Wave 2 後,71 行)
"""engine.py — Wave 2 shim
This file used to contain everything. Now it only re-exports symbols
from src/annuli/{core,creator}/ so existing call sites don't break.
All new code should import from src/annuli/ directly:
from annuli.core.memory import resolve_user
from annuli.creator.learn import do_learn
"""
from annuli.core.config import (
USERS_DIR, MEMORIES_DIR, RINGS_DIR, ...
)
from annuli.core.memory import (
UserContext, load_persona, save_persona, resolve_user,
append_log, recall_relevant_memories, ...
)
from annuli.core.chat import (
classify_intent, smart_recall, build_system_prompt, process_message,
)
from annuli.core.rings import (
archive_ring, do_reflect, get_rings_list, ...
)
from annuli.creator.learn import do_learn
from annuli.creator.post import generate_post
from annuli.creator.facebook import publish_to_fb
# ... 40 個 symbol 總共
__all__ = [
"UserContext", "load_persona", ...
]
舊程式碼 from engine import do_learn 完全不用改。
但新程式碼鼓勵直接 import 子模組:
# ❌ 舊(透過 shim,會 work 但不推薦)
from engine import do_learn
# ✅ 新(明確路徑)
from annuli.creator.learn import do_learn
Shim 是 deprecation 而非 hard break。給自己一年時間慢慢把所有 callsite 改完。
哲學轉變
Wave 2 不只是檔案搬位置,也是思維轉變。
Pre-Wave-2 思維:
「Annuli 是一個黑盒子,做所有事情。記憶、學習、發文、爬蟲、發 FB 都在 engine.py 裡。」
→ 結果是 monolithic mud ball,責任不清,加 feature 越加越痛。
Post-Wave-2 思維:
「Annuli core 是反思引擎;creator 是創作工坊。vault 是 spirit 的家。三者互相獨立但可以協作。」
→ 結果是 modular monolith,每塊責任清楚,可以單獨升級、單獨部署、單獨開源。
更深一層的轉變在反思層:
| MVP | Wave 2 | Wave 3 計畫 | |
|---|---|---|---|
/sleep 行為 |
LLM 全部重寫 persona | 同(保留舊行為) | 改為 append-only ring |
| persona 歸誰 | LLM 自己改 | 同 | 由召喚師審查後寫入 |
| 衝突解決 | 必須寫時間演進 | 同 | 同 |
MVP 的問題:每次 /sleep 都是「AI 整個脫皮重生」——LLM 把 persona 改寫一遍。長時間下來 persona 會 drift,跟最初的人格越來越不同。
Wave 3 的方向:/sleep 改成「AI 寫一頁日記、由召喚師決定要不要更新 persona」。AI 只 append ring,不改寫 soul。這個轉變要 Wave 3 才會落地,但 Wave 2 的模組化讓它變得可能——core/rings.py 跟 core/memory.py 拆開之後,要改反思行為只動 rings.py,不影響其他層。
跟 Mori 宇宙的關連
Wave 2 重構不是孤立的工程——它對齊一系列正在進行的設計:
- world-tree 定義了
spirit-template/檔案結構 → Annuli core 的路徑設計向它看齊 - mori-desktop Phase 1 也在同期 v0.0 → v0.2 → v0.x,這邊也跟著 v0.1 → v0.2 bump
- mori-journal(private repo)= Mori 的 SOUL 真正存在的地方 → Annuli 之後會讀寫她的 vault
Annuli 不是第三方工具。Annuli 是 Mori 大腦的後半部——身體(mori-desktop)負責感知與動作,Annuli 負責反思與記憶。
Wave 2 是把這個分工做出來的第一步。
Takeaways
- 2000+ 行單檔等於設計失敗 — 不管當初理由多正當,這個信號很硬
- 拆檔不改邏輯比「拆檔順便重寫」安全十倍 — Wave 2 的成功標準就是「拆完整體還能跑」
- 17 個獨立 commit 比 1 個大 commit 好十倍 — 每步可 revert、可 review、可暫停
- shim re-export 是 deprecation 的正確姿勢 — 不破舊程式碼,但鼓勵新程式碼用新路徑
- 檔頭標註來源(pre-refactor 行號)對未來的自己最有用 — 比 commit message 持久
- modular monolith 是個人專案最好的起點 — 比 microservice 簡單,比 monolith 可維護
接下來幾週,Annuli 會跟 mori-desktop Phase 3 整合,讓 Mori 桌面身體真正會用反思引擎。Wave 3 的 ring-based reflection 也會慢慢落地。
但今天,我把 monolithic mud ball 變成了 modular monolith。
森林精靈的記憶器官,終於有清楚的解剖學了。
「年年新輪,舊輪不消失——但每一輪終究要長在自己的器官裡。」