🏠

Copilotでオープンウェイト(ローカル)モデルを使ってみる

に公開

はじめに

最近のローカルLLMは少ないパラメータでも

  • 日本語がちゃんと返ってくる
  • ツールコールも動く
    ところまで来ています。

それではCopilot CLIのバックエンドをLMStudioに差し替えたら
実際どこまでできるのか? というのを試してみました
(社内でイベントとしてやりました)

この記事では導入手順と、実際に使えたモデルの選定結果まで書いてます✒️
読み終わる頃には、手元でCopilot CLIがローカルモデルで動かせるようになります。

なぜ取り組んだか

ローカルLLMの発展がここ最近目覚ましく、以前に比べて少ないパラメータでも

  • 日本語出力の安定
  • ツールコール
    などができるようになってきました。
    そのためローカルLLMの発展を見越して、現状どのくらいのモデルが手元で使えるかを体感してもらうという時間をとりました。

手順

LMStudio側

  1. https://lmstudio.ai/ をインストールします

  2. Onboardingを一通り行い、こちらの画面まで移動

  3. 左上の上から四番目のボタンを押し、「qwen 3.6 35b unsloth」などと検索

  4. Unslothが出しているモデルを選択

  5. 勝手にモデルが選択されているので、VRAMサイズに合った量子化(Full GPU Offload Possibleなもの)を選択

  6. 「DownLoad」をクリックしダウンロード

  7. ダウンロード後「Use in New Chat」をクリックし

  8. Context Lengthを調整(今回は100000に設定)また、今回はKV Cache Quantization をそれぞれQ8に設定

  9. Remember Settings for ...にチェックを入れてLoad Modelをクリック

  10. 通常のChatが立ち上がるので、適当に動作確認する

  11. 左上の上から二番目のターミナルマークを押し、

  12. StatusをRunningにする

Copilot CLI側

  1. Copilot CLIをインストールする(省略)
  2. Copilot CLIを起動する前に
export COPILOT_PROVIDER_BASE_URL=http://localhost:1234/v1 
export COPILOT_MODEL=qwen3.6-35b-a3b 

を入力

copilot

を入力して起動すると以下のようになる

試したモデル

Apache 2.0でOpenWeightになっている次のモデルを試しました

  • (unsloth)Qwen3.6 35B-A3B / Q2_K_XL

ブロック崩しを作ってもらう

プランモードで

ブロック崩しを作って

と依頼しました


(ツールコールの呼び出しに何回かこけていますが、なんとか呼び出しがうまくいっています)

プランを作ってもらって...

# ブロック崩しゲームの実装計画

## 問題定義
HTML5 Canvas + JavaScript を使って、ブラウザで動作するブロック崩しゲームを作成する。

## アプローチ
単一の HTML ファイル(index.html)と JavaScript ファイル(game.js)を作成し、以下の機能を実装する:
- パドル(マウス/キーボード操作)
- ボール(跳ね返り物理演算)
- ブロック(グリッド配置、ボール衝突で破壊)
- スコア表示
- ゲームオーバー / クリア判定

## タスク
1. index.html の作成 - Canvas要素と基本構造
2. game.js の作成 - ゲームロジックの実装
3. テスト - ブラウザで動作確認

なんとかブロック崩しができました

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ブロック崩し</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: #1a1a2e;
            font-family: 'Arial', sans-serif;
        }
        canvas {
            border: 3px solid #eee;
            border-radius: 8px;
            box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
        }
    </style>
</head>
<body>
    <canvas id="gameCanvas" width="780" height="500"></canvas>
    <script src="game.js"></script>
</body>
</html>
game.js
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

// Game constants
const CANVAS_WIDTH = canvas.width;
const CANVAS_HEIGHT = canvas.height;
const PADDLE_WIDTH = 100;
const PADDLE_HEIGHT = 15;
const BALL_RADIUS = 8;
const BLOCK_ROWS = 5;
const BLOCK_COLS = 9;
const BLOCK_WIDTH = 72;
const BLOCK_HEIGHT = 25;
const BLOCK_PADDING = 8;
const BLOCK_OFFSET_TOP = 60;
const BLOCK_OFFSET_LEFT = 35;

// Game state
let paddleX = (CANVAS_WIDTH - PADDLE_WIDTH) / 2;
let ballX = CANVAS_WIDTH / 2;
let ballY = CANVAS_HEIGHT - 40;
let ballDX = 4;
let ballDY = -4;
let score = 0;
let lives = 3;
let gameRunning = true;
let gameOver = false;

// Keys pressed
const keys = {};

// Initialize blocks
const blocks = [];
function initBlocks() {
    for (let r = 0; r < BLOCK_ROWS; r++) {
        for (let c = 0; c < BLOCK_COLS; c++) {
            const colors = ['#e63946', '#f4a261', '#e9c46a', '#2a9d8f', '#264653'];
            blocks.push({
                x: BLOCK_OFFSET_LEFT + c * (BLOCK_WIDTH + BLOCK_PADDING),
                y: BLOCK_OFFSET_TOP + r * (BLOCK_HEIGHT + BLOCK_PADDING),
                width: BLOCK_WIDTH,
                height: BLOCK_HEIGHT,
                color: colors[r],
                visible: true
            });
        }
    }
}

// Event listeners
document.addEventListener('keydown', (e) => {
    keys[e.key] = true;
});
document.addEventListener('keyup', (e) => {
    keys[e.key] = false;
});

canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();
    const mouseX = e.clientX - rect.left;
    paddleX = mouseX - PADDLE_WIDTH / 2;
    // Clamp paddle position
    if (paddleX < 0) paddleX = 0;
    if (paddleX + PADDLE_WIDTH > CANVAS_WIDTH) paddleX = CANVAS_WIDTH - PADDLE_WIDTH;
});

// Reset ball and paddle
function resetBall() {
    ballX = CANVAS_WIDTH / 2;
    ballY = CANVAS_HEIGHT - 40;
    ballDX = 4 * (Math.random() > 0.5 ? 1 : -1);
    ballDY = -4;
}

// Draw paddle
function drawPaddle() {
    ctx.fillStyle = '#4cc9f2';
    ctx.shadowBlur = 15;
    ctx.shadowColor = '#4cc9f2';
    ctx.beginPath();
    ctx.roundRect(paddleX, CANVAS_HEIGHT - PADDLE_HEIGHT - 10, PADDLE_WIDTH, PADDLE_HEIGHT, 7);
    ctx.fill();
    ctx.shadowBlur = 0;
}

// Draw ball
function drawBall() {
    ctx.fillStyle = '#ffffff';
    ctx.shadowBlur = 15;
    ctx.shadowColor = '#ffffff';
    ctx.beginPath();
    ctx.arc(ballX, ballY, BALL_RADIUS, 0, Math.PI * 2);
    ctx.fill();
    ctx.shadowBlur = 0;
}

// Draw blocks
function drawBlocks() {
    for (const block of blocks) {
        if (!block.visible) continue;
        ctx.fillStyle = block.color;
        ctx.shadowBlur = 8;
        ctx.shadowColor = block.color;
        ctx.beginPath();
        ctx.roundRect(block.x, block.y, block.width, block.height, 4);
        ctx.fill();
        ctx.shadowBlur = 0;
    }
}

// Draw score and lives
function drawUI() {
    // Score
    ctx.fillStyle = '#ffffff';
    ctx.font = '20px Arial';
    ctx.textAlign = 'left';
    ctx.fillText(`Score: ${score}`, 15, 35);

    // Lives
    ctx.textAlign = 'right';
    ctx.fillText(`Lives: ${lives}`, CANVAS_WIDTH - 15, 35);
}

// Draw game over / clear screen
function drawOverlay() {
    if (!gameRunning) return;
    
    const allBlocksDestroyed = blocks.every(b => !b.visible);
    if (allBlocksDestroyed || lives <= 0) {
        ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
        ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

        ctx.fillStyle = '#ffffff';
        ctx.font = 'bold 48px Arial';
        ctx.textAlign = 'center';
        
        if (allBlocksDestroyed) {
            ctx.fillText('Clear!', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 30);
        } else {
            ctx.fillText('Game Over', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 30);
        }

        ctx.font = '24px Arial';
        ctx.fillText(`Final Score: ${score}`, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 20);
        
        ctx.font = '18px Arial';
        ctx.fillStyle = '#cccccc';
        const msg = allBlocksDestroyed ? 'Congratulations!' : 'Try again!';
        ctx.fillText(`${msg} Press SPACE to restart`, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 60);

        document.addEventListener('keydown', function handler(e) {
            if (e.key === ' ') {
                document.removeEventListener('keydown', handler);
                resetGame();
            }
        });
    }
}

// Collision detection with blocks
function checkBlockCollision() {
    for (const block of blocks) {
        if (!block.visible) continue;

        const closestX = Math.max(block.x, Math.min(ballX, block.x + block.width));
        const closestY = Math.max(block.y, Math.min(ballY, block.y + block.height));
        const distanceX = ballX - closestX;
        const distanceY = ballY - closestY;
        const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);

        if (distance <= BALL_RADIUS) {
            // Determine collision side
            const overlapLeft = (ballX + BALL_RADIUS) - block.x;
            const overlapRight = (block.x + block.width) - (ballX - BALL_RADIUS);
            const overlapTop = (ballY + BALL_RADIUS) - block.y;
            const overlapBottom = (block.y + block.height) - (ballY - BALL_RADIUS);

            const minOverlapX = Math.min(overlapLeft, overlapRight);
            const minOverlapY = Math.min(overlapTop, overlapBottom);

            if (minOverlapX < minOverlapY) {
                ballDX *= -1;
            } else {
                ballDY *= -1;
            }

            block.visible = false;
            score += 10;
            return;
        }
    }
}

// Update game state
function update() {
    if (!gameRunning) return;

    // Keyboard paddle movement
    const speed = 7;
    if (keys['ArrowLeft'] || keys['a']) {
        paddleX -= speed;
        if (paddleX < 0) paddleX = 0;
    }
    if (keys['ArrowRight'] || keys['d']) {
        paddleX += speed;
        if (paddleX + PADDLE_WIDTH > CANVAS_WIDTH) paddleX = CANVAS_WIDTH - PADDLE_WIDTH;
    }

    // Move ball
    ballX += ballDX;
    ballY += ballDY;

    // Wall collision (left/right)
    if (ballX - BALL_RADIUS <= 0 || ballX + BALL_RADIUS >= CANVAS_WIDTH) {
        ballDX *= -1;
    }

    // Ceiling collision
    if (ballY - BALL_RADIUS <= 0) {
        ballDY *= -1;
    }

    // Bottom - paddle check or lose life
    if (ballY + BALL_RADIUS >= CANVAS_HEIGHT - PADDLE_HEIGHT - 10) {
        if (ballX >= paddleX && ballX <= paddleX + PADDLE_WIDTH) {
            ballDY *= -1;
            // Add slight angle based on where it hit the paddle
            const hitPos = (ballX - paddleX) / PADDLE_WIDTH;
            ballDX = 8 * (hitPos - 0.5);
        } else if (ballY + BALL_RADIUS >= CANVAS_HEIGHT) {
            lives--;
            resetBall();
        }
    }

    // Block collision
    checkBlockCollision();
}

// Draw everything
function draw() {
    ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    drawBlocks();
    drawPaddle();
    drawBall();
    drawUI();
    drawOverlay();
}

// Game loop
function gameLoop() {
    update();
    draw();
    requestAnimationFrame(gameLoop);
}

// Reset everything
function resetGame() {
    score = 0;
    lives = 3;
    gameOver = false;
    gameRunning = true;
    paddleX = (CANVAS_WIDTH - PADDLE_WIDTH) / 2;
    initBlocks();
    resetBall();
}

// Initialize and start
initBlocks();
resetBall();
gameLoop();

まとめ

Q2_K_XLという量子化だったので、ツールコールに何回か失敗してはいますが
最終的にブロック崩しを作ることができました🎉

ローカルLLMの進化は目覚ましいので、今後も動向を追っていければと思っています!

ポート株式会社 エンジニアブログ

Discussion