こんにちは。この連載では、AI生成技術と進化、3DCG制作現場への活用の可能性を探索していきます。今回取り上げるのは、第1回でも取り上げたChatGPT(GPT-3.5やGPT-4) APIの新たな機能 Function calling、そしてMaya上で動作するAIエージェントです。

AI関連の研究は日々驚くほどのスピードで進化しています。可能な限り正確な情報を提供するよう心掛けていますが、私自身も学習中であるため、記事中に誤りが含まれる可能性があることをご理解いただければ幸いです。

記事の目次

赤崎弘幸

Jet Studio Inc.所属のCGディレクター。得意分野はキャラクターモデリング、リギング、ツール作成等。3DCGの制作現場目線でAI活用の可能性を探究中。(Twitter)→@akasaki1211

1. エージェントとFunction calling

エージェントとは、一言でいうと「ある環境下で、行動を選択・決定し、その結果によって次の行動を決める」というある程度の意思決定を伴った動作ができるAIです。単体の能力だけでなく、別途与えられた外部の環境や機能から適切な行動を選択することができます。文脈によって多少定義が違う可能性がありますが、ここでは上記のようなものをエージェントと呼ぶこととします。第1回で作成した「エラー解説ダイアログ」や「オペレーションキャプチャツール」は言語モデルの能力の範疇で特定の1タスクに限定していましたので、その点で異なります。



Function callingとは、事前に用意した関数の中からGPTが必要に応じて使用すべき関数を選んでくれる機能です。OpenAI APIに2023年6月半ばに追加されました。ユーザーの問いに対し、関数を使うべきか否か、また使うとしたらどの関数を使うのかをモデル自身が判断し、関数名と引数を返してくれます。関数の実行はこちら側で行う必要がありますが、実行結果を添えてさらに質問を重ねることで言語モデル単体では出来ない「WEB検索」や「コード実行」などの能力を付加することができます。

以下のスクリプトはリアルタイムの天気情報を取得する関数を与えた例です。公式のサンプルを少し改変してあります。

  1. import openai
  2. import json
  3.  
  4. # 天気を返す関数(仮)。実際はここで外部APIなどに問い合わせする。
  5. def get_current_weather(location):
  6. weather_info = {
  7. "location": location,
  8. "temperature": "24度",
  9. "forecast": "晴れ",
  10. }
  11. return json.dumps(weather_info)
  12.  
  13. # 関数を動的に取得するための辞書
  14. available_functions = {
  15. "get_current_weather": get_current_weather,
  16. }
  17.  
  18. def run_conversation(prompt:str):
  19. # Step 1: 会話と利用可能な機能をGPTに送信
  20. messages = [{"role": "user", "content": prompt}]
  21. functions = [
  22. {
  23. "name": "get_current_weather", # 関数名
  24. "description": "指定した場所の現在の天気を取得する", # 関数の説明
  25. "parameters": { # 関数の引数。JSON Schemaで記述。https://json-schema.org/understanding-json-schema/
  26. "type": "object",
  27. "properties": {
  28. "location": {
  29. "type": "string",
  30. "description": "場所。例:東京, 大阪, 福岡, など",
  31. },
  32. },
  33. "required": ["location"],
  34. },
  35. }
  36. ]
  37. response = openai.ChatCompletion.create(
  38. model="gpt-3.5-turbo-0613",
  39. messages=messages,
  40. functions=functions,
  41. function_call="auto"
  42. )
  43. response_message = response["choices"][0]["message"]
  44.  
  45. # Step 2: GPTが関数を呼び出したいかチェックする
  46. if response_message.get("function_call"):
  47. # Step 3: 関数を呼び出す
  48. function_name = response_message["function_call"]["name"]
  49. function_args = json.loads(response_message["function_call"]["arguments"])
  50. function_response = available_functions[function_name](**function_args)
  51.  
  52. # Step 4: 関数コールと関数のレスポンスをGPTに送信し、再度回答を得る。
  53. messages.append(response_message)
  54. messages.append(
  55. {
  56. "role": "function",
  57. "name": function_name,
  58. "content": function_response,
  59. }
  60. )
  61. second_response = openai.ChatCompletion.create(
  62. model="gpt-3.5-turbo-0613",
  63. messages=messages,
  64. )
  65. print(json.dumps(messages, indent=4, ensure_ascii=False))
  66. return second_response["choices"][0]["message"]["content"]
  67.  
  68. # 実行
  69. print(run_conversation("今日の東京の天気は?"))
  70.  
  71. # 出力結果
  72. # 今日の東京の天気は晴れで、気温は24度です。
Function callingサンプル

以前からもReActのように機能をすべてプロンプト内に記述する手法により同等のことは出来ていました。言語モデル界隈で有名なライブラリ「LangChain」ではZERO_SHOT_REACT_DESCRIPTIONでこれが実装されています。そのため特にFunction callingで新しいことが出来るようになったわけではないのですが、公式が対応したことでより使いやすくかつ精度高く同等の機能が実現出来るようになりました。(“使いやすく”というのは個人差があるかもしれません。)

Function callingの詳細な使い方は以下をご確認ください。
GPT/Function calling - OpenAI API
openai-cookbook/examples/How_to_call_functions_with_chat_models.ipynb


また、以下の記事に本質的なことがまとまっていますので一読しておくと良いかもしれません。
[OpenAI] Function callingで遊んでみたら本質が見えてきたのでまとめてみた | DevelopersIO


現在はLangChainでもOpenAI Functions Agentが実装されています。
OpenAI functions | Langchain
OpenAI Multi Functions Agent | Langchain


前置きが長くなりましたが、今回は簡単にいくつか関数を作成し、Autodesk Maya上で動作する『あるキャラクターリグの操作に詳しいサポートエージェント』を作ってみたいと思います。シンプルに仕組みを理解できるよう、サードパーティ製ライブラリは出来るだけ使わずにやってみます。

<テスト環境>
・Windows 10
・Maya 2024 (Python 3.10.8)
・Python 3.10.9
・openai 0.28.0

事前準備として、以下数点が終わっている前提で進めます。VSCodeとMaya双方でopenaiライブラリが使用できる状態です。

API keys - OpenAI APIよりAPI Keyを取得し、環境変数OPENAI_API_KEYに設定してある。

②Python仮想環境を作成しopenaiパッケージがインストールしてある。

  1. python -m venv venv
  2. venv\scripts\activate
  3. pip install -U openai[datalib]

③Mayaにopenaiパッケージをインストールしてある。

  1. cd C:\Program Files\Autodesk\Maya2024\bin
  2. mayapy -m pip install -U openai[datalib] -t C:\Users\ユーザー名\Documents\maya\2024\scripts\site-packages
  3.  



2. エージェントに与える機能を考える

エージェントに機能として追加したい(元の言語モデルに備わっていない)関数を考えていきます。大きく分けて以下の2種が考えられます。

・既存の知識に無い部分を補う

まず「あるキャラクターリグの操作に詳しい」ということはリグの仕様を把握していなければいけません。当然、GPTにはある特定のリグに関する知識はありませんので別途情報を与える必要があります。そこで、事前に用意した仕様書を埋め込みにして外部ファイルに保存し、検索用の関数を用意します。AIが必要だと判断した時に任意の検索ワードで情報を得られるようにしてみます。埋め込みについては次項でもう少し詳しく取り上げます。

・Mayaから情報を得る/操作する

今回のケースではリグに限定しますので、あるコントローラのアトリビュートの状態を取得する関数や、アトリビュートを指定値にセットする関数などをいくつか作成します。これによりMayaシーンから情報を得て判断し次のアクションを行えるようにします。操作するだけなら「スクリプトを書いてください」という指示だけで実現できそうですが、戻り値を受け取ったり、フォーマット通りの動作をさせるためにそれぞれ関数化しておきます。

APIに送る関数の説明リストと関数本体をまとめてFunctionSetというクラスにしてみました。用意した関数は以下の5種です。

・マニュアルを検索する関数
・コントローラを選択する関数
・アトリビュートを指定値に設定する関数
・アトリビュートの現在値を取得する関数
・Pythonコードを実行する関数

関数はクラスにまとめることでPython組み込み関数「getattr」を使用して動的に呼び出せるようになります。

  1. import sys
  2. import traceback
  3. from typing import List
  4. from maya import cmds
  5.  
  6. class FunctionSet:
  7.  
  8. def __init__(self):
  9. self.functions = [
  10. {
  11. "name": "search_manual",
  12. "description": "リグの取り扱いマニュアルから関連する情報を検索します。マニュアルにはリグコントローラ名とその機能、その他の補助機能の概要が書いてあります。",
  13. "parameters": {
  14. "type": "object",
  15. "properties": {
  16. "query": {
  17. "type": "string",
  18. "description": "マニュアルからどのような内容の関連文書を取得したいかの検索語句",
  19. },
  20. },
  21. "required": ["query"],
  22. },
  23. },
  24. {
  25. "name": "select_ctls",
  26. "description": "指定した名前のリグコントローラを選択する。複数可。",
  27. "parameters": {
  28. "type": "object",
  29. "properties": {
  30. "ctl_list": {
  31. "type": "array",
  32. "description": "リグコントローラ名の配列",
  33. "items": {
  34. "type": "string",
  35. },
  36. },
  37. },
  38. "required": ["ctl_list"],
  39. },
  40. },
  41. {
  42. "name": "set_attribute",
  43. "description": "指定した名前のリグコントローラの、指定したアトリビュートを、指定した値に設定する。複数可。コントローラを選択している必要はない。",
  44. "parameters": {
  45. "type": "object",
  46. "properties": {
  47. "attribute_data_list":{
  48. "type": "array",
  49. "items": {
  50. "type": "array",
  51. "items": {
  52. "ctl_name":{
  53. "type": "string",
  54. "description": "リグコントローラ名",
  55. },
  56. "attribute_name":{
  57. "type": "string",
  58. "description": "アトリビュート名",
  59. },
  60. "value":{
  61. "type": "string",
  62. "description": "設定値",
  63. },
  64. },
  65. "minItems": 3,
  66. "maxItems": 3
  67. },
  68. },
  69. },
  70. "required": ["attribute_data_list"],
  71. },
  72. },
  73. {
  74. "name": "get_attribute_value",
  75. "description": "指定した名前のリグコントローラの、指定したアトリビュートの値を取得する。複数可。コントローラを選択している必要はない。",
  76. "parameters": {
  77. "type": "object",
  78. "properties": {
  79. "attribute_data_list":{
  80. "type": "array",
  81. "items": {
  82. "type": "array",
  83. "items": {
  84. "ctl_name":{
  85. "type": "string",
  86. "description": "リグコントローラ名",
  87. },
  88. "attribute_name":{
  89. "type": "string",
  90. "description": "アトリビュート名",
  91. },
  92. },
  93. "minItems": 2,
  94. "maxItems": 2
  95. },
  96. },
  97. },
  98. "required": ["attribute_data_list"],
  99. },
  100. },
  101. {
  102. "name": "exec_code",
  103. "description": "pythonスクリプトを実行します。",
  104. "parameters": {
  105. "type": "object",
  106. "properties": {
  107. "code": {
  108. "type": "string",
  109. "description": "pythonコード",
  110. },
  111. },
  112. "required": ["code"],
  113. },
  114. },
  115. ]
  116.  
  117.  
  118. # 既存の知識に無い部分を補う
  119. def search_manual(self, query:str):
  120. """ マニュアルから検索語句(query)に該当する箇所を取得する """
  121. # 次項で
  122. pass
  123.  
  124. # Mayaから情報を得る/操作する
  125. def select_ctls(self, ctl_list:List[str]):
  126. """ 指定したノードを選択する """
  127. msg = []
  128. for ctl in ctl_list:
  129. if not cmds.objExists(ctl):
  130. msg.append("{}というコントローラは無い。".format(ctl))
  131. if msg:
  132. msg.append("マニュアルを確認し正しいコントローラ名を得る必要がある。")
  133. return "\n".join(msg)
  134. cmds.select(ctl_list)
  135. return "{}を選択した。".format(",".join(ctl_list))
  136.  
  137. def set_attribute(self, attribute_data_list:List[List]):
  138. """ 指定したノードの指定したアトリビュートを指定値にセットする """
  139. msg = []
  140. for ctl, attr, value in attribute_data_list:
  141. full_attr = ctl + "." + attr
  142. if not cmds.objExists(ctl):
  143. msg.append("{}というコントローラは無い。マニュアルを確認し正しいコントローラ名を得る必要がある。".format(ctl))
  144. elif not cmds.objExists(full_attr):
  145. msg.append("{}に{}というアトリビュートはない。マニュアルを確認し正しいアトリビュート名を得る必要がある。".format(ctl, attr))
  146. else:
  147. if type(value) == str:
  148. value = self._convert_str(value)
  149. if type(value) == str:
  150. cmds.setAttr(full_attr, value, type="string")
  151. else:
  152. cmds.setAttr(full_attr, value)
  153. msg.append("{}を{}に設定した。".format(full_attr, value))
  154.  
  155. return "\n".join(msg)
  156.  
  157. def get_attribute_value(self, attribute_data_list:List[List]):
  158. """ 指定したノードの指定したアトリビュートの現在値を取得する """
  159. msg = []
  160. for ctl, attr in attribute_data_list:
  161. full_attr = ctl + "." + attr
  162. if not cmds.objExists(ctl):
  163. msg.append("{}というコントローラは無い。マニュアルを確認し正しいコントローラ名を得る必要がある。".format(ctl))
  164. elif not cmds.objExists(full_attr):
  165. msg.append("{}に{}というアトリビュートはない。マニュアルを確認し正しいアトリビュート名を得る必要がある。".format(ctl, attr))
  166. else:
  167. value = cmds.getAttr(full_attr)
  168. msg.append("{}の現在の値は{}。".format(full_attr, value))
  169.  
  170. return "\n".join(msg)
  171.  
  172. def exec_code(self, code:str):
  173. """ pythonコードを実行する関数 エラーが発生したらエラー文を返す """
  174. try:
  175. exec(code, {'__name__': '__main__'}, None)
  176. return 'pythonコード実行完了'
  177. except Exception:
  178. exc_type, exc_value, exc_traceback = sys.exc_info()
  179. trace = traceback.format_exception(exc_type, exc_value, exc_traceback)
  180. return "{}: {}: {}".format(exc_type.__name__, trace[-2].strip(), exc_value)
  181.  
  182. def _convert_str(self, s:str):
  183. """ 文字列の内容に基づいて適切な型(bool, int, float, str)に変換する """
  184. if s.lower() == "true":
  185. return True
  186. elif s.lower() == "false":
  187. return False
  188. try:
  189. return int(s)
  190. except ValueError:
  191. pass
  192. try:
  193. return float(s)
  194. except ValueError:
  195. pass
  196. return s
FunctionSetの定義

3. 外部の文書を参照する仕組み(RAG)

少し本題から逸れて、検索の仕組みの話になります。リグの各コントローラ、アトリビュートの正式名称や、使い方を参照させるため仕様書を用意しておきます。Function callingの話だけでいいよ!という方は次項 [4. エージェント実装] まで飛ばしてしまって大丈夫です。

ここではRAG (Retrieval-Augmented Generation)というフレームワークで外部の文書を参照させます。文書は細かく区切って事前に埋め込みベクトル(以下embedding)にしておき、推論時に類似度検索を行い関連文書を取得するという構造です。簡単に図式化すると次のようになります。


embeddingの取得には同じくOpenAI APIで「text-embedding-ada-002」というモデルを使用します。文字列を渡すとその意味を内包した1536次元のベクトルを返してくれます。お金はかかりますがタダに等しいくらい安いのできっとあまり気にならないと思います。

OpenAI Embeddingについて詳細は以下をご確認ください。
Embeddings - OpenAI API
openai-cookbook/examples/Question_answering_using_embeddings.ipynb

以下のサンプルスクリプトは ["動物","植物","乗り物","建物"] と”猫”をそれぞれembeddingにしてコサイン類似度を計算し、スコアが高い順に並べたものです。コサイン類似度というのは要は内積です。完全一致していれば1.0、真逆であれば-1.0になるので、数値が大きい方が類似しているということになります。今回はやりませんがユークリッド距離で比較する場合は小さい方が類似しているということになるのでご注意下さい。

  1. import numpy as np
  2. import openai
  3.  
  4. def get_embedding(text:str, engine="text-embedding-ada-002", **kwargs):
  5. """ 与えられたテキストの埋め込みベクトルを取得する """
  6. return openai.Embedding.create(input=[text], engine=engine, **kwargs)["data"][0]["embedding"]
  7.  
  8. def cosine_similarity(a, b):
  9. """ 2つのベクトル間のコサイン類似度を計算する """
  10. return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
  11.  
  12. # テキストのリスト
  13. texts = ["動物","植物","乗り物","建物"]
  14.  
  15. # 各テキストの埋め込みベクトルを取得し、辞書のリストとして保存する
  16. embeddings = [{"text": t, "embedding": get_embedding(t)} for t in texts]
  17.  
  18. # クエリの埋め込みベクトルを取得する
  19. query_embedding = get_embedding("猫")
  20.  
  21. # クエリの埋め込みベクトルと各テキストの埋め込みベクトルとのコサイン類似度を計算する
  22. scores = [(data["text"], cosine_similarity(query_embedding, data["embedding"])) for data in embeddings]
  23.  
  24. # 類似度の高い順にソートする
  25. sorted_scores = sorted(scores, key=lambda x: x[1], reverse=True)
  26.  
  27. # ソートされた結果を表示する
  28. for s in sorted_scores:
  29. print(s)
  30.  
  31. # 出力:
  32. # ('動物', 0.8673454180180918)
  33. # ('植物', 0.8389430771237997)
  34. # ('建物', 0.8142869467061629)
  35. # ('乗り物', 0.7896206872541609)
embeddingサンプル


「猫」と最も近いのは「動物」という結果が出ました。これを利用して、既存の文書に対し意味的な検索を行います。

仕様書は単純なテキストファイル(.txt)で作成します。今回は既存の文書ではなくembedding前提でこれから作成しますので、文の長さやトピックを意識して区切り方を自分で考えながらテキストを用意します。この点はかなり試行錯誤のしがいがあるのですが、ひとまず意識するところは「固有名詞(ノード名など)には一般的な表現で説明を付け加える」「関係ない話を同じ区切りの中に混ぜない」「長すぎず短すぎず」あたりを意識して用意していきます。

後ほどembeddingにする際に三重改行(\n\n\n)で切り分けようと思いますので、区切り箇所にのみ空の行が2つ挟まるように書いておきます。以下は用意した仕様書の一部になります。

リグ仕様書

次のスクリプトでテキストファイルを読み込み、"\n\n\n"でsplitしてそれぞれembeddingにします。取得したembeddingと元のテキストのペアをベクトルストアとしてjsonファイルで保存します。ここで作成するベクトルストアは { "content": “元のテキスト”, "embedding": [1536次元のベクトル] } を複数連ねた配列で、非常にシンプルな構造です。本格的な実装をするならFAISSなどのベクトル検索ライブラリを使用すべきところかと思いますが、今回のような単純なテストではこちらで十分かと思います。

  1. from pathlib import Path
  2. from typing import List, Dict
  3. import json
  4. import openai
  5.  
  6. def get_embeddings(
  7. list_of_text: List[str], engine="text-embedding-ada-002", **kwargs
  8. ) -> List[List[float]]:
  9. assert len(list_of_text) <= 2048, "The batch size should not be larger than 2048."
  10.  
  11.  
  12. # replace newlines, which can negatively affect performance.
  13. list_of_text = [text.replace("\n", " ") for text in list_of_text]
  14.  
  15.  
  16. data = openai.Embedding.create(input=list_of_text, engine=engine, **kwargs).data
  17. return [d["embedding"] for d in data]
  18.  
  19. def texts_to_vectorstore(text_path: Path):
  20. """ 指定されたテキストファイルをベクトルストアに変換 """
  21.  
  22.  
  23. # テキストファイルを読み取る。
  24. with open(text_path, 'r', encoding="utf-8") as f:
  25. text = f.read()
  26.  
  27.  
  28. # テキストを個々のテキストに分割。
  29. texts = [t.strip() for t in text.split("\n\n\n") if t.strip()]
  30.  
  31. # 各テキストの埋め込みを取得。
  32. embeddings = get_embeddings(texts)
  33.  
  34.  
  35. # ベクトルストアデータを準備。
  36. vector_store = []
  37. for i, t in enumerate(texts):
  38. vector_store.append(
  39. {
  40. "content": t,
  41. "embedding": embeddings[i],
  42. }
  43. )
  44.  
  45.  
  46. # ベクトルストアをJSONファイルとして保存。
  47. with open(text_path.with_suffix('.json'), mode="w", encoding="utf-8") as f:
  48. json.dump(vector_store, f, ensure_ascii=False)
  49.  
  50.  
  51. texts_to_vectorstore(Path("./rig_manual.txt"))
ベクトルストア作成スクリプト

上記スクリプトはベクトルストア作成時に一度だけ使うスクリプトです。MayaではなくPythonのvenvで事前に実施しておきます。エージェントの推論時には次のようなベクトルストアの読み込みや検索を行うクラスを別途用意します。

  1. from pathlib import Path
  2. from typing import List, Tuple
  3. import json
  4. import numpy as np
  5. import openai
  6.  
  7. class VectorStore:
  8. """
  9. ベクトルストアを管理するクラス。テキストの埋め込みを取得し、類似度検索を行う機能を提供。
  10. """
  11.  
  12.  
  13. def __init__(self, path: Path) -> None:
  14. with open(path, mode="r", encoding="utf-8") as f:
  15. self.vector_store = json.load(f)
  16.  
  17.  
  18. def _get_embedding(self, text: str, engine="text-embedding-ada-002", **kwargs) -> List[float]:
  19. """ 指定されたテキストの埋め込みを取得 """
  20. text = text.replace("\n", " ")
  21. return openai.Embedding.create(input=[text], engine=engine, **kwargs)["data"][0]["embedding"]
  22.  
  23.  
  24. def _cosine_similarity(self, a, b):
  25. """ 2つのベクトル間のコサイン類似度を計算 """
  26. return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
  27.  
  28.  
  29. def similarity_search(self, query: str, k: int = 4) -> List[Tuple]:
  30. """ 与えられたクエリに基づいて、ベクトルストア内のテキストとの類似度を計算し、上位k件の最も類似度が高いテキストとそのスコアを返す。 """
  31. query_embedding = self._get_embedding(query)
  32. scores = [(data, self._cosine_similarity(query_embedding, data["embedding"])) for data in self.vector_store]
  33. sorted_scores = sorted(scores, key=lambda x: x[1], reverse=True)
  34. return sorted_scores[:k]
ベクトルストアを管理するクラス

また、前項で作成した関数セットにこちらのベクトルストアを内包し、検索関数を以下のように更新しておきます。

  1. class FunctionSet:
  2.  
  3. def __init__(self, manual_vs:VectorStore):
  4. self.manual_vs = manual_vs
  5.  
  6. self.functions = [
  7. ## 中略 ##
  8. ]
  9. # 既存の知識に無い部分を補う
  10. def search_manual(self, query:str):
  11. """ マニュアルから検索語句(query)に該当する箇所を取得する """
  12. search_result = self.manual_vs.similarity_search(query)
  13. return "\n".join([sr[0]["content"] for sr in search_result])
  14.  
  15. ## 以下略 ##
FunctionSetの定義(更新差分)


4. エージェント実装

本題に戻ります。前項で用意したベクトルストア付き関数セットを与えてエージェントクラスを作り、Maya上で動作させてみます。ここまで来ればすでにコア要素は出来ている状態ですのであとは問い合わせに対して最終的な答えが出るまで繰り返しAPIコールを回させるだけです。

  1. from typing import List
  2. import json
  3. import openai
  4.  
  5. class Agent:
  6.  
  7. def __init__(self, function_set:FunctionSet):
  8. self.func_set = function_set
  9. self.system_prompt = """あなたはある特定のキャラクターリグの取り扱いをサポートする優秀なAIです。
  10. 質問に対し、必ずマニュアル内を検索し該当する記述があるかを確認してから返答してください。
  11. 一般的な既存の知識だけで返答することがないよう徹底してください。
  12. 不足している情報があれば無理に回答せず、ユーザーへ質問を返してください。"""
  13.  
  14. def __call__(self, query:str, messages:List=[], model:str="gpt-3.5-turbo-0613", max_call:int=10, **kwargs):
  15.  
  16. print("-------------")
  17. print("user: ", query)
  18. if len(messages) == 0:
  19. # メッセージが空だった場合(会話の最初だった場合)はmessagesにシステムプロンプト追加
  20. messages.append({"role": "system", "content": self.system_prompt})
  21. # ユーザーの問い合わせをmessagesに追加
  22. messages.append({"role": "user", "content": query})
  23.  
  24. for i in range(max_call):
  25.  
  26. # ターン数表示
  27. print("---- [{}] ----".format(i))
  28.  
  29. # APIリクエスト
  30. finish_reason, message = self.chat_completion_with_functions(messages, model, **kwargs)
  31.  
  32. if finish_reason == "function_call":
  33. ### function 使う場合 ###
  34.  
  35. # 関数名と引数取得
  36. func_name = message["function_call"]["name"]
  37. arguments = json.loads(message["function_call"]["arguments"])
  38. print("call <{}> args={}".format(func_name, arguments))
  39.  
  40. # 関数セットから関数を取得し、引数を渡して実行
  41. func_returns = getattr(self.func_set, func_name)(**arguments)
  42. print(func_returns)
  43.  
  44. # messagesに返答と関数の実行結果を追加
  45. messages.append(message)
  46. messages.append({"role": "function", "name": func_name, "content": func_returns})
  47. else:
  48. ### function 使わない場合 ###
  49.  
  50. # messagesに返答を追加して終了
  51. messages.append(message)
  52. print("agent: ", message["content"].strip())
  53.  
  54. break
  55.  
  56. return messages
  57.  
  58. def chat_completion_with_functions(self, messages:List, model:str, **kwargs):
  59. response = openai.ChatCompletion.create(
  60. model=model,
  61. messages=messages,
  62. temperature=0,
  63. top_p=1,
  64. functions=self.func_set.functions,
  65. function_call="auto",
  66. **kwargs
  67. )
  68.  
  69. finish_reason = response.choices[0]["finish_reason"]
  70. message = response.choices[0]["message"]
  71. return finish_reason, message
エージェントクラス

ここまでで作成したVectorStore、FunctionSet、Agentクラス、そして以下のスクリプトを全てひとつにまとめてMaya上で実行します。ベクトルストアのファイルパスは適宜変更してください。

  1. from pathlib import Path
  2. from maya import cmds, OpenMayaUI
  3. from PySide2 import QtWidgets
  4. from shiboken2 import wrapInstance
  5.  
  6. class InputDialog(QtWidgets.QDialog):
  7. def __init__(self, parent=None):
  8. super(InputDialog, self).__init__(parent)
  9. self.line_edit = QtWidgets.QLineEdit(self)
  10. self.line_edit.setStyleSheet("QLineEdit {font-size: 15px;}")
  11. self.ok_button = QtWidgets.QPushButton('Send', self)
  12. self.ok_button.clicked.connect(self.accept)
  13. self.layout = QtWidgets.QVBoxLayout(self)
  14. self.layout.addWidget(self.line_edit)
  15. self.layout.addWidget(self.ok_button)
  16.  
  17. def show(self):
  18. super(InputDialog, self).show()
  19. if self.exec_():
  20. return self.line_edit.text()
  21.  
  22. if __name__ == "__main__":
  23.  
  24. # VectorStore読み込み
  25. manual_vs = VectorStore(Path("./rig_manual.json"))
  26.  
  27. # 関数セットのインスタンスを作成
  28. function_set = FunctionSet(manual_vs=manual_vs)
  29.  
  30. # エージェントのインスタンスを作成
  31. agent = Agent(function_set=function_set)
  32.  
  33. # 初期の空メッセージリスト
  34. messages = []
  35.  
  36. input_dialog = InputDialog(wrapInstance(int(OpenMayaUI.MQtUtil.mainWindow()), QtWidgets.QWidget))
  37.  
  38. while True:
  39. user_input = input_dialog.show()
  40.  
  41. if user_input is None:
  42. break
  43.  
  44. if not user_input:
  45. continue
  46. # エージェントへ問い合わせて更新されたメッセージを受け取る
  47. messages = agent(query=user_input, messages=messages, model="gpt-4-0613", max_call=20)
  48.  
  49. cmds.refresh()
実行



ScriptEditorを見ていると、ひとつの問いに対し関数の実行を複数行った後、最終的な回答へ至っていることが分かります。1つ目の「ポニテのシミュオンになっている?」という問いに対しては、一度は適当なノード名とアトリビュート名で関数を実行した結果エラーとなったため、マニュアル内を検索し正しいノード名とアトリビュート名を取得しています。2つ目の「オンにしてベイクまで」という指示に対しては直前の文脈から正しいアトリビュートでオンにすることに成功していますが、その後に存在しないベイク関数を実施しようとして失敗し、再度マニュアルを確認しにいっています。下の画像の赤字が言語モデルの“判断”の部分、青字が関数の結果の部分です。

マニュアル検索の精度にだいぶ左右されそうですが、ユーザーの大雑把な指示だけでここまで到達できる点は大きな進歩のように思います。


ここまでの実装を整理したものを以下のリポジトリに置いておきます。サンプルとして、mGear 4.1.0の「Biped Template, Y-up」用に作成したマニュアルテキストもリポジトリに含んでいます。ベクトルストアの作成~エージェント起動がすぐに試せる状態になっていますので、ご興味があればご確認いただけますと幸いです。

GitHub - akasaki1211/maya_agent at aicg04

5. 最後に

第1回に引き続きGPTを取り上げましたが、質問に対する返答やスクリプト生成だけではなく、脳内の思考をシミュレートするような意思決定の道具としても使えることがわかりました。また、RAGという知識を補足するフレームワークにより、固有の目的に対して特化した能力を付加することもできそうでした。

とはいえかなり丁寧に調整をしないと思い通りに動いてくれないことも多々あります。今回のテストでは外部の情報が少量でしたが、情報量が増えると検索の精度も落ち始めます。曖昧かつ複雑な指示ではGPT-4レベルでようやくまともに対応できるかどうかという状態ですし、API料金(与えられた関数はモデルが学習時に使用した構文でシステムメッセージに挿入されるため関数を増やすほど消費トークンが増大します)や生成速度も相まって、現状ではまだまだ実用的ではないかもしれません。ただしこれらはおそらく時間の問題で、もっと精度の高い言語モデルがローカルでサクサク動くようなことになれば、それこそ会話でアプリケーションを操作する日も遠くないかと思います。

最後までお読みいただき、ありがとうございます。今回はMaya上で半自律型のサポートエージェントを動かしてみました。次回以降もさまざまなAI技術と3DCGの応用について探求していきますので、お楽しみに!