
第15回 AIエージェントの原理
ここしばらく慧仕(Huìshì)を作っています。
Huishiは「キャラクタとRAGを持った汎用AIアシスタントフレームワーク」として作っています。 そして、その原型は前に作っていた"CatchUp"です。
それまで「会計システム」みたいなエントリ並んでいて、いきなりAIエージェントの話ですから、知らない人は不思議に思うかも知れません。 私の背景の話はさておき、実はそれほど難しいわけではないので軽率に作り始めたのですが、今日はこの実はそれほど難しいわけではないという話をします。
私の技術背景についてはあんまり書いてもしょうがないですが、
こっちに詳しく書いてます。
とは言え、「あの時代」とは色々舞台装置が変わりましたし、その当時の知識がそのまま使えるわけではありません。
ただ当時萌芽のあった「deep understanding」については今でも応用の利く知見が少なからずあります。 Huishiは随所にその当時の知見を使っていたりします。
LLMと言えばエンジニアにとってはまずはChatGPTです。 「いや、今時はあれやらこれやら」という話がありますが、別に衒学したいわけじゃないですから、気になる人は「s/ChatGPT/そのなにか/g」で構いません。 ここで重要なのは「チャットUI」ということです。
チャットUIで会話していると、ちゃんと文脈を理解しているかのように見えます。 と言うか、実際に文脈に則って会話ができています。
かつてのAIブームの時は「文脈理解」は凄く大きなテーマで、かなり難しいものだとゆー話になっていたのでした(正確に言えば「ブーム」の中の人達は興味すら持ってなかった)。 「文脈理解」には「短期記憶の扱い」だけではなく、「時系列」が大事ということで、deep understanding上の難問でした。
しかし、今は「あたりまえ」になってしまっています。
ローカルでLLMを動かした人、特に非チャットUIで動かしてみたことがある人はわかると思いますが、LLMは基本的には「ステートレス」です。 つまり「状態」を持っていません。 直近のターンのことすら覚えていません。
「文脈理解」には「状態」が必要なのですがLLMは「状態」を持っていないため、「文脈」を処理する能力をLLM自身は持っていません。
実はAIエージェントを理解する上での第一のポイントは、
ということです。厳密に言えば今のLLMはプロンプトキャッシングとかあって結果的に「状態」があるのですが、それは本質ではありません。 出力に直接関係のない「効率化」のためにあるだけで、思考そのものには関係がありません。
LLMと言うか今時のAIは「プロンプト」が必要とされています。 「プロンプトエンジニアリング」という言葉があるくらい、重要なわけです。
解像度低く今時のAIを見ていると、LLMを使うというのは「プロンプトを与えてデータを処理する」という誤解をします。 これ、実際にAPIを叩いてる人ならわかると思いますが、明確に「間違い」ですねw 「プロンプト」という言葉は曖昧に使われがちですが、実際にはシステムプロンプト・ユーザープロンプト・履歴・補助情報(RAG)などをすべて含む「LLMに渡される入力全部」のことです。
ですから、「プロンプト」というのはプログラムのようなものと言うよりは、オブジェクト指向用語で言うところの「メッセージ」だと思った方がわかりいいと思います。 そしてLLMはその「メッセージ」の中身を「いい感じ」に処理してくれる何かであると。
プロンプトとはこのようなものなので、「処理に必要なもの」を何でも詰め込みます。 もちろん「これは何のデータです」とか「こういった処理をしてください」といった「指示(プロンプト)」も併せて与えます。 つまり、人に仕事を頼む時に「これやっといて」だけではなくて、「この資料使ってね」とか「この資料はこーゆー意味でこう使うのだよ」というようなことをしますが、それと全く同じだということです。
RAG(Retrieval-Augmented Generation)も結局は同じで、「検索して得た情報をプロンプトに詰めて渡す」という技術です。 原則はこれだけです。 ChatGPTのようなシステムだと1ターンの中でウェブを引っぱたりしていますが、それは「1ターン」で複数回LLMを呼び出すとか、モデルが特別なことができるようになっているという話でしかなく、細かく見ればやっているのはこれだけです。 もちろんこれはあくまでも「原則」であって、効率良くやるような工夫はされているでしょうが、基本原理はこういったところです。
LLMはLLMとして学習した「知識」を持っています。 また「ありがちの処理」はなんとなく定型処理として把握していたりします。 ですから、雑な指示雑なデータでも何となくいい感じにやってくれますし、「賢いモデル」であればそこに多くのことが期待できます。 「コーディング」のように知能そのものを求める処理の時は「賢いモデル」を使うことは重要です。
でも「小さいモデル」であっても、データをちゃんと与えるとか、指示を明確にするというような工夫をすれば、(地頭のいいモデルであれば)いい感じのことをしてくれたりもします。
先程、LLMは状態を持たないと言いました。 ですから、もし状態が必要であれば、そういったものもひっくるめてLLMに渡してやる必要があります。
チャットUIが文脈を理解しているように見えるのは、実にプロンプトの一部として「それまでの会話」を渡しているからです。
つまり、
直近の会話履歴はこれだよ、今回はこう言われたよ。これを踏まえていい感じの回答作ってね
という要求がプロンプトに入っているわけです。 「状態」を把握しているのはLLM自体ではなくて、チャットを実現しているサーバアプリの方です。
「なんだ簡単じゃん」と思ったら、適当にAPIを叩いて作ってみるといいと思います。 実際ちょっと作って動かすだけなら簡単ですから。 うまく動くと結構感動します。
とは言え、実用にするにはちょっと厄介な話があります。
と言うのも、APIの課金や処理時間はプロンプトの長さと関係します。 雑に言えば、プロンプトが長いほど処理時間はかかりますし、APIの課金もプロンプトのトークン数に依存します。
また、プロンプトの長さには限界があります。 近頃のLLMはプロンプトの長さを競うみたいなところがあるのですが、それでも「高々有限」です。
また、LLMのメモリ消費量は
モデルのメモリ + プロンプト処理のメモリ
という関係があります。 そして、「プロンプト処理のメモリ」はプロンプトの長さやモデルの規模に従って増えます。 雑に言えば、プロンプトが長いほどメモリを食います。
そうなると、履歴を短くするとかという対処が必要になります。 簡単な実装であれば、「直近○件」みたいなことでも良いのですが、そうすると「それより前」は思考に反映されなくなります。 この辺をどうするかは、実装の「腕」というところですね。 Huishiの場合は「ある程度長くなると要約して保持する」という方式にしてあります(ChatGPTもそうらしい)。 これについては機会があれば詳しく書くかも知れません。
ここまで説明すれば「エージェント」というのはどう実装されるか見当がつくと思います。
要するに、
これの組み合わせです。
RAGも同じで、どうにかしてデータを集めてプロンプトを構成してLLMに食わせる。 基本的にはこれだけです。 Huishiは「長期記憶」を持っていますが、これも履歴情報を工夫して、必要な処理の時にプロンプトに入れてLLMに食わせているだけです。
ですから、ChatGPTでも使って仕事ができる人で何らかのプログラムが書ける人であれば、誰でもエージェントを作ることが可能です。
言語もPythonである必要はありません。 APIを叩いてデータ処理ができれば何でも問題ありません。 HuishiはJavascriptで書いてますし、MastraはTypeScriptのようです。 AIで出遅れ気味のRubyがこの方面で復権してもいいんじゃないかと思います。
Electronでアプリ作りたければ、PythonよりもJavascript系の方が楽でしょうね… と言うか、Huishiはそれも考えてJavascriptにしました。 クライアントサイドで動かす可能性とか考えればその方が楽ですから。
このように、エージェントを作ることそれ自体にはあまり困難はありません。 処理パイプラインに従って、データを右から左に動かす、適当なプロンプトを構成する… だけです。 そういった意味では、「たったこれだけ」ですから基本的なところの難しさは全くありません。 軽率に誰でも作ってみると良いと思います。
とは言え、実装を実際にするとなると、結構厄介な問題がいくつかあります。 項目挙げてみますと、
というような問題があります。
ある程度様子がわかって来ると、LLMを「万能関数」のノリで使えるようになり、それに慣れて来ると色々見通しができるようになるのですが、これらの問題が普通のプログラミングと勝手の違うものにしてしまいます。
LLMは「プロンプトを見て自分の思うように」動きます。 プロンプトをちゃんと書けばちゃんと動いてくれますが、それは常にそうだと限りません。 つまり動作の「再現性」がありません。 うまく動いていたものが突然動かなくなる、逆に動かないで悩んでいたものが「何もしないのに動く」ということが起きる。 全てが確率的と言っても良いです。 実際に処理しているサーバに「ガチャ」要素があるのかも知れません。
もちろんプロンプトを調整すると、ある程度は制御できます。 しかし、それでも決定論的になってくれるわけではありません。 また、gdgdと細かい指定を書いても、その通りに動いてくれる保証はありません。 逆に「うるせーよ」とばかりに無視されたりもします。 「チャット」でいい感じで扱える人でも、なかなか思う通りにできなかったりします。 この辺を上手くやるのが「プロンプトエンジニアリング」なのですが、「AIなんだからわかれよ」とか毒づきたくなります。
同じように、「フォーマットのある出力」が苦手です。
雑に指定してもそれなりに出してくれる時もあれば、細かく指定しても「そこ?」みたいな出力をすることもあります。
JSONで出して欲しいと指定しても、何だかわけのわからないものを出したりします。
JSON.parse()
に食わせると例外になってしまうことがしばしばあります。
YAMLの方が負荷が軽いという話もありましたが、だからと言って複雑なデータ構造が作れるとは思わない方が良いです。 特に値にテキストを含んでいると面倒なことになります。quoteが必要な時にquoteしないことが多いです。
また、Geminiはフォーマット付き出力という機能があるのですが、
という厄介さがあって、飛びつく程ではありません。 Geminiしか使わないんであればいいんですけどねー。 指定のしかたとかMCPみたいに規格的なものになってりゃいいんですが、それもないし。
定型出力が難しいとなると、ハンドリングするデータは「特別な構造を持たない単なるテキストデータ」になりがちです。 そしてLLMが得意とするのは本来そういったデータですから、結局そういったものを使うことになります。 そうなると、常に「テキスト処理」をすることになります。
プログラムで扱うことを思うと、「人間が書いたテキスト」も「LLMが出力したテキスト」も同じ程度に扱いが厄介です。 結局自然言語処理をするということになります。 自然言語処理しなくて済むためには、定型出力を期待することになって…となります。 つまり自然言語処理は不可避です。
自然言語処理をする一番手軽で有効な手段はLLMを呼び出すことです。 効果も大です。 意味の解釈も「さすが」と感じさせるものがあります。 「意図を読んでテキストを整える」とかLLMの独壇場とも言えます。 古典的なテキスト処理の手法を使う手もなくはないですが、入力データが人間の自然言語だったりLLMだったりするので、これだけでも結構なコードになってしまいます。
となると、何かあればLLMを呼び出すという実装になります。 ところがLLMを呼び出すのは、クラウドのAPIであろうとローカルであろうと、結構重い処理となります。 軽率に呼び出したいのに軽率に呼び出せない。 なかなか悩ましいことになります。
呼び出す回数はレスポンスとコストに直結しますから、節約しようとしていろんな処理を一度にさせることを考えたこともありました。 そうするとプロンプトが長くなり過ぎて、他の害が出て来ることがあります。 また、知能の低いLLM(小規模のローカルLLMとか)はそういったことがまるでダメだったりします。 確実に動かすためには、「いかにプロンプトを軽くするか」ということになってしまい、それはそれで厄介の元です。
Huishiではこの辺で何度も心折られた結果、「front matter付きMarkdown」を主に扱うデータとしました。 front matterの中身はYAMLですが、簡単な構造のYAMLであれば破綻することはあまりありません(皆無でもない)。 主なデータはテキスト(Markdown)なので、この辺が程々の妥協点じゃないかなと思ってます。
AIエージェントは個々のエージェントをいい感じに組み合わせて実行します。 たとえばHuishiで「時事ブログを書く」という処理のためのタスクリストは、
name: blog
description: >
「ブログの執筆」を行います。
ユーザーが「お題」を挙げるので、それに従ったブログを事実に従ったエントリを書きます。
「ニュース」や「動向」を元に執筆します。
tasks:
- task: analize
description: ユーザの意図を解析する
inputs:
- ユーザーの入力
outputs:
- ユーザの意図・関心
- task: searchArticles
description: 記事を検索して一覧を出力する
inputs:
- ユーザの意図・関心
outputs:
- 検索された記事
- task: section.plan
description: ブログの構成を考える
inputs:
- ユーザの意図・関心
- 検索された記事
outputs:
- 構成
- task: writing
description: ブログを執筆する
inputs:
- ユーザの意図・関心
- 検索された記事
- 構成
outputs:
- ブログ本文
こんな感じです。 記述のルールについての説明は割愛しますが、なんとなく何をやっているかはわかるんじゃないかと思います。 この例(実装)だと、ここに書かれた処理を順番に処理して行くだけのように見えますね。
ここにある「task:」は単なるラベルであって、実際に処理をするエージェントを指定していません。 処理をディスパッチする処理では、「description」を見て何をするか把握して、エージェントのカタログを参照し、具体的なエージェントを選択して呼び出し(invoke)ます。
この辺のやり方は何がベストということはありません。 この辺については、
に得失がまとめてあります。
ただ、これは「汎用フレームワーク」の話であって、「とにかく動くもの」「確実に動くもの」を求めるのであれば、こういった「LLMの知性」を期待するような処理でなくて、「そういったプログラムを書く」ことで十分機能するものになります。 つまり「static routing」ですね。 Huishiの前進のCatchUpはこの方法を取っているため、「処理のフロー」が存在していました。
この頃は「static routing」でした。 そして、「知的RSSリーダ」としてはこれで十分でした。 「汎用フレームワーク」を目指さないで「目的の明確なエージェント」を作るのであれば、この方が安全確実です。 routingの負荷がないのでレイテンシの点でも有利です。 何より実装が簡単です。
ごくざくっとした感じの話なので、細かいことはオープンソースで出回っているエージェントのコードでも見てください。
でもたったこれだけわかっていると自分でも実装できたりしますし、この実装は他のプログラムの実装とはまた違った面白さがあります。 何かやりたいなーと思ったら軽率に実装してみると楽しいですし有用だと思います。