見出し画像

[Guidance#7]色々なAgentの実装方法を試してみる

今回はGuidanceを用いたエージェントの使い方についての調査内容になります。色々と調べた限り、複数パターンの実装方法がありましたので、それぞれ試していこうと思います。

先に結論を言っておくと、3番目のアプローチが良さそうです。ただしさまざまな実装方法の引き出しを持っておくことが重要だと思うので、それぞれ紹介していきます。


前準備

本記事ではGoogle Custom Search APIを用いて、最新の情報に対しても回答ができるようなエージェントを実装していきます。以下のような前準備をします。

!pip install guidance google-api-python-client

import guidance
import os
os.environ['OPENAI_API_KEY'] = "sk-xxxxxxxxxxxx"


# Google検索ツールの事前設定
# 各種情報の取得方法: https://mytech-blog.com/google-custom-search-api/
google_cse_id = "xxxxxxxxxxxxxx"
google_api_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

import html
import re
from typing import List
from googleapiclient.discovery import build

def clean_html(raw_html: str) -> str:
    """Remove HTML tags and unescape HTML special chars."""
    cleanr = re.compile('<.*?>')
    cleantext = re.sub(cleanr, '', raw_html)
    return html.unescape(cleantext)

def search_google(query: str, api_key: str, cse_id: str, **kwargs) -> List[dict]:
    service = build("customsearch", "v1", developerKey=api_key)
    res = service.cse().list(q=query, cx=cse_id, **kwargs).execute()
    return res['items']

class Search:
    def __init__(self, api_key: str, cse_id: str):
        self.api_key = api_key
        self.cse_id = cse_id

    def run(self, query: str) -> List[str]:
        raw_results = search_google(query, self.api_key, self.cse_id)
        cleaned_results = []
        for result in raw_results:
            title = clean_html(result.get('title', ''))[:100]
            snippet = clean_html(result.get('snippet', ''))[:100]
            cleaned_results.append(f"{title}\n{snippet}")
        return cleaned_results

search = Search(google_api_key, google_cse_id)
# search.run("検索クエリ") でGoogle検索の結果を取得できる

エージェントが扱うツールを事前に設定しておき、その関数をguidanceプログラムの中で呼び出す形で利用します。ツールの実装に関しては、LangChainのToolsや、Semantic KernelのSkillsが参考になると思います。

本記事のプログラムは、以下のColabで試せます。

1. シンプルなエージェント

公式notebookに載っているアプローチです。ここではユーザーの質問に答えるための検索クエリを生成して、それをもとに検索APIを用いて情報を取得。最後にそのユーザーの質問に回答するというものです。

公式notebookではBing APIを用いていましたが、本記事ではGoogle Custom Search APIを利用します。

上記の公式notebook後半に記載があります

プログラムを見る

guidance.llms.OpenAI.cache.clear()
gpt4 = guidance.llms.OpenAI('gpt-4')

def is_search(completion):
    return '<search>' in completion

demo_results = [{'title': 'OpenAI - Wikipedia',
  'snippet': 'OpenAI systems run on the fifth most powerful supercomputer in the world. [5] [6] [7] The organization was founded in San Francisco in 2015 by Sam Altman, Reid Hoffman, Jessica Livingston, Elon Musk, Ilya Sutskever, Peter Thiel and others, [8] [1] [9] who collectively pledged US$ 1 billion. Musk resigned from the board in 2018 but remained a donor.'},
 {'title': 'About - OpenAI',
  'snippet': 'About OpenAI is an AI research and deployment company. Our mission is to ensure that artificial general intelligence benefits all of humanity. Our vision for the future of AGI Our mission is to ensure that artificial general intelligence—AI systems that are generally smarter than humans—benefits all of humanity. Read our plan for AGI'},
 {'title': 'Sam Altman - Wikipedia',
  'snippet': 'Samuel H. Altman ( / ˈɔːltmən / AWLT-mən; born April 22, 1985) is an American entrepreneur, investor, and programmer. [2] He is the CEO of OpenAI and the former president of Y Combinator. [3] [4] Altman is also the co-founder of Loopt (founded in 2005) and Worldcoin (founded in 2020). Early life and education [ edit]'}]

practice_round = guidance(
'''{{#user~}}
Who are the founders of OpenAI?
{{~/user}}
{{#assistant~}}
<search>OpenAI founders</search>
{{~/assistant}}
{{#user~}}
Search results:
{{~#each results}}
<result>
{{this.title}}
{{this.snippet}}
</result>{{/each}}
{{~/user}}
{{#assistant~}}
The founders of OpenAI are Sam Altman, Reid Hoffman, Jessica Livingston, Elon Musk, Ilya Sutskever, Peter Thiel and others.
{{~/assistant}}''', llm=gpt4)
practice_round = practice_round(results=demo_results)
practice_round

prompt = guidance('''{{#system~}}
You are a helpful assistant.
{{~/system}}

{{#user~}}
From now on, whenever your response depends on any factual information, please search the web by using the function <search>query</search> before responding. I will then paste web results in, and you can respond.
{{~/user}}

{{#assistant~}}
Ok, I will do that. Let's do a practice round
{{~/assistant}}
{{>practice_round}}
{{#user~}}
That was great, now let's do another one.
{{~/user}}

{{#assistant~}}
Ok, I'm ready.
{{~/assistant}}

{{#user~}}
{{user_query}}
{{~/user}}

{{#assistant~}}
{{gen "query" stop="</search>"}}{{#if (is_search query)}}</search>{{/if}}
{{~/assistant}}

{{#if (is_search query)}}
{{#user~}}
Search results: {{search query}}
{{~/user}}

{{#assistant~}}
{{gen "answer"}}
{{~/assistant}}
{{/if}}''', llm=gpt4)

query = "最近、Nvidiaの株価はどれくらい上がりましたか?"
prompt = prompt(user_query=query, practice_round=practice_round, search=search.run, is_search=is_search)
出力結果の後半。無事に最新情報のNvidia株価について回答できています

このサンプルでは、Chat型のLLMが用いられていました。処理の流れとしては次のようになります。

in-context learning
{{>practice_round}}で事前に定義した出力例を差し込まれています。

  • 「OpenAIのファウンダーは誰か?」というお題を用いた in-context learningを行います。

  • 検索クエリを<search></search>内部に記載します

  • `demo_results`として定義しておいた辞書データを{{~#each results}} {{/each}}内部で出力します。

  • {{#assistant}}で回答を記載します。回答を生成するための{{gen …}}が利用できるのは{{#assistant}}ブロック内部のみになります。

{{#user~}}
Who are the founders of OpenAI?
{{~/user}}

{{#assistant~}}
<search>OpenAI founders</search>
{{~/assistant}}

{{#user~}}
Search results:
{{~#each results}}
<result>
{{this.title}}
{{this.snippet}}
</result>{{/each}}
{{~/user}}

{{#assistant~}}
The founders of OpenAI are Sam Altman, Reid Hoffman, Jessica Livingston, Elon Musk, Ilya Sutskever, Peter Thiel and others.
{{~/assistant}}

ユーザー質問に対する回答

  • {{user_query}}で、ユーザーからの質問が入力されます

  • 次のassistantブロックでは、その質問に対して回答します。ただし必要に応じて検索クエリが生成されます。検索すべきであれば、<search>タグで囲まれるようにして生成されます

  • <search>が含まれている場合、検索結果をプロンプトに追加します。{{search query}}のところで、search関数にquery引数を渡した結果が差し込まれます

  • 最後に{{gen "answer"}}で検索結果を踏まえた回答が生成されます

{{#user~}}
{{user_query}}
{{~/user}}

{{#assistant~}}
{{gen "query" stop="</search>"}}{{#if (is_search query)}}</search>{{/if}}
{{~/assistant}}

{{#if (is_search query)}}
{{#user~}}
Search results: {{search query}}
{{~/user}}

{{#assistant~}}
{{gen "answer"}}
{{~/assistant}}
{{/if}}

所感

今回の例では必要に応じて、<search>マークアップも含めて生成をし、それが含まれていれば検索結果を踏まえて回答するというアプローチでした。

ただしこの方法だと、他のツールを用いることが難しいです。拡張性の面で考えていくと、ReActフレームワークを用いたエージェントの実装などが一般的となってくるはずです。


2. ReActを用いたアプローチ:その1

ReActを用いて行う方法です。以下の記事に実装方法について明記されていましたので、こちらを参考にしながら試してみました。

プログラムを見る

#@title ReActフレームワークを試す
davinci = guidance.llms.OpenAI('text-davinci-003')

react_prompt_template = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
Answer the following questions as best you can. You have access to the following tools:

Google Search: A wrapper around Google Search. Useful for when you need to answer questions about current events. The input is the question to search relavant information.

Strictly use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [Google Search]
Action Input: the input to the action, should be a question.
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

for example
Question: How old is CEO of Microsoft wife?
Thought: First, I need to find who is the CEO of Microsoft.
Action: Google Search
Action Input: Who is the CEO of Microsoft?
Observation: Satya Nadella is the CEO of Microsoft.
Thought: Now, I should find out Satya Nadella's wife.
Action: Google Search
Action Input: Who is Satya Nadella's wife?
Observation: Satya Nadella's wife's name is Anupama Nadella.
Thought: Then, I need to check Anupama Nadella's age.
Action: Google Search
Action Input: How old is Anupama Nadella?
Observation: Anupama Nadella's age is 50.
Thought: I now know the final answer.
Final Answer: Anupama Nadella is 50 years old.

### Input:
{{question}}

### Response:
Question: {{question}}
Thought: {{gen 'thought' stop='\\n'}}
Action: {{select 'tool_name' options=valid_tools}}
Action Input: {{gen 'actInput' stop='\\n'}}
Observation:{{search actInput}}
Thought: {{gen 'thought2' stop='\\n'}}
Final Answer: {{gen 'final' stop='\\n'}}"""

valid_tools = ['Google Search']

guidance.llms.OpenAI.cache.clear()
prompt = guidance(react_prompt_template, llm=davinci)
result = prompt(question='最近、Nvidiaの株価はどれくらい上がりましたか?', search=search.run, valid_tools=valid_tools)
出力結果の後半部分

上記は一般的なReActのアプローチです。Response箇所がGuidance特有となっていて、例えばAction部分で利用可能なToolの中から必要に応じて使用すべきものを選択します。{{select}}を使うことで、存在しないツールを指定してしまう確率を限りなく0%にしています。

ちなみに共通して見られる`stop='\\n'`は、生成箇所を改行前までと指定しています。ReActのフォーマットを崩さずに、必要な箇所のみを生成することができています。

ただしこれだと、複数ステップに分かれるような推論ができません。そこで参考記事では、次のようなクラスを定義して解決していました。

# まずプロンプトを、最初の部分、中間の繰り返し部分、最終回答部分に分割します。


valid_answers = ['Action', 'Final Answer']
valid_tools = ['Google Search']

prompt_start_template = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
Answer the following questions as best you can. You have access to the following tools:

Google Search: A wrapper around Google Search. Useful for when you need to answer questions about current events. The input is the question to search relavant information.

Strictly use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take, should be one of [Google Search]
Action Input: the input to the action, should be a question.
Observation: the result of the action
... (this Thought/Action/Action Input/Observation can repeat N times)
Thought: I now know the final answer
Final Answer: the final answer to the original input question

for example
Question: How old is CEO of Microsoft wife?
Thought: First, I need to find who is the CEO of Microsoft.
Action: Google Search
Action Input: Who is the CEO of Microsoft?
Observation: Satya Nadella is the CEO of Microsoft.
Thought: Now, I should find out Satya Nadella's wife.
Action: Google Search
Action Input: Who is Satya Nadella's wife?
Observation: Satya Nadella's wife's name is Anupama Nadella.
Thought: Then, I need to check Anupama Nadella's age.
Action: Google Search
Action Input: How old is Anupama Nadella?
Observation: Anupama Nadella's age is 50.
Thought: I now know the final answer.
Final Answer: Anupama Nadella is 50 years old.

### Input:
{{question}}

### Response:
Question: {{question}}
Thought: {{gen 't1' stop='\\n'}}
{{select 'answer' options=valid_answers}}: """

prompt_mid_template = """{{history}}{{select 'tool_name' options=valid_tools}}
Action Input: {{gen 'actInput' stop='\\n'}}
Observation: {{do_tool tool_name actInput}}
Thought: {{gen 'thought' stop='\\n'}}
{{select 'answer' options=valid_answers}}: """

prompt_final_template = """{{history}}{{select 'tool_name' options=valid_tools}}
Action Input: {{gen 'actInput' stop='\\n'}}
Observation: {{do_tool tool_name actInput}}
Thought: {{gen 'thought' stop='\\n'}}
{{select 'answer' options=valid_answers}}: {{gen 'fn' stop='\\n'}}"""
# 必要に応じて外部ツールを使った推論を行うAgentクラスを定義(複数ステップにも対応)
class CustomAgentGuidance:
    def __init__(self, guidance, llm, tools, num_iter=3):
        self.guidance = guidance
        self.guidance.llm = llm
        self.tools = tools
        self.num_iter = num_iter

    def do_tool(self, tool_name, actInput):
        return self.tools[tool_name](actInput)
    
    def __call__(self, query):
        prompt_start = self.guidance(prompt_start_template)
        result_start = prompt_start(question=query, valid_answers=valid_answers)

        result_mid = result_start
        
        for _ in range(self.num_iter - 1):
            if result_mid['answer'] == 'Final Answer':
                break
            history = result_mid.__str__()
            prompt_mid = self.guidance(prompt_mid_template)
            result_mid = prompt_mid(history=history, do_tool=self.do_tool, valid_answers=valid_answers, valid_tools=valid_tools)
        
        if result_mid['answer'] != 'Final Answer':
            history = result_mid.__str__()
            prompt_mid = self.guidance(prompt_final_template)
            result_final = prompt_mid(history=history, do_tool=self.do_tool, valid_answers=['Final Answer'], valid_tools=valid_tools)
        else:
            history = result_mid.__str__()
            prompt_mid = self.guidance(history + "{{gen 'fn' stop='\\n'}}")
            result_final = prompt_mid()
        return result_final['fn']


dict_tools = {
    'Google Search': search.run
}
custom_agent = CustomAgentGuidance(guidance, davinci, dict_tools)
final_answer = custom_agent('OpenAIの2023年5月の動向を教えてください。')
出力結果。最新情報でも適宜検索して回答している

やっていることとしては、まずReActフレームワークの定義。そしてここまでの内容を踏まえて"Action"を取るべきか(ツールを使うべきか)、"Final Answer"をするべきかを項目として生成しています。

{{select 'answer' options=valid_answers}}: 

もしActionであれば`prompt_mid_template`がプロンプトに繰り返し追記されていき、その中でActionで指定されたツールを用いた結果が追加されます。

この処理は"Final Answer"項目が生成されるか、あるいは上限回数がくるまで処理が行われます。最終的にここまでの内容を踏まえてFinal Answerが生成され、ユーザーへの回答となります。

if result_mid['answer'] != 'Final Answer':
   history = result_mid.__str__()
   prompt_mid = self.guidance(prompt_final_template)
   result_final = prompt_mid(history=history, do_tool=self.do_tool, valid_answers=['Final Answer'], valid_tools=valid_tools)

詳細な内容が気になる方は参考記事をご覧ください。

所感

ReActフレームワークを用いることで、ツール利用の拡張性が高まりました。今回はGoogle Custom Searchのみでしたが、別なツールを追加したい場合は`dict_tools`への追加とプロンプトへのツール説明の追加をすることで実現できます。

また複数ステップの推論は、Agentクラス内部で実現しています。これによりクラスを呼び出す際は、内部の複雑なステップを考慮する必要はありませんが、一方で複数の条件分岐やプロンプトが存在しているため、可読性はあまり良くないです。

Guidanceの特徴の一つとして、Guidanceプログラム内部でプロンプトと論理制御を行えるということがあります。これを用いると可読性を高めつつ同様のことが実現できそうです(3番目の紹介する方法です)


3. ReActを用いたアプローチ:その2

Guidanceの持ち味である、プロンプトと論理制御を一箇所に集約する形でReActエージェントを実装していく方法です。複数ステップ部分の処理は {{geneach}} を用いて実装します。

こちらは以下の記事を参考にさせていただきました。詳細な説明がされているため、理解が深まりました。

プログラムを見る

参考記事のプロンプトに少し手を加えています。Few-shot Learningやgeneachの変数の扱い部分がやや異なりますが、大枠は踏襲しています。

#@title geneachを用いたReActエージェント

prompt = """Answer the following questions as best you can. You have access to the following tools:

{{~! 使用可能なツールを記述する ~}}
{{~#each tool_explanations}}
{{this.name}}: {{this.description}}
{{~/each}}

ALWAYS use the following format:

Question: the input question you must answer
Thought: you should always think about what to do
Action: the action to take
Action Input: the input to the action.
Observation: the result of the action
Answer: Determines whether to continue running or if the task is complete
... (this Thought/Action/Observation/Answer can repeat N times)

for example
Question: How old is CEO of Microsoft wife?
Thought: First, I need to find who is the CEO of Microsoft.
Action: Google Search
Action Input: Who is the CEO of Microsoft?
Observation: Satya Nadella is the CEO of Microsoft.
Thought: Now, I should find out Satya Nadella's wife.
Action: Google Search
Action Input: Who is Satya Nadella's wife?
Observation: Satya Nadella's wife's name is Anupama Nadella.
Thought: Then, I need to check Anupama Nadella's age.
Action: Google Search
Action Input: How old is Anupama Nadella?
Observation: Anupama Nadella's age is 50.
Thought: I now know the final answer.
Final Answer: Anupama Nadella is 50 years old.

### Input:
{{question}}

### Response:
Question:{{question}}
{{~#geneach 'conversation' stop=False~}}
Thought:{{gen 'this.before_thought' temperature=0 max_tokens=100 stop='\\n'}}
Action: "{{select 'this.action' options=valid_tools}}"
Action input: "{{gen 'this.action_input'}}"
Observation: {{run_tool this.action this.action_input}}
Thought: {{gen 'this.last_thought' temperature=0 max_tokens=100 stop='\\n'}}
{{#block hidden=True}}Answer: {{select 'this.answer' options=valid_answers}}{{~/block}}
{{#if (contains this.answer "FINISH")}}{{break}}{{/if}}
{{~/geneach}}
Question:{{question}}
Final Answer: '{{gen 'fn'}}'"""


tools = [{
    "name": "Google Search",
    "description": "A wrapper around Google Search. Useful for when you need to answer questions about current events. The input is the question to search relavant information.",
    "func": search
}]

tool_explanations = [{"name": tool["name"], "description": tool["description"]} for tool in tools]
valid_tools = [tool["name"] for tool in tool_explanations]
tool_dict = { tool["name"]: tool["func"] for tool in tools }

def run_tool(action: str, input: str):
    return tool_dict[action].run(input)
    
valid_answers = ["CONTINUE", "FINISH"]

guidance.llms.OpenAI.cache.clear()
agent = guidance(prompt, llm=davinci)
question = 'OpenAIとはなんですか?また2023年5月の動向を教えてください。'
agent(tool_explanations=tool_explanations, valid_tools=valid_tools, run_tool=run_tool, valid_answers=valid_answers, question=question)
出力結果の後半部分。最新情報でも適宜検索して回答している

注目ポイントとしては以下です。

### Response:
Question:{{question}}
{{~#geneach 'conversation' stop=False~}}
Thought:{{gen 'this.before_thought' temperature=0 max_tokens=100 stop='\\n'}}
Action: "{{select 'this.action' options=valid_tools}}"
Action input: "{{gen 'this.action_input'}}"
Observation: {{run_tool this.action this.action_input}}
Thought: {{gen 'this.last_thought' temperature=0 max_tokens=100 stop='\\n'}}
{{#block hidden=True}}Answer: {{select 'this.answer' options=valid_answers}}{{~/block}}
{{#if (contains this.answer "FINISH")}}{{break}}{{/if}}
{{~/geneach}}
Question:{{question}}
Final Answer: '{{gen 'fn'}}
  • 繰り返し処理の実現

    • {{~#geneach 'conversation' stop=False~}} とすることで、Though, Action, Action input, Observation, Thoughtのステップを繰り返すようにしています。

  • 繰り返し処理の終了

    • 繰り返し処理の最後に Answer の項目をつけており、ここでCONTINUE もしくは FINISH が生成されます

    • FINISHの場合は{{break}}とすることで、geneachループを抜け出しています

  • 質問への回答

    • 最後にFinal Answerを生成することで、ここまでの情報(例えば検索結果など)を踏まえた回答を行うことができます。

所感

2番目のアプローチと処理結果はほとんど同じまま、処理の流れを一箇所に集約することで可読性が高くなったと思います。

繰り返し処理、条件分岐、プロンプトの非表示(下準備する際など)があるため、LLMを中心とした処理はある程度Guidance programに集約できるのではないかと感じてきました。集約することによるメリット/デメリットなどは実際に手を動かして見ないとなかなか実感を持ちにくそうです。


おわりに

エージェントの実装を試してみる中で、色々な気づきがありました。

  • guidanceを工夫して使えば、LLMを中心とした複雑な処理を可読性が高い形で実装ができそう

  • LangChainと異なって、Guidanceを利用する場合はツールを自前で実装する必要がある。ただツールの実装を参考にするドキュメントは豊富にあるため、その敷居は低そう。

  • 限られた選択肢の中から生成する {{select}} が、システムにおけるLLM活用のエラー率減少にかなり効いてきそう&汎用性が高そう

  • `text-davinci-003`を利用していると、生成する文字列は少しでも入力プロンプトが多い&呼び出し回数も多いので、コスト面が高くつきそう

Guidanceは、実装してみる中での気づきがとても多いです。引き続き色々と手を動かしながら、発信していければなと思います。

この記事が気に入ったらサポートをしてみませんか?