歡迎來到 open-notebook
教學系列的第六章!在上一章 LangGraph 狀態機 (Graph Workflows) 中,我們學習了如何使用 LangGraph 這個強大的工具,像導演一樣編排複雜的多步驟 AI 工作流程,例如「智慧問答」。我們看到不同的「演員」(節點)如何根據「劇本」(圖)來協同工作。
現在,讓我們思考一下這些 AI 演員(特別是大型語言模型 LLM)是如何知道自己該做什麼的。就像演員需要劇本和台詞一樣,AI 模型需要清晰的「提示詞 (Prompt)」來引導它們的思考和輸出。
例如,在「智慧問答」流程中,我們需要告訴「策略家 AI」:
reasoning
和 searches
的 JSON 物件)如果每次都要手動把這些指令和使用者輸入拼湊在一起,會非常容易出錯,而且難以管理。如果我們想調整 AI 的角色描述或輸出格式,就得去修改程式碼的很多地方。
這就是「提示詞管理器 (Prompter)」登場的時候了!
想像一下,您想寄送很多內容類似但收件人不同的邀請函。您不會為每個人都從頭寫一封信,而是會準備一個信件模板:
親愛的 [收件人姓名]:
誠摯邀請您參加 [活動名稱] 活動,時間是 [日期],地點在 [地點]。
期待您的光臨!
[您的姓名] 敬上
有了這個模板,您只需要填入 [收件人姓名]
、[活動名稱]
等變數,就能快速生成大量的個人化邀請函。
在與 AI 模型溝通時,我們也面臨類似的情況:
如果沒有一個好的管理方式,將這些固定指令和動態資料組合起來會很麻煩。提示詞管理器就是為了解決這個問題而設計的。
提示詞管理器 (Prompter) 是 open-notebook
中一個專門用來管理和生成提示詞的工具。它就像我們前面提到的「信件模板」或「填字遊戲模板」系統。
您可以把它想像成一個智慧填字遊戲模板製作器:
.jinja
檔案中(例如 prompts/ask/entry.jinja
)。這些模板包含了固定的指令文字,以及一些用特殊符號(例如 {{ question }}
)標記出來的「空格」(稱為佔位符或變數)。Prompter
使用一個名為 Jinja2 的強大模板引擎。這個引擎知道如何讀取模板檔案,並將您提供的動態資料填入對應的「空格」。prompts/
資料夾)找到這些模板檔案。ask/entry
)以及要填入的資料(例如使用者問題 question
)時,它會使用 Jinja2 引擎。{{ question }}
空格。透過 Prompter
,我們可以將提示詞的結構(模板)和內容(動態資料)分開管理,讓提示詞的創建、修改和維護變得更加簡單和有條理。
Prompter
的主要程式碼位於 open_notebook/prompter.py
檔案中。
使用 Prompter
非常直觀。通常是在需要與 AI 模型互動之前,用它來準備好要發送的提示詞。
讓我們回到LangGraph 狀態機 (Graph Workflows) 中「智慧問答」流程的「策略家 AI」節點 (call_model_with_messages
)。在這個節點中,我們需要根據使用者問題生成一個包含策略的 JSON。
首先,我們需要建立一個 Prompter
物件,並告訴它要使用哪個模板檔案。這個模板檔案(prompts/ask/entry.jinja
)包含了給「策略家 AI」的所有固定指令和輸出格式要求。
# 假設在 call_model_with_messages 節點函式內部
from open_notebook.prompter import Prompter
# 建立 Prompter 物件,指定使用 'ask/entry' 模板
# Prompter 會自動尋找 prompts/ask/entry.jinja 檔案
strategy_prompter = Prompter(prompt_template="ask/entry")
print("策略提示詞模板已載入!")
程式碼解釋:
open_notebook.prompter
模組匯入 Prompter
類別。Prompter(prompt_template="ask/entry")
會建立一個 Prompter
實例。它會去 prompts/
資料夾下尋找名為 ask/entry.jinja
的模板檔案,並準備好用 Jinja2 引擎來處理它。接下來,我們需要準備一個字典,包含所有需要填入模板「空格」的動態資料。對於「策略家 AI」,我們需要提供使用者實際提出的問題。
# 假設 state 是傳入節點的狀態字典
# state = {"question": "關於 AI 倫理的筆記有哪些主要觀點?", ...}
user_question = state.get("question")
# 準備要填入模板的資料字典
data_to_render = {
"question": user_question
# Jinja 模板中可能還有其他變數,例如 {{format_instructions}}
# Prompter 會自動處理一些常用的變數,例如 current_time
# 如果 Prompter 初始化時傳入了 parser,它也會自動加入 format_instructions
}
print(f"準備填入的資料:{data_to_render}")
程式碼解釋:
state
) 中獲取使用者的問題。data_to_render
的字典。字典的鍵(例如 "question"
)必須對應到 .jinja
模板檔案中使用的變數名稱(例如 {{ question }}
)。最後,我們呼叫 Prompter
物件的 render()
方法,並傳入準備好的資料字典。Prompter
會使用 Jinja2 引擎將資料填入模板,並返回最終的提示詞字串。
# 呼叫 render 方法,傳入資料字典
final_prompt = strategy_prompter.render(data_to_render)
# final_prompt 現在是一個包含所有指令和使用者問題的完整字串
print("\n--- 生成的最終提示詞 (部分預覽) ---")
print(final_prompt[:500] + "...") # 只顯示前 500 個字元預覽
print("--- 提示詞生成完畢 ---")
# 這個 final_prompt 現在可以傳遞給 AI 模型了
# 例如:llm_response = language_model.invoke(final_prompt)
程式碼解釋:
data_to_render
字典,包含 { "question": "關於 AI 倫理的筆記有哪些主要觀點?" }
。strategy_prompter.render(...)
):Prompter
找到之前載入的 ask/entry.jinja
模板。{{ question }}
佔位符。data_to_render
字典中取出 question
鍵對應的值(也就是實際的使用者問題)。{{ question }}
。Prompter
還會自動加入一些額外資訊,例如當前時間 ({{ current_time }}
) 和格式化指令 ({{ format_instructions }}
,如果有的話)。final_prompt
): 一個長字串,其中包含了 ask/entry.jinja
模板裡的所有固定指令,並且 {{ question }}
的部分已經被替換成了實際的使用者問題。這個字串可以直接用來呼叫 AI 模型。以下是 prompts/ask/entry.jinja
模板的部分內容,讓您感受一下:
{# prompts/ask/entry.jinja 的部分內容 #}
# SYSTEM ROLE
You are a cognitive study assistant...
# YOUR JOB
Based on the user question, you need to analyze...
Step 1: develop your search strategy (reasoning)
Step 2: formulate your search queries (searches)
Return both the reasoning and searches as a JSON object...
# EXAMPLE
... (省略範例) ...
# OUTPUT FORMATTING
{{format_instructions}} {# <-- 這個由 Prompter 自動填入 #}
# USER QUESTION
{{question}} {# <-- 這個會被替換成實際問題 #}
# ANSWER
透過這種方式,Prompter
幫助我們將複雜的提示詞邏輯封裝在模板檔案中,讓主要程式碼(例如 LangGraph 的節點函式)保持簡潔,只需要負責準備資料和呼叫 render()
即可。
現在我們知道如何使用 Prompter
,讓我們簡單了解一下它內部是如何運作的。
當您執行 prompter = Prompter(prompt_template="ask/entry")
和 prompter.render({"question": "..."})
時,大致會發生以下事情:
__init__
& setup
):Prompter
物件時,它會儲存您提供的 prompt_template
名稱(例如 "ask/entry"
)。setup()
方法會被呼叫。Environment
。這個環境知道要去哪個資料夾(由環境變數 PROMPT_PATH
指定,預設是專案根目錄下的 prompts/
)尋找模板檔案。env.get_template("ask/entry.jinja")
會實際載入模板檔案,並將其解析成一個 Jinja2 Template
物件,儲存在 prompter.template
中。render
):render(data)
時,方法會先在您的 data
字典中加入一些額外資訊,例如 current_time
和 format_instructions
(如果 Prompter
初始化時有提供 parser
)。Template
物件的 render()
方法,並將合併後的資料字典傳遞給它。{{ variable }}
替換成字典中對應的值。以下是一個簡化的序列圖,展示了這個過程:
Prompter
的核心程式碼片段讓我們看看 open_notebook/prompter.py
中 Prompter
類別的一些關鍵部分(已簡化):
# open_notebook/prompter.py (簡化片段)
import os
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional, Union
# 匯入 Jinja2 的主要類別
from jinja2 import Environment, FileSystemLoader, Template
# 取得目前檔案的目錄 和 專案根目錄
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
# 建立 Jinja2 環境,設定模板載入器
# FileSystemLoader 告訴 Jinja2 從哪個資料夾載入模板
# os.environ.get("PROMPT_PATH", "prompts") 允許透過環境變數設定路徑,預設是 'prompts'
env = Environment(
loader=FileSystemLoader(
os.path.join(project_root, os.environ.get("PROMPT_PATH", "prompts"))
)
)
@dataclass # 使用 dataclass 簡化類別定義
class Prompter:
prompt_template: Optional[str] = None # 模板檔案名稱 (不含 .jinja)
prompt_text: Optional[str] = None # 或直接提供模板文字
template: Optional[Union[str, Template]] = None # 儲存載入的 Jinja 模板物件
parser: Optional[Any] = None # 可選的輸出解析器 (用於 format_instructions)
def __init__(self, prompt_template=None, prompt_text=None, parser=None):
"""初始化,儲存模板名稱或文字,以及解析器"""
self.prompt_template = prompt_template
self.prompt_text = prompt_text
self.parser = parser
self.setup() # 呼叫 setup 進行模板載入
def setup(self):
"""載入 Jinja2 模板"""
if self.prompt_template:
# 如果提供了模板名稱,使用環境的 get_template 載入
# 會自動加上 .jinja 副檔名
self.template = env.get_template(f"{self.prompt_template}.jinja")
elif self.prompt_text:
# 如果直接提供了模板文字,使用 Template 類別直接建立模板物件
self.template = Template(self.prompt_text)
else:
# 必須提供其中一種
raise ValueError("必須提供 prompt_template 或 prompt_text")
# 確認模板已成功載入
assert self.template, "提示詞模板未定義"
def render(self, data) -> str:
"""使用提供的資料渲染模板"""
# 自動加入當前時間
data["current_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 如果有提供解析器,加入格式化指令
if self.parser:
data["format_instructions"] = self.parser.get_format_instructions()
# 再次確認 template 物件存在且是 Jinja2 的 Template 類型
assert self.template and isinstance(self.template, Template), "模板未正確載入"
# 呼叫 Jinja2 模板物件的 render 方法,傳入資料
return self.template.render(data)
@classmethod
def from_text(cls, text: str):
"""一個方便的類別方法,可以直接從文字建立 Prompter"""
return cls(prompt_text=text)
程式碼解釋:
env = Environment(...)
: 這段程式碼在模組載入時就會執行一次。它會建立一個全域的 Jinja2 Environment
物件。FileSystemLoader
告訴這個環境去哪裡找 .jinja
檔案(預設是專案根目錄下的 prompts
資料夾)。__init__(...)
和 setup()
: 建構函式 __init__
負責儲存使用者提供的模板名稱 (prompt_template
) 或模板文字 (prompt_text
),以及可選的 parser
。然後它會呼叫 setup()
。setup()
根據是否有 prompt_template
或 prompt_text
來載入或建立 Jinja2 Template
物件,並存到 self.template
。render(data)
: 這是最核心的方法。data
字典裡自動加入 current_time
。Prompter
在初始化時收到了 parser
物件(通常是 LangChain 的輸出解析器),它會呼叫 parser.get_format_instructions()
來獲取格式化指令(告訴 AI 如何格式化其輸出的文字),並將其加入 data
字典的 format_instructions
鍵中。這就是模板中 {{ format_instructions }}
變數的來源。self.template.render(data)
,讓 Jinja2 引擎完成最終的模板渲染工作,並返回結果字串。from_text(text)
: 提供一個便利的方式,讓您可以直接用一段包含 Jinja 語法的文字字串來建立 Prompter
,而不必先建立一個檔案。在本章中,我們認識了「提示詞管理器 (Prompter)」,它是 open-notebook
中用來管理和生成 AI 提示詞的利器。
Prompter
透過 Jinja2 模板引擎,讓我們可以將提示詞的固定結構(寫在 .jinja
檔案中)和動態資料(在程式碼中準備)分開處理。Prompter
物件(指定模板檔案),準備包含動態資料的字典,然後呼叫 render()
方法來生成最終的、完整的提示詞字串。render()
方法如何結合資料和模板產生輸出。Prompter
讓提示詞的管理變得更加模組化和清晰。它與 LangGraph 狀態機 (Graph Workflows) 緊密合作,在流程的每個需要與 AI 互動的節點中,負責準備好高品質的指令。
在我們的許多工作流程中,例如處理來源內容或根據使用者指令修改筆記,我們不僅需要與 AI 模型溝通,還需要對文字進行一系列的結構化操作,例如:摘要、翻譯、提取關鍵字、改變格式等等。open-notebook
將這些常見的文字處理操作抽象成「轉換 (Transformations)」。
在下一章,我們將探索 轉換 (Transformations),了解它們是什麼,以及如何使用它們來自動化地處理和豐富我們的筆記內容。