Chapter 6: 提示詞管理器 (Prompter)

歡迎來到 open-notebook 教學系列的第六章!在上一章 LangGraph 狀態機 (Graph Workflows) 中,我們學習了如何使用 LangGraph 這個強大的工具,像導演一樣編排複雜的多步驟 AI 工作流程,例如「智慧問答」。我們看到不同的「演員」(節點)如何根據「劇本」(圖)來協同工作。

現在,讓我們思考一下這些 AI 演員(特別是大型語言模型 LLM)是如何知道自己該做什麼的。就像演員需要劇本和台詞一樣,AI 模型需要清晰的「提示詞 (Prompt)」來引導它們的思考和輸出。

例如,在「智慧問答」流程中,我們需要告訴「策略家 AI」:

如果每次都要手動把這些指令和使用者輸入拼湊在一起,會非常容易出錯,而且難以管理。如果我們想調整 AI 的角色描述或輸出格式,就得去修改程式碼的很多地方。

這就是「提示詞管理器 (Prompter)」登場的時候了!

為什麼我們需要提示詞管理器?

想像一下,您想寄送很多內容類似但收件人不同的邀請函。您不會為每個人都從頭寫一封信,而是會準備一個信件模板

親愛的 [收件人姓名]

誠摯邀請您參加 [活動名稱] 活動,時間是 [日期],地點在 [地點]

期待您的光臨!

[您的姓名] 敬上

有了這個模板,您只需要填入 [收件人姓名][活動名稱] 等變數,就能快速生成大量的個人化邀請函。

在與 AI 模型溝通時,我們也面臨類似的情況:

如果沒有一個好的管理方式,將這些固定指令和動態資料組合起來會很麻煩。提示詞管理器就是為了解決這個問題而設計的。

什麼是提示詞管理器 (Prompter)?

提示詞管理器 (Prompter) 是 open-notebook 中一個專門用來管理和生成提示詞的工具。它就像我們前面提到的「信件模板」或「填字遊戲模板」系統。

您可以把它想像成一個智慧填字遊戲模板製作器

  1. 模板檔案 (Template Files): 我們預先將提示詞的固定結構寫在一些 .jinja 檔案中(例如 prompts/ask/entry.jinja)。這些模板包含了固定的指令文字,以及一些用特殊符號(例如 {{ question }})標記出來的「空格」(稱為佔位符變數)。
  2. 模板引擎 (Jinja2): Prompter 使用一個名為 Jinja2 的強大模板引擎。這個引擎知道如何讀取模板檔案,並將您提供的動態資料填入對應的「空格」。
  3. 提示詞管理器 (Prompter):
    • 它知道去哪裡(prompts/ 資料夾)找到這些模板檔案。
    • 當您告訴它要使用哪個模板(例如 ask/entry)以及要填入的資料(例如使用者問題 question)時,它會使用 Jinja2 引擎。
    • 填入資料: 將您提供的資料(例如實際的使用者問題文字)填入模板中對應的 {{ question }} 空格。
    • 生成最終提示詞: 產生一份完整、可以直接發送給 AI 模型的提示詞文字。

透過 Prompter,我們可以將提示詞的結構(模板)和內容(動態資料)分開管理,讓提示詞的創建、修改和維護變得更加簡單和有條理。

Prompter 的主要程式碼位於 open_notebook/prompter.py 檔案中。

如何使用提示詞管理器

使用 Prompter 非常直觀。通常是在需要與 AI 模型互動之前,用它來準備好要發送的提示詞。

讓我們回到LangGraph 狀態機 (Graph Workflows) 中「智慧問答」流程的「策略家 AI」節點 (call_model_with_messages)。在這個節點中,我們需要根據使用者問題生成一個包含策略的 JSON。

1. 建立 Prompter 物件

首先,我們需要建立一個 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("策略提示詞模板已載入!")

程式碼解釋:

2. 準備動態資料

接下來,我們需要準備一個字典,包含所有需要填入模板「空格」的動態資料。對於「策略家 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}")

程式碼解釋:

3. 渲染 (生成) 提示詞

最後,我們呼叫 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)

程式碼解釋:

以下是 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 = Prompter(prompt_template="ask/entry")prompter.render({"question": "..."}) 時,大致會發生以下事情:

  1. 初始化 (__init__ & setup):
    • 當您建立 Prompter 物件時,它會儲存您提供的 prompt_template 名稱(例如 "ask/entry")。
    • setup() 方法會被呼叫。
    • 它會設定一個 Jinja2 的 Environment。這個環境知道要去哪個資料夾(由環境變數 PROMPT_PATH 指定,預設是專案根目錄下的 prompts/)尋找模板檔案。
    • env.get_template("ask/entry.jinja") 會實際載入模板檔案,並將其解析成一個 Jinja2 Template 物件,儲存在 prompter.template 中。
  2. 渲染 (render):
    • 當您呼叫 render(data) 時,方法會先在您的 data 字典中加入一些額外資訊,例如 current_timeformat_instructions (如果 Prompter 初始化時有提供 parser)。
    • 接著,它會呼叫儲存的 Jinja2 Template 物件的 render() 方法,並將合併後的資料字典傳遞給它。
    • Jinja2 引擎執行模板替換邏輯,將所有 {{ variable }} 替換成字典中對應的值。
    • 最終生成的完整字串被返回。

以下是一個簡化的序列圖,展示了這個過程:

sequenceDiagram participant UserCode as 使用者程式碼 participant PrompterObject as Prompter 物件 participant JinjaEnv as Jinja2 環境 participant JinjaTemplate as Jinja2 模板物件 participant FileSystem as 檔案系統 UserCode->>PrompterObject: __init__(prompt_template="ask/entry") PrompterObject->>PrompterObject: setup() PrompterObject->>JinjaEnv: 設定 (指向 prompts/ 資料夾) PrompterObject->>JinjaEnv: get_template("ask/entry.jinja") JinjaEnv->>FileSystem: 讀取 prompts/ask/entry.jinja 檔案 FileSystem-->>JinjaEnv: 回傳檔案內容 JinjaEnv-->>PrompterObject: 返回已解析的 Template 物件 PrompterObject-->>UserCode: 初始化完成 UserCode->>PrompterObject: render({"question": "使用者問題"}) PrompterObject->>PrompterObject: 加入額外資料 (時間, 格式指令) PrompterObject->>JinjaTemplate: render(合併後的資料) JinjaTemplate->>JinjaTemplate: 執行模板替換邏輯 JinjaTemplate-->>PrompterObject: 返回渲染後的完整字串 PrompterObject-->>UserCode: 返回最終提示詞字串

Prompter 的核心程式碼片段

讓我們看看 open_notebook/prompter.pyPrompter 類別的一些關鍵部分(已簡化):

# 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)

程式碼解釋:

總結

在本章中,我們認識了「提示詞管理器 (Prompter)」,它是 open-notebook 中用來管理和生成 AI 提示詞的利器。

Prompter 讓提示詞的管理變得更加模組化和清晰。它與 LangGraph 狀態機 (Graph Workflows) 緊密合作,在流程的每個需要與 AI 互動的節點中,負責準備好高品質的指令。

在我們的許多工作流程中,例如處理來源內容或根據使用者指令修改筆記,我們不僅需要與 AI 模型溝通,還需要對文字進行一系列的結構化操作,例如:摘要、翻譯、提取關鍵字、改變格式等等。open-notebook 將這些常見的文字處理操作抽象成「轉換 (Transformations)」。

在下一章,我們將探索 轉換 (Transformations),了解它們是什麼,以及如何使用它們來自動化地處理和豐富我們的筆記內容。