歡迎來到 open-notebook
教學系列的第五章!在上一章 內容處理流程 (Content Processing Graph) 中,我們了解到 open-notebook
如何使用一個自動化的流程來從各種來源(如網址、PDF、音檔)提取純文字內容。我們也稍微提到了這個流程是使用 LangGraph 這個函式庫建立的。現在,我們要更深入地探索 LangGraph,看看它是如何讓我們能夠編排更複雜、多步驟的人工智慧任務。
想像一下,您想在 open-notebook
中加入一個「智慧問答」功能。您希望能直接向您的筆記提問,例如:「我上次關於 AI 倫理的筆記中,提到了哪些主要觀點?」
要回答這個問題,單純呼叫一次 AI 模型可能不夠。一個比較完善的處理流程可能會是這樣:
這種包含多個步驟、條件分支甚至循環的複雜任務,如果只用傳統的程式碼一行行寫下來,很快就會變得難以管理和維護。每次想調整流程中的某個環節,都可能牽一髮而動全身。
這就是 LangGraph 發揮作用的地方。LangGraph 就像一位電影導演,手上有個劇本(工作流程圖)。劇本中定義了不同的場景(節點)和演員(AI 模型、工具)的出場順序(邊)。導演會根據劇本,一步步指導演員完成拍攝,確保整個流程順暢地進行,最終完成一部電影(任務)。
LangGraph 是一個 Python 函式庫,專門用來建構狀態化、多參與者的應用程式,特別適合需要協調多個大型語言模型 (LLM) 和工具來完成複雜任務的場景。它讓您可以將複雜的流程定義成一個「圖」(Graph)。
以下是 LangGraph 的幾個核心概念,讓我們用電影導演的例子來理解:
在 open-notebook
中,內容處理流程 (Content Processing Graph) 就是用 LangGraph 建立的。除此之外,「智慧問答」(Ask)、「聊天」(Chat)、「處理新來源並應用轉換」(Source processing with Transformations) 等功能,也都是透過 LangGraph 編排的複雜工作流程。
open-notebook
中運作:「智慧問答」範例讓我們以 open-notebook
中的「智慧問答」(Ask) 功能為例,看看 LangGraph 是如何運作的。這個功能的程式碼主要位於 open_notebook/graphs/ask.py
。
當您問:「我關於 AI 倫理的筆記中,提到了哪些主要觀點?」時,背後的 LangGraph 工作流程大致如下:
首先,我們需要定義在這個「智慧問答」流程中,需要在節點之間傳遞哪些資訊。這通常透過一個 Python 的 TypedDict
來定義。
# open_notebook/graphs/ask.py (簡化片段)
from typing import Annotated, List
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
import operator # 用於合併列表
class Search(BaseModel): # 代表一次搜尋請求的結構
term: str # 搜尋詞彙
instructions: str # 給回答模型的指示,告訴它要從搜尋結果中提取什麼資訊
class Strategy(BaseModel): # 代表搜尋策略的結構
reasoning: str # AI 思考為何要這樣搜尋的理由
searches: List[Search] = Field(default_factory=list) # 包含多次搜尋請求的列表
class ThreadState(TypedDict): # 定義整個流程的狀態
question: str # 使用者提出的原始問題
strategy: Strategy # AI 生成的搜尋策略
answers: Annotated[list, operator.add] # 從各個搜尋中收集到的答案片段 (會不斷累加)
final_answer: str # 最終給使用者的完整回答
程式碼解釋:
Search
和 Strategy
是使用 Pydantic 定義的資料模型,用來結構化 AI 生成的搜尋計畫。ThreadState
就是我們這個「智慧問答」流程的「共享記事板」。question
: 儲存使用者一開始問的問題。strategy
: 儲存第一個 AI 節點生成的搜尋策略。answers
: 這是一個特別的欄位。Annotated[list, operator.add]
表示這個欄位是一個列表,並且當多個節點都嘗試更新它時,它們的結果會被「加入」(add) 到這個列表中,而不是互相覆蓋。這對於收集多次搜尋的結果非常有用。final_answer
: 儲存最後一個 AI 節點生成的總結性答案。接下來,我們定義流程中的各個「演員」(處理步驟)。每個節點都是一個 Python 函式,它會接收目前的 ThreadState
,執行一些操作,然後返回一個字典來更新狀態。
open_notebook/graphs/ask.py
中定義了幾個主要節點:
call_model_with_messages
(策略制定節點):
question
。Strategy
(包含一個或多個 Search
請求)。{"strategy": ...}
來更新狀態中的 strategy
欄位。# open_notebook/graphs/ask.py (簡化片段 - 策略制定節點)
async def call_model_with_messages(state: ThreadState, config: RunnableConfig) -> dict:
# ... (省略了設定提示詞和呼叫模型的細節) ...
# 假設 ai_response_strategy 是一個 Strategy 物件
# ai_response_strategy = llm.invoke(...)
print(f"策略家 AI:根據問題 '{state['question']}',我制定的策略是...") # 示意
# 此處模擬 AI 回應
simulated_strategy = Strategy(
reasoning="為了回答關於AI倫理的問題,我需要搜尋相關筆記並提取主要觀點。",
searches=[
Search(term="AI倫理", instructions="提取關於AI倫理的主要論述"),
Search(term="道德準則", instructions="找出提及的AI道德準則")
]
)
return {"strategy": simulated_strategy}
provide_answer
(搜尋與初步回答節點):
Search
請求。Search
請求的 term
(搜尋詞) 和 instructions
(提取指示)。vector_search(...)
)來找到相關的筆記片段。instructions
從搜尋結果中提取相關資訊。{"answers": ["提取到的答案片段"]}
。由於 answers
欄位設定了 operator.add
,這個片段會被加入到狀態的 answers
列表中。# open_notebook/graphs/ask.py (簡化片段 - 搜尋與初步回答節點)
async def provide_answer(state: SubGraphState, config: RunnableConfig) -> dict:
# SubGraphState 是一個更細粒度的狀態,用於此節點的單次執行
# state['term'] 和 state['instructions'] 來自策略
print(f"搜尋員:正在搜尋 '{state['term']}' 並提取 '{state['instructions']}'...") # 示意
# results = vector_search(state["term"], ...) # 實際執行搜尋
# ... (省略了呼叫 AI 模型處理搜尋結果的細節) ...
# extracted_info = llm.invoke(...)
simulated_extracted_info = f"關於 '{state['term']}' 的筆記片段:...提及了觀點X..."
return {"answers": [simulated_extracted_info]} # 返回一個包含單個答案的列表
注意:provide_answer
實際上接收的是 SubGraphState
,這是 LangGraph 中用於扇出 (fan-out) 任務時,傳遞給每個並行分支的獨立狀態。但最終它的輸出會被合併回主 ThreadState
的 answers
欄位。
write_final_answer
(最終答案生成節點):
question
和從 provide_answer
節點收集到的所有 answers
片段。{"final_answer": "..."}
。# open_notebook/graphs/ask.py (簡化片段 - 最終答案生成節點)
async def write_final_answer(state: ThreadState, config: RunnableConfig) -> dict:
print(f"總結家 AI:正在根據問題和以下資訊撰寫最終答案:{state['answers']}") # 示意
# ... (省略了呼叫 AI 模型生成最終答案的細節) ...
# final_response = llm.invoke(...)
simulated_final_response = f"關於您問題 '{state['question']}' 的主要觀點是:觀點X,觀點Y..."
return {"final_answer": simulated_final_response}
定義好節點後,我們需要用「邊」來連接它們,告訴 LangGraph 流程應該如何進行。
# open_notebook/graphs/ask.py (簡化片段)
from langgraph.graph import StateGraph, END, START, Send
# 假設 agent_state 是 StateGraph(ThreadState) 的實例
agent_state = StateGraph(ThreadState)
# 新增節點 (前面已定義)
agent_state.add_node("agent", call_model_with_messages) # "agent" 是策略制定節點的名稱
agent_state.add_node("provide_answer", provide_answer)
agent_state.add_node("write_final_answer", write_final_answer)
# 設定起始點
agent_state.add_edge(START, "agent") # 流程從 START 開始,首先執行 "agent" 節點
# 條件邊:從 "agent" 節點出發,根據 trigger_queries 函式的結果決定下一步
# trigger_queries 會根據策略中的 searches 列表,為每個 search 產生一個到 "provide_answer" 的任務
agent_state.add_conditional_edges(
"agent", # 起點節點
trigger_queries, # 一個判斷函式,返回下一步要去哪裡或發送哪些並行任務
["provide_answer"] # 可能的目標節點列表 (簡化,實際更複雜)
)
# trigger_queries 函式大致如下:
async def trigger_queries(state: ThreadState, config: RunnableConfig):
# 為策略中的每個搜尋任務,創建一個 "Send" 指令
# 指示 LangGraph 將這些任務發送到 "provide_answer" 節點並行處理
return [
Send(
"provide_answer", # 要發送到的目標節點
{ # 傳遞給該節點的 SubGraphState
"question": state["question"],
"instructions": s.instructions,
"term": s.term,
},
)
for s in state["strategy"].searches # 遍歷策略中的所有搜尋
]
# 固定邊:當 "provide_answer" 節點(的所有並行任務)完成後,流程進入 "write_final_answer" 節點
agent_state.add_edge("provide_answer", "write_final_answer")
# 固定邊:當 "write_final_answer" 節點完成後,流程結束 (END)
agent_state.add_edge("write_final_answer", END)
程式碼解釋:
agent_state = StateGraph(ThreadState)
: 創建一個狀態圖的實例,並告訴它我們流程的狀態結構是 ThreadState
。agent_state.add_node("節點名稱", 節點函式)
: 將我們之前定義的 Python 函式註冊為圖中的節點。agent_state.add_edge(START, "agent")
: 設定流程的起點。START
是 LangGraph 的一個特殊標記。agent_state.add_conditional_edges("agent", trigger_queries, ...)
: 這是一個關鍵的條件邊。agent
節點(策略制定)完成後,會呼叫 trigger_queries
函式。trigger_queries
函式會檢查 state.strategy.searches
列表。如果有多個搜尋請求,它會為每個請求產生一個 Send
指令。Send("provide_answer", ...)
的意思是:「啟動一個到 provide_answer
節點的新任務,並傳遞這些資料給它」。LangGraph 會並行地執行這些任務。agent_state.add_edge("provide_answer", "write_final_answer")
: 當所有由 trigger_queries
觸發的 provide_answer
任務都完成後(LangGraph 會自動處理這種 扇入 fan-in),流程會自動轉到 write_final_answer
節點。agent_state.add_edge("write_final_answer", END)
: write_final_answer
完成後,流程到達 END
(LangGraph 的特殊結束標記)。最後,我們將定義好的圖「編譯」成一個可執行的物件,然後就可以呼叫它了。
# open_notebook/graphs/ask.py (結尾)
# 編譯圖
graph = agent_state.compile()
# 理論上,執行圖的方式如下 (實際使用可能更複雜,需要傳入設定):
# async def run_ask_graph(user_question: str):
# initial_state = {"question": user_question, "answers": []}
# async for event in graph.astream(initial_state):
# # 可以查看每一步的狀態變化
# print(event)
# # 或者直接獲取最終結果
# # final_result = await graph.ainvoke(initial_state)
# # return final_result.get("final_answer")
# 範例:直接調用 (簡化)
# import asyncio
# async def main():
# user_question = "我關於 AI 倫理的筆記中,提到了哪些主要觀點?"
# # 初始狀態,注意 answers 必須是列表,因為它是 Annotated[list, operator.add]
# initial_input = {"question": user_question, "answers": []}
# final_state = await graph.ainvoke(initial_input)
# print(f"最終答案:{final_state.get('final_answer')}")
# if __name__ == "__main__":
# asyncio.run(main())
程式碼解釋:
graph = agent_state.compile()
: 這行程式碼將我們所有的節點和邊定義「鎖定」,產生一個可以執行的 graph
物件。graph.ainvoke(initial_input)
: 這是執行整個圖(電影劇本)的方法。我們提供一個包含初始 question
的字典作為輸入。LangGraph 會從 START
開始,依照我們定義的節點和邊一步步執行,直到 END
。最後,它會返回整個流程結束時的最終 ThreadState
。{"question": "AI 倫理的主要觀點?", "answers": []}
。final_state
): 一個包含所有執行結果的字典,我們最關心的是其中的 final_answer
欄位,例如:{"question": "...", "strategy": ..., "answers": [...], "final_answer": "關於您問題的主要觀點是:觀點X,觀點Y..."}
。這就是 LangGraph 如何幫助我們將一個複雜的「智慧問答」任務,拆解成一系列定義清晰、易於管理的步驟,並自動化地執行它們。
當我們呼叫 graph.ainvoke(...)
時,LangGraph 內部發生了什麼事呢?
ThreadState
的定義來初始化當前的工作流程狀態。START
邊指向的節點開始(在我們的例子中是 agent
節點)。call_model_with_messages
),並將當前的狀態傳遞給它。agent
節點返回 {"strategy": ...}
,狀態中的 strategy
欄位就會被更新。trigger_queries
),條件函式會接收當前狀態並返回下一步的目標節點名稱(或多個 Send
指令)。Send
指令(像我們的 trigger_queries
那樣),LangGraph 會為每個 Send
指令創建一個並行的任務分支。它會等待所有這些分支都執行完畢,並將它們的結果(透過 operator.add
等機制)合併回主狀態後,才會繼續執行下一個固定的邊(例如從 provide_answer
到 write_final_answer
)。END
標記。END
後,LangGraph 返回最終的狀態。以下是一個簡化的序列圖,展示了「智慧問答」流程的執行過程:
open-notebook
中還有其他地方也使用了 LangGraph 來編排工作流程,例如:
open_notebook/graphs/chat.py
: 用於處理聊天互動,特別是需要管理對話歷史(記憶)的場景。它使用 SqliteSaver
來將對話狀態持久化儲存。open_notebook/graphs/source.py
: 這是一個更複雜的圖,它首先呼叫我們在上一章討論的 內容處理流程 (Content Processing Graph) 來提取來源內容,然後將提取出的內容儲存為 Source
物件,接著還可以選擇性地對這個 Source
應用一系列的轉換 (Transformations)。open_notebook/graphs/transformation.py
: 一個相對簡單的圖,用於對輸入文字執行單個的轉換 (Transformations)(例如摘要、翻譯)。這些例子都展示了 LangGraph 在定義和執行有狀態、多步驟 AI 工作流程方面的靈活性和強大功能。
在本章中,我們深入了解了 LangGraph 如何作為 open-notebook
中複雜工作流程的「導演」。
open-notebook
中其他模組的應用。LangGraph 讓開發者能夠更清晰地設計和管理複雜的 AI 應用程式邏輯。在這些由 LangGraph 編排的工作流程中,許多節點的核心任務都是與大型語言模型 (LLM) 進行互動。但是,我們如何有效地構建給這些 LLM 的指令(也就是「提示詞」)呢?如何確保提示詞既清晰又能引導模型產生我們期望的輸出格式呢?
在下一章,我們將探討 提示詞管理器 (Prompter),了解它是如何幫助我們管理和生成這些至關重要的提示詞的。