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側では、この出力を次の構造体で受け取ります。
type Decision struct {
Action string `json:"action"`
Path string `json:"path,omitempty"`
Answer string `json:"answer,omitempty"`
}read_file が選ばれたら、Goがファイルを読みます。その内容を会話履歴に追加して、再びLLMに次の操作を問い合わせます。必要な情報が集まれば、LLMは finish と回答文を返します。
処理の流れは次のようになります。
flowchart TD
A["質問と履歴を渡す"] --> B["LLMが操作を選ぶ"]
B --> C["GoがJSONを検証する"]
C --> D{"操作"}
D -->|資料やAPI| E["ツールを実行する"]
E --> F["結果を履歴に追加する"]
F --> A
D -->|finish| G["回答して終了する"]これは操作の流れを示した図です。不正な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 …
参考ドキュメントは以下となっている。

コメントを残す