BonsaiでSKILLは使えるか試してみた

BonsaiでSKILLは使えるか試してみた

2026.04.13

背景

AIエージェントにおけるスキル は強力な機能の1つですが、これがローカルで動く小さなLLMでも実現できるのか気になっていました。
きっかけはGoogleが公開したGemma4で、ツール呼び出しに対応しているという情報を知ったこと。Gemma4はGoogle AI Edge Gallery経由でAndroidアプリ内でも動作し、SKILLも利用可能です。
パフォーマンスの良さで定評があるBonsaiの方も気になったので、この8BモデルでSKILLが使えるのか試してみることにしました。

試したモデルと環境

Bonsai

Bonsaiは、PrismMLが開発した1ビット量子化を採用したモデルです。
公式発表によると、標準的な16-bit 8Bモデル(Qwen3 8B等)と比べて14倍の圧縮を実現し、わずか1.15GBのサイズに収まります。

セットアップ

セットアップは公式のBonsai-demoリポジトリを活用しました。

git clone https://github.com/PrismML-Eng/Bonsai-demo
cd Bonsai-demo
bash setup.sh
bash scripts/run-llama-server.sh

このコマンドを実行するだけで、llama-serverが起動し、モデルの推論サーバーが利用可能になります。
不足ツールがあれば自動で案内してくれるため、追加の調査は不要です。

Gemma4

比較対象として、UnslothがHugging Faceで公開しているGemma4のGGUF形式モデルも試しました。

hf download unsloth/gemma-4-E4B-it-GGUF gemma-4-E4B-it-Q4_K_M.gguf \
  --local-dir ~/models/gemma4/

llama-serverの起動コマンドは以下の通りです。--reasoning-budget 0enable_thinking: falseでthinkingを無効化し、ツール呼び出しに集中させます。
ポートはBonsaiと衝突しないよう8081を使います。

curl -OL https://github.com/ggml-org/llama.cpp/releases/download/b8739/llama-b8739-bin-macos-arm64.tar.gz
tar xvfz llama-b8739-bin-macos-arm64.tar.gz -C /tmp/
/tmp/llama-b8739/llama-server \
  -m ~/models/gemma4/gemma-4-E4B-it-Q4_K_M.gguf \
  --host 0.0.0.0 --port 8081 \
  -ngl 99 -c 65536 \
  --reasoning-budget 0 --reasoning-format none \
  --chat-template-kwargs '{"enable_thinking": false}'

ADKでのスキル実装

Google Agent Development Kit(ADK)は、Googleが提供するLLMエージェント構築フレームワークです。
スキルはツールセットとして定義でき、エージェントに渡すとLLMが使い方を判断して呼び出します。

スキルの構成

ADKではスキルは任意のディレクトリに配置できます。今回は.agents/skills/<skill-name>/以下に置きました。最小限の構成は以下の通りです。

.agents/skills/code-review/
├── SKILL.md
└── scripts/
    └── review.sh

SKILL.mdはメタデータとスキルの説明を含むファイルで、次のような形式です。

---
name: code-review
description: Review Python code files for bugs, style issues, and improvements. Use when asked to review or check code quality.
---

# Code Review Skill

Review Python code and report issues.

## How to use

1. Ask the user which file to review (or accept a file path as input)
2. Run `scripts/review.sh` with the file path as argument
3. Report the findings

## Script

Use `run_skill_script` with:

- `skill_name`: "code-review"
- `file_path`: "scripts/review.sh"
- `args`: [the file path to review]

After running the script, you MUST provide a code review of the output.
Review the code for:

- Bugs or errors
- Style issues
- Suggestions for improvement

Format your response as:

## Code Review: <filename>

- Line X: <issue>
- Line Y: <issue>

## Summary: <overall assessment>

スクリプトはSKILL.mdからrun_skill_scriptツールで実行します。今回使ったreview.shは、ruffでlintを実行してGitHub Actions形式で結果を出力します。

#!/bin/bash
set -e
if [ -z "$1" ]; then
  echo "Usage: review.sh <file_path>"
  exit 1
fi
uv run ruff check --select ALL --output-format github "$1"

実装の流れ

ADKでのスキル実装は、Python APIでload_skill_from_dir()を使ってスキルを読み込み、SkillToolsetに渡します。
ただしADK公式ではスクリプト実行を「実験段階・未サポート」と明記しており、今回使ったrun_skill_scriptを利用するにはUnsafeLocalCodeExecutorの設定が必要です。
将来のバージョンで動作しなくなる可能性があります。

AIがスキルの説明(SKILL.md)を見て自動判断するか、明示的にメッセージで指示します。

スキルをディレクトリから読み込み、SkillToolsetにまとめてエージェントに渡すのが最小限の実装です。

#!/usr/bin/env uv run -s
# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "google-adk>=1.28.1",
#   "litellm>=1.83.0",
# ]
# ///

import argparse
import asyncio
import os
import sys
from pathlib import Path

from google.adk.agents import Agent
from google.adk.code_executors import UnsafeLocalCodeExecutor
from google.adk.models import LiteLlm
from google.adk.runners import InMemoryRunner
from google.adk.skills import load_skill_from_dir
from google.adk.tools.skill_toolset import SkillToolset
from google.genai import types

MODEL_NAME = os.environ.get("MODEL_NAME", "Bonsai-8B.gguf")  # or "gemma-4-E4B-it-Q4_K_M.gguf"
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8080/v1")
SKILL_DIR = Path(".agents/skills")

def load_skills(skill_dir: Path) -> list:
    skills = []
    if not skill_dir.exists():
        return skills
    for subdir in skill_dir.iterdir():
        if subdir.is_dir() and (subdir / "SKILL.md").exists():
            skills.append(load_skill_from_dir(subdir))
    return skills

async def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--message", "-m", type=str)
    parser.add_argument("--skill-dir", type=str, action="append", dest="skill_dirs")
    args = parser.parse_args()

    message = args.message
    if not message and not sys.stdin.isatty():
        message = sys.stdin.read().strip()
    if not message:
        message = "このPythonコードをレビューしてください: ..."

    skill_dirs = [Path(d) for d in args.skill_dirs] if args.skill_dirs else [SKILL_DIR]
    skills = []
    for d in skill_dirs:
        skills.extend(load_skills(d))

    tools = [
        SkillToolset(
            skills=skills,
            code_executor=UnsafeLocalCodeExecutor(),
        )
    ] if skills else []

    agent = Agent(
        model=LiteLlm(
            model=f"openai/{MODEL_NAME}",
            api_base=BASE_URL,
            api_key="not-needed",
        ),
        name="mini_adk_agent",
        instruction="You are a helpful assistant with access to skills.",
        tools=tools,
    )
    runner = InMemoryRunner(agent=agent, app_name="mini-adk-agent")
    await runner.session_service.create_session(
        app_name="mini-adk-agent",
        user_id="user",
        session_id="session",
    )

    async for event in runner.run_async(
        user_id="user",
        session_id="session",
        new_message=types.Content(role="user", parts=[types.Part(text=message)]),
    ):
        for call in event.get_function_calls():
            import json
            args_str = json.dumps(call.args, ensure_ascii=False) if call.args else "{}"
            print(f"  → tool: {call.name}({args_str})", file=sys.stderr)
        for resp in event.get_function_responses():
            print(f"  ← {resp.name}: (response received)", file=sys.stderr)
        if event.content and event.content.parts:
            for part in event.content.parts:
                if part.text:
                    print(part.text, end="")

if __name__ == "__main__":
    asyncio.run(main())

実験結果

テスト方法

テストはスクリプトで自動化して、再現性を確保しました。モデルを切り替えながら複数回実行し、結果をファイルに保存します。

テストスクリプトは2種類です。

スクリプト 概要
test_code_review.sh code-reviewスキルを単体で呼び出す単体テスト。10回実行してスキルの呼び出し成否を記録する
test_composite.sh task-managementとcode-reviewを組み合わせた複合テスト。複数ステップの指示追従を検証する

エージェント本体(mini_adk_agent.py)に--skill-dir--messageを渡して実行し、標準出力とエラー出力をファイルに保存します。
MODEL_NAMEBASE_URLの環境変数を切り替えてBonsaiとGemma4の両方をテストします。

code-review 単体

単体テストはtest_code_review.shで実行します。結果はresults/code-review/に保存されます。

#!/bin/bash
set -e

MODEL_NAME="${MODEL_NAME:-Bonsai-8B.gguf}"
BASE_URL="${BASE_URL:-http://localhost:8080/v1}"
RUNS="${RUNS:-10}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
TARGET_FILE="$SCRIPT_DIR/mini_adk_agent.py"
RESULTS_DIR="${RESULTS_DIR:-$PROJECT_DIR/results/code-review}"

mkdir -p "$RESULTS_DIR"

TEST_MESSAGE="Call run_skill_script with skill_name=code-review, script_path=scripts/review.sh, args=[\"$TARGET_FILE\"]."

SAFE_MODEL="${MODEL_NAME//./-}"
SUCCESS=0; FAIL=0

for i in $(seq 1 "$RUNS"); do
    OUT_FILE="$RESULTS_DIR/${SAFE_MODEL}_run${i}.txt"
    echo -n "Run $i/$RUNS ... "
    export MODEL_NAME BASE_URL
    cd "$PROJECT_DIR"
    if uv run "$SCRIPT_DIR/mini_adk_agent.py" \
            --skill-dir .agents/skills \
            --message "$TEST_MESSAGE" \
            > "$OUT_FILE" 2>&1; then
        echo "OK"; SUCCESS=$((SUCCESS + 1))
    else
        echo "FAIL"; FAIL=$((FAIL + 1))
    fi
done

echo "Results: $SUCCESS/$RUNS succeeded ($FAIL failed)"

Bonsai-8Bは10回実行したすべてのrun(10/10)が成功しました。
毎回以下の形式でスキルを正確に実行し、ruffのlint結果を正常に返しました。

{
  "skill_name": "code-review",
  "file_path": "scripts/review.sh",
  "args": ["<absolute_path>"]
}

複合テスト

複合テストはtest_composite.shで実行します。各runで独立したTODOファイル(mktempで生成)を使い、runをまたいでタスクが蓄積しないようにしました。
実行後はTODOファイルの内容を結果ファイルに追記します。

#!/bin/bash
set -e

MODEL_NAME="${MODEL_NAME:-Bonsai-8B.gguf}"
BASE_URL="${BASE_URL:-http://localhost:8080/v1}"
RUNS="${RUNS:-10}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
TARGET_FILE="$SCRIPT_DIR/mini_adk_agent.py"
RESULTS_DIR="${RESULTS_DIR:-$PROJECT_DIR/results/composite}"

mkdir -p "$RESULTS_DIR"

TEST_MESSAGE="Do the following in order:
1. Call run_skill_script with skill_name=task-management, script_path=scripts/todo.sh, args=[\"add\", \"コードレビュー\"].
2. Call run_skill_script with skill_name=code-review, script_path=scripts/review.sh, args=[\"$TARGET_FILE\"].
3. For EACH issue in the review output, call run_skill_script with skill_name=task-management, script_path=scripts/todo.sh, args=[\"add\", \"<issue text>\"]. One call per issue.
4. Call run_skill_script with skill_name=task-management, script_path=scripts/todo.sh, args=[\"list\"]."

SAFE_MODEL="${MODEL_NAME//./-}"
SUCCESS=0; FAIL=0

for i in $(seq 1 "$RUNS"); do
    TODO_FILE="$(mktemp /tmp/verify_todos_XXXXXX.tsv)"
    OUT_FILE="$RESULTS_DIR/${SAFE_MODEL}_run${i}.txt"
    echo -n "Run $i/$RUNS ... "
    export MODEL_NAME BASE_URL TODO_FILE
    cd "$PROJECT_DIR"
    if uv run "$SCRIPT_DIR/mini_adk_agent.py" \
            --skill-dir .agents/skills \
            --message "$TEST_MESSAGE" \
            > "$OUT_FILE" 2>&1; then
        echo "OK"; SUCCESS=$((SUCCESS + 1))
    else
        echo "FAIL"; FAIL=$((FAIL + 1))
    fi
    if [ -f "$TODO_FILE" ]; then
        echo "" >> "$OUT_FILE"
        echo "=== TODO FILE ===" >> "$OUT_FILE"
        cat "$TODO_FILE" >> "$OUT_FILE"
        rm -f "$TODO_FILE"
    fi
done

echo "Results: $SUCCESS/$RUNS succeeded ($FAIL failed)"
# Gemma4で複合テスト実行
MODEL_NAME=gemma-4-E4B-it-Q4_K_M.gguf BASE_URL=http://localhost:8081/v1 \
  bash blog_scripts/test_composite.sh

task-managementスキルはTODOリストをTSVファイルで管理します。add/list/delコマンドで操作します。

SKILL.mdファイルはこうです。

---
name: task-management
description: Manage TODO tasks - list, add, or delete tasks. Use when asked to manage tasks, todos, or when asked to add/list/delete a task.
---

# Task Management Skill

Manage a TODO list stored in a TSV file.

## How to use

Run `scripts/todo.sh` with one of the following subcommands:

### List all tasks

```bash
scripts/todo.sh list
```

Output:

```
  1	Task 1
  2	Task 2
```

### Add a task

```bash
scripts/todo.sh add "New task description"
```

### Delete a task by number

```bash
scripts/todo.sh del 1
```

## Implementation

The TODO list is stored in `$HOME/.todos.tsv` (configurable via `TODO_FILE` environment variable).

todo.shはこのコードです。

#!/bin/bash
TODO_FILE="${TODO_FILE:-$HOME/.todos.tsv}"

cmd="${1:-list}"
shift

case "$cmd" in
  list)
    [ ! -f "$TODO_FILE" ] && echo "(no tasks)" && exit 0
    nl -ba -nrz -w3 "$TODO_FILE" | awk -F'\t' '{printf "%s\t%s\n", $1, $2}'
    ;;
  add)
    [ -z "$*" ] && { echo "Usage: todo add <text>"; exit 1; }
    echo "$*" >> "$TODO_FILE"
    echo "Added: $*"
    ;;
  del)
    [ -z "$1" ] && { echo "Usage: todo del <number>"; exit 1; }
    sed -i "" "${1}d" "$TODO_FILE"
    echo "Deleted task $1"
    ;;
  *)
    echo "Usage: todo.sh [list|add|del]"
    exit 1
    ;;
esac

Bonsai-8Bの動作ログです。

  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["add", "コードレビュー"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "code-review", "file_path": "scripts/review.sh", "args": ["/path/to/mini_adk_agent.py"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["add", "Shebang is present but file is not executable"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["add", "Missing docstring in public module"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["add", "Line too long (94 > 88)"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["add", "Missing docstring in public function"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["add", "Trailing comma missing"]})
  ← run_skill_script: (response received)
  → tool: run_skill_script({"skill_name": "task-management", "file_path": "scripts/todo.sh", "args": ["list"]})
  ← run_skill_script: (response received)

最終的なTODOファイルの内容です。

コードレビュー
Shebang is present but file is not executable
Missing docstring in public module
Line too long (94 > 88)
Missing docstring in public function
Trailing comma missing

複合テスト結果

Bonsai-8Bの複合テスト(10回実行)では、すべてのrunがエラーなく完了しましたが、ステップ3(問題ごとの個別タスク追加)で10回中5回が期待通りに動作しませんでした。
正常時のツール呼び出し数は8回(タスク追加1回+コードレビュー1回+個別問題タスク5回+リスト表示1回)ですが、残り5回は4回の呼び出しに留まりました。
4回の場合は検出された5つの問題を1つのタスクにまとめてしまい(タスク追加1回+コードレビュー1回+まとめたタスク追加1回+リスト表示1回)、個別タスクとして追加する指示に従えていませんでした。

Gemma4の複合テスト(10回実行)はBonsai-8Bとは異なる問題が出ました。
各runでツール呼び出し回数が5〜15回の範囲でばらつき、一貫性がありませんでした。
ステップ1・2(タスク追加・コードレビュー実行)はほぼ成功しますが、ステップ3(問題ごとの個別タスク追加)で崩れます。
多くのrunでコードレビュー結果の一部しかタスク化されず、引数形式のエラー(URLエンコードの混入、"add"キーワードの欠落)も出ました。
10回中、ステップ3をすべて実行できたのは1回だけです。

まとめ

Bonsai-8Bで単一スキルの呼び出しは安定して動作しました(10/10成功)。正確なパラメータ形式で実行できます。
ただし複合的な指示では、10回中5回で複数の問題を1つのタスクに統合してしまうなど、指示追従の精度が落ちました。
Gemma4では、ツール呼び出し回数や問題の検出件数がrunごとにばらつき、引数形式のエラーも見られました。これはモデルサイズやツール呼び出し能力の違いを示唆しており、用途に応じた選択が必要です。
小型ローカルLLMでのSKILL利用は、単純な呼び出しなら可能ですが、複合タスクでは安定した指示追従を期待するのはまだ難しそうです。

この記事をシェアする

関連記事