「TapeAgents」を試す
GitHubレポジトリ
TapeAgents
TapeAgentsは、エージェントセッションの構造化された再生可能なログ(Tape)を活用し、LLMエージェント開発ライフサイクルの全段階を支援するフレームワークです。TapeAgentsでは、エージェントはTapeとLLMの出力を処理することで推論を行い、新しい思考、アクション、制御フローのステップを生成し、それをTapeに追加します。その後、環境はエージェントのアクションに反応し、観察ステップをTapeに追加する形で応答します。
主な特徴:
- エージェントを低レベルの状態機械として、または高レベルのマルチエージェントチーム構成として、あるいは複数のプロンプトに基づいてガイドされる単一エージェントとして構築可能
- TapeAgent StudioやTapeBrowserアプリでエージェントをデバッグ
- 応答ストリーミングでエージェントを提供
- 成功したTapeを用いてエージェント構成を最適化し、改訂されたTapeを使用してLLMをファインチューニング
TapeAgentsのTape中心の設計は、プロジェクトの全段階で役立ちます:
- プロンプト作成や次のステップ生成にTapeを活用することで、究極の柔軟性を実現
- プロンプトやチーム構造を変更しても、新しいエージェントが既存のTapeから継続可能であれば、デバッグセッションを再開可能
- アプリでTapeAgentを使用する際、エージェントのTapeやアクションを完全に制御可能
- Tape、ステップ、LLMコール、エージェント構成を結び付ける慎重に設計されたメタデータ構造を使用して、Tapeやエージェントを最適化
テープのように記録して巻き戻し・再再生することで、デバッグを容易にする、という考え方みたい。
インストール
introductory Jupyter notebookが用意されているのでこれに従って進める。Colaboratoryで。
なお、ノートブックの冒頭にあるが、
TapeAgents は、エージェントセッションの構造化された再生可能なログ(Tape)を活用し、LLMエージェント開発ライフサイクルのすべての段階を支援するフレームワークです。TapeAgentsでは、エージェントがTapeとLLM出力を処理することで推論を行い、新しい思考、アクション、制御フローのステップを生成し、それをTapeに追加します。その後、環境がエージェントのアクションに応答し、観察ステップをTapeに追加する形で反応します。
このチュートリアルでは、次の内容を学びます:
- 低レベルAPIを使用してTapeAgentsを作成する方法
- TapeAgentsを実行し、再開する方法
- あるTapeAgentが別のTapeAgentのTapeをトレーニングデータとして再利用する方法
今後のチュートリアルでは、次の内容も学べます:
- サブエージェントを持つチームTapeAgentの作成方法
- 高レベルAPIを使用したTapeAgentsの構築方法
- 部分的なステップをストリーミングするTapeAgentの構築方法
その他のチュートリアルと例では、次の内容を扱います:
- コード実行とブラウザの使用
- ファインチューニング
- TapeAgentsアプリ(StudioおよびBrowser)の使用方法
とあり、他のチュートリアルも進めるならばローカルでやったほうが色々良さそうな気もする。とりあえずまずはこのノートブックの部分だけ。
ではインストール。ノートブックではMakefileを使ってconda環境を作成しているようだが、手動で。
レポジトリクローン
!git clone https://github.com/ServiceNow/TapeAgents
%cd TapeAgents
依存パッケージをインストール。このタイミングでランタイム再起動が求められると思う。
!pip install -r ./requirements.txt -r ./requirements.dev.txt -r ./requirements.finetune.txt -r ./requirements.converters.txt
TapeAgentsをインストール。ランタイム再起動しているはずなので、ディレクトリ移動を忘れずに。
%cd TapeAgents
!pip install -e .
バージョンはv0.0.3。
Successfully installed TapeAgents-0.0.3
LLMはOpenAIを使う。APIキーを環境変数にセット。
import os
from google.colab import userdata
os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
最初のTapeAgent
まずリプレイを可能にするために日付を指定しておく。この時点ではこれがどういう意味になるのかはわかっていない。
today = "2024-11-21"
シンプルなエージェントの例。
from tapeagents.agent import Agent, Node
from tapeagents.core import Prompt, SetNextNode
from tapeagents.dialog_tape import AssistantStep, UserStep, DialogTape
from tapeagents.llms import LLMStream, LiteLLM
from tapeagents.prompting import tape_to_messages
llm = LiteLLM(model_name="gpt-4o-mini")
class MainNode(Node):
name: str = "main"
def make_prompt(self, agent: Agent, tape: DialogTape) -> Prompt:
# テープ全体をプロンプトにレンダリングし、各ステップをメッセージに変換
return Prompt(messages=tape_to_messages(tape))
def generate_steps(self, agent: Agent, tape: DialogTape, llm_stream: LLMStream):
# LLMの出力ストリームから新しいステップを生成。
yield AssistantStep(content=llm_stream.get_text())
# どのノードを次に実行するか、これは後述
yield SetNextNode(next_node="main")
agent = Agent[DialogTape].create(llm, nodes=[MainNode()])
start_tape = DialogTape(steps=[UserStep(content="競馬の楽しみ方を5つリストアップしてください。簡潔に。")])
final_tape = agent.run(start_tape).get_final_tape() # エージェントが最初のノードの実行を開始
print(f"最終テープ: {final_tape.model_dump_json(indent=2)}")
最終テープ: {
"metadata": {
"id": "cfec84ec-ce6c-4982-b00b-c6d088d82acf",
"parent_id": "cb7eac64-45c8-4cff-9462-e526429829a2",
"author": "Agent",
"author_tape_id": null,
"n_added_steps": 2,
"error": null,
"result": {}
},
"context": null,
"steps": [
{
"metadata": {
"id": "3522eaac-ed6d-49b7-988d-2a4b101cbcaf",
"prompt_id": "",
"node": "",
"agent": "",
"other": {}
},
"kind": "user",
"content": "競馬の楽しみ方を5つリストアップしてください。簡潔に。"
},
{
"metadata": {
"id": "3522eaac-ed6d-49b7-988d-2a4b101cbcaf",
"prompt_id": "13e2b00c-95b0-4fdf-8d0a-6bd38a894743",
"node": "main",
"agent": "Agent",
"other": {}
},
"kind": "assistant",
"content": "1. **予想を楽しむ**: 馬の能力や騎手の実績を分析し、独自の予想を立てる。\n2. **レース観戦**: 実際のレースを生で観ることで興奮や臨場感を体験する。\n3. **馬券購入**: 自分の予想に基づいて馬券を購入し、当たった時の喜びを味わう。\n4. **友人との交流**: 競馬仲間と情報を共有し、レースについて語り合うことで楽しさが倍増。\n5. **競馬イベント参加**: フェスティバルやトークショーなどのイベントに参加し、競馬文化を深く知る。"
},
{
"metadata": {
"id": "3522eaac-ed6d-49b7-988d-2a4b101cbcaf",
"prompt_id": "13e2b00c-95b0-4fdf-8d0a-6bd38a894743",
"node": "main",
"agent": "Agent",
"other": {}
},
"kind": "set_next_node",
"next_node": "main"
}
]
}
ふむ。なんとなく雰囲気はわかるような気がする。
各コンポーネントごとに見ていく。
- tapes
- steps
- prompts
- llm streams
- nodes
- agents
Tapes/Steps
Tapeはエージェントのセッションを記録するログであり、コンテキストと一連のStepから成る。StepにはUserStep
やAssistant
ステップが含まれ、これらをTapeに追加することで、エージェントが動作する。
Tapeにはいくつかの種類があるようだが最も基本的なものが、ユーザとアシスタントの対話を扱うDialogTape
になる。
DialogTape
で使えるStepを見てみる。
DialogTape
tapeagents.core.Tape[Union[DialogContext, NoneType], Union[UserStep, ToolResult, SystemStep, AssistantThought, SetNextNode, Pass, Call, Respond, FinalStep, AssistantStep, ToolCalls]]
def __init__(self, /, **data: Any) -> None
主なStepはこのあたり。
-
UserStep
:role=user
のメッセージ -
AssistantStep
:role=assistant
のメッセージ -
SystemStep
:role=system
のメッセージ -
ToolResult
:role=tool
のメッセージ -
ToolCalls
: LLMからツール実行を要求されるtool_calls
のメッセージ -
AssistantThought
: LLMの中間的思考のメッセージ -
SetNextNode
: TapeAgentsの内部ステップ。次のステップでどのノードを実行するかを制御する。 -
Pass
: TapeAgentsの内部ステップ。次のステップでどのノードを実行するかを制御する。
なるほど、LLMとの個々のやりとりがStepとして扱われるように思える。つまりTapeはLLMとの個々のやり取りを記録しておくものと言える。
LLM/Prompts
ここはごくごく一般的なchat completionsになっている。
Prompt.model_fields
{'id': FieldInfo(annotation=str, required=False, default_factory=<lambda>),
'tools': FieldInfo(annotation=Union[list[dict], NoneType], required=False, default=None),
'messages': FieldInfo(annotation=list[dict], required=False, default_factory=list)}
LLMはLiteLLMを使用している様子。TapeAgentのPromptオブジェクトでラップしてLLMに渡すとLLMStreamオブジェクトが返ってくる。LLMStreamオブジェクトを使うことで、レスポンスを早送りしたり、部分的にストリーミングしたりできる。
llm_stream = LiteLLM(model_name="gpt-4o-mini-2024-07-18", stream=True)
# ストリーミング
prompt = Prompt(messages=[{"role": "user", "content": "JavaでHello Worldを出力するコードを書いて。"}])
for event in llm_stream.generate(prompt):
print(event.chunk, end="")
# 非ストリーミング
# (注意: TapeAgents では、1 回以上の LLM 呼び出しに Prompt オブジェクトを使用できない。)
prompt = Prompt(messages=[{"role": "user", "content": "JavaでHello Worldを出力するコードを書いて。"}])
print("\n" + "-" * 30)
print(llm_stream.generate(prompt).get_text())
Javaで"Hello, World!"を出力するコードは以下のようになります。
```java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
このコードを実行するには、以下の手順を踏んでください。
1. 上記のコードを`HelloWorld.java`という名前のファイルに保存します。
2. コマンドラインでそのファイルが保存されているディレクトリに移動します。
3. 次のコマンドを使ってコンパイルします:
```bash
javac HelloWorld.java
```
4. コンパイルが成功したら、次のコマンドでプログラムを実行します:
```bash
java HelloWorld
```
これで、`Hello, World!`が出力されるはずです。None
------------------------------
もちろんです!Javaで「Hello, World」を出力するシンプルなコードは以下の通りです。
```java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World");
}
}
```
このコードを実行するには、次の手順を踏んでください:
1. 上記のコードを `HelloWorld.java` というファイル名で保存します。
2. コマンドライン(またはターミナル)を開き、ファイルが保存されたディレクトリに移動します。
3. 次のコマンドを入力してコンパイルします:
```
javac HelloWorld.java
```
4. コンパイルが成功したら、次のコマンドでプログラムを実行します:
```
java HelloWorld
```
これで「Hello, World」と出力されるはずです。
テープからプロンプトを作成するにはtape_to_messages
を使う。以下の例ではUserStep
とAssistantStep
を作成し、それらをDialogTape
に渡して、tape_to_messages
でプロンプトとして出力している。
print((user := UserStep(content="こんにちは、AI!")))
print((assistant := AssistantStep(content="こんにちは、人間")))
print(tape_to_messages(DialogTape(steps=[user, assistant])))
metadata=StepMetadata(id='3522eaac-ed6d-49b7-988d-2a4b101cbcaf', prompt_id='', node='', agent='', other={}) kind='user' content='こんにちは、AI!'
metadata=StepMetadata(id='3522eaac-ed6d-49b7-988d-2a4b101cbcaf', prompt_id='', node='', agent='', other={}) kind='assistant' content='こんにちは、人間'
[{'role': 'user', 'content': 'こんにちは、AI!'}, {'role': 'assistant', 'content': 'こんにちは、人間'}]
tape_to_messages
は内部でstep.llm_dict()
が呼んで各ステップのメッセージをプロンプトにしているみたい。
print((user := UserStep(content="こんにちは、AI!")).llm_dict())
print((assistant := AssistantStep(content="こんにちは、人間")).llm_dict()
{'kind': 'user', 'content': 'こんにちは、AI!'}
{'kind': 'assistant', 'content': 'こんにちは、人間'}
TapeAgentはエージェントを実行して得られるデータを利用することを重要な優先事項としているため、FineTuning用のデータを生成できるLLMもある様子。ここはちょっとよくわかってない。
from tapeagents.core import LLMOutput
from tapeagents.llms import TrainableLLM
trainable_llm = TrainableLLM(
base_url="", # ここではモデルのトークナイザーのみを使用し、推論のためのbase_urlは必要ない
model_name="microsoft/Phi-3.5-MoE-instruct",
tokenizer_name="microsoft/Phi-3.5-MoE-instruct",
)
simple_tape = DialogTape(
steps=[
UserStep(content="blaと3回言って、そしてfooと2回言って。"),
AssistantStep(content="わかりました! bla bla bla foo foo"),
]
)
prompt = Prompt(messages=tape_to_messages(simple_tape[:1])) # type: ignore
output = agent.make_llm_output(simple_tape, index=1)
text = trainable_llm.make_training_text(prompt=prompt, output=output)
print("--- ALL TEXT ---")
print(text.text)
print("--- PREDICTED CHARACTERS ---")
print(text.output_text)
--- ALL TEXT ---
<|user|>
blaと3回言って、そしてfooと2回言って。<|end|>
<|assistant|>
わかりました! bla bla bla foo foo<|end|>
<|endoftext|>
--- PREDICTED CHARACTERS ---
わかりました! bla bla bla foo foo<|end|>
<|endoftext|>
Nodes
Nodeは、TapeAgentにおける中断不可能な実行の最小単位となる。Nodeが実行される場合、以下の2つが使用される。
-
make_prompt
: TapeからLLMプロンプトを作成 -
generate_steps
: LLMの出力から新しいステップを生成。
Nodeを作成する場合はNode
クラスからサブクラスを作成して、上記の関数をオーバーライドする。TapeAgentsはストリーミングに最適化されたフレームワークであるため、generate_steps
はジェネレータとして実装する必要がある。
こんな感じ。
from tapeagents.llms import LLMEvent
class MainNode(Node):
name: str = "main"
def make_prompt(self, agent, tape: DialogTape) -> Prompt:
return Prompt(messages=tape_to_messages(tape))
def generate_steps(self, agent, tape, llm_stream: LLMStream):
yield AssistantStep(content=llm_stream.get_text())
yield SetNextNode(next_node="main") # 同じノードで継続する
node = MainNode()
それぞれのメソッドを単体で実行してみる。まず、make_prompt
。
prompt = node.make_prompt(agent=None, tape=DialogTape(steps=[UserStep(content="こんにちは、AI!")]))
print(f"生のノードのプロンプト:\n{prompt}\n")
生のノードのプロンプト:
id='6b663bc7-108f-4d42-befa-0cffe58aecea' tools=None messages=[{'role': 'user', 'content': 'こんにちは、AI!'}]
次にgenerate_steps
。こちらは実際にはLLMStreamを生成しなければならないので、ダミーで生成する。
def _generator():
yield LLMEvent(output=LLMOutput(content="こんにちは、人間"))
stream = LLMStream(_generator(), Prompt())
step_stream = node.generate_steps(agent=None, tape=DialogTape(), llm_stream=stream)
print(f"ノードのジェネレータによって生成されたステップ:\n{list(step_stream)}\n")
ノードのジェネレータによって生成されたステップ:
[AssistantStep(metadata=StepMetadata(id='3522eaac-ed6d-49b7-988d-2a4b101cbcaf', prompt_id='', node='', agent='', other={}), kind='assistant', content='こんにちは、人間'), SetNextNode(metadata=StepMetadata(id='3522eaac-ed6d-49b7-988d-2a4b101cbcaf', prompt_id='', node='', agent='', other={}), kind='set_next_node', next_node='main')]
エージェントでノードを実行するには以下のようなステップとなる。
# エージェントがノードを実行する場合、以下の3つのステップに相当する:
# Step 1: プロンプトを作成
start_tape = DialogTape(steps=[UserStep(content="こんにちは、AI!")])
prompt = node.make_prompt(agent, start_tape)
# Step 2: プロンプトから LLMStream を構築。(エージェント内部で発生する)
stream = llm.generate(prompt)
# Step 3: エージェントがテープに追加するステップを生成。
print("生成されたステップ:")
for step in node.generate_steps(agent, start_tape, stream):
print(step.model_dump_json(indent=2))
生成されたステップ:
{
"metadata": {
"id": "3522eaac-ed6d-49b7-988d-2a4b101cbcaf",
"prompt_id": "",
"node": "",
"agent": "",
"other": {}
},
"kind": "assistant",
"content": "こんにちは!どのようにお手伝いできますか?"
}
{
"metadata": {
"id": "3522eaac-ed6d-49b7-988d-2a4b101cbcaf",
"prompt_id": "",
"node": "",
"agent": "",
"other": {}
},
"kind": "set_next_node",
"next_node": "main"
}
Agentsのところを進めてみたのだけど、うーん、自分にはちょっと理解が難しい・・・
「テープ」という抽象化はものすごくいいと思うんだけど、そのために必要な各コンポーネントの内部処理みたいなところを先に説明されても理解が追いつかない気がするんだよなぁ・・・使いこなすにはその部分の理解が必要にはなるのはわかるんだけど、もう少し実践的に動くものを題材にするのがいいのではないかという気がする。
以下にサンプルコードが多数用意されているのだけど、
自分的にはどれもだいぶ低レイヤーに思えて、ちょっとしんどい。
このあたりのバランス感は難しいと思いつつも、学習コスト高めに感じてしまったので、一旦ペンディングかな・・・