OllamaのAPIをPythonから叩いてローカルLLMチャットCLIを作る

前回までの記事で、MacBook Air M4にOllamaを導入し、Open WebUIで快適に使う環境を整え、Gemma 3を3サイズで比較しました。本記事ではOllamaのAPI活用編として、PythonからOllamaのAPIを叩いてオリジナルのチャットCLIを作成する手順を解説します。最終的にはストリーミング表示、モデル切り替え、persona(system prompt)切り替え、会話保存などを備えた実用的なCLIまで仕上げます。

OllamaのAPIについて

Ollamaは起動と同時に localhost:11434 でAPIサーバーを自動的に立ち上げます。Open WebUIもこのAPIを利用してOllamaと通信しています。自作のアプリやスクリプトからも同じAPIを叩けるため、ローカルLLMを自分のツールやワークフローに組み込めます。

主なエンドポイントは以下のとおりです。

エンドポイント用途
POST /api/generate単発のテキスト生成(会話履歴なし)
POST /api/chatチャット形式の対話(messages配列で履歴を保持)
POST /api/embeddingsテキストのベクトル化(RAG等で使用)
GET /api/tagsインストール済みモデル一覧
GET /api/ps現在ロード中のモデル一覧

本記事では対話アプリを作るので /api/chat を中心に扱います。

まずはcurlでAPIの動作を確認する

実装に入る前に、curlで直接APIを叩いて感触を確かめます。

curl http://localhost:11434/api/generate -d '{
  "model": "gemma3:12b",
  "prompt": "日本の首都はどこですか?",
  "stream": false
}'

"stream": false を指定すると、全部生成し終わってからJSONで一括返却されます。レスポンスは以下のような形式です。

{
  "model": "gemma3:12b",
  "created_at": "2026-05-20T07:33:25.302446Z",
  "response": "日本の首都は東京都です。\n",
  "done": true,
  "done_reason": "stop",
  "total_duration": 7306910500,
  "load_duration": 6158475417,
  "prompt_eval_count": 15,
  "prompt_eval_duration": 354117334,
  "eval_count": 8
}

response フィールドに生成テキスト、その他にもメタ情報が含まれます。total_duration などの時間情報はナノ秒単位です。

なお、初回呼び出し時は load_duration(モデルをメモリに読み込む時間)が大きな割合を占めますが、2回目以降は既にロード済みのモデルが再利用されるため、load_duration はほぼ0になります。

Pythonの作業環境を準備する

プロジェクト用のディレクトリを作成し、仮想環境をセットアップします。

cd ~/works
mkdir ollama-chat-cli
cd ollama-chat-cli

# 仮想環境の作成と有効化
python3 -m venv .venv
source .venv/bin/activate

プロンプトの先頭に (.venv) が表示されれば仮想環境が有効化されています。

ステップ1: requestsで最小実装

まずはOllamaの公式ライブラリを使わず、requests ライブラリだけでAPIを直接叩く実装から始めます。仕組みを理解するための足場づくりです。

pip install requests

chat.py を作成します。

import requests

OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "gemma3:12b"


def chat(messages):
    """Ollamaに会話履歴を送って応答を取得する"""
    response = requests.post(
        OLLAMA_URL,
        json={
            "model": MODEL,
            "messages": messages,
            "stream": False,
        },
    )
    response.raise_for_status()
    return response.json()["message"]["content"]


def main():
    print(f"=== Ollama Chat CLI ({MODEL}) ===")
    print("終了するには 'exit' または Ctrl+C を入力してください")
    print()

    messages = []

    while True:
        try:
            user_input = input("あなた> ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n終了します")
            break

        if not user_input:
            continue
        if user_input.lower() in {"exit", "quit", "bye"}:
            print("終了します")
            break

        messages.append({"role": "user", "content": user_input})

        print("AI> ", end="", flush=True)
        try:
            reply = chat(messages)
        except requests.RequestException as e:
            print(f"\nエラー: {e}")
            messages.pop()
            continue

        print(reply)
        print()
        messages.append({"role": "assistant", "content": reply})


if __name__ == "__main__":
    main()

実行します。

python chat.py

実行例は以下のようになります。

=== Ollama Chat CLI (gemma3:12b) ===
終了するには 'exit' または Ctrl+C を入力してください

あなた> 私の名前は深尾です
AI> 深尾さん、こんにちは!深尾さんというお名前を伺えて嬉しいです。何かお手伝いできることはありますか?

あなた> 私の名前を覚えていますか?
AI> はい、深尾さんのお名前は覚えています。先ほど教えていただきました。

messages リストに会話履歴を溜めて毎回全部送ることで、前の発言を踏まえた応答が成立します。Ollamaのチャット用APIはステートレスなので、履歴管理はクライアント側の責務です。

ステップ2: 公式ライブラリ+全機能を盛り込んだ完成版

仕組みが分かったので、Ollama公式のPythonライブラリを使って完成版を作ります。公式ライブラリを使うと、ストリーミング処理やエラーハンドリングが簡潔に書けます。

pip install ollama

完成版の chat.py は以下のとおりです。

"""
Ollama Chat CLI
- ストリーミング表示
- モデル切り替え(起動時引数)
- system promptで性格付け(プリセット切り替え)
- 対話中コマンド(/help, /reset, /system, /save)
"""
import argparse
from datetime import datetime
from pathlib import Path

import ollama

# system promptのプリセット
PERSONAS = {
    "default": "あなたは親切で有能なアシスタントです。日本語で丁寧に答えてください。",
    "kansai": "あなたは関西弁で話すフレンドリーなアシスタントです。語尾は「〜やで」「〜やん」などを自然に使ってください。",
    "expert": "あなたは技術分野の専門家です。正確で簡潔に、根拠を示しながら答えてください。不確かなことは「分からない」と明言してください。",
    "concise": "あなたは要点だけを伝えるアシスタントです。前置きや謝辞は省き、必要最小限の言葉で答えてください。",
    "teacher": "あなたは小学生にも分かるように説明するのが得意な先生です。難しい言葉は避け、たとえ話を交えて答えてください。",
}


def stream_chat(model: str, messages: list) -> str:
    """ストリーミングでOllamaに送信し、応答を逐次表示しながら全文を返す"""
    full_response = ""
    stream = ollama.chat(model=model, messages=messages, stream=True)
    for chunk in stream:
        content = chunk["message"]["content"]
        print(content, end="", flush=True)
        full_response += content
    print()
    return full_response


def print_help():
    print()
    print("=== コマンド一覧 ===")
    print("/help        このヘルプを表示")
    print("/reset       会話履歴をリセット(system promptは維持)")
    print("/system      利用可能なpersonaを表示")
    print("/system NAME personaを切り替え(履歴もリセット)")
    print("/save        現在の会話をMarkdownファイルに保存")
    print("/exit        終了(exit, quit, bye でも可)")
    print()


def save_conversation(messages: list, model: str, persona: str):
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"chat_{timestamp}.md"
    path = Path(filename)

    lines = [
        f"# Ollama Chat Log",
        f"",
        f"- 日時: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
        f"- モデル: `{model}`",
        f"- Persona: `{persona}`",
        f"",
        "---",
        "",
    ]

    for msg in messages:
        role = msg["role"]
        content = msg["content"]
        if role == "system":
            lines.append(f"## System Prompt\n\n> {content}\n")
        elif role == "user":
            lines.append(f"## あなた\n\n{content}\n")
        elif role == "assistant":
            lines.append(f"## AI\n\n{content}\n")

    path.write_text("\n".join(lines), encoding="utf-8")
    print(f"保存しました: {path.absolute()}")


def build_initial_messages(persona: str) -> list:
    return [{"role": "system", "content": PERSONAS[persona]}]


def main():
    parser = argparse.ArgumentParser(description="Ollama Chat CLI")
    parser.add_argument(
        "-m", "--model",
        default="gemma3:12b",
        help="使用するモデル名(デフォルト: gemma3:12b)",
    )
    parser.add_argument(
        "-p", "--persona",
        default="default",
        choices=PERSONAS.keys(),
        help=f"persona({', '.join(PERSONAS.keys())})",
    )
    args = parser.parse_args()

    model = args.model
    persona = args.persona
    messages = build_initial_messages(persona)

    print(f"=== Ollama Chat CLI ===")
    print(f"モデル: {model}")
    print(f"Persona: {persona}")
    print("コマンド一覧は /help、終了は /exit")
    print()

    while True:
        try:
            user_input = input("あなた> ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n終了します")
            break

        if not user_input:
            continue

        if user_input.startswith("/"):
            parts = user_input.split(maxsplit=1)
            cmd = parts[0].lower()

            if cmd in {"/exit", "/quit", "/bye"}:
                print("終了します")
                break

            if cmd == "/help":
                print_help()
                continue

            if cmd == "/reset":
                messages = build_initial_messages(persona)
                print("会話履歴をリセットしました")
                continue

            if cmd == "/system":
                if len(parts) < 2:
                    print(f"利用可能なpersona: {', '.join(PERSONAS.keys())}")
                    print(f"現在: {persona}")
                else:
                    new_persona = parts[1].strip()
                    if new_persona not in PERSONAS:
                        print(f"未知のpersona: {new_persona}")
                        print(f"利用可能: {', '.join(PERSONAS.keys())}")
                    else:
                        persona = new_persona
                        messages = build_initial_messages(persona)
                        print(f"personaを '{persona}' に切り替え、履歴をリセットしました")
                continue

            if cmd == "/save":
                if len(messages) <= 1:
                    print("保存する会話がありません")
                else:
                    save_conversation(messages, model, persona)
                continue

            print(f"未知のコマンド: {cmd}(/help で一覧表示)")
            continue

        if user_input.lower() in {"exit", "quit", "bye"}:
            print("終了します")
            break

        messages.append({"role": "user", "content": user_input})

        print("AI> ", end="", flush=True)
        try:
            reply = stream_chat(model, messages)
        except ollama.ResponseError as e:
            print(f"\nOllamaエラー: {e}")
            messages.pop()
            continue
        except Exception as e:
            print(f"\nエラー: {e}")
            messages.pop()
            continue

        messages.append({"role": "assistant", "content": reply})


if __name__ == "__main__":
    main()

完成版の使い方

起動時の引数でモデルとpersonaを指定できます。

# デフォルト(gemma3:12b、defaultペルソナ)
python chat.py

# モデル指定
python chat.py -m gemma3:4b

# persona指定(関西弁モード)
python chat.py -p kansai

# 組み合わせ
python chat.py -m gemma3:27b -p expert

# ヘルプ
python chat.py -h

対話中に使えるコマンドは以下のとおりです。

コマンド機能
/helpヘルプを表示
/reset会話履歴をリセット(system promptは維持)
/system利用可能なpersona一覧を表示
/system NAMEpersonaを切り替え(履歴もリセット)
/save会話をMarkdownファイルに保存
/exit終了

用意したpersona

system promptを切り替えるだけで、同じモデルでも応答の傾向が大きく変わります。今回は5種類のpersonaを用意しました。

  • default: 親切で丁寧な標準アシスタント
  • kansai: 関西弁で話す
  • expert: 技術分野の専門家、根拠を示す
  • concise: 要点のみ、最小限の言葉
  • teacher: 小学生にも分かるように、たとえ話を交える

PERSONAS 辞書に追加するだけで自由に増やせます。

実際の動作例

関西弁モードで動かしてみた例です。

あなた> /system kansai
personaを 'kansai' に切り替え、履歴をリセットしました
あなた> いつも有り難う!
AI> おおきに!こちらこそいつも感謝やで!何かお手伝いできること、あったら遠慮なく言ってやん!😊

たった1つのsystem promptで、ここまで自然に性格が変わるのがLLMの面白いところです。Gemma 3 12Bは「おおきに」「〜やで」「〜やん」といった関西弁の語尾を自然に使い分けており、絵文字まで添えてくれました。

実装のポイント

messagesの構造(OpenAI互換)

Ollamaのチャット用APIで使う messages は、OpenAIのChat Completion APIとほぼ同じ構造です。

[
    {"role": "system", "content": "あなたは親切なアシスタントです"},
    {"role": "user", "content": "こんにちは"},
    {"role": "assistant", "content": "こんにちは!"},
    {"role": "user", "content": "今日はどんな日?"},
]

役割は system / user / assistant の3種類。system はモデルへの指示で、配列の先頭に1つ置くのが一般的です。

ストリーミング処理

ollama.chat()stream=True を渡すと、トークンごとに分割されたチャンクをイテレータで受け取れます。各チャンクの message.content を逐次出力することで、ChatGPT風の「文字が少しずつ出てくる」表示が実現できます。

stream = ollama.chat(model=model, messages=messages, stream=True)
for chunk in stream:
    content = chunk["message"]["content"]
    print(content, end="", flush=True)
    full_response += content

print(..., end="", flush=True)flush=True が重要で、これがないとバッファリングされてストリーミングの効果が消えてしまいます。

エラー時の履歴管理

通信失敗時は失敗したユーザーメッセージを履歴から削除しています。

messages.append({"role": "user", "content": user_input})
try:
    reply = stream_chat(model, messages)
except Exception as e:
    print(f"\nエラー: {e}")
    messages.pop()  # 失敗時は履歴から消す
    continue

これをしないと、エラー後の会話で「対応するassistantメッセージが無いuserメッセージ」が履歴に残り、以降のやりとりが不自然になります。

まとめ

本記事では以下の内容を扱いました。

  • OllamaのAPIエンドポイントの概要
  • curlで直接APIを叩いて動作確認
  • requestsライブラリを使った最小実装
  • 公式ライブラリ(ollama)を使った完成版の作成
  • ストリーミング表示、モデル切り替え、persona切り替え、会話保存などの実装
  • system promptで応答の性格が大きく変わる体験

Ollamaは起動するだけでAPIが立ち上がるため、ローカルLLMを自分のツールに組み込むハードルが非常に低いことが分かりました。Pythonライブラリも整っており、100行強のコードで実用的なチャットCLIが作れます。

ここから先の応用としては、ファイル読み込みからの要約、RAG(検索拡張生成)、構造化出力(JSON返却)、複数モデルの並列実行など、用途に応じてさまざまな展開が可能です。クラウドAPIと違って従量課金もないため、思いついたツールをすぐに試せるのもローカルLLMの強みです。

コメント

タイトルとURLをコピーしました