ローカル環境だけで動く音声操作AIエージェントを自作した

Dev.to / 2026/4/18

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • 著者はクラウドに接続せずAPIキーも使わない、ローカル環境のみで動作する音声操作AIエージェントを構築し、プライバシーと処理の見通しを高めました。
  • エージェントは音声をテキストに変換し、ローカルLLMでユーザーの意図をJSON形式の実行計画に分類してから、コード生成・ファイル作成・要約・チャットなどの各ツールを実行します。
  • 音声1つの指示で複数のステップを順番に実行できるチェーニングに対応し、ディスクへファイルを書き込む前にユーザー確認を求める仕組みも備えています。
  • システムは段階的な線形パイプラインとして設計され、音声・意図分類・実行・ツール・メモリなど6つのモジュールに分割されています。
  • さらに、ローカルで動く一連のパイプラインを成立させる際に直面した実際の開発上の課題や、モデル選定(例:STTにfaster-whisper、Ollama経由でllama3.1)にも触れています。

クラウドなし。APIキーなし。必要なのは、あなたの声(または音声入力)、ローカルLLM、そして実際に動くクリーンなパイプラインだけです。

アイデア

ほとんどのAIアシスタントはクラウド依存です。あなたが何かを言うと、それはどこかのサーバーに送信され、処理されて、戻ってきます。これはそれで問題なく動きます。ですが、プライバシーを気にするようになると、遅延が気になるようになると、あるいは「裏側で実際に何が起きているのか」を理解したくなると、話は別です。

私は別のものを作りたくなりました。完全にローカルで動作する、音声操作のAIエージェントです。あなたが話す(または入力する)と、それが何をしたいのかを判断し、それを実行します。コードを書くことでも、ファイルを作ることでも、テキストを要約することでも、会話をすることでも、すべてです。すべてはあなたのマシン上で行われます。

この記事では、私がそれをどのように作ったのか、選んだモデル、そして途中で直面した本当の課題について説明します。

エージェントができること

アーキテクチャに入る前に、完成したシステムがユーザー視点でどう見えるかを示します:

  • "Pythonのリトライ関数を書いて、retry.pyとして保存して" → コードを生成し、output/フォルダに保存します
  • "このテキストを要約して、notes.txtとして保存して" → 内容を要約してからファイルを書き込みます
  • "projectsというフォルダを作成して" → 完了
  • "再帰(recursion)って何?" → 会話のように応答します

さらに、チェーンにも対応しています。つまり、1つの音声コマンドが複数の手順を順番に実行できます。そして、ファイルがディスクに書き込まれる前に確認パネルが表示されるので、常に自分でコントロールできます。

アーキテクチャ概要

このシステムは線形のパイプラインです。各ステージには1つの仕事があり、その出力を次のステージへ渡します。

 音声入力(マイクまたはファイルアップロード)
        ↓
️  音声認識(Speech-to-Text)        [faster-whisper, Whisper base]
        ↓
  意図分類(Intent Classifier)      [Ollama経由でllama3.1:8b]
        ↓
⚙️  ツール実行(Tool Executor)
   ├── WRITE_CODE          → qwen2.5-coder:7b
   ├── SAVE_FILE           → output/へ書き込み
   ├── CREATE_FILE         → 空のファイルを作成
   ├── CREATE_FOLDER       → ディレクトリを作成
   ├── SUMMARIZE_TEXT      → llama3.1:8b
   └── GENERAL_CHAT        → llama3.1:8b
        ↓
️  Gradio UI

このプロジェクトは、6つの焦点を絞ったモジュールに分かれています:

ファイル 役割
voice.py faster-whisperを使って音声をテキストに変換
intent_classifier.py テキストをLLMへ送信し、手順のJSONプランを解析する
executor.py 各ステップを順番に実行し、ステップ間で出力を連結する
tools.py 実際のツール関数——ファイル操作、コード生成、チャット
memory.py セッション内で会話履歴をロールイングで保持する
main.py Gradio UIとイベントの配線

各モジュールは独立しています。STTモデルを差し替えたり、OllamaをAPI呼び出しに置き換えたり、新しいツールを追加したりしても、他の部分には触れずに済みます。

私が選んだモデル(そしてその理由)

各仕事に適したモデルを選ぶことは、見た目以上に重要でした。すべてに対して1つの汎用モデルを使う方が単純ですが、専門モデルを使ったときの品質差は非常に大きいです。

音声認識(Speech-to-Text):faster-whisper(Whisper base)

faster-whisper は、CTranslate2を使ったOpenAIのWhisperの再実装です。このプロジェクトでは、CPUで int8 の量子化を適用した base モデルを使用しました。

フルWhisperよりこれを選んだ理由:

  • int8の量子化により、標準のfloat32モデルと比べてメモリ使用量がだいたい半分になります
  • CPU上では明らかに速いです——通常、5秒の音声クリップで 1〜3秒程度です
  • VAD(Voice Activity Detection:発話区間検出)のフィルタリングが組み込まれており、無音区間を自動でスキップするため、静かな録音での幻覚(ハルシネーション)を減らせます
  • baseモデルは十分小さく、すぐに読み込めて、明瞭な英語のコマンドには十分に正確です

音声コマンド(短くて目的がはっきりした文)では、baseモデルが非常にうまく機能します。長く、ニュアンスのある話を文字起こしする場合にのみ、より大きなモデルが必要になるでしょう。

意図分類(Intent Classification):llama3.1:8b をOllama経由で

このシステムの核です。文字起こしの後、このモデルがユーザーのテキストを読み取り、実行すべき手順を正確に記述する構造化JSONプランを返します。

なぜ llama3.1:8b なのか?

  • ここで重要なのは確実に指示に従うことです——必要なのは毎回必ず有効なJSONを返すことであり、文章ではありません
  • 80億パラメータ(8 billion)なので、ニュアンスのあるコマンドを理解するのに十分ですが、16GB RAMのマシンでも動かせる程度に小さい
  • 複数ステップの分解をうまく処理します。たとえば「コードを書いて保存して」なら、正しい順序で2つのステップを適切に別々に出力します
  • 分類器には温度(temperature)を0に設定しているため、応答が決定的で一貫します

コード生成:qwen2.5-coder:7b をOllama経由で

意図が WRITE_CODE の場合、要求は汎用のモデルではなく、コードに特化した別のモデルへ送られます。

コード用に別モデルを使う理由:
なぜなら、実際により良いコードを書けるからです。qwen2.5-coder はプログラミングタスクに特化して微調整されています。実際のところ、その差ははっきりしています——よりきれいな構造、より良い変数名、より慣用的なパターンです。汎用モデルでコード生成することも可能ですが、コード特化モデルの方がより良い結果になります。

意図分類器がどのように動くか

ここが最も面白い部分なので、詳しく説明する価値があります。

ユーザーのテキストが届くと、細心の注意を払って作ったシステムプロンプトとともに llama3.1:8b に送られます。このプロンプトは、モデルに対してJSONのみを返すよう指示します——前置きなし、説明なし、Markdownのフェンスなし。JSONは、手順の順序付きリストを記述します:

返却形式: {"translated": "翻訳されたHTML"}
{
  "steps": [
    {
      "intent": "WRITE_CODE",
      "query": "Python retry function",
      "meta": { "language": "python" }
    },
    {
      "intent": "SAVE_FILE",
      "query": "save code",
      "meta": { "filename": "retry.py", "content_source": "previous_step" }
    }
  ]
}

content_source: "previous_step" フィールドは、ステップチェaining(連鎖)を機能させる方法です。実行器が SAVE_FILE に到達すると、このフラグを確認し、直前のステップ(生成されたコード)をファイルの内容として使用します。手動の配線は不要です。

モデルが応答した後、出力は2段階のバリデーションを通ります:

  1. _extract_json() — 周囲のテキストを取り除き、モデルが「書かないように」と指示されていたのにもかかわらず文章を付けてしまった場合に備えて、最初に有効な {...} ブロックだけを取り出します
  2. _normalize() — すべてのステップに必要なキーが揃っていることを確認し、未知の intent があってもクラッシュせずに GENERAL_CHAT に静かに置き換えます

この2層構成により、モデルが振る舞いを誤った場合でも、システムは常に有用な何かを生成します。

ステップの実行と出力の連結

意図(インテント)分類器がプランを返したら、実行器は各ステップを順番に実行します。

for i, step in enumerate(steps, start=1):
    intent = step.get("intent")

    if intent == "WRITE_CODE":
        result = write_code_tool(query, language=language)
        previous_output = result  # 次のステップのために保存する
    elif intent == "SAVE_FILE":
        content = previous_output if content_source == "previous_step" else original_text
        result = save_file_tool(filename, content)

previous_output という変数は、ステップ間の単純なパイプとして機能します。これが、複雑なオーケストレーションの仕組みなしに複合コマンドを成立させる理由です。

Human-in-the-Loop(人の確認)

ディスクへ書き込む任意のステップ — SAVE_FILECREATE_FILECREATE_FOLDER — は、実行前にフラグが立てられます。すぐに書き込む代わりに、UI が確認パネルを表示します:

⚠ ファイル操作を確認
 ファイルを保存: retry.py
[ファイル名入力 — 編集可能]
[確認]  [キャンセル]

ユーザーは確認する前にファイル名を変更することも、完全にキャンセルすることもできます。実行されるのは確認後だけです。これは意図的な設計判断でした:ユーザーに尋ねずに、あなたのマシンへ黙ってファイルを書き込むAIエージェントはリスクになります。確認ステップを2秒挟むことで、潜在的なトラブルの多くを防げます。

セッションメモリ

ConversationMemory クラスは、Python の dequemaxlen を使って直近10メッセージのローリングウィンドウを維持します:

class ConversationMemory:
    def __init__(self, max_messages: int = 10):
        self.messages = deque(maxlen=max_messages)

ウィンドウがいっぱいになると、最も古いメッセージが自動的に削除されます。これによりメモリ使用量を上限付きに保ちつつ、"それをファイルに保存して" のようなフォローアップ質問にもLLMが十分な文脈を持って対応できる状態が維持されます。

会話履歴は、すべてのLLM呼び出し(分類、コード生成、要約、チャット)に渡されます。これにより、余計なロジックなしで、エージェントは過去の発言への参照を理解できます。

安全性:サンドボックス化されたファイル操作

すべてのファイル書き込みは、ディスクに触れる前に _safe_path() 関数を通します:

返却形式: {"translated": "翻訳されたHTML"}
def _safe_path(name: str) -> Path:
    name = name.strip().lstrip("/\\")
    resolved = (OUTPUT_DIR / name).resolve()
    if not str(resolved).startswith(str(OUTPUT_DIR)):
        raise ValueError("Unsafe path. All writes must stay inside output/.")
    return resolved

これによりディレクトリトラバーサル攻撃を防ぎます。"save to ../../etc/hosts" のようなコマンドは output/ の外側のパスに解決され、ディスクへの書き込みが発生する前に拒否されます。生成されるすべてのファイルは、プロジェクトディレクトリ内の output/ フォルダに収められます。

チャレンジとして直面したこと

1. LLMに一貫したJSONを返させること

最も難しかったのはこの部分でした。LLMは役に立とうとして、対話的に説明しようとします。"return ONLY valid JSON" のような明示的な指示があっても、モデルはときどき出力をmarkdownのフェンスで囲んだり、その前に1文付け加えたり、少しだけ不正なJSONを返したりしてしまいます。

解決策は3つの段階でした:

  • temperature: 0 を設定して、可能な限り出力を決定的にする
  • _extract_json() を使って、周囲に文字があってもJSONブロックを取り出す
  • _normalize() を使って、キーの欠落や未知の意図にもうまく対処する

この3つの層の後は、分類器は本番投入に使えるほど信頼できるようになりました。

2. 要約時のコンテキストウィンドウのオーバーフロー

ユーザーが非常に長いテキストを貼り付けて要約を求めると、システムプロンプト+会話履歴+ユーザーのテキストを合わせると、モデルのコンテキストウィンドウを超えることがあります。実際には、これによりサイレントな失敗が起きました――モデルは空の出力を返すか、クラッシュすることがありました。

修正は、要約ツールに送る前に入力を12,000文字で上限をかける _truncate() 関数をエグゼキュータに追加することでした。単語の途中ではなく、文境界で切り取ろうとし、テキストがトリミングされたことをモデルが理解できるよう注記を追記します:

return cutoff + "

[... text truncated for summarization ...]"

シンプルですが、このオーバーフローによるクラッシュを完全に無くしてくれました。

3. 複数出力のGradioイベントの組み立て

Gradioのイベントシステムでは、すべての出力を事前に宣言する必要があり、ジェネレーター関数内のすべての yield における出力数は完全に一致していなければなりません。確認パネルを追加したことで(新しい状態変数が導入されました)、run_classify() の既存のすべての yield を、新しい出力を含むように更新する必要がありました。

その結果、一部のコードパスで返す値の数が間違ってしまい、UIではサイレントに失敗するという微妙なバグが発生しました。修正は、空/デフォルトの状態を _blank() ヘルパーに集約し、すべての yield ポイントで同じ形になるようにしたことです。

次に作るなら

  • ストリーミング出力 — 応答全体を待つのではなく、LLMの応答をトークンごとに表示する
  • より多くの意図 — アプリを開く、Webを検索する、ターミナルコマンドを実行する
  • — 会話履歴をディスクに保存し、セッションをまたいでもコンテキストを維持する
  • モデルのベンチマーク — さまざまなOllamaモデルに対して、遅延と精度を体系的に測定し、各タスクに対する最適なトレードオフを見つける

最後に

この構築を通して、AIエージェントの難しい部分はAIそのものではなく、配管(プランビング)だと学びました。モデルから構造化された出力を確実に返させること、エッジケースをうまく扱うこと、ローカル推論が遅くてもUIをレスポンシブに感じさせること――これらはすべてAIの問題ではなく、エンジニアリングの問題です。

結果として、本当に動くシステムになりました。コマンドを話しかけ、分類されるのを見て、必要なら確認し、その結果を確認できます。完全にローカルで、完全に透明で、拡張もしやすいです。

完全なコードはGitHubで公開しています:[your repo link here]

faster-whisper、llama3.1:8b、qwen2.5-coder:7b、Ollama、Gradioで作成。