【SmolAgents】Dockerコンテナをサンドボックスとして使用する時はGradioを使うべし


はじめに

SmolAgents(v1.12.0)でDockerコンテナをサンドボックスとして使用した時に出力が段階的にならない問題に直面しました。

昨日なんとか解決方法を見つけたのですが、その後Gradioを使えばもっと簡単に解決することがわかりました。
touch-sp.hatenablog.com
SmolAgentsライブラリではGradioでAgentを実行するための「GradioUI」というのが用意されています。

Dockerfile

FROM python:3.12-bullseye

# ビルド依存関係のインストール
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential \
        python3-dev \
	# ポートのリスニング状態を確認するため(Gradioを使う時)
	net-tools && \
    pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir 'smolagents[openai,mcp,gradio]' && \
    # Node.jsの公式リポジトリを追加
    curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
    apt-get install -y nodejs && \
    npm install -g npm@latest && \
    # npmグローバルパッケージのインストール
    npm i -g @modelcontextprotocol/server-filesystem && \
    # クリーンアップ
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 作業ディレクトリの設定
WORKDIR /app

# デフォルトコマンド
CMD ["python", "-c", "print('Container ready')"]
docker build --force-rm=true -t agent-sandbox .

sandbox.py

import docker
import time

class DockerSandbox:
    def __init__(self, image_name="agent-sandbox"):
        self.client = docker.from_env()
        self.container = None
        self.image_name = image_name

    def create_container(self):
        try:
            # コンテナを作成(ポートマッピングを追加)
            self.container = self.client.containers.run(
                self.image_name,
                command="tail -f /dev/null",  # コンテナを実行状態に保つ
                detach=True,
                tty=True,
                extra_hosts={"host.docker.internal": "host-gateway"},
                network_mode="bridge",
                ports={'7860/tcp': 7860},  # Gradioのデフォルトポート
                volumes={
                    "/home/hoge/data": {"bind": "/app/data", "mode": "rw"}
                }
            )
            print(f"コンテナを作成しました (ID: {self.container.id[:8]}...)")
        except Exception as e:
            raise Exception(f"コンテナ作成エラー: {e}")

    def gradio_run(self, code: str) -> None:
        if not self.container:
            self.create_container()

        # バックグラウンドで実行
        self.container.exec_run(
            cmd=["python", "-c", code],
            detach=True
        )
        
        # ポート待機確認
        print("Gradioサーバーを起動中...", end="", flush=True)
        max_attempts = 10
        for attempt in range(max_attempts):
            time.sleep(1)
            print(".", end="", flush=True)
            
            # netstatを使用してポートのリスニング状態を確認
            netstat_result = self.container.exec_run(
                cmd=["bash", "-c", "netstat -tulpn 2>/dev/null | grep 7860 || echo ''"]
            )
            
            if netstat_result.output.strip():
                print(" 完了!")
                print("\n✅ Gradioアプリが起動しました")
                print("📊 http://localhost:7860 でアクセスできます")
                return None
        
        print("\n❌ サーバー起動に失敗しました")
        return None
            
    def cleanup(self):
        if self.container:
            try:
                self.container.stop()
                self.container.remove()
                print("Container stopped and removed successfully")
            except Exception as e:
                print(f"エラー: {e}")
            finally:
                self.container = None
    
    def get_logs(self):
        """コンテナ内のプロセス状態を取得"""
        if not self.container:
            return "コンテナが起動していません"
            
        # プロセス確認
        ps_cmd = "ps aux | grep python | grep -v grep"
        ps_result = self.container.exec_run(cmd=["bash", "-c", ps_cmd])
        ps_output = ps_result.output.decode('utf-8').strip()
        
        # ポート確認
        port_cmd = "netstat -tulpn 2>/dev/null | grep 7860 || echo 'ポートが開いていません'"
        port_result = self.container.exec_run(cmd=["bash", "-c", port_cmd])
        port_output = port_result.output.decode('utf-8').strip()
        
        return f"プロセス状態:\n{ps_output}\n\nポート状態:\n{port_output}"

Agent実行ファイル

from sandbox import DockerSandbox

# DockerSandboxのインスタンスを作成
sandbox = DockerSandbox()

agent_code = """
from smolagents import CodeAgent, OpenAIServerModel, ToolCollection, GradioUI
from mcp import StdioServerParameters

model=OpenAIServerModel(
    model_id="gemma-3-12b-it-4bit",
    api_base="http://host.docker.internal:8080",
    api_key="EMPTY"
)

server_parameters = StdioServerParameters(
    command="npx",
    args=["-y", "@modelcontextprotocol/server-filesystem","/app/data"]
)

with ToolCollection.from_mcp(server_parameters) as tool_collection:
    agent = CodeAgent(
        model=model,
        tools=[*tool_collection.tools]
    )

    # エージェントの実行
    GradioUI(agent).launch(server_name='0.0.0.0', server_port=7860, share=False)
"""



try:
    # 最小限の出力でGradioアプリを起動
    sandbox.gradio_run(agent_code)
    
    # ユーザーが終了するまで待機
    print("\nアプリ実行中... Ctrl+C で終了します")
    
    while True:
        try:
            cmd = input("\n> ")
            if cmd.lower() == "exit" or cmd.lower() == "quit":
                break
            elif cmd.lower() == "status":
                print("\n" + sandbox.get_logs())
            elif cmd.lower() == "help":
                print("\nコマンド一覧:")
                print("  status - サーバー状態を確認")
                print("  exit   - アプリを終了")
                print("  help   - このヘルプを表示")
            elif cmd.strip() == "":
                pass
            else:
                print(f"不明なコマンド: {cmd}. 'help'と入力してコマンド一覧を表示")
        except KeyboardInterrupt:
            print("\n終了します...")
            break
    
except Exception as e:
    print(f"エラーが発生しました: {e}")
finally:
    # 終了処理
    sandbox.cleanup()

言語モデル

言語モデルはllama.cppで実行している「gemma-3-12b-it-Q4_K_M.gguf」です。

./llama-server -m ~/models/gemma-3-12b-it-Q4_K_M.gguf -c 8192 -ngl 30 --host 0.0.0.0

Dockerコンテナからアクセスする場合には「--host 0.0.0.0」が必須です。