始める前に1つ、正直にお伝えしておきたいことがあります。「ローカルAIエージェント」というフレーズは、現行のAI環境で最も過剰に使われている(負荷がかかっている)用語のひとつです。ネット上で見つかるデモの半分は、ファイルピッカーが付いたチャットボットです。残り半分は、起動するだけで24GBのVRAMを搭載した$3,000のワークステーションが必要になります。
EchoKernelは、その中間を目指して私が作り始めたものです。つまり、ローカルマシン上で本当にアクションを実行する、音声操作のエージェントです(ファイルの作成、コードの書き込み、テキストの要約など)。GPUなしで、どんなノートPCでも動きます。そして、各段階があまりにも透過的(transparent)なので、あなたはすべてのステージを理解し、改変することさえできます。
この記事では、完全なアーキテクチャ、主要な意思決定の背後にある理由、そして私を最も悩ませた具体的なバグまでを順に説明します。ソースコードはGitHubにあります。
やっていること
あなたがコマンドを話す(またはタイプする)。EchoKernelは次を行います:
- GroqのWhisper APIを使って、音声をテキストに書き起こします
- LLaMA 3.3 70Bにその書き起こしを渡し、意図を構造化されたJSONとして分類します
- 意図に応じて適切なローカルツールへルーティングします(ファイル作成、コード生成、要約、またはチャット)
- 書き起こし、検出した意図、実行したアクション、そして出力を、わかりやすい3分割UIで表示します
完全なやり取りは次のようになります:
User says: "Write a Python function that retries failed HTTP requests and save it"
Pipeline:
audio blob → Whisper Large v3 → "Write a Python function that..."
transcript → LLaMA 3.3 70B → {"primary_intent": "write_code",
"target_filename": "retry.py", ... }
intent JSON → tool executor → generates code, writes output/retry.py
result → UI → shows code block + download link
生成されたものはすべて output/ ディレクトリに着地します。あなたのファイルシステムの残りの領域には何も触れません。
アーキテクチャ
Browser (frontend/index.html)
│
│ multipart/form-data (audio blob + session metadata)
│ application/json (text commands, confirmations)
│
▼
FastAPI (backend/main.py)
│
├── [1] STT Service → Groq Whisper API → transcript text
├── [2] Intent Service → Groq LLaMA 3.3 70B → structured JSON intent
├── [3] Tool Executor → local Python functions → file / code / summary / chat
└── [4] Memory Store → in-process dict → per-session chat history
│
▼
output/ ← every file write is sandboxed here
パイプラインは意図的に「逐次的」かつ「単一責務」です。各段階は型付きのPydanticオブジェクトを生成し、それを次へ渡します:
TranscriptionResult → IntentResult → ToolResult
これにより、どの段階も他の段階に触れることなく置き換えられます。Groq Whisperをローカルの faster-whisper バイナリに差し替えたいですか?その場合は stt.py の中の async関数を1つだけ変更します。残りのパイプラインはそれを知りませんし、気にもしません。
ステージ1:音声認識(Speech-to-Text)
仕組み
ブラウザはWebの MediaRecorder API を使って音声を録音し、生のblobをmultipartとして /agent/audio エンドポイントにアップロードします。FastAPIはバイト列を読み取り、Groqの /v1/audio/transcriptions エンドポイントへ転送します。これはOpenAI-APIと互換性があり、GroqのLPUハードウェア上でWhisper Large v3を実行します。
async def transcribe_audio(audio_bytes: bytes, filename: str, content_type: str) -> TranscriptionResult:
async with httpx.AsyncClient(timeout=60.0) as client:
files = {"file": (filename, audio_bytes, content_type)}
data = {"model": "whisper-large-v3", "response_format": "verbose_json"}
headers = {"Authorization": f"Bearer {GROQ_API_KEY}"}
返却形式: {"translated": "翻訳されたHTML"}response = await client.post(
"https://api.groq.com/openai/v1/audio/transcriptions",
headers=headers, files=files, data=data,
)
response.raise_for_status()
payload = response.json()
return TranscriptionResult(
text=payload.get("text", "").strip(),
language=payload.get("language"),
duration=payload.get("duration"),
)
2時間を無駄にしたバグ
ブラウザは録音した音声を audio/webm;codecs=opus として出力します。元々のコンテンツタイプのバリデーションでは、Pythonの集合(set)のメンバーシップチェックを使っていました:
ALLOWED_AUDIO_TYPES = {"audio/wav", "audio/mpeg", "audio/webm", ...}
if audio.content_type not in ALLOWED_AUDIO_TYPES:
raise HTTPException(status_code=415, ...)
すべてのマイク録音が 415 Unsupported Media Type を返しました。問題は "audio/webm;codecs=opus" が "audio/webm" と等しくないことです。コーデックのサフィックスが付いていることで、別の文字列になってしまうのです。
修正は、完全一致からプレフィックス一致へ切り替え、さらにGroqへ転送する前にコーデックのサフィックスを別途取り除くことでした(Groqも完全な文字列を拒否します):
ALLOWED_AUDIO_PREFIXES = ("audio/wav", "audio/mpeg", "audio/webm", "audio/ogg", ...)
content_type = audio.content_type or ""
if not any(content_type.startswith(p) for p in ALLOWED_AUDIO_PREFIXES):
raise HTTPException(status_code=415, ...)
# groq doesn't accept codec params — strip before forwarding
clean_content_type = content_type.split(";")[0].strip()
教訓:ブラウザが出力するMIMEタイプは、常に「完全一致した文字列」ではなく「プレフィックス」として扱うこと。
なぜ Whisper Large v3 なのか?
large-v3 は、短くコマンドのような発話では medium や small より明確に優れています—まさにボイスエージェントが受け取るものです。より小さいチェックポイントは、フィラー語を幻覚(hallucination)したり、技術用語を聞き間違えたりしやすくなります(例:「YAMLファイルを作成する」が「YAMLの山を作る」になる)。GroqのLPUにおけるレイテンシ差は十分に小さく(約100〜200ms)精度を犠牲にする価値はありません。
ステージ2:意図の分類
ここがシステム上で最も設計的に面白い部分です。課題は、自由形式で文字起こしされたテキスト—それが「config dot yaml というファイルを作って」から「JavaScriptでイベントをデバウンスする関数を書いて」まで何でもあり得る—を、ツール実行側が確実に操作できる「構造化された型付きオブジェクト」に変換することです。
プロンプトの設計
システムプロンプトは、LLaMA 3.3 70B に JSONオブジェクトのみ を返すよう指示します。つまり、前置きなし、説明なし、Markdownのフェンスなし:
INTENT_SYSTEM_PROMPT = """あなたは、音声制御AIエージェントのための意図(インテント)分類器です。
ユーザーの's が文字起こしされた発話を分析し、"有効なJSONオブジェクトのみ"を返してください—Markdownも説明も一切不要です。
返却形式: {"translated": "翻訳されたHTML"}"""
API呼び出しは、response_format: {"type": "json_object"} を使ってモデルレベルでこれを強制します。つまり、モデルには整形式のJSON出力を保証するよう指示するということです。これにより json.loads() が例外を投げることはありません。モデルが想定外のスキーマを返そうと判断したとしても、依然としてパース可能であり、欠落フィールドがあっても dict.get() がデフォルト値で問題なく処理します。
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{GROQ_BASE_URL}/chat/completions",
headers={"Authorization": f"Bearer {GROQ_API_KEY}"},
json={
"model": "llama-3.3-70b-versatile",
"messages": messages,
"temperature": 0.1, # 分類を決定的にするための低い温度
"max_tokens": 300,
"response_format": {"type": "json_object"},
},
)
温度は 0 ではなく 0.1 に設定しています。これにより、分類タスクに対しては決定的に近いままでも、モデルが退化した出力に行き詰まるのを防げます。
なぜJSONスキーマに target_filename と extracted_content があるのか
システムの初期バージョンでは、ツール実行側が元のトランスクリプトを再パースして、「このファイルに名前を付けたいのは何?」のような情報を特定させていました。これは脆いです。ツール実行側が独自のミニNLP層を実装する必要があるからです。
その代わりに、意図(intent)分類器が一度だけその作業を行い、結果を構造化されたフィールドにまとめます。primary_intent が write_code のとき、target_filename にはすでに "retry_handler.py" のような値が入っており、extracted_content には書くべき内容の説明が入っています。ツール実行側は、生のテキストに再度触れなくても、必要なものをすべて受け取れます。
コンテキストとしてのセッション履歴
会話履歴の最後の4ターンを意図呼び出しに注入します:
for msg in conversation_history[-4:]:
messages.append({"role": msg["role"], "content": msg["content"]})
これは実際の使い勝手の問題を解決します。要約を生成した後、ユーザーは「今それをファイルに保存して」と言うかもしれません。文脈がないと、この発話は chat と分類されます(そのフレーズには明示的なアクションがありません)。しかし、直前のターンを文脈として与えると、モデルはそれを正しく複合の summarize + create_file 意図として分類します。要約コンテンツは、アシスタントの直前の応答から抽出された要約内容です。
分類のためにLLaMA 3.3 70Bを使う理由?
なぜLLaMA 3.3 70Bで分類するのか?
返却形式: {"translated": "翻訳されたHTML"}開発中に、より小さなモデルでこれをテストしました。失敗の原因となるパターンは、意図(intent)の境界付近におけるエッジケースでした。たとえば「リトライ関数付きのPythonファイルを作成して」のようなコマンドは、妥当に create_file OR write_code にもなり得ます(実際には両方で、複合意図です)。70Bモデルは一貫してこれらを複合として識別します。8B〜13Bの範囲のモデルは、複合の発話を単一意図に崩してしまい、二次のアクションを見落としがちです。
第3段階:ツール実行
ルーティングのパターン
ツールエグゼキュータは、primary_intent の文字列を非同期ハンドラ関数へマッピングするディスパッチャです:
async def execute_tool(intent: IntentResult, transcribed_text: str, history: list[dict]) -> ToolResult:
primary = intent.primary_intent
if primary == "create_file": return await _handle_create_file(intent)
if primary == "write_code": return await _handle_write_code(intent, transcribed_text, history)
if primary == "summarize": return await _handle_summarize(intent, transcribed_text, history)
if primary == "compound": return await _handle_compound(intent, transcribed_text, history)
return await _handle_chat(transcribed_text, history) # デフォルトのフォールバック
各ハンドラは ToolResult を返します。これは、success、action_taken、output、file_path、code_content のフィールドを持つ型付きPydanticモデルです。フロントエンドは、どのフィールドが設定されているかに応じて異なるUIコンポーネントを描画します(code_content が設定されている場合はコードブロック、file_path が設定されている場合はダウンロードリンク)。
ファイルシステムのサンドボックス
すべてのファイル操作は、何かがディスクに触れる前に、2つのバリデーション層を通ります。
レイヤ1 — ファイル名のサニタイズ:
def _safe_filename(name: str) -> str:
# 出力ディレクトリから脱出し得るものを取り除く
clean = re.sub(r"[^\w.\-]", "_", Path(name).name)
return clean or "output.txt"
Path(name).name はディレクトリ成分をすべて落とします(そのため "../../etc/passwd" は "passwd" になります)。その後、正規表現が単語文字、ピリオド、またはハイフン以外のものを取り除きます。
レイヤ2 — 解決されたパスのバリデーション:
def _resolve_output_path(filename: str) -> Path:
safe = _safe_filename(filename)
path = OUTPUT_DIR / safe
path.resolve().relative_to(OUTPUT_DIR.resolve()) # 外にある場合は ValueError
return path
サニタイズの後でも、パスは実際のファイルシステムに対して解決され、OUTPUT_DIR と照合されます。何らかの理由で、サニタイズされたファイル名がそれでも output/ の外側に解決される場合(シンボリックリンク攻撃や、OS固有のエッジケースなど)は、書き込みが行われる前に ValueError が発生します。2つの独立した層があることで、最初の層をバイパスできても、必ずしも2つ目の層をバイパスできるとは限りません。
コード生成とマークダウンフェンス問題
LLMは、マークダウンフェンス内にコードを整形するよう学習されています。「明示的に『生のコードだけを返して、マークダウンフェンスは使うな』と指示しても」、フロンティアモデルは約95%の確率で従います——しかし残りの5%は、ファイルが `python で始まるため、無効なPythonをディスクに書き込んでしまいます。
対策は、モデルが従ったかどうかに関係なく動作する後処理のストリップです:
`python
code = await _call_llm(messages)
指示にもかかわらず一部のモデルが紛れ込ませるマークダウンフェンスを除去する
code = re.sub(r"^[\w]*
?", "", code.strip())
返却形式: {"translated": "翻訳されたHTML"}code = re.sub(r"
?$", "", code.strip())
path.write_text(code, encoding="utf-8")
`
これは取るに足らない量の正規表現処理(パス)を追加するだけで、コードの書き込みを「95%」から「100%確実」にします。
複合コマンド
意図がcompoundの場合、エグゼキュータはsecondary_intentsを順に処理し、それぞれのサブ意図に対して再帰的にexecute_toolを呼び出し、結果をつなぎ合わせます:
`python
async def _handle_compound(intent, transcribed_text, history):
outputs = []
for sub in intent.secondary_intents or ["chat"]:
sub_intent = IntentResult(
primary_intent=sub if sub in VALID_INTENTS else "chat",
secondary_intents=[],
target_filename=intent.target_filename,
extracted_content=intent.extracted_content,
...
)
result = await execute_tool(sub_intent, transcribed_text, history)
outputs.append(f"[{sub}] {result.output}")
return ToolResult(
action_taken="Executed compound command",
output="
".join(outputs),
...
)
`
つまり、「この文章を要約してnotes.txtに保存して」といったコマンドは、2つのツール呼び出しが順番に実行されます——まず要約、次にファイル書き込み——そしてユーザーには、両方の結果が1つのレスポンスカードに統合された状態で表示されます。
ステージ4:セッション・メモリ
メモリストアは意図的にシンプルです:
`python
_sessions: dict[str, SessionHistory] = {}
def append_message(session_id: str, role: str, content: str, intent: str | None = None):
session = _sessions.setdefault(session_id, SessionHistory(session_id=session_id))
session.messages.append(ChatMessage(role=role, content=content, intent=intent, ...))
def get_history(session_id: str) -> list[dict]:
session = _sessions.get(session_id)
return [{"role": m.role, "content": m.content} for m in session.messages] if session else []
`
プレーンなPythonのdictで、データベースもRedisも使いません。単一ユーザーのローカルエージェントであれば、これがまさに適切な選択です——セットアップの摩擦ゼロ、運用上のオーバーヘッドゼロ、データのスコープはサーバープロセスのライフタイムに限定されます。フロントエンドは最初のレスポンスでUUIDを生成し、その後のすべてのリクエストに付与するため、セッションは自然に分離されます。
トレードオフは、サーバーを再起動するとセッションが消えることです。ローカル開発ツールならこれは許容できます。プロダクション展開では、dictをRedisまたは軽量なSQLiteへの書き込みに置き換えることになります——そしてシングルレスポンシビリティ設計のおかげで、この変更はmemory.pyだけに対して行えば済みます。
フロントエンド
UIはビルド手順のない、1つのHTMLファイルです。フレームワークもnode_modulesもありません。double-clickするだけでブラウザ上で直接開きます。
3つのパネル構成は意図的に選びました:
- 左 — 入力コントロール(マイク、ファイルアップロード、テキスト、トグル)
- 中央 — 会話フィード(スクロール可能)で、各インタラクションのパイプライン全結果を表示
- 右 — 出力ファイルブラウザとセッション履歴ログ
最も面白いフロントエンドの技術的課題は、スクロールの封じ込め(containment)でした。CSSグリッドのレイアウトで、祖先チェーンのどこかでoverflow: hiddenを設定すると、フレックスの子要素が独立してスクロールできなくなります。解決には特定のプロパティの組み合わせが必要です:
`css
feed-col {
display: flex;
flex-direction: column;
min-height: 0; /* 重要:これがないと、列(column)が縮まない */
}
feed {
flex: 1;
overflow-y: auto;
min-height: 0; /* 無限に伸びるのではなく、フィードがスクロールできる */
}
.msg-card {
flex-shrink: 0; /* レイアウトによるカードの押しつぶしを防ぐ */
}
`
min-height: 0を列(column)とスクロール可能な子の両方に付けないと、フィードはコンテンツに合わせて伸びてしまい、スクロールしません——そのため3〜4通のメッセージの後にレイアウトが崩れ、スクロールが機能しなくなります。これはCSSフレックスボックスの微妙な挙動で、仕様を読んでも直感的には分かりにくいものです。
人間の介入(Human-in-the-Loop)
HITLトグルがオンの場合、/agent/audioエンドポイントは即座に実行するのではなく、HTTP 202(Acceptedだがまだ処理されていない)を返します:
`python
if require_confirmation and detected_intent.primary_intent in ("create_file", "write_code"):
return JSONResponse(
status_code=202,
content={
"status": "awaiting_confirmation",
"session_id": sid,
"transcription": transcription.model_dump(),
"intent": detected_intent.model_dump(),
},
)
`
フロントエンドは202を検知し、実行 / キャンセルのプロンプトを描画します。そしてユーザーが承認した場合にのみ/agent/confirmエンドポイントを呼び出します。これはカスタムのステータスコードを捏造するのではなく、HTTPの意味論に従っています——202は「リクエストは理解され、追加のアクションがあるまで処理される」という意味です。
モデルとプロバイダの判断
なぜOpenAIではなくGroqを選んだのか?(Anthropicやローカルモデルも含む)
OpenAIとの比較: APIの表面は同一です——GroqはOpenAIの仕様を実装しているため、切り替えはURLを1行変えるだけです。違いはレイテンシです。GroqのLPU(Language Processing Unit)はトランスフォーマー推論のために専用設計されたシリコンで、GPUベースのプロバイダより約3〜5倍速いトークンスループットを実現します。音声パイプラインではSTTとLLMが逐次で待たされるため、この差は「応答が機敏に感じるインタラクション」と「2008年のWeb検索のように感じるインタラクション」の境界です。
ローカル推論との比較(Ollama、llama.cpp、LM Studio): LLaMA 3.3 70Bをローカルで動かすには、受け入れ可能なGPU推論のために16〜24GBのVRAMが必要か、またはCPUで15〜30秒の応答時間が必要です。どちらも音声エージェントでは使い物になりません。正直なトレードオフはこうです:Groqなら、インターネット接続のあるどんなノートPCでもEchoKernelを動かせる代わりに、APIキーが1つ必要で、利用は1日数セント程度かかります。アーキテクチャは、ローカル推論に戻すのがコード10行で済むように設計されています:
`python
stt.py — Groq呼び出しを faster-whisper に置き換える
import faster_whisper
model = faster_whisper.WhisperModel("large-v3", device="cpu")
segments, _ = model.transcribe(audio_bytes_io)
transcript = " ".join(s.text for s in segments)
intent.py — Groq呼び出しを Ollama に置き換える
import ollama
response = ollama.chat(model="llama3.3", messages=messages)
raw = response["message"]["content"]
`
Anthropic / Googleとの比較: どちらも優れたLLMを提供していますが、Whisper相当のSTT APIはどちらも提供していません。1つのパイプラインに2つのプロバイダが必要になります。すべてをGroqに寄せておけば、APIキーは1つ、ベースURLも1つ、請求アカウントも1つで済みます。
課題
MIMEタイプサフィックスの問題が最も厄介なバグでした——415エラーが出て原因が分からず、サーバーログでaudio.content_typeに実際に何が入っているかを確認するまで手がかりがありませんでした。ブラウザ文字列audio/webm;codecs=opusは、技術的にはパラメータ付きの有効なMIMEタイプ(RFC 2045)ですが、それを文字列の集合に対する完全一致として扱うと、マイクで録音したすべてのデータが静かに拒否されます。
意図分類器に複合的な意図を確実に出力させるには、いくつかのプロンプト反復が必要でした。基本的なプロンプトだけでも、複合コマンドを正しく識別できる確率は約70%でした。システムプロンプト内で複合意図と単一意図の明示的な例を追加し、temperatureを0.1に下げたことで、95%以上まで引き上げられました。
グリッドレイアウトにおけるCSSのスクロール封じ込めは、想定より時間がかかりました。症状は、フィードが数件のメッセージの後にスクロールを停止してしまうことでした。根本原因は、グリッドの列の中にあるflex子要素に min-height: 0 が指定されていなかったことです。これはほとんどの文脈では意味を持たないプロパティですが、ここでは重要です。ブラウザのデフォルトである flex アイテムに対する min-height: auto は、コンテンツサイズ未満には縮まないことを意味します。その結果、スクロール可能なコンテナが成長してしまい、スクロールされません。
session_id の422エラーは、デフォルトを持たない必須フィールドとして session_id: str が定義されたPydanticスキーマに起因していました。フロントエンドは初回リクエスト時に null を正しく送信します(まだセッションIDがないため)が、Pydanticは非オプショナルの str に対する null を拒否しました。修正は session_id: Optional[str] = None です。
次に作りたいもの
アーキテクチャには、きれいに拡張できる4つのポイントがあります。
永続メモリ — in-process のdictを aiosqlite を使ったSQLiteに置き換えます。これにより、セッションはサーバ再起動後も保持され、日をまたいで履歴を参照できるようになります。
より多くのツール — ツール実行器はディスパッチャにすぎません。 search_web ツール、 run_shell_command ツール(適切な確認ゲート付き)、コンテキスト注入用の read_file ツールなどを追加するのはすべて、tools.py への独立した追加として実装できます。
ストリーミングレスポンス — 現在のアーキテクチャは、LLMが完了してから完全なレスポンスを返します。Server-Sent Eventsを追加すれば、モデルが生成するコードトークンをUIが逐次レンダリングできるようになり、長い出力に対する体感遅延が大幅に改善されます。
ローカルモデルのベンチマーク — CPU上で同じテストスイートを faster-whisper medium と large-v3 に対して実行し、Ollama上で llama3.1 8B と llama3.3 70B に対して実行すれば、現在のモデル選択を直感ではなくデータで裏付ける具体的なレイテンシ/精度の数値が得られます。
自分で動かしてみる
`bash
git clone https://github.com/ManasRanjanJena253/EchoKernel
cd EchoKernel
cp .env.example .env
あなたのGROQ_API_KEYを .env に追加してください
pip install -r backend/requirements.txt
python run.py
次に、ブラウザで frontend/index.html を開きます
`
無料のGroq APIキーは console.groq.com で取得できます。無料枠でも開発やデモには十分すぎるほどです。
アーキテクチャのどの部分についても質問がある場合、または私が取り上げなかったバグに遭遇した場合は、GitHubのissuesは公開されています。そして、新しいツールの追加やローカルモデルの差し替えで拡張することになったなら、ぜひそれを見てみたいです。




