Chapter 2: 共享儲存 (Shared Store)

在上一章 節點 (Node) 中,我們學習了 PocketFlow 的基本建構單元——節點,以及它們如何透過 prepexecpost 三個階段來執行任務。我們也看到 prep 階段會從某處讀取資料,而 post 階段會將結果寫回某處。那個「某處」究竟是什麼呢?這就是本章的主角:共享儲存 (Shared Store)

為何需要共享儲存?——節點間的溝通橋樑

想像一下,您和您的團隊正在共同完成一個專案。團隊成員 A 完成了市場調研報告,成員 B 需要根據這份報告來制定行銷策略,而成員 C 則要根據行銷策略設計宣傳材料。他們如何有效地共享資訊呢?他們可能會使用一個共享的雲端硬碟、一個專案管理工具的看板,或甚至只是一塊大家都能看到的白板。

在 PocketFlow 中,不同的節點 (Node) 就像是這些團隊成員,它們各自負責一部分工作。共享儲存 (Shared Store) 就扮演著那個共享白板或公告欄的角色。它是節點與流程之間主要的數據交換媒介。

如果沒有共享儲存,每個節點都會是孤立的,無法將自己的工作成果傳遞給下一個節點,也無法利用先前節點的成果。例如,一個節點下載了數據,另一個節點需要分析這些數據,它們之間必須有一個共通的地方來存放和讀取這些數據。

核心用途:讓數據在節點間順暢流動。

讓我們思考一個簡單的場景:

  1. 第一個節點 (GetUserNameNode):負責取得使用者的名稱。
  2. 第二個節點 (GreetUserNode):負責使用這個名稱來向使用者打招呼。

GetUserNameNode 如何將獲取的名稱告訴 GreetUserNode 呢?答案就是透過共享儲存!

什麼是共享儲存?

共享儲存(Shared Store)通常是一個記憶體內的字典 (dictionary)。您可以把它想像成一個公共的儲物櫃,每個儲物格都有一個標籤(稱為「鍵」,key),裡面存放著物品(稱為「值」,value)。

圖示:共享儲存就像一個團隊的共享白板

這種機制將數據與計算邏輯分離。節點專注於「如何處理數據」,而共享儲存則負責「數據是什麼以及在哪裡」。這使得整個流程設計更加靈活且易於管理。

如何使用共享儲存?

使用共享儲存非常直觀。因為它本質上是一個 Python 字典,您可以像操作普通字典一樣操作它。

1. 初始化共享儲存

在運行您的節點或流程之前,您通常會先初始化一個空的字典作為共享儲存。

# 一個空的字典,作為我們的共享儲存
shared_data = {}

這個 shared_data 字典將被傳遞給流程中的每一個節點。

2. 節點寫入共享儲存 (post 方法)

假設我們有一個節點 GetUserNameNode,它的任務是獲取使用者名稱並存起來。

from pocketflow import Node # 假設 pocketflow 已安裝

class GetUserNameNode(Node):
    def exec(self, prep_res):
        # 實際上這裡可能是 input() 或從某處讀取
        print("模擬:正在獲取使用者名稱...")
        user_name = "小明" # 假設我們獲取到的名稱是 "小明"
        return user_name

    def post(self, shared, prep_res, exec_res):
        # exec_res 就是從 exec 方法返回的 user_name
        print(f"GetUserNameNode: 將名稱 '{exec_res}' 存入共享儲存。")
        shared["user_name_key"] = exec_res # 將名稱存到 shared store
        # 沒有明確 return,預設返回 "default"

在這個例子中:

如果我們運行這個節點:

shared_data = {}
get_name_node = GetUserNameNode()
get_name_node.run(shared_data)

print(f"共享儲存的內容:{shared_data}")

輸出將會是:

模擬:正在獲取使用者名稱...
GetUserNameNode: 將名稱 '小明' 存入共享儲存。
共享儲存的內容:{'user_name_key': '小明'}

看!shared_data 字典現在包含了使用者名稱。

3. 節點讀取共享儲存 (prep 方法)

現在,我們需要另一個節點 GreetUserNode 來讀取這個名稱並產生問候語。

class GreetUserNode(Node):
    def prep(self, shared):
        # 從 shared store 讀取 "user_name_key"
        name = shared.get("user_name_key", "訪客") # 如果找不到,預設為 "訪客"
        print(f"GreetUserNode: 從共享儲存讀取到名稱 '{name}'。")
        return name # 將名稱傳遞給 exec

    def exec(self, prep_res): # prep_res 是從 prep 方法返回的 name
        greeting = f"你好,{prep_res}!歡迎使用 PocketFlow。"
        return greeting

    def post(self, shared, prep_res, exec_res):
        # exec_res 是從 exec 方法返回的 greeting
        print(f"GreetUserNode: 將問候語 '{exec_res}' 存入共享儲存。")
        shared["greeting_message_key"] = exec_res

在這個節點中:

讓我們接續上面的例子,運行這個節點:

# shared_data 已經是 {'user_name_key': '小明'}
greet_user_node = GreetUserNode()
greet_user_node.run(shared_data)

print(f"共享儲存最終的內容:{shared_data}")

輸出將會是:

GreetUserNode: 從共享儲存讀取到名稱 '小明'。
GreetUserNode: 將問候語 '你好,小明!歡迎使用 PocketFlow。' 存入共享儲存。
共享儲存最終的內容:{'user_name_key': '小明', 'greeting_message_key': '你好,小明!歡迎使用 PocketFlow。'}

現在,shared_data 包含了兩個節點的成果!這就是共享儲存的魔力——它讓數據在節點之間無縫流動。

設計您的共享儲存結構

雖然共享儲存通常是一個簡單的字典,但在實際應用中,預先規劃好這個字典的結構(即您會使用哪些鍵,以及這些鍵對應的值是什麼類型)是非常重要的。這就像是為您的團隊協作定義好共享文件的格式和內容。

例如,您可以決定:

良好的共享儲存結構可以讓您的流程更加清晰和易於維護。

最佳實踐: 優先使用共享儲存來實現關注點分離,將數據綱要計算邏輯分開!這種方法既靈活又易於管理,能產出更易維護的程式碼。 {: .best-practice}

共享儲存的內部機制

您可能會好奇,當一個節點修改了 shared 字典,為什麼其他節點(或主程式)能看到這些變動?

在 Python 中,當您將字典(或其他可變對象,如列表)作為參數傳遞給函數(或方法,如節點的 run, prep, post)時,實際上传遞的是該對象在記憶體中的引用(或稱為地址)。

這意味著,run 方法、prep 方法和 post 方法內部操作的 shared 字典,與您在外部創建並傳入的那個 shared_data 字典,實際上是同一個對象

非程式碼逐步解析:

  1. 初始化:您創建一個字典,例如 my_shared_space = {}
  2. 傳遞:您呼叫 a_node.run(my_shared_space)。PocketFlow 將 my_shared_space 的引用傳遞給 a_node
  3. prep 階段a_nodeprep(shared) 方法被呼叫。此時,prep 方法中的 shared 參數就是指向 my_shared_space 的引用。如果 prep 讀取 shared['some_key'],它就是在讀取 my_shared_space['some_key']
  4. post 階段a_nodepost(shared, prep_res, exec_res) 方法被呼叫。同樣,post 方法中的 shared 參數也是指向 my_shared_space 的引用。當 post 執行 shared['new_key'] = 'new_value' 時,它實際上是在修改 my_shared_space 這個原始字典,在其上添加或更新鍵值對。
  5. 結果可見:當 a_node.run() 結束後,您在外部檢查 my_shared_space,就會發現它已經被 a_nodepost 方法修改了。

序列圖 (Sequence Diagram) 概覽:

這張圖展示了一個節點如何與共享儲存互動:

sequenceDiagram participant 使用者 participant MyNode as 節點實例 participant SharedStore as 共享儲存 (字典) 使用者->>MyNode: run(shared_store_instance) MyNode->>MyNode: prep(shared_store_instance) Note right of MyNode: 讀取 shared_store_instance 中的數據 MyNode-->>SharedStore: 讀取 shared_store_instance['input_data'] SharedStore-->>MyNode: input_data 的值 MyNode->>MyNode: exec(prep_result) Note right of MyNode: 執行核心計算,不直接存取共享儲存 MyNode->>MyNode: post(shared_store_instance, prep_result, exec_result) Note right of MyNode: 將結果寫回 shared_store_instance MyNode-->>SharedStore: 寫入 shared_store_instance['output_result'] = result SharedStore-->>MyNode: (字典被更新) MyNode-->>使用者: 行動指令

您可以參考 節點 (Node) 章節中 Node 類別的 _run 方法的簡化版程式碼,可以看到 shared 物件是如何在 preppost 之間傳遞的:

# 位於 pocketflow/__init__.py (為教學目的簡化)
# class BaseNode:
#     def _run(self,shared):
#         p = self.prep(shared)       # 1. shared 被傳入 prep
#         e = self._exec(p)           # 2. _exec 通常不直接使用 shared
#         return self.post(shared,p,e)# 3. shared 再次被傳入 post

因為 shared 是一個可變的字典,並且在這些方法之間傳遞的是它的引用,所以在 post 方法中對 shared 的任何修改都會反映到原始的字典物件上。

共享儲存 與 參數 (Params)

PocketFlow 中還有另一種傳遞資訊的方式,稱為「參數 (Params)」。您可能在其他地方看到過它。這裡簡單區分一下:

  1. 共享儲存 (Shared Store) (本章重點):

    • 用途:幾乎適用於所有節點間的數據交換。非常適合存放結果數據、大型內容,或任何多個節點都需要存取的資訊。
    • 特性:全域數據結構(通常是記憶體內字典),所有節點皆可讀取 (prep()) 和寫入 (post())。
    • 比喻:像電腦記憶體中的堆積 (heap),所有函數呼叫都可以共享。
  2. 參數 (Params):

    • 用途:主要用於 批次處理 (Batch) 中的任務標識。適合存放像檔案名稱或數字 ID 這樣的標識符。
    • 特性:每個節點擁有一個局部的、臨時的 params 字典,由父流程傳入。參數的鍵和值應為不可變的
    • 比喻:像函數呼叫時的堆疊 (stack),由呼叫者分配。

對於大多數情況,您應該優先使用共享儲存。它能更好地將數據與計算邏輯分離,使您的流程更清晰。我們會在後續的 批次處理 (Batch) 章節中更詳細地介紹參數 (Params)。

總結

在本章中,我們深入探討了 共享儲存 (Shared Store)

共享儲存是構建 PocketFlow 應用的重要概念。它讓獨立的節點 (Node) 能夠協同工作,共同完成複雜的任務。

現在我們知道了單個節點如何工作,以及它們如何透過共享儲存交換數據。那麼,我們如何將多個節點串聯起來,形成一個有順序的工作流程呢?這就是下一章 流程 (Flow) 將要揭曉的內容。