1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカル LLM で AI コーディング支援環境を構築する⑦ (Hugging Face モデル導入)

1
Posted at

はじめに

!!!!!! モデルおよびコードの利用は自己責任でお願いします !!!!!


本稿は「ローカル LLM で AI コーディング支援環境を構築する」シリーズの第⑦回です。今回は、Foundry Local に Hugging Face のモデルを導入する手順をお届けします。
手順は Hugging Face モデルをコンパイルし、Foundry Local で実行する - Microsoft Learn をベースにしていますが、Learn と少し違うことをやろうとするたびにいろいろなことが起こりました。その解決の過程で学べたことも含めてお送りします。

シリーズ記事

  1. Bonsai-8B セットアップ
  2. Foundry Local セットアップ
  3. ベンチマーク準備&熱暴走対策
  4. ベンチマーク結果
  5. ボトルネック解析
  6. 続 Foundry Local セットアップ
  7. Hugging Face モデル導入

前提条件

今回、新しい端末(機種としては新しくもないのですが)を導入しました。

  • クラウド生成 AI サービスは利用不可
  • 開発端末はローカル PC(GPU: NVIDIA GeForce RTX 4060、VRAM: 8GB)
  • 確認端末はローカル PC(GPU: NVIDIA T500、VRAM: 4GB)
  • エディタは VSCode と GitHub Copilot CLI
  • ローカル LLM サーバーは Foundry Local
  • モデルは Qwen2.5-coder:3b

ONNX コンパイル環境の構築

それでは本題に入っていきましょう。
Foundry Local で動かすには ONNX 形式のモデルが必要ですので、まずは Hugging Face → ONNX のコンパイル環境を構築します。

仮想環境の作成

後述のパッチはインストール済みの olive モジュールを直接書き換えるため、仮想環境を作成して作業します。

python -m venv .venv
.\.venv\Scripts\Activate.ps1

olive-ai と依存関係のインストール

コンパイラーの olive-ai と、必要な依存関係をインストールします。
onnxruntime-genai-cuda は 0.15.2 を指定してインストールしています。本稿時点の最新版である 0.16.0 では models/quantized_model.py が削除されており、olive 0.13.0 が import に失敗するためです。

python -m pip install --upgrade pip
python -m pip install olive-ai datasets requests transformers "onnxruntime-genai-cuda==0.15.2"

コンパイルエラー対応のパッチ適用

さて、早速怪しい風が吹いてきます。
コンパイルエラーが設定レベルではどうにもならず、インストールされた olive モジュールに直接パッチを当てて対応することになりました。

パッチの内容は以下の 2 点です。

  • ort_inference.py:onnxruntime-genai-cuda の依存である onnxruntime-gpu には TensorRT EP の DLL が同梱されています。olive がこれを含めた EP の DLL を一括登録しようとし、TensorRT 本体(nvinfer_10.dll)がないため失敗する事象を回避します
  • model_builder.py:onnxruntime-genai-cuda 0.15 から create_model() の前に parse_extra_options() の呼び出しが必須になりましたが、olive 0.13.0 にはこの呼び出しがありませんので追加します
$oliveOrtInference = ".\.venv\Lib\site-packages\olive\common\ort_inference.py"
$builderPath = ".\.venv\Lib\site-packages\olive\passes\onnx\model_builder.py"

# ort_inference.py
# onnxruntime-genai-cuda の依存である onnxruntime-gpu に同梱の TensorRT EP の DLL を一括登録しようとして、nvinfer_10.dll がないため失敗する問題を回避する
(Get-Content $oliveOrtInference -Raw) `
  -replace 'return platform\.system\(\) == "Windows" and hasattr\(ort, "get_ep_devices"\)', 'return False' |
  Set-Content -Encoding UTF8 $oliveOrtInference


# model_builder.py
# onnxruntime-genai-cuda 0.15 で必須の parse_extra_options() の呼び出しが、olive 0.13.0 に欠けている問題を補う
$builderSource = [System.IO.File]::ReadAllText(
    (Resolve-Path $builderPath)
)

if ($builderSource -notmatch "parse_extra_options") {
    $oldImport = @'
        from onnxruntime_genai.models.builder import create_model
'@

    $newImport = @'
        from onnxruntime_genai.models.builder import create_model, parse_extra_options
'@

    if (-not $builderSource.Contains($oldImport)) {
        throw "ModelBuilder の create_model import 箇所が見つかりません。"
    }

    $builderSource = $builderSource.Replace($oldImport, $newImport)

    $oldCreate = @'
        try:
            logger.debug("Building model with the following args: %s", extra_args)
            create_model(
'@

    $newCreate = @'
        try:
            logger.debug("Building model with the following args: %s", extra_args)
            parsed_extra_args = parse_extra_options(
                model_name=model_path,
                input_path=input_path,
                output_dir=str(output_model_filepath.parent),
                precision=precision,
                execution_provider=target_execution_provider,
                cache_dir=HF_HUB_CACHE,
                extra_options=[
                    f"{key}={value}"
                    for key, value in extra_args.items()
                    if key != "filename"
                ],
            )
            extra_args.update(parsed_extra_args)
            create_model(
'@

    if (-not $builderSource.Contains($oldCreate)) {
        throw "ModelBuilder の create_model 呼び出し箇所が見つかりません。"
    }

    $builderSource = $builderSource.Replace($oldCreate, $newCreate)

    [System.IO.File]::WriteAllText(
        (Resolve-Path $builderPath),
        $builderSource,
        (New-Object System.Text.UTF8Encoding($false))
    )

    Write-Host "Applied compatibility patch."
}
else {
    Write-Host "Compatibility patch is already applied."
}

モデルコンパイル

いろいろありましたが、何とかモデルのコンパイルまでたどり着きました。
……が、ここでもまたいろいろありまして、以下のコードにはコンパイル処理の設定(config.json)にパッチを当てる処理が含まれています。

  • --dry_run で config.json だけを生成する
  • Hugging Face の認証を無効化する(hf_token = "false")
  • ModelBuilder 自身が int8 化するため、後段の静的量子化(onnx_static_quantization)を削除する
  • config.json を ASCII JSON として保存し直す
  • 編集済みの config.json で olive run を実行する
$outputPath = "models\qwen2.5-coder-3b"
$configPath = "$outputPath\config.json"

# ------------------------------------------------------------
# config.json を生成する
# ------------------------------------------------------------
olive optimize `
  --model_name_or_path Qwen/Qwen2.5-Coder-3B-Instruct `
  --trust_remote_code `
  --output_path $outputPath `
  --device gpu `
  --provider CUDAExecutionProvider `
  --precision int8 `
  --log_level 1 `
  --dry_run

if ($LASTEXITCODE -ne 0) {
    throw "olive optimize --dry_run に失敗しました。"
}

# 生成された JSON を読み込む
$config = Get-Content $configPath -Raw | ConvertFrom-Json

# Hugging Face への認証を無効化する
$config.passes.model_builder.extra_options = @{
    hf_token = "false"
}

# ModelBuilder が int8 化するため後段の二重静的量子化を削除する
$staticQuantization = $config.passes.PSObject.Properties[
    "onnx_static_quantization"
]

if ($null -ne $staticQuantization) {
    $config.passes.PSObject.Properties.Remove(
        "onnx_static_quantization"
    )
}

# ------------------------------------------------------------
# config.json を ASCII JSON として保存する
# ------------------------------------------------------------
$json = $config | ConvertTo-Json -Depth 100

$asciiJson = [regex]::Replace(
    $json,
    '[^\x00-\x7F]',
    {
        param($match)

        $escaped = $match.Value.ToCharArray() | ForEach-Object {
            '\u{0:X4}' -f [int][char]$_
        }

        $escaped -join ''
    }
)

[System.IO.File]::WriteAllText(
    (Resolve-Path $configPath),
    $asciiJson,
    (New-Object System.Text.ASCIIEncoding)
)

# ------------------------------------------------------------
# 編集済み config.json でモデル変換を実行する
# ------------------------------------------------------------
olive run --config $configPath

if ($LASTEXITCODE -ne 0) {
    throw "olive run に失敗しました。inference_model.json は生成しません。"
}

# ------------------------------------------------------------
# inference_model.json を生成する
# ------------------------------------------------------------
$modelName = Split-Path $outputPath -Leaf

$inferenceModel = @{
    Name = "$modelName`:1"
} | ConvertTo-Json

[System.IO.File]::WriteAllText(
    "$outputPath\inference_model.json",
    $inferenceModel,
    (New-Object System.Text.UTF8Encoding($false))
)

Write-Host "Created $outputPath\inference_model.json"

動作確認

モデルができ上がりましたので、GitHub Copilot で動作確認を行います。

Foundry Local 起動

Foundry Local の起動は、さすがにすんなりいきました。

# 変数の設定
$ModelPath  = "c:\work\olive\models\qwen2.5-coder-3b"
$FolderName = Split-Path $ModelPath -Leaf
$ModelAlias = "qwen2.5-coder-3b"

# キャッシュディレクトリへのモデル登録 (ジャンクション作成)
$targetCachePath = "$env:USERPROFILE\.foundry\cache\models\$FolderName"
if (-not (Test-Path $targetCachePath)) {
    New-Item -ItemType Junction -Path $targetCachePath -Target $ModelPath
}

# サーバー起動・モデルロード・推論実行
foundry server start
foundry model load $ModelAlias
foundry complete $ModelAlias "自己紹介をお願いします。"

動作確認 → 失敗 → トラブルシューティング

Foundry Local + GitHub Copilot CLI は前回動作確認できているから大丈夫、と思いきや、ここでも問題が発生します。
具体的には、「自己紹介して」と入力すると HTTP 通信自体は成功してレスポンスも返ってくるものの、内容が * などの記号の反復になるという事象が発生しました。
原因は GitHub Copilot CLI が送信する以下のパラメーターを Foundry Local 側がうまく扱えない点にあり、

{
  "temperature": 0,
  "top_p": 0.95,
  "frequency_penalty": 0,
  "presence_penalty": 0,
  "max_tokens": 256
}

このうち frequency_penalty を除外すると、正常な日本語の応答が返ってくる ことが判明しました。
ところがこのパラメーターは GitHub Copilot CLI 側でチューニングできないため、無理やりですがプロキシでリクエストをフックしてパッチを当てることで回避しています。

プロキシによるパッチ適用

ということでプロキシのコードです。
これを作るにも、パラメーターを足したり引いたりして動作が安定するところまで持っていくのにかなりの手間がかかりました。

$ErrorActionPreference = "Stop"

# 固定の接続先
$listenUrl = "http://127.0.0.1:5273/"
$upstreamUrl = "http://127.0.0.1:5272"

# HTTP待受と上流接続用のクライアントを作成
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add($listenUrl)

$client = [System.Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromMinutes(10)

try {
    # プロキシの待受を開始
    $listener.Start()

    Write-Host "プロキシ待受: $listenUrl"
    Write-Host "転送先      : $upstreamUrl"
    Write-Host "削除項目    : frequency_penalty"
    Write-Host "停止するにはCtrl+Cを押してください。"

    while ($listener.IsListening) {
        # リクエスト待機中もCtrl+Cで停止処理へ戻れるようにする
        $contextTask = $listener.GetContextAsync()

        while (-not $contextTask.Wait(500)) {
            if (-not $listener.IsListening) {
                break
            }
        }

        if (-not $listener.IsListening) {
            break
        }

        $context = $contextTask.Result

        try {
            $targetUrl = "$($upstreamUrl.TrimEnd('/'))$($context.Request.Url.PathAndQuery)"

            # リクエスト本文を読み込む
            $reader = [System.IO.StreamReader]::new(
                $context.Request.InputStream,
                $context.Request.ContentEncoding
            )

            try {
                $requestBody = $reader.ReadToEnd()
            }
            finally {
                $reader.Dispose()
            }

            # Chat CompletionsのJSONからfrequency_penaltyだけを削除
            if ($context.Request.HttpMethod -eq "POST" -and $requestBody) {
                $json = $requestBody | ConvertFrom-Json
                $before = $json.frequency_penalty
                $json.PSObject.Properties.Remove("frequency_penalty")
                $requestBody = $json | ConvertTo-Json -Depth 30 -Compress

                Write-Host "[$(Get-Date -Format o)] $($context.Request.Url.PathAndQuery)"
                Write-Host "frequency_penalty: $before -> 削除"
            }

            # 上流へ同じHTTPメソッドで転送
            $request = [System.Net.Http.HttpRequestMessage]::new(
                [System.Net.Http.HttpMethod]::new($context.Request.HttpMethod),
                $targetUrl
            )

            if ($requestBody) {
                $request.Content = [System.Net.Http.StringContent]::new(
                    $requestBody,
                    [System.Text.Encoding]::UTF8,
                    "application/json"
                )
            }

            try {
                $response = $client.SendAsync($request).GetAwaiter().GetResult()
                $responseBody = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()

                # 上流のステータスと本文を下流へ返す
                $context.Response.StatusCode = [int]$response.StatusCode
                $context.Response.ContentLength64 = $responseBody.Length

                if ($response.Content.Headers.ContentType) {
                    $context.Response.ContentType =
                        $response.Content.Headers.ContentType.ToString()
                }

                $context.Response.OutputStream.Write(
                    $responseBody,
                    0,
                    $responseBody.Length
                )
            }
            finally {
                $request.Dispose()
            }
        }
        catch {
            # 転送中のエラーは502として返す
            try {
                $context.Response.StatusCode = 502
                $errorBody = [System.Text.Encoding]::UTF8.GetBytes(
                    $_.Exception.Message
                )
                $context.Response.ContentType = "text/plain; charset=utf-8"
                $context.Response.ContentLength64 = $errorBody.Length
                $context.Response.OutputStream.Write(
                    $errorBody,
                    0,
                    $errorBody.Length
                )
            }
            catch {
            }
        }
        finally {
            # 下流接続を必ず閉じる
            try {
                $context.Response.Close()
            }
            catch {
            }
        }
    }
}
finally {
    # Ctrl+C後のリソース解放
    try {
        $client.Dispose()
    }
    catch {
    }

    try {
        if ($listener.IsListening) {
            $listener.Stop()
        }
    }
    catch {
    }

    try {
        $listener.Close()
    }
    catch {
    }

    Write-Host "プロキシを停止しました。"
}

プロキシを指定して GitHub Copilot CLI を起動

さあ、ようやく環境が整いました。
プロキシは 5273 番ポートで待ち受けていますので、そこを指定して GitHub Copilot CLI を起動します。

$env:COPILOT_PROVIDER_BASE_URL = "http://127.0.0.1:5273/v1"
$env:COPILOT_PROVIDER_TYPE = "openai"
$env:COPILOT_PROVIDER_API_KEY = "local"
$env:COPILOT_PROVIDER_WIRE_MODEL = "qwen2.5-coder-3b"
$env:COPILOT_MODEL = "qwen2.5-coder-3b"
$env:COPILOT_OFFLINE = "true"
$env:COPILOT_PROVIDER_MAX_PROMPT_TOKENS = "32256"
$env:COPILOT_PROVIDER_MAX_OUTPUT_TOKENS = "512"

copilot `
  --model=qwen2.5-coder-3b `
  --no-custom-instructions `
  --disable-builtin-mcps `
  --no-remote `
  --no-remote-export `
  --available-tools=powershell,view,edit `
  --stream=off

結果

正常な日本語で自己紹介が返ってきました!
確認端末(NVIDIA T500 の PC)でも、遅いながらも動作を確認できています。

Context Usage
○ ○ ○ ○ · · · · · ·   qwen2.5-coder-3b · 2k/33k tokens (6%)
· · · · · · · · · ·   ○ System Prompt          2.0k   (6%)
· · · · · · · · · ·   ○ System Tools              0   (0%)
· · · · · · · · · ·   ● MCP Tools                 0   (0%)
· · · · · · · · · ·   ◉ Messages                  0   (0%)
· · · · · · · · · ·   · Free Space            28.6k  (87%)
· · · · · ◎ ◎ ◎ ◎ ◎   ◎ Buffer                 2.1k   (6%)

自己紹介して
私はGitHub Copilot CLIです。AIアシスタントで、ソフトウェアエンジニアのタスクを支援します。質問や指示に応じて、コード変更、リファクタリング、テスト、ドキュメント更新など、さまざまなタスクを手助けします。

Foundry Local では提供されていない Qwen2.5-coder:3b を動かしたかっただけなのに、大変でした……。

今回の学び

まとめの前に、本稿で学んだことを少し整理します。

1. バージョン互換性を侮るな

olive-ai と onnxruntime-genai の互換性問題しかり、前回は動いていた Foundry Local + GitHub Copilot CLI の問題しかり(こちらはバージョン起因かどうかはわかりませんが)、バージョン互換性を侮ってはいけない ことを学びました。
とはいえ、リリースノートをしっかり読めば大丈夫というものでもないので、何か問題が発生した場合に疑うべきポイントとして念頭に置いておく、ということになると思います。

2. いざとなったらコードを書くしかない

今回はいくつか無理やりパッチを当てています。このレベルになると、コードを書いて対処するしかありません。
もう一つ、AI アプリケーションといえどもインターフェースは HTTP/HTTPS なので、プロキシを噛ませれば力技が効く ということも学べました。

3. Windows 環境依存はやっぱりある

バージョン互換性の問題やパッチの中にも、Windows でなければ起きなかった問題があると思うんですよね……。

おわりに

今回は、

  • Hugging Face モデルの ONNX へのコンパイル
  • Foundry Local での実行
  • GitHub Copilot CLI からの動作確認

までを進めることができました。
Learn の手順どおりであれば何の問題もなく進んだのかもしれませんが、寄り道をした分学べることもありました。まあ正直、再利用性の高い学びではなかったとも思いますが……。
何にせよ、スペックギリギリのモデルを幅広く選択できるようになったのは良いことだと思います。

次回からは、本丸となるであろう GitHub Copilot CLI のコンテキストチューニングを進めていこうと思います。
今の丸裸のコンテキストに何を付け加えれば AI コーディング支援環境たり得るのか、インフラから離れたあまり知見のない分野ではありますが、それゆえに興味は尽きません。

本稿を読んでいただきありがとうございました!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?