Ollamaのコンテキストウィンドウを広げてローカルLLMの可能性を探る話

公開: 2026年1月31日 土曜日
更新: 2026年2月11日 水曜日

Ollamaのコンテキストウィンドウを広げてローカルLLMの可能性を探る話

ローカルで大規模言語モデル(LLM)を動かす際、多くの人が直面するのが「コンテキストウィンドウの狭さ」の問題です。特に、Ollamaのデフォルト設定(約4kトークン)では、少し長い会話や複雑なコード生成を試みると、すぐに文脈を忘れてしまい、「話にならない」と感じることも少なくありませんでした。

しかし、これはOllamaやローカルLLM自体の限界ではありません。適切な設定変更により、その能力は劇的に向上します。本記事では、MacBook Pro(M4, 48GBメモリ)という環境を例に、Ollamaで利用する各種モデルのコンテキストウィンドウを限界まで広げ、その実用性を探る取り組みを紹介します。

なぜコンテキストウィンドウの拡張が重要なのか?

コンテキストウィンドウとは、モデルが一度に記憶・処理できる情報量(トークン数)のことです。これが広いほど、以下のようなメリットがあります。

  • 長文の読解・要約: 数万文字のドキュメントを一度に読み込ませて要約や分析ができる。
  • 複雑なコーディング: プロジェクト全体のコードを読み込ませ、文脈を理解した上での修正や機能追加が可能になる。
  • 会話の記憶: 長時間にわたる会話の文脈を維持し、より一貫性のある応答が期待できる。

クラウドモデル(GPT, Geminiなど)が非常に賢く感じる一因は、このコンテキストウィンドウが広大である点にあります。ローカルLLMでも、この設定を最適化することで、以前とは比較にならないほどの実用性を手に入れることができます。

Ollama vs LM Studio:ローカルLLM環境の選択

ローカルLLMを動かすツールとして、主にOllamaとLM Studioが挙げられます。

  • LM Studio: GUIが美しく、Hugging Faceからモデルを検索・ダウンロードする機能が非常に優秀。どのモデルが自分のマシンで動くか試す「実験フェーズ」に最適です。
  • Ollama: CLIベースで動作が非常に軽量。一度設定すればバックグラウンドで安定稼働するため、OpenCodeのようなツールと連携して「日常的に使う道具」として非常に優れています。

今回は、自動化と安定性を重視し、Ollamaをメインの環境として選択しました。

コンテキストウィンドウ拡張への道筋

具体的な手順は、「既存のモデルをベースに、コンテキスト長を書き換えた新しいModelfileを作成し、別名で保存する」という流れになります。

1. モデル設定の確認とModelfileの作成

まず、ベースとなるモデルの設定を確認します。例えばllama3モデルの設定は以下のコマンドで表示できます。

ollama show --modelfile llama3

この内容を元に、コンテキスト長を調整するためのModelfileを作成します。

2. コンテキスト長 (num_ctx) の設定

ModelfilePARAMETER num_ctx <数値>を追記することで、コンテキスト長を指定できます。

FROM llama3
PARAMETER num_ctx 8192 # 例: 8kに設定

この数値をどこまで増やせるかは、「モデル自体の設計上の上限」と「マシンのメモリ(VRAM)容量」に依存します。48GBのメモリを持つMacBook Proであれば、Llama 3.1 (8B)のような軽量モデルなら、モデルの最大値である128k (131,072) を設定しても問題なく動作します。

3. 新しいモデルのビルド

作成したModelfileを使い、ollama createコマンドで新しいモデルをビルドします。

ollama create llama3-8k -f Modelfile_8k

これにより、元のllama3とは別に、コンテキスト長が拡張されたllama3-8kというモデルが利用可能になります。

自動化スクリプトによる最適化

このプロセスを、インストール済みの全モデルに対して手動で行うのは非常に手間がかかります。そこで、以下の機能を持つPythonスクリプトを作成しました。

  1. モデル情報の自動解析: ollama showコマンドを実行し、モデルのパラメータ数や設計上の最大コンテキスト長を取得。
  2. 動的なコンテキスト長計算: マシンの物理メモリ(48GB)とモデルのサイズから、スワップが発生しない安全な範囲で最大のコンテキスト長を動的に計算。しきい値ではなく、利用可能なメモリを最大限使い切るロジックを採用。
  3. 精度向上パラメータの適用: temperature0.2に下げるなど、長文読解やコーディングに適した論理・正確性重視の設定を自動で追加。
  4. 重複防止と上書き機能: :extという接尾辞をつけた拡張版モデルを生成。すでに存在する場合はスキップし、--forceオプションで強制的に上書きも可能。
  5. 安全装置: モデルのメタデータに記載された学習済みコンテキスト長を上限とする安全なモードをデフォルトとしつつ、--ignore-training-limitオプションでその制限を解除し、モデルの潜在能力を最大限引き出す実験的な設定も可能に。

このスクリプトにより、新しいモデルをollama pullするたびにコマンド一発で、手持ちの全モデルを自身のマシン環境に合わせて最適化できるようになりました。

#!/usr/bin/env python3
import subprocess
import sys
import tempfile
import os
import re
import argparse
import math

"""
Ollama モデルのコンテキスト長最適化スクリプト

このスクリプトは、インストールされている Ollama モデルのコンテキスト長(num_ctx)を
システムの利用可能な RAM(このスクリプトでは 48GB Mac を想定)に基づいて自動的に最適化し、
新しいカスタムモデル(サフィックス :ext 付き)を作成します。

主な機能:
- インストール済みモデルのサイズを取得
- モデルの「重さ」と空きメモリから、動的に最大コンテキスト長を算出(2048単位)
- 正確性・論理性重視のパラメータ(temperature 0.2 など)を適用

使い方:
  python3 optimize-ctx [モデル名...] [オプション]

引数:
  models                  最適化対象のモデル名(部分一致可)。指定しない場合は全モデルが対象。
  --force, -f             既存の拡張モデル(:ext)を上書きする。
  --ignore-training-limit モデルが学習時に設定された上限(メタデータ)を無視し、メモリ限界まで拡張する。
"""

# --- CONFIGURATION ---
TARGET_SUFFIX = "ext"
SYSTEM_RAM_GB = 48  # Your Mac's Total RAM
SYSTEM_RESERVE_GB = 6 # RAM reserved for macOS & other apps (Safe margin)

# Accuracy-focused parameters (Improved for Logic/Facts/Coding)
# Temperature is lowered to reduce hallucinations and improve consistency.
ACCURACY_PARAMS = {
    "temperature": 0.2,     # Lowered for consistency and fact-retrieval
    "top_p": 0.9,           # Standard filter
    "repeat_penalty": 1.1,  # Keep explicit penalty to avoid long-context loops
}

def parse_size_to_gb(size_str):
    """Parses size string like '4.7 GB' or '200 MB' into float GB."""
    size_str = size_str.upper().strip()
    match = re.search(r'([\d\.]+)\s*([GMTP]B)', size_str)
    if not match:
        return 0.0
    
    value = float(match.group(1))
    unit = match.group(2)
    
    if unit == 'GB':
        return value
    elif unit == 'MB':
        return value / 1024.0
    elif unit == 'TB':
        return value * 1024.0
    return value

def get_installed_models_info():
    """
    Returns a dict of {model_name: size_in_gb}.
    Fetches actual disk size from `ollama list`.
    """
    models_info = {}
    try:
        result = subprocess.run(['ollama', 'list'], capture_output=True, text=True, check=True)
        lines = result.stdout.strip().split('\n')[1:] # Skip header
        
        for line in lines:
            if not line: continue
            parts = line.split()
            name = parts[0]
            
            # Simple heuristic to find size part
            rest_of_line = " ".join(parts[1:])
            size_match = re.search(r'([\d\.]+\s*[GMTP]B)', rest_of_line, re.IGNORECASE)
            
            if size_match:
                size_gb = parse_size_to_gb(size_match.group(1))
                models_info[name] = size_gb
            else:
                models_info[name] = 0.0
                
        return models_info
    except Exception as e:
        print(f"Error fetching models list: {e}")
        return {}

def get_model_metadata(model_name):
    """
    Retrieves metadata (trained context length) from `ollama show`.
    """
    try:
        result = subprocess.run(['ollama', 'show', model_name], capture_output=True, text=True, check=True)
        output = result.stdout
        
        metadata = {
            "trained_context_length": 8192, # Default fallback
        }

        # Parse Context Length (e.g., "131072")
        ctx_match = re.search(r'context length\s+(\d+)', output)
        if ctx_match:
            metadata["trained_context_length"] = int(ctx_match.group(1))

        return metadata

    except subprocess.CalledProcessError:
        print(f"[!] Could not retrieve metadata for {model_name}. Using defaults.")
        return None

def calculate_dynamic_ctx(model_name, model_size_gb, trained_limit, ignore_limit=False):
    """
    Calculates dynamic context length based on remaining RAM and model "heaviness".
    If ignore_limit is True, ignores the model's trained limit.
    """
    # 1. Calculate Available RAM for KV Cache
    available_ram_gb = SYSTEM_RAM_GB - SYSTEM_RESERVE_GB - model_size_gb
    available_ram_mb = available_ram_gb * 1024
    
    if available_ram_mb <= 0:
        # Extremely tight scenario, return minimum safe context
        return 2048, f"(Low RAM: {available_ram_gb:.2f}GB free)"

    # 2. Estimate KV Cache "Cost per 1k tokens" (MB)
    # Heuristic: Larger models (more layers/dim) have heavier KV caches.
    # Base cost ~35MB + Scaled cost based on model size.
    estimated_mb_per_1k = 35 + (2.5 * model_size_gb)
    
    # 3. Calculate Max Possible Context
    max_possible_k = available_ram_mb / estimated_mb_per_1k
    max_possible_ctx = int(max_possible_k * 1024)

    # 4. Round down to nearest 2048 for alignment/neatness
    step = 2048
    max_possible_ctx = (max_possible_ctx // step) * step
    
    # Ensure reasonable bounds
    if max_possible_ctx < 2048:
        max_possible_ctx = 2048

    # 5. Final Decision
    if ignore_limit:
        final_ctx = max_possible_ctx
        reason = f"[UNLIMITED] (Free: {available_ram_gb:.1f}GB)"
    else:
        final_ctx = min(max_possible_ctx, trained_limit)
        reason = f"(Free: {available_ram_gb:.1f}GB, Limit: {trained_limit})"
    
    return final_ctx, reason

def create_extended_model(base_model, model_size_gb, force_update=False, ignore_limit=False):
    # Avoid recursion: Logic handled in main, but safety check here
    if base_model.endswith(f":{TARGET_SUFFIX}") and not force_update:
        return

    # Determine names
    base_name_only = base_model.split(':')[0]
    new_model_name = f"{base_name_only}:{TARGET_SUFFIX}"
    
    installed_models = get_installed_models_info()
    
    if new_model_name in installed_models and not force_update:
        print(f"[-] Skip: {new_model_name} exists (use --force to overwrite).")
        return

    print(f"[*] Analyzing {base_model} ({model_size_gb:.1f} GB)...")
    metadata = get_model_metadata(base_model)
    
    if not metadata:
        print(f"[!] Skipping {base_model} due to metadata error.")
        return

    trained_limit = metadata["trained_context_length"]
    
    # Use Dynamic Calculation Logic
    ctx_size, reason = calculate_dynamic_ctx(base_model, model_size_gb, trained_limit, ignore_limit)
    
    print(f"    - Model Size: {model_size_gb:.1f} GB")
    print(f"    - Training Limit: {trained_limit}")
    print(f"    - Dynamic Limit: {ctx_size} {reason}")

    print(f"[+] Building {new_model_name}...")
    
    modelfile_lines = [f"FROM {base_model}"]
    modelfile_lines.append(f"PARAMETER num_ctx {ctx_size}")
    
    for param, value in ACCURACY_PARAMS.items():
        modelfile_lines.append(f"PARAMETER {param} {value}")
    
    modelfile_content = "\n".join(modelfile_lines)
    
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
        tmp.write(modelfile_content)
        tmp_path = tmp.name

    try:
        subprocess.run(['ollama', 'create', new_model_name, '-f', tmp_path], check=True)
        print(f"[OK] Successfully created/updated {new_model_name}")
    except subprocess.CalledProcessError:
        print(f"[Error] Failed to create {new_model_name}")
    finally:
        os.remove(tmp_path)

def main():
    parser = argparse.ArgumentParser(description='Ollama Context Optimizer for 48GB Mac')
    parser.add_argument('models', nargs='*', help='Specific model names to optimize (optional)')
    parser.add_argument('--force', '-f', action='store_true', help='Overwrite existing extended models')
    parser.add_argument('--ignore-training-limit', action='store_true', help='Ignore the model\'s trained context limit and use max RAM.')
    
    args = parser.parse_args()

    # Get all models with their sizes
    all_models_info = get_installed_models_info()
    
    # Filter out existing extension models from sources using endswith for precision
    base_models = [m for m in all_models_info.keys() if not m.endswith(f":{TARGET_SUFFIX}")]

    targets = []
    
    if args.models:
        for req in args.models:
            matches = [m for m in base_models if req in m]
            if not matches:
                print(f"[!] No installed base model matches '{req}'")
            targets.extend(matches)
    else:
        targets = base_models

    if not targets:
        print("No models found to process.")
        return

    print(f"--- Optimizing {len(targets)} models (RAM: {SYSTEM_RAM_GB}GB, LimitCheck: {not args.ignore_training_limit}) ---")
    
    for model in targets:
        size = all_models_info.get(model, 0.0)
        create_extended_model(model, size, force_update=args.force, ignore_limit=args.ignore_training_limit)

if __name__ == "__main__":
    main()

まとめ:ローカルLLMは「話になる」

48GBのメモリを積んだMacBook ProでOllamaのコンテキストウィンドウを適切に広げた結果、ローカルLLMは「全く話にならない」レベルから、「意識して使えば実用的」なレベルへと進化しました。

例えば、glm-4-flashのようなモデルを使えば、クラウドAPIを叩かずにローカル環境だけでテトリスのような簡単なゲームをバイブコーディング的に作らせることも十分可能です。

もちろん、クラウドの最新・最強モデルにはまだ及びませんが、オフラインで動作するプライバシーの高さ、APIコストからの解放、そして何より「自分のマシンで賢いAIが動いている」という満足感は、それを補って余りある魅力です。ローカルLLMの可能性は、自身のマシンのメモリと少しの工夫次第で、まだまだ広がります。

参考情報:OpenCodeとのモデル同期

OpenCodeではOllamaのモデルを自動同期できないようだったので、スクリプトの実行で同期できるものを用意しました。

"""
Ollama モデルと OpenCode 設定の同期スクリプト

このスクリプトは、ローカルの Ollama にインストールされている全モデルを取得し、
OpenCode の設定ファイル (~/.config/opencode/opencode.json) の
provider.ollama.models セクションを同期(完全上書き)します。

主な機能:
- `ollama list` コマンドでモデル一覧を取得
- モデル名をアルファベット順にソート
- 設定ファイルが存在しない場合はエラーを表示
- 既存の設定を保持しつつ、モデルリストのみを更新

使い方:
  python3 sync-opencode.py

注意:
  - 実行には `ollama` コマンドがパスに通っている必要があります。
  - 設定ファイル (~/.config/opencode/opencode.json) が存在する必要があります。
"""
import json
import os
import subprocess
import sys

# 設定ファイルのパス
CONFIG_PATH = os.path.expanduser("~/.config/opencode/opencode.json")

def get_ollama_models():
    """Ollamaから全モデルリストを取得し、ソートして返す"""
    try:
        # ollama list コマンドの結果を取得
        result = subprocess.run(["ollama", "list"], capture_output=True, text=True, check=True)
        lines = result.stdout.strip().split('\n')
        
        models = []
        # 1行目はヘッダーなのでスキップ
        for line in lines[1:]:
            if line.strip():
                # 空白で分割して最初の要素(モデル名)を取得
                model_name = line.split()[0]
                models.append(model_name)
        
        # 名前順でソート
        models.sort()
        print(f"Ollamaで {len(models)} 個のモデルを検出しました。")
        return models

    except FileNotFoundError:
        print("エラー: 'ollama' コマンドが見つかりません。")
        sys.exit(1)
    except Exception as e:
        print(f"Ollamaからのモデル取得中にエラーが発生しました: {e}")
        sys.exit(1)

def update_config(models):
    """config.jsonを更新する(完全同期)"""
    if not os.path.exists(CONFIG_PATH):
        print(f"エラー: 設定ファイルが見つかりません -> {CONFIG_PATH}")
        return

    try:
        with open(CONFIG_PATH, 'r') as f:
            data = json.load(f)

        # プロバイダー設定が存在するか確認・作成
        if "provider" not in data:
            data["provider"] = {}
        
        # ollamaセクションの初期化
        if "ollama" not in data["provider"]:
            data["provider"]["ollama"] = {
                "npm": "@ai-sdk/openai-compatible",
                "name": "Ollama Local",
                "options": {"baseURL": "http://localhost:11434/v1"},
                "models": {}
            }

        ollama_section = data["provider"]["ollama"]
        
        # 新しいモデル構成を作成(名前順ソート済み)
        new_models_config = {}
        for model in models:
            new_models_config[model] = {"name": model}
        
        # 以前のモデル数と新しいモデル数を比較
        old_count = len(ollama_section.get("models", {}))
        new_count = len(new_models_config)
        
        # 完全上書き(これにより削除されたモデルは消え、順序もソートされる)
        ollama_section["models"] = new_models_config

        # 保存
        with open(CONFIG_PATH, 'w') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
        
        print("-" * 30)
        print(f"同期完了:")
        print(f"  旧モデル数: {old_count}")
        print(f"  新モデル数: {new_count}")
        print(f"  設定ファイル: {CONFIG_PATH}")

    except json.JSONDecodeError:
        print("エラー: config.json の形式が壊れています。")
    except Exception as e:
        print(f"設定更新中にエラーが発生しました: {e}")

if __name__ == "__main__":
    models = get_ollama_models()
    update_config(models)