Fragments of verbose memory

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

Feb 20, 2026 - 日記

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

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

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

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

SubsystemTrait役割実装例
AI ModelsProviderLLMとの会話OpenAI, Anthropic, Ollama, カスタムエンドポイント
ChannelsChannelメッセージ送受信CLI, Telegram, Discord, Slack, WhatsApp
MemoryMemoryデータ永続化SQLite, PostgreSQL, Markdown, なし
ToolsToolエージェントの機能shell, file, git, browser, http_request
RuntimeRuntimeAdapterコード実行環境Native, Docker
TunnelTunnel外部公開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のアーキテクチャは、その優れた実例と言えるでしょう。

参考リンク