Chapter 3: 共享狀態 (Shared State) (value in Traditional chinese)

在上一章 節點 (Node) 中,我們了解到整個自動化教學生成流程是由許多獨立的「工作站」(也就是節點)所組成,每個節點負責一項特定任務。但您可能會好奇:這些辛勤工作的節點之間是如何溝通和傳遞資訊的呢?如果「抓取程式碼」節點下載了程式碼,它要如何將這些程式碼交給下一個「識別核心概念」節點呢?

這就是「共享狀態 (Shared State)」登場的時刻了!

什麼是共享狀態?團隊的中央白板!

想像一下,您的專案團隊正在共同完成一個大型企劃。為了確保資訊同步,團隊可能會使用一個中央白板共享雲端硬碟。每個人都可以在上面寫下進度、記錄發現,或者拿取其他人提供的資料。

共享狀態 (Shared State) 的核心概念: 共享狀態好比是專案團隊的中央白板或共享雲端硬碟。所有節點 (Node)都可以在這個共享空間中讀取和寫入資訊。例如,「程式碼擷取」節點會將抓到的檔案內容放入共享狀態,而「章節生成」節點則會從中讀取這些內容來撰寫教學。這確保了資訊在不同工作階段間的順暢傳遞。

把它想像成我們自動化流程中的「公共資訊中心」。當一個節點完成它的任務後,它可以把結果(例如一份文件、一個列表、一些分析數據)放到這個共享狀態中。接著,下一個需要這些結果的節點就可以從共享狀態中把它們取出來使用。

打個比方:

如果沒有共享狀態,每個節點就會像孤島一樣,無法有效地合作。共享狀態是串聯起整個流程編排 (Flow Orchestration)中各個節點的關鍵橋樑。

共享狀態如何運作?資訊的流動

在我們的 PocketFlow 專案中,共享狀態實際上是一個 Python 的「字典」(dictionary)。如果您還不熟悉字典,可以把它想像成一個有很多標籤抽屜的櫃子。每個抽屜都有一個獨特的標籤(我們稱之為「鍵」,key),抽屜裡面存放著具體的物品(我們稱之為「值」,value)。

PocketFlow 框架會負責管理這個共享字典,並在執行流程時,將它傳遞給每一個節點。

節點們與共享狀態的互動主要發生在它們生命週期的特定階段:

  1. prep(準備階段):節點在這個階段讀取共享狀態,獲取它執行任務所需的輸入資料或配置。就像廚師在做菜前,先看看食譜(配置),然後從冰箱(共享狀態)拿出雞蛋、蔬菜(輸入資料)。
  2. post(收尾階段):節點在完成核心任務後,在這個階段將其產出的結果寫入共享狀態,供後續節點使用。就像廚師把做好的菜放到餐桌上。

讓我們用一個簡單的圖示來看看這個資訊流動的過程:

flowchart LR NodeA[節點 A. 例如:程式碼擷取] -- "將抓取的檔案列表寫入" --> SharedState((共享狀態)); SharedState -- "讀取檔案列表" --> NodeB[節點 B. 例如:識別核心概念];

在這個例子中:

共享狀態裡有什麼?專案的「大秘寶箱」

這個共享狀態的「櫃子」(字典)裡存放著各式各樣的資訊,貫穿整個教學文件生成的流程。這些資訊大致可以分為幾類:

  1. 初始輸入 (Initial Inputs)

    • 使用者在啟動專案時提供的資訊,例如程式碼庫的網址 (repo_url)、專案名稱 (project_name)、教學文件的語言 (language) 等。
    • 這些通常是在流程開始前就放入共享狀態的。
  2. 中繼資料 (Intermediate Data)

    • 一個節點處理完後,產生給下一個或多個節點使用的資料。
    • 例如:
      • FetchRepo 節點產生的 files(包含所有程式碼檔案路徑和內容的列表)。
      • IdentifyAbstractions 節點產生的 abstractions(核心概念的名稱、描述和相關檔案索引的列表)。
      • OrderChapters 節點產生的 chapter_order(教學章節的順序列表)。
  3. 最終輸出 (Final Outputs)

    • 流程中最後產生的重要成果,例如 CombineTutorial 節點產生的 final_output_dir(最終教學文件存放的資料夾路徑)。

我們可以看看一個共享狀態字典 (shared) 在流程中某個時刻可能長的樣子 (簡化版):

# 共享狀態 (shared) 看起來像這樣一個 Python 字典:
shared = {
    # --- 初始輸入 ---
    "repo_url": "https://github.com/The-Pocket/Tutorial-Codebase-Knowledge.git",
    "project_name": "Tutorial-Codebase-Knowledge",
    "language": "traditional_chinese", # 教學文件語言
    "max_file_size": 100000, # 最大檔案大小限制

    # --- 中繼資料 (由不同節點逐步填入) ---
    "files": [ # 由 FetchRepo 節點填入
        ("README.md", "# 這是一個範例專案..."),
        ("main.py", "def main():\n  print('Hello World')")
        # ... 其他檔案
    ],
    "abstractions": [ # 由 IdentifyAbstractions 節點填入
        {"name": "流程編排", "description": "如同專案的總指揮...", "files": [1]}
        # ... 其他核心概念
    ],
    "relationships": { # 由 AnalyzeRelationships 節點填入
        "summary": "這個專案旨在自動生成教學文件...",
        "details": [{"from": 0, "to": 1, "label": "包含"}]
    },
    "chapter_order": [0, 1], # 由 OrderChapters 節點填入
    "chapters": [ # 由 WriteChapters 節點填入 (批次處理結果)
        "# 第 1 章:流程編排\n...",
        "# 第 2 章:節點\n..."
    ],

    # --- 最終輸出 (可能由流程末端的節點填入) ---
    "final_output_dir": "output/Tutorial-Codebase-Knowledge" # 由 CombineTutorial 節點填入
}

這個字典就像一個動態的日誌,記錄著專案從開始到結束的每一步重要資訊。專案的設計文件 docs/design.md 中的 "Shared Store" 部分詳細定義了這個字典中預期會有哪些鍵和它們對應的資料型態。

節點如何與共享狀態互動?實際例子

讓我們透過簡化的程式碼範例,更具體地看看節點是如何在 preppost 方法中與共享狀態互動的。

1. 從共享狀態讀取資料 (prep 方法)

假設我們有一個 PrepareContextNode 節點,它的任務是準備一些上下文資訊。它需要在 prep 階段從共享狀態中讀取 project_namelanguage

# 檔案: nodes.py (某個節點的 prep 方法 - 簡化示意)
from pocketflow import Node # 假設 Node 類別已匯入

class PrepareContextNode(Node):
    def prep(self, shared):
        # 從 shared (共享狀態字典) 中讀取 'project_name'
        # .get() 方法允許我們安全地讀取,如果鍵不存在,可以指定一個預設值 (此處未指定)
        project_name = shared.get("project_name")
        language = shared.get("language")

        print(f"節點 {self.name}: 我將為專案 '{project_name}' 以 '{language}' 語言準備上下文。")

        # prep 方法通常會回傳一個字典,供 exec 方法使用
        return {"project": project_name, "lang": language, "current_abstractions": shared.get("abstractions", [])}

    def exec(self, prep_res):
        # exec 方法可以使用 prep_res 中的資訊來執行任務
        print(f"節點 {self.name}: 正在使用專案 '{prep_res['project']}' 的資訊。")
        # ... 執行核心邏輯 ...
        return f"為 {prep_res['project']} 準備好的上下文資料"

    # post 方法可以選擇性地將 exec 的結果寫回 shared
    def post(self, shared, prep_res, exec_res):
        shared["prepared_context"] = exec_res # 將 exec_res 存入 shared
        print(f"節點 {self.name}: 已將準備好的上下文存入共享狀態。")

在這個 prep 方法中,shared.get("project_name") 就是在從共享狀態字典中取出 project_name 鍵對應的值。

2. 向共享狀態寫入資料 (post 方法)

現在想像我們的 FetchRepo 節點,它在 exec 方法中成功抓取了所有程式碼檔案。它需要在 post 階段將這些檔案列表存回共享狀態,鍵名為 "files"

# 檔案: nodes.py (FetchRepo 節點的 post 方法 - 簡化示意)
from pocketflow import Node

class FetchRepo(Node):
    def prep(self, shared):
        # ... 準備抓取所需的 repo_url 等 ...
        repo_url = shared.get("repo_url")
        print(f"節點 {self.name}: 準備從 {repo_url} 抓取檔案。")
        return {"url_to_fetch": repo_url}

    def exec(self, prep_res):
        # ... 實際執行抓取程式碼的動作 ...
        print(f"節點 {self.name}: 正在從 {prep_res['url_to_fetch']} 抓取檔案...")
        # 假設抓取結果是一個包含 (檔案路徑, 檔案內容) 元組的列表
        fetched_code_files = [
            ("main.py", "print('Hello')"),
            ("utils.py", "def helper(): pass")
        ]
        return fetched_code_files # 回傳抓取到的檔案

    def post(self, shared, prep_res, exec_res):
        # exec_res 就是 exec 方法回傳的 fetched_code_files
        downloaded_files = exec_res

        # 將抓取到的檔案列表存入共享狀態,鍵為 "files"
        shared["files"] = downloaded_files
        print(f"節點 {self.name}: 已將 {len(downloaded_files)} 個檔案的內容存入共享狀態的 'files' 鍵中。")

post 方法中,shared["files"] = downloaded_files 這行程式碼就是將 downloaded_files 的值存儲到共享狀態字典中,並與 "files" 這個鍵關聯起來。

整個互動過程可以用一個時序圖來表示:

sequenceDiagram participant PocketFlow框架 participant 某節點 participant 共享狀態 as 共享狀態 (字典) PocketFlow框架->>某節點: 輪到你了!請執行 prep(共享狀態) activate 某節點 某節點->>共享狀態: (prep) 我需要 'input_key' 的資料! activate 共享狀態 共享狀態-->>某節點: 這是 'input_key' 的資料! deactivate 共享狀態 某節點-->>PocketFlow框架: (prep) 準備完成 (回傳 prep_res) deactivate 某節點 PocketFlow框架->>某節點: 請執行 exec(prep_res) activate 某節點 Note right of 某節點: (exec) 節點執行其核心任務... 某節點-->>PocketFlow框架: (exec) 任務完成 (回傳 exec_res) deactivate 某節點 PocketFlow框架->>某節點: 請執行 post(共享狀態, prep_res, exec_res) activate 某節點 某節點->>共享狀態: (post) 我要把結果 exec_res 存到 'output_key'! activate 共享狀態 共享狀態-->>某節點: (post) 資料已儲存! deactivate 共享狀態 某節點-->>PocketFlow框架: 所有工作完成! deactivate 某節點

共享狀態的起點:main.py

您可能會問,這個共享狀態的字典最初是從哪裡來的呢?

答案是在我們專案的入口點,也就是 main.py 檔案中。當我們執行專案時,main.py 會負責:

  1. 解析使用者透過命令列提供的參數(例如程式碼庫的網址、目標語言等)。
  2. 將這些參數以及一些預設值,初始化為一個 shared 字典。
  3. 建立我們的教學生成流程 (tutorial_flow)。
  4. 呼叫流程的 run 方法,並將這個初始化的 shared 字典傳遞進去。

讓我們看看 main.py 中的簡化片段:

# 檔案: main.py (簡化片段)
# ... 匯入必要的模組和 create_tutorial_flow 函數 ...

def main():
    # ... 使用 argparse 解析使用者輸入的命令列參數 (args) ...
    # 例如:args.repo (程式碼庫網址), args.language (語言)

    # 初始化共享狀態字典
    shared = {
        "repo_url": args.repo,          # 使用者提供的程式碼庫網址
        "local_dir": args.dir,          # 使用者提供的本地目錄路徑 (如果有的話)
        "project_name": args.name,      # 使用者提供的專案名稱 (可選)
        "language": args.language,      # 教學文件語言
        "output_dir": args.output,      # 輸出目錄
        "use_cache": not args.no_cache, # 是否使用 LLM 快取
        # ... 其他初始設定 ...

        # 為後續節點預留的鍵,初始值通常是空的或預設值
        "files": [],
        "abstractions": [],
        "relationships": {},
        "chapter_order": [],
        "chapters": [],
        "final_output_dir": None
    }

    # 顯示起始訊息
    print(f"開始為:{args.repo or args.dir}{args.language.capitalize()} 語言生成教學文件")

    # 建立流程實例
    tutorial_flow = create_tutorial_flow()

    # 執行流程,並傳入初始化的共享狀態
    tutorial_flow.run(shared)

if __name__ == "__main__":
    main()

從這裡開始,shared 字典就會在 PocketFlow 框架的引導下,在各個節點之間傳遞和更新,直到整個流程結束。

共享狀態的重要性

使用共享狀態機制帶來了幾個重要的好處:

總結

在本章中,我們深入探討了「共享狀態 (Shared State)」這個在 PocketFlow-Tutorial-Codebase-Knowledge 專案中扮演著資訊傳遞中樞的角色。

理解了共享狀態是如何運作的,我們就能更好地明白整個自動化教學生成流程中,資訊是如何從一個步驟流向下一個步驟的。

在接下來的章節中,我們將開始逐一探索流程中的各個具體節點,看看它們是如何利用共享狀態來完成各自的任務。首先,讓我們來看看第一個實際工作的節點:第 4 章:程式碼擷取 (Code Fetching),它將負責從程式碼庫抓取原始碼,並將結果放入共享狀態。