GoとローカルLLMでAIエージェントを作って学んだ判断と実行と再開の仕組み

GoとローカルLLMを使い、フォルダ内の資料を調べて質問に答える小さなAIエージェントを作りました。

作ったのは local-agent-go です。ローカルLLMの実行にtensai、処理の進行管理にTemporalを使っています。

このサンプルでは、LLMが次の操作をJSONで選び、Goが検証して実行します。実行結果をLLMに渡して判断を繰り返し、回答まで進めます。Temporalは、その間の実行履歴と再試行を管理します。

実装を通して整理できた、LLMの判断、ツールの実行、停止後の再開という3つの仕組みを紹介します。

資料を調べて答えるエージェントを作る

調査対象には、架空の「社内資料管理API」の資料を用意しています。構成を説明する architecture.md や、起動手順を説明する setup.md などのMarkdownファイルです。

たとえば、次のように質問します。

./local-agent-go -question '資料に書かれた社内資料管理APIの起動手順とヘルスチェック方法を教えて'

エージェントは資料を読み、起動手順やヘルスチェックの方法を回答します。読むファイルや順序はLLMが選択します。

資料中のAPIやデータベースは架空のプロジェクトの説明です。このエージェントを動かすために、それらを起動する必要はありません。

LLMが操作を選びGoが実行する

LLMが選べる操作は、次の4つに絞っています。

操作役割
list_files指定したディレクトリのファイル一覧を取得する
read_file指定した資料ファイルを読む
say_hello設定済みのローカルAPIに GET /hello を送る
finish最終回答を返して終了する

今回の実装では、モデルのネイティブなtool_calls機能を使わず、操作を表すJSONを返す方式にしています。

たとえば、資料を読む操作は次の形です。

{"action":"read_file","path":"setup.md"}

Go側では、この出力を次の構造体で受け取ります。

read_file が選ばれたら、Goがファイルを読みます。その内容を会話履歴に追加して、再びLLMに次の操作を問い合わせます。必要な情報が集まれば、LLMは finish と回答文を返します。

処理の流れは次のようになります。

これは操作の流れを示した図です。不正なJSONへの修正要求や、API結果を直接返す分岐については後で説明します。

この構造にすると、LLMに渡す役割と、Goで実装する役割が具体的になります。LLMは次に何をするかを選び、Goは用意した処理を実行します。

LLMが返したJSONを検証してから実行する

LLMの出力を受け取ったら、まず parseDecision で検証します。

この関数では、JSONとして読み取れることに加え、未知のフィールドが含まれていないこと、後ろに別のJSONや説明が続いていないことも確認しています。操作名は4つの許可済みの名前と照合し、ファイル操作にはパス、finish には空でない回答を要求します。

たとえば、次の出力はJSONとしては正しくても、許可されていない操作なので拒否します。

{"action":"delete_file","path":"setup.md"}

ファイルのパスも検証します。資料ルートからの相対パスを使い、隠しパスや .. による移動などは受け付けません。

さらに、最初の判断でツールを使わずに finish を返した場合も拒否します。ただし、この条件で確認できるのは、ツールの観測結果が先にあることまでです。回答が資料に正確に裏づけられているかは、別途確認が必要です。

出力が不正だった場合、Decide はその出力とエラーの内容を履歴に追加し、正しいJSONを返すようLLMに指示します。初回を含め最大3回、全体で4分以内に収める実装です。

修正できなければ、形式エラーとして終了します。同じ入力に対するTemporalの自動再試行も打ち切ります。通信障害などに対する再試行とは、扱いを分けています。

プロンプトで出力形式を伝えたうえで、実行条件をGo側にも書く。この分担が、LLMの出力を実際の処理につなぐ基本になっています。

ツールが扱える範囲をコードで決める

ファイル読み取りは os.Root を使い、Worker起動時に指定した資料フォルダの中に制限しています。加えて、読み取れる対象にも条件を設けています。

  • UTF-8の .md と .txt を対象にする
  • 1ファイルは8 KiB以下にする
  • 一覧取得は1ディレクトリ200エントリ以下にする
  • 隠しパスやシンボリックリンクを拒否する

このサンプルには、ファイルへの書き込みやシェル実行の機能はありません。資料に起動コマンドが書かれていても、それを実行するツールは用意していません。

また、ファイルが見つからないときなどは、操作上のエラーを観測結果としてLLMに返します。LLMはその結果を受けて、別のファイルを選び直せます。

ここでも役割が分かれます。LLMには資料の選択を任せ、アクセス範囲や読み取り条件はコードで固定しています。

Temporalで実行履歴と再試行を管理する

判断と実行のループは、TemporalのWorkflowである Investigate に実装しています。外部とのやり取りはActivityに分けています。

処理Temporal上の役割
Investigate判断とツール実行の順序、終了条件を管理するWorkflow
DecideローカルLLMをHTTPで呼び出すActivity
Read資料の一覧取得や読み取りを行うActivity
SayHelloローカルAPIを呼び出すActivity

Workflowは、Activityの結果を受け取りながら次の処理を決めます。LLM呼び出しやファイル読み取りは、Activity側で行います。

この分離は、停止後の再開にも関係します。Workerが再起動すると、Temporalは実行履歴を再生し、完了記録のあるActivityについては記録済みの結果を使います。その結果から、会話履歴と進行位置が組み立て直されます。

たとえば、資料を読むActivityの完了が記録されていれば、履歴の再生時にはその読み取り結果が使われます。完了記録のない途中のActivityは、タイムアウト後に再実行される場合があります。

そのため、Temporalを使う場合も、外部APIが複数回呼ばれる可能性を考える必要があります。今後、更新系の操作を追加するなら、同じ依頼を繰り返しても重複処理にならない設計が必要です。

今回のコードでは、Activityの初回実行を含めた最大試行回数を3回に設定しています。一方、Workflow全体のLLM判断回数は既定で最大10回です。通信などの失敗に対する再試行と、次の操作を選ぶ判断ループは、それぞれに上限を設けています。

Workerを止めて再開の仕組みを確かめる

復旧の挙動を確認するため、ツール実行後に待機時間を入れるオプションを用意しています。Temporalとtensai、Workerを起動した状態で、次のように実行します。

./local-agent-go -id recovery-demo-1 -delay 30s -question '社内資料管理APIの構成と起動方法は?'

Temporal UIでActivityの完了とTimer待機を確認し、Workerを停止します。その後、同じ資料、モデル、Task Queueを指定してWorkerを再起動します。

ここで観察したいのは、完了記録のある処理の結果が再利用され、残りの処理が進むことです。質問CLIも停止した場合は、Workflow IDを指定して結果を取得できます。

./local-agent-go -mode result -id recovery-demo-1

この確認には、Temporal Serverが動き続けていること、または同じDBファイルを使って再起動することが必要です。起動手順の詳細はリポジトリのREADMEにまとめています。

また、実行中のWorkflowに対してコードを変更すると、既存履歴との互換性が問題になります。このサンプルにも、挨拶APIの結果を直接返す変更に対応するため、workflow.GetVersion による分岐が入っています。

APIの結果をそのまま返す場面もある

say_hello は、ローカルAPIから挨拶のメッセージを取得するツールです。新しいWorkflowでは、成功結果に空でない message があれば、その内容を直接返して完了します。

たとえば、APIが次のように返した場合です。

{"message":"Hello, World!"}

このメッセージの返却には、LLMによる文章の生成を挟みません。LLMは最初のツール選択を担当し、取得後の返却処理はGoが担当します。

資料を読んで質問に答える処理では、情報を整理するためにLLMを使います。定型のAPI結果を表示する処理では、取得した値をそのまま使えます。処理ごとにLLMへ任せる範囲を決める設計です。

実装を通して整理できた役割分担

今回のサンプルでは、LLM、Go、Temporalにそれぞれ役割を持たせました。

LLMは、質問と観測結果をもとに次の操作を選びます。Goは、その選択を検証し、許可したツールを実行します。Temporalは、処理の順序、実行履歴、再試行を管理します。

この役割分担をコードで追うことで、エージェントの動作を具体的な処理として理解できました。特に、出力の検証、終了条件、停止した処理の再開は、全体の動きを支える部分です。

現在の実装は、小さなテキスト資料を対象にした学習用の構成です。PDFやベクトル検索、複数利用者向けの権限制御は含んでいません。質問、文書の読み取り結果、回答はTemporalの履歴にも保存されます。

小さく範囲を絞ったことで、質問を受け取ってから回答するまでの流れと、それを進める各部品の役割を追えるサンプルになりました。

ソースコード

今回の記事で利用したソースコードは以下においてある。

GitHub – taako-502/local-agent-go: Go …

GitHub – taako-502/local-agent-go: Go …

Go + Temporal + Local LLMでAIエージェントのサンプルプログラム. Contribute to taako-502/local-agent-go development by creating an account …

Go + Temporal + Local LLMでAIエージェントのサンプルプログラム. Contribute to taako-502/local-agent-go development by creating an account …

参考ドキュメントは以下となっている。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です