Livebook と Ollama で Phi-4 モデルによる AI チャットを構築する

## はじめに

以前の記事で Ollama と Livebook を連携する手法を紹介しました

https://qiita.com/RyoWakabayashi/items/463601c2e455d8b0f9f4

本記事では Ollama と Livebook で簡易的な AI チャットを実装してみます

AI には比較的軽量で CPU 環境でもそれなりに速く動いてくれる Phi-4 を使用します

https://huggingface.co/microsoft/phi-4

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/ollama/ollama_chat.livemd

## コンテナの準備

:::note info
Livebook をコンテナで起動し、 Ollama をホスト上で実行する場合、

Ollama はホスト名 `host.docker.internal` でアクセス可能です
:::

本記事では Livebook と Ollam をコンテナで起動します

docker-compose.with-ollama.yml

```yml
---

services:
  livebook_with_ollama:
    image: ghcr.io/livebook-dev/livebook:0.14.7
    container_name: livebook_with_ollama
    ports:
      - '8080:8080'
      - '8081:8081'
    volumes:
      - ./tmp:/tmp

  ollama:
    image: ollama/ollama:0.5.11
    container_name: ollama_for_livebook
    ports:
      - '11434:11434'
    volumes:
      - ollama:/root/.ollama

volumes:
  ollama:
```

### コンテナのビルド・起動

以下のコマンドでコンテナをビルド、起動します

```bash
docker compose --file docker-compose.with-ollama.yml up
```

両方のビルドが完了したら Livebook の URL にアクセスします

## Livebook 上での AI チャットの実装

### セットアップ

Livebook で新しいノートブックを開き、セットアップセルで以下のコードを実行します

```elixir
Mix.install([
  {:ollama, "~> 0.8"},
  {:kino, "~> 0.14"}
])
```

### モデルの用意

コンテナで起動している Ollama にアクセスするためのクライアントを用意します

URLでしていしている「ollama」の部分は docker-compose で指定したサービス名です

応答に時間がかかることを想定して、 `receive_timeout` に 300 秒を指定します

```elixir
client = Ollama.init(base_url: "http://ollama:11434/api", receive_timeout: 300_000)
```

クライアントで Phi-4 のモデルをダウンロードします

```elixir
Ollama.pull_model(client, name: "phi4")
```

読み込みに時間がかかるので、実行前に読み込んでおきます

```elixir
Ollama.preload(client, model: "phi4")
```

### Ollama によるストリーミング応答

ストリーミングで受け取ったトークン毎に表示を更新します

回答表示用のフレームを用意します

```elixir
answer_frame = Kino.Frame.new()
```

回答用の関数を用意します

ストリーミングで処理した後、全部のトークンを結合して返すようにしています

```elixir
answer = fn input, frame ->
  {:ok, stream} =
    Ollama.completion(
      client,
      model: "phi4",
      prompt: input,
      stream: true
    )

  stream
  |> Stream.transform("AI: ", fn chunk, acc ->
    response = acc <> chunk["response"]

    markdown = Kino.Markdown.new(response)
    Kino.Frame.render(frame, markdown)

    {[chunk["response"]], response}
  end)
  |> Enum.join()
end
```

Phi-4 は日本文化についても結構知っているので、ドラえもんについて質問してみましょう

```elixir
answer.("ドラえもんについて100文字程度で説明して", answer_frame)
```

実行結果

![doraemon.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/58be2584-a06b-402c-b053-39a3e53b1b1b.gif)

### フォームの作成

チャットアプリらしいフォームを用意します

```elixir
# 出力用フレーム
output_frame = Kino.Frame.new()

# ストリーミング用フレーム
stream_frame = Kino.Frame.new()

# 入力用フォーム
input_form =
  Kino.Control.form(
    [
      input_text: Kino.Input.textarea("メッセージ")
    ],
    submit: "送信"
  )


Kino.Frame.render(output_frame, Kino.Markdown.new(""))
Kino.Frame.render(stream_frame, Kino.Markdown.new(""))

# フォーム送信時の処理
Kino.listen(input_form, fn %{data: %{input_text: input}} ->
  Kino.Frame.append(output_frame, Kino.Markdown.new("あなた: " <> input))
  full_response = answer.(input, stream_frame)
  Kino.Frame.render(stream_frame, Kino.Markdown.new(""))
  Kino.Frame.append(output_frame, Kino.Markdown.new("AI: " <> full_response))
end)

# 入出力を並べて表示
Kino.Layout.grid([output_frame, stream_frame, input_form], columns: 1)
```

実行結果

![okonomiyaki.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/893ffac9-e885-4efd-8bec-1a536e87a8f4.gif)

AI チャットが実装できました

## まとめ

Livebook と Ollama で AI チャットが実装できました

Phi-4 であればローカルマシンでも十分な速度で応答してくれます