Claude Code などのAIエージェントを使っていると、なぜこんなに賢いんだろうといつも思います。 そこでAIエージェントをスクラッチで自作することで、その動作原理を理解してみようと思います。

ソースコードはこちらのリポジトリにおいています。 裏では Anthropic SDK を使用していますが、抽象層を挟んでいるため直接 SDK を叩いているコードはこの記事では出てきません。

#ReAct

ReAct は Reasoning(推論)Acting(行動) を組み合わせたパターンです。LLM が次に何をすべきかを考え(Thought)、必要に応じてツールを呼び出し(Action)、その結果を観測する(Observation)というサイクルを、最終的な答えにたどり着くまで繰り返します。この繰り返しをエージェントループといいます。

flowchart TD
  A["ユーザーの入力"] --> B["Thought<br/>次の行動を推論"]
  B --> C{"ツールが必要か?"}
  C -->|必要| D["Action<br/>ツールを実行"]
  D --> E["Observation<br/>実行結果を観測"]
  E --> B
  C -->|不要| F["最終回答を生成"]
  F --> G["ユーザーへ応答"]

#エージェントループ

エージェントループはエージェントが推論とツール実行を繰り返すサイクルです。 このループの最大ステップは、maxSteps としてエージェントのパラメータに保持されます。 このパラメータがないと、エージェントループが無限に繰り返されてしまう危険性があります。 ループの各ステップでは、LLM のレスポンスに含まれる StopReason を見て次の挙動を決めます。

const (
	StopReasonEndTurn StopReason = "end_turn" // LLM が応答を完了した
	StopReasonToolUse StopReason = "tool_use" // ツールの使用を要求している
	StopReasonMaxTokens StopReason = "max_tokens" // Token 数の上限に達した
)

ちなみに、今回裏で使用している Anthropic SDK ではこれ以外にも StopReason があります。 https://platform.claude.com/docs/ja/build-with-claude/handling-stop-reasons#quick-reference

エージェントは以下のように StopReason によって次の挙動を決めます。

for step := 1; step <= a.maxSteps; step++ {
  // LLM による推論 (後述)
  resp, err := a.client.Complete(ctx, domain.LLMRequest {
    System: a.systemPrompt,
    Messages: a.memory.GetHistory(),
    MaxTokens: 1024,
    Tools: toolDefs,
  })

  // --- 省略 ---

  if resp.StopReason == domain.StopReasonToolUse {
    // ここで Tool を実行する
    continue
  }
  if resp.StopReason == domain.StopReasonEndTurn{
    // LLM が生成を終了すると判断した場合、エージェントループを抜けて回答を返す
    return textOut, nil
  }
  if resp.StopReason == domain.StopReasonMaxTokens {
    // Token の上限に達した場合はエラーを返す
    return "", fmt.Errorf("max tokens reached at step %d", step)
  }
}

#推論

先ほども出てきましたが、エージェントループ内での推論は以下のパラメータを渡します。

resp, err := a.client.Complete(ctx, domain.LLMRequest {
  System: a.systemPrompt, // システムプロンプト
  Messages: a.memory.GetHistory(), // 会話履歴 (コンテキスト)
  MaxTokens: 1024, // Token 数の上限
  Tools: toolDefs, // エージェントが利用できるツールの定義
})

##Messages - 会話履歴

Messages は以下のようなデータ構造です。

const (
	RoleUser Role = "user"
	RoleAssistant Role = "assistant"
)

type Message struct {
	Role Role
	Blocks []Block
}

type LLMRequest struct {
	System string
	Messages []Message // ←これ
	MaxTokens int
	Tools []ToolDefinition
}

複数会話していくと、会話の履歴が以下のように積み上がっていきます。

[
  {"role": "user", "content": "こんにちは"},
  {"role": "assistant", "content": "こんにちは。何かお手伝いできることはありますか?"},
  {"role": "user", "content": "AIエージェントの動作原理を教えて"},
  // ...
]

Note: 先ほどの Message.Block"content" で方が違いますが、このJSONは実際にAPIに送られる形で、Goの構造体はそれを抽象化したものになっています。詳しくはソースコードを参照してください。

LLM にリクエストする際は、サーバー側はステートレスなので、このように会話履歴を全て渡して推論してもらう必要があります。

##Tools - ツール定義

エージェントループに入る前のエージェントの初期化で、以下のようにツール定義をしています。

type ToolDefinition struct {
	Name string // ツールの名前
	Description string // ツールの説明
	InputSchema map[string]any // ツール実行時の引数の型
}

// ... 省略 ...

// エージェントに渡されたツール定義をメモリに乗せておく
// LLM にリクエストする度にこの変数を渡す
toolDefs := make([]domain.ToolDefinition, 0, len(a.tools))
for _, t := range a.tools {
  toolDefs = append(toolDefs, t.Definition())
}

例えば、ファイルを読むツールを使えるようにするには、以下ようなツール定義をエージェントに渡します。

// ツール定義
func (t *ReadFileTool) Definition() domain.ToolDefinition {
	return domain.ToolDefinition{
		Name: "read_file",
		Description: "指定したパスのファイルを読み込み、内容を返す",
		InputSchema: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"path": map[string]any{"type": "string", "description": "ファイルパス"},
			},
			"required": []string{"path"},
		},
	}
}

// ... 省略 ...

// ツール実行時に呼ばれる
func (t *ReadFileTool) Execute(input map[string]any) (string, error) {
	path, _ := input["path"].(string)
	data, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

#ツール呼び出し

エージェントループのところでもあったように、LLM がツール使用を要求した場合はツールが実行されます。

if resp.StopReason == domain.StopReasonToolUse {
  var resultBlocks []domain.Block
  for _, b := range resp.Blocks {
    tu, ok := b.(domain.ToolUseBlock)
    if !ok {
      continue
    }
    tool, exists := a.tools[tu.Name]
    if !exists {
      resultBlocks = append(resultBlocks, domain.ToolResultBlock{
        ToolUseID: tu.ID,
        Content: fmt.Sprintf("unknown tool: %s", tu.Name),
      })
      continue
    }
    result, err := tool.Execute(tu.Input) // ここでツールを実行
    if err != nil {
      result = fmt.Sprintf("error: %v", err)
    }
    resultBlocks = append(resultBlocks, domain.ToolResultBlock{
      ToolUseID: tu.ID,
      Content: result,
    })
  }

  // ツールの実行結果をメモリに追加して、次のエージェントループの Messages に履歴として追加される
  a.memory.Add(domain.Message{Role: domain.RoleUser, Blocks: resultBlocks})
  continue
}

#実行結果

AIエージェント実行結果

それっぽく動いてる。

#まとめ

AIエージェントをスクラッチで実装することで、その動作原理を理解することができました。

エージェントループの中で、LLM での推論が停止した理由で条件分岐し、ツールの使用が要求されたら事前に定義されたツールを実行し、その結果をもとにさらに推論を行うという処理を繰り返していることがわかりました。