מודולים עמוקים — ולמה הם חשובים יותר בעידן ה-AI
ראיתי את ההרצאה של Matt Pocock על יסודות תוכנה בעידן ה-AI, ורעיון אחד נשאר איתי: מודולים עמוקים. אז ישבתי עם Claude ופירקתי את זה מהיסוד. זה המאמר שיצא מהתהליך.
ראיתי את ההרצאה של Matt Pocock, Software Fundamentals Matter More Than Ever, ורעיון אחד נשאר איתי אחריה: מודולים עמוקים. בהרצאה הוא חזר ל-A Philosophy of Software Design של Ousterhout וטען, בקצרה, שהעקרונות הישנים של תכנון תוכנה לא נהיו פחות רלוונטיים בעידן ה-AI. להפך. הם נהיו חשובים יותר. Pocock הצליח לגרום לזה להישמע ברור בשתי דקות. אני רציתי להבין את זה באמת, לא רק להסכים עם הראש.
אז ישבתי עם Claude ופירקתי את הרעיון מהיסוד: מה זה בכלל module, למה interface צר עדיף על interface רחב, איך יודעים אם module הוא עמוק או שטוח בלי לדבר בסיסמאות, ומה עידן ה-AI משנה בתוך הטיעון המקורי של Ousterhout. הגרסה האינטראקטיבית המלאה — עם slider לצורת module, השוואת diff, decision tree וגרף שמראה איך שורות קוד מצטברות — נמצאת כאן: barnessn.com/deep-modules. מאז, זאת העדשה שאני משתמש בה כמעט בכל החלטת interface כשאני בונה עם agents.
Module הוא קו שמחליטים לצייר סביב קוד
במקום להתחיל ממטאפורות, עדיף להתחיל מהמכניקה. כשאתה שם כמה פונקציות בקובץ ומחליט אילו מהן לעשות export, ציירת גבול. מה שמיוצא גלוי לעולם. מה שלא מיוצא נשאר בפנים. בשכבות אחרות הגבול נראה אחרת: public ו-private במחלקה, URL של REST API, או אפילו ההבדל בין מה שצוות אחד מחזיק בעצמו לבין מה שהוא מבקש מצוות אחר. אבל הרעיון זהה: יש פנים, יש חוץ, ויש גבול מכוון ביניהם.
לכל module יש שני צדדים. יש לו interface — החלק שהמשתמש של ה-module צריך להכיר. ויש לו implementation — כל מה שקורה בפנים, ושבכוונה לא אמור לעניין את המשתמש. זאת כל הסיבה לצייר את הגבול: להגיד לעולם, “מחוץ לקו הזה צריך לדעת רק את זה. כל שאר המורכבות נשארת עליי.”
עמוק מול שטוח
אפשר לדמיין module כמו מלבן. הרוחב שלו הוא ה-interface: כמה דברים צריך ללמוד כדי להשתמש בו. העומק שלו הוא כמות העבודה שהוא עושה מאחורי הקלעים.
Module עמוק הוא צר למעלה ושמן למטה. מבחוץ יש פעולה פשוטה; מבפנים קורה הרבה. הדוגמה הקלאסית היא read(fd, buf, n) ב-Unix. שלושה ארגומנטים, ומאחוריהם filesystem, page cache, device drivers, networking ועוד שכבות שאף caller רגיל לא צריך להחזיק בראש.
Module שטוח הוא ההפך. הרבה surface מבחוץ, מעט מאוד ערך בפנים. למשל wrapper עם שמונה פרמטרים שרק מעביר אותם הלאה לפונקציה אחרת. ה-caller משלם מחיר מלא של למידה, אבל כמעט שום מורכבות לא הוסתרה ממנו.
הכלל פשוט: module מצדיק את עצמו רק אם הוא מסתיר יותר ממה שהוא חושף. אם הוא לא מסתיר הרבה, הוא לא abstraction. הוא רעש.
המבחן החשוב: האם ה-interface באמת דוחס משהו?
יש מבחן מוכר: לכסות את ה-implementation ולקרוא רק את ה-interface. האם אפשר להבין מה ה-module עושה? אבל צריך לדייק את המילה “להבין”. יש הבדל בין לנחש את הקוד שורה אחרי שורה לבין להבין את החוזה.
Module עמוק מסתיר את הקוד, אבל חושף את החוזה. מ-read(fd, buf, n) אי אפשר לדעת איך ה-page cache עובד, אבל כן אפשר להבין את ההבטחה: הוא ימלא את ה-buffer בעד n bytes מה-file descriptor. זה סיכום טוב. קצר, מדויק, ודחוס.
Module שטוח נכשל בדיוק בנקודה הזאת. אם יש פונקציה כמו buildHeaders(userId, env, contentType, accept, traceId), והגוף שלה רק ממפה את חמשת הקלטים האלה לחמישה headers, אז ה-interface כמעט באותו גודל כמו ה-implementation. לא חסכנו לקורא כלום. רק נתנו שם לדבר שלא באמת דחס מורכבות.
המבחן שאני משתמש בו היום הוא זה: האם אפשר לתאר את החוזה בפחות מילים ממה שצריך כדי לקרוא את הגוף? אם כן, כנראה שיש פה module עמוק. אם לא, כנראה שה-abstraction לא שילם את שכר הדירה שלו.
למה זה נהיה חשוב יותר עם AI
אצל Ousterhout, הטיעון המקורי היה על זיכרון עבודה אנושי. בני אדם לא יכולים להחזיק הרבה פרטים בראש בבת אחת. Module עמוק מאפשר לקורא לשכוח זמנית את ה-implementation ולהתעסק רק בחוזה. זה עדיין נכון.
אבל אצל language models הבעיה נראית אחרת. בתוך context אחד יש להם המון מקום לעבוד. מצד שני, אין להם זיכרון יציב בין sessions, והם נוטים להשתמש ב-API רחב בצורה שנראית סבירה אבל לא באמת נכונה. לכן העיקרון עובר ל-AI, אבל לא מאותה סיבה בדיוק. Interface רחב עולה יותר tokens, נותן ל-agent יותר דרכים לטעות, ומגדיל את ה-blast radius כשהוא כן טועה.
לכן “תחזרו לספרים הישנים” זה מסגור נכון בתוצאה, אבל קצת מטעה בסיבה. העקרונות הישנים לא חוזרים בגלל נוסטלגיה. הם חוזרים כי failure modes של בני אדם ושל agents נפגשים באותה נקודה: גבולות גרועים יוצרים עומס, טעויות וכפילות.
Code שטוח מצטבר מהר מאוד
פה הרעיון הפסיק להיות תיאוריה והתחיל להיראות לי בכל מקום.
דמיינו ארבעה API routes כמעט זהים, כל אחד עבור סוג תוכן אחר. כל route הוא בערך 350 שורות. כולם עושים כמעט את אותו auth, quota, streaming, history loading ו-persistence. ההבדלים האמיתיים הם קטנים: schema אחר, פונקציית sources אחרת, prompt אחר. עכשיו מוסיפים סוג תוכן חמישי ב-copy-paste. ברגע אחד הוספתם עוד 350 שורות של כפילות, ועוד מקום שבו הבאג הבא ב-abort, quota או persistence יצטרך תיקון.
AI מאוד טוב בלייצר את הצורה הזאת. הוא גם מאוד גרוע בלנווט אותה אחר כך. בכל turn הוא רואה חלון חלקי של הקוד. בטוח לו יותר להוסיף פונקציה קטנה בצד מאשר להבין את הגבול הנכון. והוא למד מהרבה קוד שבו “פונקציות קטנות” נראות כמו סגנון טוב גם כשהן לא מסתירות שום דבר.
ברגע שקודבייס מתחיל להיות שטוח, כל feature חדש פותח עוד מניפה של כפילות. ואז ה-agent הבא נכנס לקודבייס רחב יותר, עם יותר paths כמעט-זהים, ועם יותר מקומות לבחור ביניהם לא נכון.
התיקון עצמו לא חכם במיוחד. פשוט שמים לב איפה הכפילות באמת נמצאת. במקרה הזה, הכפילות היא לא ב-router, לא ב-answer streamer ולא ב-history loader. השכבות האלה כבר עמוקות: הן מקבלות content type או config ועושות עבודה גדולה מאחורי interface קטן. הדליפה נמצאת ב-route shell.
אז מחלצים את ה-shell הזה ל-module עמוק נוסף. מבחוץ: config קטן לכל content type. מבפנים: כל ה-plumbing של auth, quota, streaming ו-persistence. אחרי זה סוג תוכן חדש הוא caller דק של 80 שורות, לא route מועתק של 350.
לפעמים המהלך העמוק ביותר נמצא בכלל ב-schema
זה החלק שכמעט פספסתי.
ה-refactor ברמת האפליקציה עובד רק כי ה-database כבר עשה את ההפרדה הנכונה. יש טבלת parent בשם content_items, שמחזיקה כל סוג תוכן עם contentType. ויש טבלת child בשם content_transcripts, שמחזיקה טקסט מתומלל בלי עמודת type בכלל.
למה זה חשוב? כי העבודה שתלויה במדיום קורית קודם. אודיו עובר speech-to-text. וידאו מקבל captions. כל זה קורה ב-ingestion pipeline. אבל ברגע שהמידע מגיע ל-content_transcripts, הוא כבר פשוט טקסט. משם והלאה loader אחד יכול לעבוד על כל סוג תוכן מתומלל, כי צורת הנתונים זהה.
ה-schema מקודד כלל מאוד חזק: משתפים כשהצורה זהה, מפרידים כשהצורה באמת שונה. אודיו ווידאו שונים בעולם. ה-transcripts שלהם לא שונים באותה מידה. ה-database עושה את ההבחנה הזאת, והקוד שמעליו מרוויח ממנה.
מה שנשאר נפרד הוא לא כישלון
Module עמוק טוב לא מנסה לאחד הכל. להפך. חלק מהכוח שלו הוא לדעת איפה לעצור.
אם סוג תוכן אחד נשמר כ-structured markdown ב-object storage, עם page markers מיוחדים, וסוג תוכן אחר הוא row רגיל ב-Postgres, לא בהכרח צריך לדחוף אותם דרך אותו loader. איחוד כזה יכניס לתוך ה-shared loader לוגיקה ספציפית לאחסון, ובסוף ה-abstraction תהיה פחות נקייה ממה שהיה לפני.
אותו דבר לגבי query logs. אם לסוג תוכן אחד יש foreign keys אחרים וצרכי analytics אחרים, אולי מגיעה לו טבלה משלו. לא כל הבדל הוא כפילות. לפעמים ההבדל הוא הסימן שהגבול נמצא במקום הנכון.
השם וה-type של ה-shell צריכים להגיד בדיוק מה הוא מכסה. מה שלא נכנס אליו הוא לא בהכרח חוב טכני. לפעמים זה החלק שבו ה-design אומר את האמת.
הלקח
בעידן ה-AI, נקודת המינוף זזה מ”לכתוב את הקוד” ל”לצייר את הגבול הנכון”. קל לחשוב שזה מוריד את הערך של שיקול דעת הנדסי בכיר. בעיניי זה עושה בדיוק את ההפך. AI יכול לכתוב הרבה implementation מאחורי כל interface שניתן לו. אבל הבחירה באיזה interface לתת לו היא עדיין העבודה הקשה.
ה-refactor שמופיע בגרסה האינטראקטיבית הוא לא תעלול ארכיטקטוני. הוא שלוש החלטות קטנות:
- לזהות שה-routes זהים כמעט לגמרי בשכבת ה-orchestration.
- לזהות שהשכבות הנמוכות כבר היו מודולים עמוקים.
- לאחד רק את מה שבאמת חולק צורה, ולהשאיר בחוץ את מה שבאמת שונה.
אלה לא החלטות מכניות. אלה החלטות של זיהוי צורה. החלק המכני — לכתוב את ה-shell ואת ה-route הדק החדש — הוא החלק הקל.
אם יש שאלה אחת שכדאי לקחת מהמאמר הזה, היא לא “איזה סוג דבר זה בעולם?” אלא “מה צורת הנתונים, ואיפה נגמרת העבודה שתלויה במדיום?” השאלה הזאת מפרידה בין דברים שצריכים להתכנס לבין דברים שצריכים להישאר נפרדים. כל השאר נובע ממנה.
הגרסה האינטראקטיבית עוזרת לזה להיתפס בעיניים: slider שמראה module עמוק מול שטוח, diff explorer שמפריד בין מה שזהה למה ששונה, decision tree לשאלה “האם לאחד את שתי הפונקציות האלה?”, וגרף שמראה איך code שטוח גדל ליניארית בזמן שגבול עמוק משאיר כל סוג תוכן חדש קטן בהרבה. אם הטקסט פה עדיין מרגיש מופשט, שם זה נהיה מוחשי.