[備忘録] Qwen3の"考える様子"を見てみよう - Google Colabで実装

## はじめに

「Qwen3:4b」を使い、「考えている途中」と「最終的な回答」を分けて表示する実装を試みました。Jupyter notebook環境で動くインタラクティブなUIを作り、思考の履歴を表示する試みの備忘録です。

**この記事で分かること：**
- Ollamaを使ってGoogle Colabで軽量LLMを動かす方法
- モデルの思考プロセスを可視化する実装テクニック
- ipywidgetsを使ったインタラクティブなチャットインターフェースの作り方

**注意事項：**
- 著者はCPU環境で試しました。回答が返ってくるまで時間がかかります。筆者の目的としては、Qwen3、思考プロセスを手持ちの環境で体験することと、仕組みを理解するのにやってみました。
- 同じ条件で動作させると返事かえってくるのに多少時間かかります。試すとしたら気長にやっていただくか、パワーアップした環境でやってみてください。

![thinking_process_diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/5f27ce08-be0d-4b4d-ac2a-df514e1c12c4.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/7dbd1040-9446-4bcb-988e-269e6f65db95.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/40ad385d-1a22-433e-b3f0-b12dd36257b1.png)



## 環境準備

今回は以下の技術スタックを使用します：

- **Google Colab**: クラウド上のJupyter環境
- **Ollama**: 軽量LLMランタイム
- **Qwen3:4b**: 比較的小さいながらも高性能な言語モデル
- **ipywidgets**: インタラクティブなUI構築用

## 実装手順

### 1. 必要なパッケージのインストール

まず、必要なパッケージをインストールします。

```python
!pip install ollama -q
!pip install ipywidgets -q
!pip install nest_asyncio -q
```

### 2. Ollamaのインストールと起動

Google Colabの環境にOllamaをインストールし、バックグラウンドで起動します。

```python
# Ollamaのインストール (初回のみ実行)
!curl -fsSL https://ollama.com/install.sh | sh

# バックグラウンドでOllamaサーバーを起動
!nohup ollama serve > /dev/null 2>&1 &
```

### 3. モデルのダウンロード

今回はQwen3の4bモデルを使用します。小さいモデルなのでColab環境でも比較的スムーズに動作します。

```python
# Qwen3:4bモデルをダウンロード
!ollama pull qwen3:4b
```

### 4. 必要なライブラリのインポート

```python
import re
import time
import nest_asyncio
from IPython.display import display, clear_output, HTML
import ipywidgets as widgets
from ollama import chat

# Jupyterでasyncを動作させるための設定
nest_asyncio.apply()
```

### 5. システムプロンプトの設定

思考プロセスを`<think></think>`タグで囲むようにモデルに指示するシステムプロンプトを設定します。回答は日本語で返すよう指定しています。

```python
# 会話履歴の初期化（思考プロセスタグの使用法を指定）
conversation_history = [
    {"role": "system", "content": """
あなたは役立つアシスタントです。思考プロセスを示すために <think></think> タグを使ってください。
回答は必ず日本語で行ってください。思考プロセスはあなたの自然な形で構いません。

例：

ユーザー: 15 + 27は？

<think>
I need to calculate 15 + 27.
First, I'll add the units: 5 + 7 = 12
I'll write down 2 and carry the 1.
Then, I'll add the tens: 1 + 1 + 2 = 4
So the answer is 42.
</think>

15 + 27は42です。
"""}
]
```

### 6. UIコンポーネントの作成

ipywidgetsを使って、見やすいUIを作成します。ここではユーザー入力欄、送信ボタン、履歴クリアボタン、チャット表示エリア、思考プロセス表示エリアなどを定義します。

```python
# UIコンポーネントの作成
title = widgets.HTML(value="<h2 style='color: #2c3e50;'>思考プロセス表示型チャット</h2>")
instructions = widgets.HTML(value="""
<div style="background-color: #e1f5fe; padding: 10px; margin: 10px 0; border-radius: 5px; border-left: 5px solid #0288d1; color: #01579b;">
<b>使い方:</b>
<ul>
<li>下のテキストエリアにメッセージを入力して「送信」をクリックしてください</li>
<li>アシスタントのリアルタイムの思考プロセスが表示されます</li>
<li>回答は日本語で返信されます</li>
</ul>
</div>
""")

text_input = widgets.Textarea(
    placeholder='ここにメッセージを入力...',
    layout=widgets.Layout(width='100%', height='80px')
)
send_button = widgets.Button(
    description="送信",
    button_style='primary',
    icon='paper-plane'
)
clear_button = widgets.Button(
    description="履歴クリア",
    button_style='danger',
    icon='trash'
)
# 省略: その他のUIコンポーネント
```

### 7. 思考プロセスとチャット履歴の処理関数

思考プロセスを抽出・表示する関数や、チャット履歴を管理する関数を実装します。

```python
# 思考内容を抽出してフォーマットする関数
def format_thinking_content(text):
    pattern = r'<think>(.*?)</think>'
    matches = re.findall(pattern, text, re.DOTALL)
    if matches:
        return matches[0].strip()
    return ""

# 思考タグを最終回答から削除する関数
def remove_thinking_tags(text):
    return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()

# 会話履歴をクリアする関数
def clear_history(b):
    global conversation_history, thinking_history
    conversation_history = [
        {"role": "system", "content": "...初期プロンプト..." }
    ]
    thinking_history = []
    
    with output_area:
        clear_output(wait=True)
        display(HTML("<div style='padding: 10px; color: #004d40; background-color: #e0f2f1; border-radius: 5px;'>チャット履歴をクリアしました</div>"))
```

### 8. メッセージ送信とレスポンス処理の実装

ユーザーのメッセージを送信し、Ollamaからのレスポンスをストリーミング形式で受け取り、思考プロセスと回答を分離して表示する機能を実装します。

```python
# メッセージ送信を処理する関数
def on_send_button_clicked(b):
    user_input = text_input.value.strip()
    if not user_input:
        return
    
    # ユーザーメッセージを会話に追加して表示
    conversation_history.append({"role": "user", "content": user_input})
    
    # 処理中の表示と入力の無効化
    text_input.disabled = True
    send_button.disabled = True
    text_input.value = ""
    
    with output_area:
        display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #e3f2fd; border-radius: 5px; border-left: 4px solid #2196F3; color: #0d47a1;'><b>あなた:</b> {user_input}</div>"))
    
    # モデルからの応答を取得
    thinking_content = ""
    response_content = ""
    in_thinking = False
    
    # 思考中インジケータを表示
    with thinking_area:
        clear_output(wait=True)
        thinking_html = HTML("<div style='margin: 10px; padding: 10px; background-color: #f1f8e9; border-radius: 5px; border-left: 4px solid #8bc34a; color: #33691e;'><b>アシスタント考え中...</b></div>")
        display(thinking_html)
    
    try:
        # レスポンスをストリーミング
        for chunk in chat(model="qwen3:4b", messages=conversation_history, stream=True):
            content = chunk["message"]["content"] if "message" in chunk and "content" in chunk["message"] else ""
            
            # 思考プロセスの開始タグを検出
            if "<think>" in content and not in_thinking:
                in_thinking = True
                thinking_content = ""
            
            # 思考プロセス内の内容を処理
            if in_thinking:
                thinking_content += content
                
                with thinking_area:
                    clear_output(wait=True)
                    formatted_thinking = thinking_content.replace("<think>", "").replace("</think>", "")
                    display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #f1f8e9; border-radius: 5px; border-left: 4px solid #8bc34a; color: #33691e;'><b>思考プロセス:</b><pre style='white-space: pre-wrap; background-color: #fafafa; padding: 8px; border-radius: 4px; color: #37474f;'>{formatted_thinking}</pre></div>"))
                
                # 思考プロセスの終了タグを検出
                if "</think>" in content:
                    in_thinking = False
                    
                    # 思考履歴に追加
                    if formatted_thinking:
                        thinking_history.append({
                            "question": user_input,
                            "thinking": formatted_thinking
                        })
                        
                        # 思考履歴表示を更新（省略）
            
            # 思考プロセス以外の内容（最終回答）を処理
            else:
                response_content += content
                
                # レスポンス表示を更新
                with output_area:
                    # 省略: 履歴表示のコード
                    
                    # 現在のレスポンスを表示
                    display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #e8f5e9; border-radius: 5px; border-left: 4px solid #4caf50; color: #1b5e20;'><b>アシスタント:</b> {response_content}</div>"))
        
        # アシスタントのレスポンスを会話履歴に追加
        full_response = thinking_content + response_content
        conversation_history.append({"role": "assistant", "content": full_response})
    
    except Exception as e:
        # エラー処理
        with output_area:
            display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #ffebee; border-radius: 5px; border-left: 4px solid #f44336; color: #b71c1c;'><b>エラー:</b> {str(e)}</div>"))
    
    finally:
        # 入力を再有効化
        text_input.disabled = False
        send_button.disabled = False
        # 思考エリアをクリア
        with thinking_area:
            clear_output()
```

### 9. イベントリスナーの設定とインターフェースの表示

各種ボタンとイベントを連携し、最終的なUIを表示します。

```python
# イベントリスナーを設定
send_button.on_click(on_send_button_clicked)
clear_button.on_click(clear_history)
# その他のイベントリスナー設定（省略）

# 最終的なチャットUIを構築
chat_ui = widgets.VBox([
    title,
    instructions,
    widgets.HBox([chat_label]),
    output_area,
    widgets.HBox([thinking_label]),
    thinking_area,
    # 省略: その他のUI要素
], layout=widgets.Layout(width='100%', padding='10px'))

# チャットインターフェースを表示
display(chat_ui)
```

## 実装例

Google Colabのセルに以下の実装をコピー＆ペーストして実行してみました。

```python
# 思考プロセス表示型チャット（Google Colab用）

# 必要なパッケージのインストール
!pip install ollama -q
!pip install ipywidgets -q
!pip install nest_asyncio -q

# Ollamaのインストール (初回のみ実行)
!curl -fsSL https://ollama.com/install.sh | sh

# バックグラウンドでOllamaサーバーを起動し、モデルをダウンロード
!nohup ollama serve > /dev/null 2>&1 &
!ollama pull qwen3:4b

# 必要なライブラリをインポート
import re
import time
import nest_asyncio
from IPython.display import display, clear_output, HTML
import ipywidgets as widgets
from ollama import chat

# Jupyterでasyncを動作させるための設定
nest_asyncio.apply()

# 会話履歴の初期化（思考プロセスタグの使用法を指定）
conversation_history = [
    {"role": "system", "content": """
あなたは役立つアシスタントです。思考プロセスを示すために <think></think> タグを使ってください。
回答は必ず日本語で行ってください。思考プロセスはあなたの自然な形で構いません。

例：

ユーザー: 15 + 27は？

<think>
I need to calculate 15 + 27.
First, I'll add the units: 5 + 7 = 12
I'll write down 2 and carry the 1.
Then, I'll add the tens: 1 + 1 + 2 = 4
So the answer is 42.
</think>

15 + 27は42です。
"""}
]

# 思考プロセスの履歴保存のための変数
thinking_history = []

# UIコンポーネントの作成 - 色合いを見やすく調整
title = widgets.HTML(value="<h2 style='color: #2c3e50;'>思考プロセス表示型チャット</h2>")
instructions = widgets.HTML(value="""
<div style="background-color: #e1f5fe; padding: 10px; margin: 10px 0; border-radius: 5px; border-left: 5px solid #0288d1; color: #01579b;">
<b>使い方:</b>
<ul>
<li>下のテキストエリアにメッセージを入力して「送信」をクリックしてください</li>
<li>アシスタントのリアルタイムの思考プロセスが表示されます</li>
<li>回答は日本語で返信されます</li>
<li>思考プロセス履歴の表示/非表示を切り替えることができます</li>
</ul>
</div>
""")

text_input = widgets.Textarea(
    placeholder='ここにメッセージを入力...',
    layout=widgets.Layout(width='100%', height='80px')
)
send_button = widgets.Button(
    description="送信",
    button_style='primary',
    icon='paper-plane'
)
clear_button = widgets.Button(
    description="履歴クリア",
    button_style='danger',
    icon='trash'
)
show_thinking_history = widgets.Checkbox(
    value=True,
    description='思考プロセス履歴を表示',
    indent=False
)

output_area = widgets.Output(layout=widgets.Layout(height='400px', max_height='600px', overflow='auto'))
thinking_area = widgets.Output(layout=widgets.Layout(height='200px', max_height='300px', overflow='auto', border='1px solid #bdbdbd'))
thinking_history_area = widgets.Output(layout=widgets.Layout(height='200px', max_height='600px', overflow='auto', border='1px solid #bdbdbd'))

# ラベルの作成
thinking_label = widgets.HTML(value="<h4 style='color: #2c3e50; margin-bottom: 5px;'>思考プロセス (リアルタイム):</h4>")
chat_label = widgets.HTML(value="<h4 style='color: #2c3e50; margin-bottom: 5px;'>チャット履歴:</h4>")
thinking_history_label = widgets.HTML(value="<h4 style='color: #2c3e50; margin-bottom: 5px;'>思考プロセス履歴:</h4>")

# 思考内容を抽出してフォーマットする関数
def format_thinking_content(text):
    pattern = r'<think>(.*?)</think>'
    matches = re.findall(pattern, text, re.DOTALL)
    if matches:
        return matches[0].strip()
    return ""

# 思考タグを最終回答から削除する関数
def remove_thinking_tags(text):
    return re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()

# 会話履歴をクリアする関数
def clear_history(b):
    global conversation_history, thinking_history
    conversation_history = [
        {"role": "system", "content": """
あなたは役立つアシスタントです。思考プロセスを示すために <think></think> タグを使ってください。
回答は必ず日本語で行ってください。思考プロセスはあなたの自然な形で構いません。

例：

ユーザー: 15 + 27は？

<think>
I need to calculate 15 + 27.
First, I'll add the units: 5 + 7 = 12
I'll write down 2 and carry the 1.
Then, I'll add the tens: 1 + 1 + 2 = 4
So the answer is 42.
</think>

15 + 27は42です。
"""}
    ]
    thinking_history = []
    
    with output_area:
        clear_output(wait=True)
        display(HTML("<div style='padding: 10px; color: #004d40; background-color: #e0f2f1; border-radius: 5px;'>チャット履歴をクリアしました</div>"))
    
    with thinking_history_area:
        clear_output(wait=True)
        display(HTML("<div style='padding: 10px; color: #004d40; background-color: #e0f2f1; border-radius: 5px;'>思考履歴をクリアしました</div>"))

# 思考履歴の表示/非表示を切り替える関数
def toggle_thinking_history(change):
    if change['new']:
        thinking_history_box.layout.display = 'flex'
    else:
        thinking_history_box.layout.display = 'none'

# メッセージ送信を処理する関数
def on_send_button_clicked(b):
    user_input = text_input.value.strip()
    if not user_input:
        return
    
    # 処理中は入力を無効化
    text_input.disabled = True
    send_button.disabled = True
    
    # 入力フィールドをクリア
    text_input.value = ""
    
    # ユーザーメッセージを会話に追加して表示
    conversation_history.append({"role": "user", "content": user_input})
    
    with output_area:
        display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #e3f2fd; border-radius: 5px; border-left: 4px solid #2196F3; color: #0d47a1;'><b>あなた:</b> {user_input}</div>"))
    
    # モデルからの応答を取得
    thinking_content = ""
    response_content = ""
    in_thinking = False
    
    # 思考中インジケータを開始
    with thinking_area:
        clear_output(wait=True)
        thinking_html = HTML("<div style='margin: 10px; padding: 10px; background-color: #f1f8e9; border-radius: 5px; border-left: 4px solid #8bc34a; color: #33691e;'><b>アシスタント考え中...</b></div>")
        display(thinking_html)
    
    # レスポンスをストリーミング
    try:
        for chunk in chat(model="qwen3:4b", messages=conversation_history, stream=True):
            content = chunk["message"]["content"] if "message" in chunk and "content" in chunk["message"] else ""
            
            if "<think>" in content and not in_thinking:
                in_thinking = True
                thinking_content = ""
            
            if in_thinking:
                thinking_content += content
                
                # 思考表示を更新
                with thinking_area:
                    clear_output(wait=True)
                    formatted_thinking = thinking_content.replace("<think>", "").replace("</think>", "")
                    display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #f1f8e9; border-radius: 5px; border-left: 4px solid #8bc34a; color: #33691e;'><b>思考プロセス:</b><pre style='white-space: pre-wrap; background-color: #fafafa; padding: 8px; border-radius: 4px; color: #37474f;'>{formatted_thinking}</pre></div>"))
                
                if "</think>" in content:
                    in_thinking = False
                    
                    # 思考履歴に追加
                    if formatted_thinking:
                        thinking_history.append({
                            "question": user_input,
                            "thinking": formatted_thinking
                        })
                        
                        # 思考履歴表示を更新
                        with thinking_history_area:
                            clear_output(wait=True)
                            for i, item in enumerate(thinking_history):
                                display(HTML(f"""
                                <div style='margin: 10px; padding: 10px; background-color: #fff8e1; border-radius: 5px; border-left: 4px solid #ffc107; margin-bottom: 15px;'>
                                    <div style='font-weight: bold; color: #bf360c; margin-bottom: 5px;'>質問 {i+1}: {item['question']}</div>
                                    <div style='background-color: #fafafa; padding: 8px; border-radius: 4px; color: #37474f;'><pre style='white-space: pre-wrap; margin: 0;'>{item['thinking']}</pre></div>
                                </div>
                                """))
            else:
                response_content += content
                
                # レスポンス表示を更新
                with output_area:
                    # 最新のメッセージを含む全履歴を表示
                    clear_output(wait=True)
                    # 過去のメッセージを再表示
                    for i, msg in enumerate(conversation_history):
                        if msg["role"] == "user":
                            display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #e3f2fd; border-radius: 5px; border-left: 4px solid #2196F3; color: #0d47a1;'><b>あなた:</b> {msg['content']}</div>"))
                        elif msg["role"] == "assistant" and i > 0:  # システムメッセージをスキップ
                            display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #e8f5e9; border-radius: 5px; border-left: 4px solid #4caf50; color: #1b5e20;'><b>アシスタント:</b> {remove_thinking_tags(msg['content'])}</div>"))
                    
                    # 現在のレスポンスを表示
                    display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #e8f5e9; border-radius: 5px; border-left: 4px solid #4caf50; color: #1b5e20;'><b>アシスタント:</b> {response_content}</div>"))
        
        # アシスタントのレスポンスを会話履歴に追加
        full_response = thinking_content + response_content
        conversation_history.append({"role": "assistant", "content": full_response})
    
    except Exception as e:
        with output_area:
            display(HTML(f"<div style='margin: 10px; padding: 10px; background-color: #ffebee; border-radius: 5px; border-left: 4px solid #f44336; color: #b71c1c;'><b>エラー:</b> {str(e)}</div>"))
    
    finally:
        # 入力を再有効化
        text_input.disabled = False
        send_button.disabled = False
        # 思考エリアをクリア
        with thinking_area:
            clear_output()

# イベントリスナーを設定
send_button.on_click(on_send_button_clicked)
clear_button.on_click(clear_history)
show_thinking_history.observe(toggle_thinking_history, names='value')

# Enterキーでも送信できるように（Shift+Enterは改行用に予約）
def on_key_press(widget, event):
    if event.get('type') == 'keydown' and event.get('key') == 'Enter' and not event.get('shiftKey'):
        on_send_button_clicked(None)
        return True
    return False

text_input.observe(on_key_press, names=['_keydown'])

# ボタンをグループ化
button_box = widgets.HBox([send_button, clear_button], layout=widgets.Layout(justify_content='space-between'))

# 思考履歴ボックスを作成
thinking_history_box = widgets.VBox([
    thinking_history_label,
    thinking_history_area
], layout=widgets.Layout(display='flex', margin='10px 0'))

# 最終的なチャットUIを構築
chat_ui = widgets.VBox([
    title,
    instructions,
    widgets.HBox([chat_label]),
    output_area,
    widgets.HBox([thinking_label]),
    thinking_area,
    widgets.HBox([show_thinking_history]),
    thinking_history_box,
    widgets.HBox([text_input]),
    button_box
], layout=widgets.Layout(width='100%', padding='10px'))

# チャットインターフェースを表示
display(chat_ui)
```

## 動作の仕組み

このインターフェースでは、以下の仕組みで思考プロセスを可視化しています。

1. **システムプロンプトによる指示**: LLMに対して思考プロセスを`<think></think>`タグで囲むよう指示
2. **ストリーミングレスポンスの解析**: レスポンスをストリーミングで受け取り、タグを検出して思考部分と回答部分を分離
3. **リアルタイム表示**: 思考プロセスを専用のエリアにリアルタイムで表示
4. **履歴保存**: 思考プロセスの履歴を保存して後から参照可能に

## 試してみた質問

Q. 今日は天気がいい。お昼はカレーにしたい。どんなカレーがいいかな？

```text
Qwen3さん： 天気がいい日にお昼は、軽くさっぱりしたカレーがおすすめです！ 例えば、**野菜たっぷりのカレーやトマトベースのカレー**なら、温かさと涼しさのバランスが取りやすく、食欲もアップします。 もし肉がお好きななら、**牛肉や鶏肉のカレー**も美味しいですが、季節感を重視するなら、きんぴらやナッツを加えた「**カレーライスに加えてトマトサルサ**」とか、**カレーパン**もおすすめです。 何がお好みですか？ 😊
```

思考プロセス （機械翻訳) :

```text
質問 1: 今日は天気がいい。お昼はカレーにしたい。どんなカレーがいいかな？
ユーザーは、天気が良いのでお昼にどんなカレーを食べようか悩んでいるようです。さて、どんなカレーが良いか考えてみましょう。

まず、季節や気候を考慮するべきですね。天気が良いなら、少し軽めのカレーが良いかもしれません。例えば、ベジタリアン向けのカレーとか。それとも、風味を求めてスパイシーなものがいいでしょうか。

一般的なカレーには、ビーフ、チキン、野菜、エビなどがあります。ただし、ユーザーは特に食事制限などには触れていません。いくつかの選択肢を提案して、それぞれの良さを説明するのが良さそうです。

また、簡単に作れるものや、味のバランスが良いカレーを求めているかもしれません。ご飯と一緒に食べる定番のカレーや、野菜の副菜がついたカレーなども紹介できそうです。

ユーザーが自分の好みに合わせて選べるように、親しみやすく、役に立つ形で複数のタイプのカレーを提案するべきですね。
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/e9f535aa-0a25-479d-87ed-57856f104a89.png)

4BだがLLMの知ってる世界であれば、まともな提案している。1年まえから考えると進化している。自分より良い提案してるかもしれん・・・。

## まとめ

今回は、Google Colabを使ってローカルLLMのQwen3の思考プロセスを可視化する方法を紹介しました。この手法を使うことで、LLMがどのように推論を組み立て、結論に至るのかを観察できます。
ぜひ皆さんも、LLMの「頭の中」を覗いてみてください。思わぬ発見があるかもしれません！

## 参考資料

- [Ollama公式ドキュメント](https://github.com/ollama/ollama)
- [Qwen3モデル情報](https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f)
- [ipywidgetsドキュメント](https://ipywidgets.readthedocs.io/)

## 免責事項

- 本記事の作成にあたり、文章および図解の一部に生成AIを活用しています。最終的な編集および内容の確認は筆者が責任を持って行っています。