Fragments of verbose memory

冗長な記憶の断片 - Web技術のメモをほぼ毎日更新

Feb 20, 2026 - 日記

ZeroClawのtrait駆動設計: AIエージェントで「swap anything」を実現する方法

ZeroClawのtrait駆動設計: AIエージェントで「swap anything」を実現する方法

AIエージェントを本番運用する際、「LLMプロバイダーを切り替えたい」「チャットツールを変更したい」といった要求は頻繁に発生します。しかし、多くのフレームワークでは、こうした変更にコード修正が必要です。

ZeroClaw は、この問題を「trait駆動設計」で解決しています。設定ファイル1行の変更だけで、LLMプロバイダー、チャットツール、メモリバックエンド、実行環境を切り替えられます。本記事では、Rustの「trait」という仕組みを使った、この柔軟なアーキテクチャを解説します。

ZeroClawとは

ZeroClawは、Rust で書かれた超軽量AIエージェントインフラストラクチャです。メモリ使用量5MB未満、起動時間10ms以下という性能で、$10のハードウェアでも動作します。

主な特徴は以下の通りです:

  • 超軽量: メモリ5MB未満(Node.js版OpenClawと比較して99%削減)
  • 高速起動: 10ms以下(0.8GHzコアで)
  • 完全スワップ可能: Provider/Channel/Tool/Memory全てが設定変更だけで切り替え可能
  • セキュリティ: ペアリング認証、サンドボックス、allowlist標準装備

本記事では、この「完全スワップ可能」を実現する設計に焦点を当てます。

「trait」とは何か

Rustの「trait」は、他の言語でいう「インターフェース」に相当する機能です。簡単に言えば、「こういう機能を持っていますよ」という約束を定義するものです。

クラス図で理解する

「音を鳴らせるもの」というtraitを例に、クラス図で構造を見てみましょう:

classDiagram
    class Soundable["Soundable (trait)"] {
        +make_sound() String
    }
    class Dog {
        +make_sound() String
    }
    class Cat {
        +make_sound() String
    }
    class Car {
        +make_sound() String
    }
    Soundable <|.. Dog : implements
    Soundable <|.. Cat : implements
    Soundable <|.. Car : implements

Soundableというtraitは「make_sound()メソッドを持つこと」を要求します。DogCatCarはそれぞれ独自の実装を提供しますが、使う側は「Soundableなもの」として統一的に扱えます。

Rustのコードで書くと以下のようになります:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
trait Soundable {
    fn make_sound(&self) -> String;
}

struct Dog;
impl Soundable for Dog {
    fn make_sound(&self) -> String {
        "ワンワン".to_string()
    }
}

struct Cat;
impl Soundable for Cat {
    fn make_sound(&self) -> String {
        "ニャー".to_string()
    }
}

C++の純粋仮想関数との違い

C++経験者なら「純粋仮想関数と何が違うの?」と思うかもしれません。確かに、抽象基底クラスを使えば同様の構造は実現できます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// C++の純粋仮想関数
class Soundable {
public:
    virtual std::string make_sound() = 0;  // 純粋仮想関数
    virtual ~Soundable() = default;
};

class Dog : public Soundable {
public:
    std::string make_sound() override {
        return "ワンワン";
    }
};

しかし、Rustのtraitには重要な違いがあります:

観点 C++ 純粋仮想関数 Rust trait
継承関係 クラス定義時に決定(class Dog : public Soundable 後から追加可能(impl Soundable for Dog
多重継承 ダイヤモンド継承問題が発生しうる 複数traitを安全に実装可能
既存型への適用 不可能(元のクラス定義を変更する必要) 可能(外部の型にもtraitを実装できる)
デフォルト実装 仮想関数で可能だが複雑 シンプルに記述可能
vtableコスト 常に発生 静的ディスパッチならゼロコスト

特に重要なのは「後から追加可能」という点です。例えば、標準ライブラリのString型に対して、自分で定義したtraitを実装できます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 自分で定義したtrait
trait Reversible {
    fn reverse_string(&self) -> String;
}

// 標準ライブラリのString型にtraitを実装
impl Reversible for String {
    fn reverse_string(&self) -> String {
        self.chars().rev().collect()
    }
}

C++では、std::stringクラスを変更せずに新しい仮想関数を追加することは不可能です。この柔軟性が、ZeroClawのようなプラグイン可能なアーキテクチャを実現する鍵になっています。

AIエージェントでの応用

ZeroClawでは、この仕組みをAIエージェントの各コンポーネントに適用しています。「LLMと会話できるもの」「メッセージを送受信できるもの」「データを保存できるもの」といった抽象的な機能を定義し、具体的な実装を差し替え可能にしています。

ZeroClawのtrait駆動アーキテクチャ

ZeroClawでは、主要なコンポーネント全てがtraitとして定義されています。以下のクラス図は、Provider traitの構造を示しています:

classDiagram
    class Provider["Provider (trait)"] {
        +chat(messages) Result
        +stream_chat(messages) Stream
    }
    class OpenAIProvider {
        -api_key: String
        -model: String
        +chat(messages) Result
        +stream_chat(messages) Stream
    }
    class AnthropicProvider {
        -api_key: String
        -model: String
        +chat(messages) Result
        +stream_chat(messages) Stream
    }
    class OllamaProvider {
        -endpoint: String
        -model: String
        +chat(messages) Result
        +stream_chat(messages) Stream
    }
    class CustomProvider {
        -api_url: String
        -api_key: String
        +chat(messages) Result
        +stream_chat(messages) Stream
    }
    Provider <|.. OpenAIProvider : implements
    Provider <|.. AnthropicProvider : implements
    Provider <|.. OllamaProvider : implements
    Provider <|.. CustomProvider : implements

同様の構造が、他のコンポーネントにも適用されています:

Subsystem Trait 役割 実装例
AI Models Provider LLMとの会話 OpenAI, Anthropic, Ollama, カスタムエンドポイント
Channels Channel メッセージ送受信 CLI, Telegram, Discord, Slack, WhatsApp
Memory Memory データ永続化 SQLite, PostgreSQL, Markdown, なし
Tools Tool エージェントの機能 shell, file, git, browser, http_request
Runtime RuntimeAdapter コード実行環境 Native, Docker
Tunnel Tunnel 外部公開 Cloudflare, Tailscale, ngrok, なし

この設計により、コードを1行も変更せずに、設定ファイルだけで実装を切り替えられます。

実例: LLMプロバイダーの切り替え

最も分かりやすい例として、LLMプロバイダーの切り替えを見てみましょう。

OpenAIからAnthropicへ

設定ファイル(~/.zeroclaw/config.toml)で、以下の2行を変更するだけです:

1
2
3
4
5
6
7
# 変更前: OpenAI
default_provider = "openai"
default_model = "gpt-4"

# 変更後: Anthropic
default_provider = "anthropic"
default_model = "claude-sonnet-4"

これだけで、エージェントの会話相手がOpenAIからAnthropicに切り替わります。コードの再コンパイルも、アプリケーションの再起動も不要です(チャネルが起動中なら、次のメッセージから自動的に新しい設定が適用されます)。

ローカルLlamaへの切り替え

さらに、ローカルで動作するOllama に切り替えることもできます:

1
2
3
default_provider = "ollama"
default_model = "llama3.2"
# api_urlは未設定(デフォルトでlocalhost:11434を使用)

カスタムエンドポイントの使用

自前のLLM APIサーバーを使う場合も、設定変更だけで対応できます:

1
2
3
default_provider = "custom:https://your-api.com"
default_model = "your-model"
api_key = "your-key"

これらの切り替えが可能なのは、ZeroClawが「Providerというtraitを実装したもの」として各LLMを扱っているからです。

実例: チャネルの切り替え

メッセージの送受信方法(チャネル)も、同じ仕組みで切り替えられます。

CLIからTelegramへ

開発時はCLIで動作確認し、本番ではTelegramで運用する、といった使い分けができます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 開発環境: CLI
[channels_config]
enabled_channels = ["cli"]

# 本番環境: Telegram
[channels_config]
enabled_channels = ["telegram"]

[channels_config.telegram]
bot_token = "your-bot-token"
allowed_users = ["your-username"]

複数チャネルの同時利用

設定を追加するだけで、複数のチャネルを同時に有効化できます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[channels_config]
enabled_channels = ["telegram", "discord", "slack"]

[channels_config.telegram]
bot_token = "telegram-token"
allowed_users = ["user1"]

[channels_config.discord]
bot_token = "discord-token"
allowed_users = ["123456789"]

[channels_config.slack]
bot_token = "slack-token"
allowed_users = ["U12345678"]

これにより、Telegram、Discord、Slackのどこからメッセージを送っても、同じエージェントが応答します。

実例: メモリバックエンドの切り替え

データの保存方法(メモリバックエンド)も、設定変更だけで切り替えられます。

開発時はSQLite、本番はPostgreSQL

開発環境ではローカルのSQLiteを使い、本番環境では共有のPostgreSQLを使う、といった構成が簡単に実現できます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 開発環境: SQLite
[memory]
backend = "sqlite"
auto_save = true

# 本番環境: PostgreSQL
[memory]
backend = "postgres"
auto_save = true

[storage.provider.config]
provider = "postgres"
db_url = "postgres://user:password@host:5432/zeroclaw"
schema = "public"
table = "memories"

メモリを完全に無効化

テスト環境やステートレスな用途では、メモリを完全に無効化することもできます:

1
2
[memory]
backend = "none"

この場合、Memory traitの「何もしない実装」(no-op backend)が使われます。

なぜtrait駆動設計が重要なのか

1. 依存関係の逆転

通常、アプリケーションは具体的な実装(例: OpenAI APIクライアント)に依存します。これでは、実装を変更するたびにアプリケーションコードを修正する必要があります。

trait駆動設計では、アプリケーションは抽象的なtrait(例: Provider)に依存し、具体的な実装は外部から注入されます。これにより、アプリケーションコードを変更せずに実装を差し替えられます

通常の設計:
  Application → OpenAI API Client

trait駆動設計:
  Application → Provider trait ← OpenAI/Anthropic/Ollama

2. テスト容易性の向上

本番環境では実際のLLM APIを使い、テスト環境ではモックを使う、といった切り替えが簡単になります:

1
2
3
4
5
6
7
// テスト用のモック実装
struct MockProvider;
impl Provider for MockProvider {
    fn chat(&self, message: &str) -> String {
        "テスト応答".to_string()
    }
}

3. 段階的な移行

新しいLLMプロバイダーやチャットツールに移行する際、一部のユーザーだけ新しい実装を試す、といった段階的な移行が可能になります。

4. ベンダーロックインの回避

特定のLLMプロバイダーやクラウドサービスに依存しない設計になるため、価格改定やサービス終了のリスクに柔軟に対応できます。

ZeroClawの実装を見てみる

実際のコードを見ると、trait駆動設計の仕組みがより明確になります。

Provider traitの定義

ZeroClawでは、Provider traitが以下のように定義されています(簡略化):

1
2
3
4
pub trait Provider {
    fn chat(&self, messages: Vec<Message>) -> Result<String>;
    fn stream_chat(&self, messages: Vec<Message>) -> Result<Stream<String>>;
}

このtraitを実装すれば、どんなLLMでもZeroClawで使えます。

具体的な実装例

OpenAI用の実装は、このtraitを満たすように書かれています:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pub struct OpenAIProvider {
    api_key: String,
    model: String,
}

impl Provider for OpenAIProvider {
    fn chat(&self, messages: Vec<Message>) -> Result<String> {
        // OpenAI APIを呼び出す実装
    }
    
    fn stream_chat(&self, messages: Vec<Message>) -> Result<Stream<String>> {
        // ストリーミング応答の実装
    }
}

Anthropic用も同じtraitを実装します:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pub struct AnthropicProvider {
    api_key: String,
    model: String,
}

impl Provider for AnthropicProvider {
    fn chat(&self, messages: Vec<Message>) -> Result<String> {
        // Anthropic APIを呼び出す実装
    }
    
    fn stream_chat(&self, messages: Vec<Message>) -> Result<Stream<String>> {
        // ストリーミング応答の実装
    }
}

実装の選択

設定ファイルの内容に基づいて、実行時に適切な実装が選択されます:

1
2
3
4
5
6
7
8
fn create_provider(config: &Config) -> Box<dyn Provider> {
    match config.default_provider.as_str() {
        "openai" => Box::new(OpenAIProvider::new(config)),
        "anthropic" => Box::new(AnthropicProvider::new(config)),
        "ollama" => Box::new(OllamaProvider::new(config)),
        _ => Box::new(CustomProvider::new(config)),
    }
}

このBox<dyn Provider>という型が重要です。これは「Providerというtraitを実装した何か」を表し、具体的な型(OpenAIかAnthropicか)を気にせず扱えます。

他のフレームワークとの比較

LangChain(Python)

LangChain もプロバイダーの抽象化を提供していますが、Pythonの動的型付けに依存しています:

1
2
3
4
5
# LangChainの例
from langchain.llms import OpenAI, Anthropic

# 実行時に型が決まる
llm = OpenAI() if use_openai else Anthropic()

ZeroClawのtrait駆動設計では、コンパイル時に型安全性が保証されます。

LlamaIndex(Python)

LlamaIndex も同様に、Pythonの柔軟性を活かした設計ですが、型チェックは実行時になります。

ZeroClawの優位性

Rustのtrait駆動設計により、以下の利点があります:

  • コンパイル時の型チェック: 実装の不整合を実行前に検出
  • ゼロコストの抽象化: 実行時のオーバーヘッドがない
  • 明示的な契約: traitが「何ができるか」を明確に定義

trait駆動設計を自分のプロジェクトに応用する

ZeroClawのアプローチは、Rust以外の言語でも応用できます。

TypeScriptでの応用

TypeScript のinterfaceを使えば、同様の設計が可能です:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface Provider {
  chat(messages: Message[]): Promise<string>;
  streamChat(messages: Message[]): AsyncIterator<string>;
}

class OpenAIProvider implements Provider {
  async chat(messages: Message[]): Promise<string> {
    // OpenAI API呼び出し
  }
  
  async *streamChat(messages: Message[]): AsyncIterator<string> {
    // ストリーミング実装
  }
}

class AnthropicProvider implements Provider {
  async chat(messages: Message[]): Promise<string> {
    // Anthropic API呼び出し
  }
  
  async *streamChat(messages: Message[]): AsyncIterator<string> {
    // ストリーミング実装
  }
}

Goでの応用

Go のinterfaceも同じ考え方で使えます:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type Provider interface {
    Chat(messages []Message) (string, error)
    StreamChat(messages []Message) (<-chan string, error)
}

type OpenAIProvider struct {
    apiKey string
    model  string
}

func (p *OpenAIProvider) Chat(messages []Message) (string, error) {
    // OpenAI API呼び出し
}

func (p *OpenAIProvider) StreamChat(messages []Message) (<-chan string, error) {
    // ストリーミング実装
}

設計のポイント

どの言語でも、以下のポイントを押さえることが重要です:

  1. 小さなインターフェース: 必要最小限のメソッドだけを定義
  2. 明確な責務: 1つのインターフェースは1つの責務に集中
  3. 設定駆動: 実装の選択を設定ファイルで制御
  4. 依存性の注入: 具体的な実装を外部から注入

ZeroClawを試してみる

ZeroClawはGitHub で公開されています。Rustの開発環境があれば、以下のコマンドでビルドできます:

1
2
3
4
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
cargo build --release --locked
cargo install --path . --force --locked

Homebrewを使う場合は、さらに簡単です:

1
brew install zeroclaw

インストール後、以下のコマンドで対話的なセットアップが始まります:

1
zeroclaw onboard --interactive

プロバイダー、モデル、チャネルを選択すると、設定ファイルが自動生成されます。

まとめ

ZeroClawのtrait駆動設計は、AIエージェントの柔軟性を大きく向上させます:

  • 設定変更だけで実装を切り替え: コード修正不要
  • 複数の実装を同時利用: Telegram/Discord/Slackを同時に有効化
  • テストと本番で異なる実装: モックと実際のAPIを簡単に切り替え
  • ベンダーロックインの回避: 特定のサービスに依存しない

この設計は、Rustの「trait」という仕組みを活用していますが、考え方自体は他の言語でも応用できます。TypeScriptのinterface、Goのinterface、Javaのinterfaceなど、多くの言語が同様の抽象化機能を提供しています。

AIエージェントを構築する際は、「具体的な実装に依存しない」設計を意識することで、長期的なメンテナンス性と柔軟性を確保できます。ZeroClawのアーキテクチャは、その優れた実例と言えるでしょう。

参考リンク