💬

ローカルLLMでClaude Codeっぽいものを自作してみた話

に公開

はじめに

どうも神部凱斗です、
みなさん、Claude Code使ってますか?

ターミナルでAIと話しながらファイルを読んだり書いたり、コマンドを実行してもらったり——あの体験、一度ハマると抜け出せないですよね。

ただ、Claude CodeはAnthropicのAPIを使う有料サービスです。「もっと気軽に使いたい」「自分でカスタマイズしたい」「仕組みを理解したい」という気持ちが積もった結果、ローカルLLMで動くClaude Code風アシスタント「KaitoCode」を自作しました。

この記事では、作ってみてわかった技術的な面白さや詰まりポイントを包み隠さず書いていきます。


完成したもの

まずは完成形を見てください。

 ██░░░ ██░░░░ ███░░░░ ████░ ████████░░ ███████░░
 ██░░ ██░░░░ ██ ██░░░░ ██░░░░░ ██░░░░ ██.... ██░
 ██░ ██░░░░ ██░. ██░░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 █████░░░░ ██░░░. ██░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 ██. ██░░░ █████████░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 ██░. ██░░ ██.... ██░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 ██░░. ██░ ██░░░░ ██░ ████░░░░ ██░░░░. ███████░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░ ██████░░░ ███████░░ ████████░░ ████████░
 ██... ██░ ██.... ██░ ██.... ██░ ██.....░░
 ██░░░..░░ ██░░░░ ██░ ██░░░░ ██░ ██░░░░░░░
 ██░░░░░░░ ██░░░░ ██░ ██░░░░ ██░ ██████░░░
 ██░░░░░░░ ██░░░░ ██░ ██░░░░ ██░ ██...░░░░
 ██░░░ ██░ ██░░░░ ██░ ██░░░░ ██░ ██░░░░░░░
. ██████░░. ███████░░ ████████░░ ████████░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

Tips for getting started:
  1. Ask questions, edit files, or run commands.
  2. Be specific for the best results.
  3. /help for more information.

  model: gemma2:2b  ·  ollama
  
[kaitocode] ❯ /tmp/hello.py を作って。内容は print("hello world")

  ✏️  write_file  /tmp/hello.py  ✓ done

◆ /tmp/hello.py を作成しました。

ターミナルから kaito と打つだけで起動し、日本語で話しかけると実際にファイルを作成したりコマンドを実行してくれます。

すべてローカルで動作。APIコストゼロ。


技術スタック

~/clode/
├── backend/    FastAPI (Python)  — port 8002
├── frontend/   Next.js 15        — port 3031
└── cli/kaito   Python CLI        — ターミナルから直接起動
レイヤー 技術
フロントエンド Next.js 15 + Tailwind CSS 4
バックエンド FastAPI + httpx
AI エンジン Ollama(gemma2:2b)
CLI Python + Rich
通信 SSE(Server-Sent Events)

一番面白かった技術的チャレンジ:Tool Callingの自作

Claude Codeの核心は「AIがファイルを読んだりコマンドを実行する」機能、いわゆるTool Callingです。

OpenAIやAnthropicのAPIは公式でtool callingをサポートしています。ところがローカルLLMのOllamaは、モデルによってはtool callingに非対応なのです。

今回使った gemma2:2b でtool callingを試すと……

{"error": "registry.ollama.ai/library/gemma2:2b does not support tools"}

詰みました。でも、これが一番面白かったポイントです。


解決策:プロンプトエンジニアリングで自作する

考えてみれば当然なのですが、tool callingはプロンプトとパーサーで再現できます。

ChatGPTやClaudeが裏でやっていることも、突き詰めれば「特定フォーマットのテキストを出力させて、それをパースして関数を呼ぶ」という話です。

Step 1: システムプロンプトで「ツールの書き方」を教える

SYSTEM_PROMPT = """あなたはKaitoCode、AIコーディングアシスタントです。

ファイルの読み書きやコマンド実行が必要なときだけ、以下の形式を出力してすぐに止まること:
TOOL_CALL: {"name": "ツール名", "arguments": {...}}

使えるツール:
- write_file : {"path": "文字列", "content": "文字列"}
- read_file  : {"path": "文字列"}
- run_bash   : {"command": "文字列"}
...

【絶対ルール】
- ファイル操作を頼まれたら説明せず即TOOL_CALLを出力して止まる
- TOOL_CALLは1回につき1つだけ
"""

Step 2: モデルの出力をパースしてツールを実行する

モデルは指示通りに TOOL_CALL: {...} を出力します。これをパースして実際の関数を呼びます。

# バックエンドのループ
for _ in range(10):  # 最大10回のツール呼び出し
    response = await ollama_complete(messages)

    text_before, tool_data = extract_tool_call(response)

    if tool_data is None:
        # ツール呼び出しなし → 最終回答
        yield sse("done", {"content": response})
        return

    # ツールを実行
    result = call_tool(tool_data["name"], tool_data["arguments"])

    # 結果をAIに返して次のループへ
    messages.append({"role": "assistant", "content": response})
    messages.append({"role": "user", "content": f"Tool result:\n{result}\n\n完了を一言で報告してください。"})

パーサーの罠:ネストしたJSONが壊れる

最初は正規表現でパースしていました。

re.compile(r'TOOL_CALL:\s*(\{.*?\})', re.DOTALL)

これ、一見うまく動くのですが、ネストしたJSONで壊れます。

{.*?} は非貪欲マッチなので、最初に見つかった } で止まります。

TOOL_CALL: {"name": "write_file", "arguments": {"path": "/tmp/a.py", "content": "..."}}

                                               この } で止まってしまう(外側の } まで届かない)

結果として json.loads() に渡る文字列が不完全になり、パースエラーで無音のまま失敗していました。

解決策:バランス括弧パーサー

正規表現を捨てて、括弧の深さをカウントするパーサーを自作しました。

def _extract_json_object(text: str, start: int) -> str | None:
    depth = 0
    in_str = False
    escape = False
    i = start
    while i < len(text):
        ch = text[i]
        if escape:
            escape = False
        elif ch == '\\' and in_str:
            escape = True
        elif ch == '"':
            in_str = not in_str
        elif not in_str:
            if ch == '{':
                depth += 1
            elif ch == '}':
                depth -= 1
                if depth == 0:
                    return text[start:i + 1]  # 完全なJSONオブジェクト
        i += 1
    return None

文字列の中の { } は無視し、本物の括弧だけをカウントします。これで複雑なネスト構造も正確に抽出できるようになりました。


もう一つの罠:モデルがJSONに余計な文字を混ぜる

print('hello world') というコードをファイルに書くよう頼んだとき、モデルが出力したのがこれです。

TOOL_CALL: {"name": "write_file", "arguments": {"path": "/tmp/a.py", "content": "print('hello world')")}

よく見ると ")} — 末尾に ) が余計についています。

Pythonコードの print(...)) をモデルがJSONの一部だと誤認識したようです。これも json.loads() を無言で失敗させる罠でした。

解決策:JSON自動修正関数

def _try_parse_json(raw: str) -> dict | None:
    for candidate in [
        raw,
        re.sub(r'(?<=")\)', '', raw),       # 閉じ引用符の直後の ) を除去
        re.sub(r',(\s*[}\]])', r'\1', raw), # 末尾カンマを除去
    ]:
        try:
            return json.loads(candidate)
        except json.JSONDecodeError:
            continue
    return None

複数のパターンを試して最初に成功したものを返します。


SSEでストリーミング表示する

Claude Codeの「タイプしているように見える」体験はSSE(Server-Sent Events)で実現しています。

バックエンドからこんなフォーマットでイベントを流します。

event: text_delta
data: {"content": "こんにちは"}

event: tool_call
data: {"name": "write_file", "arguments": {"path": "/tmp/a.py", ...}}

event: tool_result
data: {"name": "write_file", "result": "{\"message\": \"File written\"}"}

event: done
data: {"content": "ファイルを作成しました。"}

フロントエンド(Next.js)とCLI(Python)の両方がこのSSEを読んでリアルタイム表示します。

# CLI側のSSE受信
with client.stream("POST", f"{BACKEND_URL}/api/chat/stream", ...) as response:
    for raw in response.iter_lines():
        if raw.startswith("event: "):
            event_type = raw[7:].strip()
        elif raw.startswith("data: "):
            data = json.loads(raw[6:])

            if event_type == "text_delta":
                console.print(data["content"], end="")
            elif event_type == "tool_call":
                render_tool_call(data["name"], data["arguments"])
            elif event_type == "tool_result":
                render_tool_result(data["name"], data["result"])

CLIツールに触発したASSCIIロゴ

CLIツールのあの起動画面、かっこよくないですか?

   █████████  ██████████ ██████   ██████ ...
  ███░░░░░███░░███░░░░░█░░██████ ██████ ...

pyfiglet というライブラリで同じスタイルを作れます。

import pyfiglet

text = pyfiglet.figlet_format("Kaito", font="banner3-D")
# '#' を '█' に、':' を '░' に置換してブロック文字化
text = text.replace('#', '█').replace(':', '░').replace("'", ' ')
print(text)

出力:

 ██░░░ ██░░░░ ███░░░░ ████░ ████████░░ ███████░░
 ██░░ ██░░░░ ██ ██░░░░ ██░░░░░ ██░░░░ ██.... ██░
 ██░ ██░░░░ ██░. ██░░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 █████░░░░ ██░░░. ██░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 ██. ██░░░ █████████░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 ██░. ██░░ ██.... ██░░ ██░░░░░ ██░░░░ ██░░░░ ██░
 ██░░. ██░ ██░░░░ ██░ ████░░░░ ██░░░░. ███████░░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
░ ██████░░░ ███████░░ ████████░░ ████████░
 ██... ██░ ██.... ██░ ██.... ██░ ██.....░░
 ██░░░..░░ ██░░░░ ██░ ██░░░░ ██░ ██░░░░░░░
 ██░░░░░░░ ██░░░░ ██░ ██░░░░ ██░ ██████░░░
 ██░░░░░░░ ██░░░░ ██░ ██░░░░ ██░ ██...░░░░
 ██░░░ ██░ ██░░░░ ██░ ██░░░░ ██░ ██░░░░░░░
. ██████░░. ███████░░ ████████░░ ████████░
░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

たった3行で本格的なASCIIロゴが作れます。


セキュリティ:危険なコマンドをブロックする

AIにbashコマンドを実行させるのは強力な反面、怖い部分もあります。

BLOCKED_PATTERNS = [
    r"\brm\s+-[rf]",   # rm -rf
    r"\bsudo\b",       # sudo
    r"\bdd\b.*of=",    # dd if=... of=...
    r":\(\)\s*\{",     # fork bomb
    r"\bshutdown\b",
    r"\bcurl\b.*\|\s*bash",  # curl | bash
]

def is_dangerous(command: str) -> bool:
    return any(re.search(p, command, re.IGNORECASE) for p in BLOCKED_PATTERNS)

悪意あるプロンプトで rm -rf ~ を実行させようとしてもブロックされます。


作ってみてわかったこと

1. ローカルLLMは「指示の従い方」が弱い

gemma2:2b(1.6GB)はすごくコンパクトですが、複雑なシステムプロンプトを完璧には守れません。「TOOL_CALLを出力したら止まれ」と書いても、ときどき余計なことを話し続けます。

モデルが大きいほど指示への追従性は上がります。実用的には llama3.1:8b 以上がおすすめです。

2. プロンプトエンジニアリングはデバッグが難しい

コードのバグと違って「なぜこの出力になったか」がわかりにくいです。実際、今回のJSON壊れ問題は「パーサーのバグかと思ったら実はJSONが壊れていた」という二段構えの問題で、原因特定に時間がかかりました。

3. 「自作」は理解への最短経路

Claude Codeを使っているだけでは「tool callingって何?」「なぜAIがファイルを読めるの?」が曖昧なままです。自作することでその仕組みが手触りとして理解できます。


試してみる

# Ollama インストール(未インストールの場合)
brew install ollama
ollama pull gemma2:2b  # または llama3.2, mistral など

# プロジェクトのセットアップ
git clone [your-repo]
cd clode

# バックエンド起動
cd backend && python3 -m venv venv && source venv/bin/activate
pip install fastapi uvicorn httpx pydantic python-dotenv rich pyfiglet
uvicorn app.main:app --port 8002

# フロントエンド起動(別タブ)
cd frontend && npm install && npm run dev -- --port 3031

# CLIとして使う場合
chmod +x cli/kaito
./cli/kaito

おわりに

「Claude Codeっぽいもの自作」と言うと大変そうに聞こえますが、核心部分は**「AIに特定フォーマットのテキストを出力させて、パースして関数を呼ぶ」**というシンプルなアイデアです。

ローカルLLMはAPIコストゼロで動き、カスタマイズも自由。プロンプトを自分で書くことで、AIの動作原理への理解もぐっと深まります。

みなさんもぜひ自分だけのAIコーディングアシスタントを作ってみてください!


参考

  • Ollama — ローカルLLM実行環境
  • FastAPI — Python製高速APIフレームワーク
  • Rich — Pythonターミナル装飾ライブラリ
  • pyfiglet — ASCIIアートジェネレーター
  • Gemini CLI — インスピレーション元

この記事で紹介したコードはすべて実際に動作確認済みです。質問やフィードバックはコメントでお気軽にどうぞ!

Discussion