はじめに
Agentic Era と謳われ久しいですね。
様々なことが Agent により代行できるようになり便利な時代だなと思います。
私も業務でコードを書いたりはしてきましたが、ソフトウェア開発のうちコーディングに費やされる時間は、確かに限りなく短くなったなという実感があります。
その進化もめざましく、一度 SNS のフィードなどを眺めれば、やれ新しいモデルが出ただの新しい機能が追加されただの、またそこから議論百出、有象無象であったり。
しかし、エンジニアとして「大いなる力には責任が伴う」ということは常に心に留めておきたいものです。
Agent は間違いなく、産業モデルを変えるレベルで革新的なものであるが、自分は
- Agent が何をしているのか?
- どう動いているのか?
- なぜこんな高度なことができるのか?
に答えられないと気づきました。
そして、どの投稿を眺めても「それはただツールの話をしているだけでは...」という感覚や、自作してみた系の記事を見ても、「それはただ API Wrapper なのでは...」というモヤモヤした感覚が残っていました。
自分は10年近く前の学部時代に統計学を専攻していたので、当時バズワード化していた「機械学習」の延長であろうというのは推測がついていました。
しかし、エンジニアとして働く今、いわゆる数学/統計的なモデルで分析を行うデータサイエンスと、OS をはじめとしたコンピュータサイエンスとの間を繋ぐような情報をあまり見かけず、これがモヤモヤの原因だと気づいたのです。
この記事は、つい最近転職した先の会社で Ruby(および Rails)と向き合うことになった Python 好きエンジニアが、「Ruby と Rails を学習するがてら Agent や LLM についても勉強してみよう」と思い立って検証していた内容を書き下したものです。
また、新人の頃に私はメンターから「再発明は悪いことではない。わからないなら考えて自分で作れ」と厳しく教育させられていたので、「LLM のミニチュア的なものなら作れるのでは」と思い立ったのもあります。
そのため、調査や壁打ちには Gemini Flash を利用しましたが、コードは泥臭く手で書いています。
執筆にあたってはできるだけ「どう考えたのか」がわかるように努めている点と、私は Ruby の熟達者でもなければプロの AI エンジニアでもないというのは最初に言い訳をさせていただきます。
(むしろ改善点などを見つけた方は feedback をいただきたいです)
なお、何某かのウェブアプリケーションフレームワークの利用経験があればすんなりと理解/再現できるように意識はしています。
全体像
Agentic なシステム・サービスを構成するものはおおよそ、以下のコンポーネントから構成されているようです。
それぞれの詳細については、後述の各章で掘り下げるため、ここではまず登場人物とその概要を掴みます。
- ユーザーインターフェイス
- 言わずもがな、我々人間が自然言語でプロンプトを入力したり、応答を確認したりするためのコンポーネントです
- オーケストレーター
- ユーザインターフェイスから受け取ったプロンプトを LLM に渡したり、必要に応じて MCP Server を呼び出すコンポーネントです
- 必要なコンポーネントを連携をし、Agent が人間に対してユーザーインターフェイス経由で最終的な回答を返す役割も持っています
- MCP Server
- ユーザからのプロンプトの内容に応じて、Agent が何かの処理を行う際にその接続部分となるコンポーネントです
- MCP Server は各種 SaaS のプロバイダーが提供していることもあれば、自分でサーバをホストして利用することも可能です
- Agent からみると MCP Server のアドレスを登録することで、プロンプトに応じた処理を行うことができるようになるため、MCP Server が Agent の手足となると考えることもできます
- LLM
- Agentic なシステムはその脳として LLM を持ちます
- ユーザが複数のモデルから利用したいモデルを選択できるのは、オーケストレーターの背後に複数モデルが存在している形になります
- コンピュータであれば CPU にあたるコンポーネントとなり、LLM 自体はステートレスとなります
- Sandbox 環境
- Coding Agent などでは LLM から出力された回答内容の妥当性を担保するために、実際に試す環境が必要になります
- 多くの場合は軽量な Linux などが利用され、コンテナ技術や MicroVM により使い捨ての検証環境を持ちます
Grand Design
ChatGPT や Claude, Gemini などで普段我々が利用している LLM は、大量の GPU サーバーをホストする巨大なデータセンターの中で動いており、当然ながらとても個人の Macbook で再現できるようなものではありません。
しかし、これらのコンポーネントの論理的な関係や機能のみを取り出して実装することは GPU を積まない一般的なサーバーでも再現は可能であろうと考えました。
また、今回は Agentic なシステムのうち、LLM の動き(どう計算が行われレスポンスを返すのか)を知りたいというのが主たる目的であるため、MCP Server および Sandbox 環境の実装は行いません。
(LLM が必要とする MCP Server を特定するロジックもまた面白いので今後追加はしていきたいと思っています)
コンポーネントの実装について
今回の試みには Rails の学習も含まれています。
Ralls においてこれをどう落とし込むかが最初のポイントです。
Agentic システムは複数のコンポーネントから構成される分散システムであるため、これを実装する際に直感的なのは、複数の Rails アプリケーションを異なるポートで立ち上げ、API でのやり取りにより実装する形です。
しかし、各コンポーネントの API 設計などが必要となるため、今回は単一の Rails アプリケーションで完結させる形とします。
Rails は他のウェブアプリケーションフレームワーク同様に MVC(Python Django だと MVT)な実装をすることができ、Zeitwerk と呼ばれる auto loading の仕組みによって app/ ディレクトリ以下の Ruby ファイルを自動的に読み込み、クラスやモジュール、メソッドなどは明示的に require せずとも呼び出すことが可能なようです。
今回は Agentic システムを成す「各コンポーネント間でどのようなやり取りがなされるのか」を知りたい目的もあるため、app/services 配下に各コンポーネントの機能群をクラスとして実装し、単一の controller からそれらを呼び出すという MVCS の形にするとします。
アプリケーションルーティングについて
Rails では config/routes.rb によりルーティングを定義しており、HTTP メソッドとエンドポイントに対応する形で controller(およびそのメソッド)を指定するのが基本なようです。
今回は壮大な RESTful なアプリケーションを作ることが目的でもないため、利用するエンドポイントは以下の2つのみとします。
| HTTP Method/Endpoint | 用途 |
|---|---|
GET / |
ルートエンドポイント(動作確認用) |
POST /query |
プロンプトを投げ込むエンドポイント |
ユーザーインターフェイスの実装
多くの Agentic なサービスでは、人間からのプロンプトを受け取るためにいくつかのインターフェイス実装パターンがあります。
- ブラウザ
- インタラクティブシェル
- ネイティブアプリケーション
今回は Rails(Puma)でサーバーを起動することもあり、/query エンドポイントに投げた JSON データからプロンプトを受け取れれば良い形とします。
最低限ここが定まれば、取り急ぎ実装を開始できそうです。
早速 Rails プロジェクトを立ち上げますが、プロンプトは API 経由で受け取れれば良いので、API mode で scaffold します。
(なお、Ruby や Rails のインストールなどは今回は割愛します)
% mkdir rails-lm
% cd rails-lm
% bundle exec rails new . --minimal --api
次にデフォルトのルーティングからエンドポイントを実装します。
Rails ではデフォルトで ApplicationController と呼ばれる以下のような controller が作成されています。
# file: app/controllers/application_controller.rb
class ApplicationController < ActionController::API
end
この ApplicationController クラスは Rails においてユーザが作成するすべての controller に継承されるクラスであるようで、実装する controller で共通的な処理のみをここに実装するのが Rails のお作法だそうです。
つまり、ルーティング先となるクラスおよびメソッドを実装する際には、ApplicationController に直接メソッドを追加するのでなく、新たに controller を実装する形となります。
# file: config/routes.rb
Rails.application.routes.draw do
# ...
root "root#index"
post "query" => "root#create"
end
ルーティング先となる controller を作成して、処理内容を実装します。
% bundle exec rails generate controller root
create app/controllers/root_controller.rb
invoke test_unit
create test/controllers/root_controller_test.rb
GET / の際には RootController の index メソッド、POST /query の際には RootController の create メソッドにルーティングされるため、処理を実装します。
Rails でリクエストボディを取得するには、params と呼ばれるオブジェクトを利用する方法や request.body.read でストリーム読み出しをするなどいくつかやり方があるようですが、今回は JSON で投げるデータを受け取れればよい、という要件しかないのでシンプルな params を利用する形にします。
class RootController < ApplicationController
def index
render(json: {status: "ok"}, status: :ok)
end
def create
req = params[:prompt]
render(json: {user_prompt: req}, status: :created)
end
この状態では、ただのおうむ返しなアプリケーションですが、まずは確認を進めます。
% bundle exec rails server &
% curl -s -X GET localhost:3000 ; echo
{"status":"ok"}
% curl -s -H "Content-Type: application/json" -d '{"prompt": "hello"}' -X POST localhost:3000/query ; echo
{"user_prompt":"hello"}
モデル部分の実装
モデルで行われる主な処理としては大きく分けると以下の2つの処理です。
- Tokenization
- 出力文字列の確率的予測
これらの処理を行うことが可能で、巨大なパラメーターを持つ統計モデルが Python などのプログラミング言語として実装されており、自律的にモデル精度を向上させて行っているものが LLM と捉えることができます。
それぞれの処理を実装する前に、前提となる考えを以下にまとめます。
統計的なモデルとは?
統計分析の目的は、無機質なデータからパターンや特性を発見し、データに現在の側面を多面的に持たせること、またその結果から将来の事象を予測することにあります。
重要なのは、分析の結果、データから得られる関係性(相関関係)は必ずしも現実の因果関係とは一致せず、相関と因果を分けて考えるのは統計学における最も重要な基礎となります。
統計モデルの最もシンプルなものは誰もが見たことあるような以下のような一次方程式です。
(これも立派な単回帰モデルと呼ばれるものです)
y = ax + b
この方程式で、それぞれは
- y: 被説明変数(目的変数)
- a: 回帰係数
- x: 説明変数
- b: 切片および誤差項
と呼ばれ、x の変化により y の推移を説明しようとする関係となっています。
教科書的な例としては「気温(x)とアイスクリームの売り上げ(y)」を上記の式に落とし込んだものであり、「気温(x)が増加すれば、アイスクリームの売り上げ(y)も増加する」という単純な線形増加を説明するものです。
しかし、現実には、アイスクリームの売り上げに寄与する要素は気温以外にも多数ある(これらの要素を交絡因子と呼んだりします)ため、統計的に上記のモデルが正しい(=相関関係を持つ)としても、それは因果関係に一致するとは限らない、というのは直感的にも理解できるかと思います。
統計におけるモデルは、上記の一次関数のような極めてシンプルなものから、複数の説明変数を扱う重回帰モデルや、順序性を考慮した順序プロビットモデル、など現実世界をより正確に投影・予測できるような様々なモデルが存在しています。
これを踏まえると、LLM の「モデル」もつまるところは超巨大な方程式が動いており、これをプログラミング言語として実装しているものだと理解できるのではないかと思います。
LLM における統計モデルと実装について
前述の通り、LLM 自体はステートレスであるため、例えば単回帰モデルを任意のプログラミング言語で実装する、つまり、x を入力として受け取り、y を返すような関数を実装すればよいと推測できます。
しかし、LLM はこのような単純なモデルではなく、重みづけニューラルネットワーキングなど非常に高度な統計手法を用いたモデルが利用されています。
そのため、モデルをプログラミング言語ですべて実装するということは行わず、外部のファイルとして外出したものをロードする形をとっています。
また、我々がチャットを通じてエージェントに自然言語でプロンプトや質問を投げかけ、自然言語で応答があるということから、この高度な統計モデルは、自然言語を入力として受け付け、自然言語を出力とする巨大複雑な関数と考えることができます。
ユーザーに代わり処理を代行してくれる Agentic なシステムの場合には、出力は推論や思考の過程であり、アウトプットとして何某かの「処理」であるとも言えるかと思います。
つまり、自作 LLM の実装方針に際しては、
- 外部ファイルをプログラムで読み込み
- その内容に応じて自然言語を計算可能な形式に分解し
- 自然言語を返す(あるいは何かの処理をする)
というのがモデル部分の核となると言えます。
Tokenization
LLM で行われる処理として Tokenization があります。
これは、ユーザの自然言語をプログラムが計算可能な形に処理することであり、具体的には文字列や文章を行列の形に落とし込むことです。
では「計算可能な形」とは具体的に何になるでしょうか。
LLM においては「単語同士の意味の近さ」を図ることができる超高次元のベクトル空間が利用されます。
例として、"I like Python" というプロンプトを投げた時、これらは "I", "like", "Python" という単語に分解され、以下のように数値化されます。
- "I" = [0.9, 0.0, 0.2]
- "like" = [0.7, 0.9, 0.0]
- "Python" = [-0.5, 0.0, 1.0]
自然言語を行列に変換する上で、上記の各列は「意味ベクトル」と呼ばれます。
意味ベクトルとは、直感的には理解がしにくいものですが、上記の場合だと例えば
- 第一列(0.9, 0.7, -0.5)は「人間っぽさ」を表す数値
- 第二列(0.0, 0.9, 0.0)は「ポジティブさ」を表す数値
- 第三列(0.2, 0.0, 1.0)は「機械っぽさ」を表す数値
のような形になります。
意味ベクトルは LLM が自然言語を捉えるための物差しや軸のようなもの(特徴量)で、これにより自然言語のコンテキスト(文脈)を捉えていると言えます。
この意味ベクトルへの変換は、色彩学において色を RGB や CYMK に変換する処理と全く同様の位置付けとも捉えることができます。
上記の例だと、意味ベクトルは3つの軸(人間っぽさ、ポジティブさ、機械っぽさ)を持つベクトルとなっています。
現実の LLM においては、プロンプトを単語に分解する際、形態素解析や N-gram などの手法が用いられ、上記のように単純にスペースで分割をしているわけではなく、意味ベクトルの列数も上記のように3列程度ではなく 200-4096 列となっているようです。
Tokenization 処理の実装方針
Tokenization は LLM 開発を行うベンダーによりいくつかの実装があり、著名なものだと以下の2つがあるようです
- Tokenizers (Hugging Face)
-
tokenizer.jsonというファイルを Rust 製のライブラリで読み込む - JSON 形式のファイルであるため、Tokenization 処理を人間が調整しやすい
-
- SentencePiece (Google)
-
tokenizer.modelというファイル - ProtocolBuffer でシリアライズされているため、そのまま開くことはできない
-
今回は素人が Tokenization を実装するのもあり、JSON ファイルで調整が可能な Tokenizer の実装を真似て見るのが良さそうです。
出力文字列の確率的予測
Tokenization により意味ベクトルを用いてユーザプロンプトをベクトル空間に落とし込むことはできそうですが、普段我々が Agent から得られている自然言語のレスポンスはどのように生成されているのでしょうか。
LLM の根幹は、超巨大なベクトル空間の中で、「ある単語が来た際には(類似度が近い=文脈的に確率が高い)この単語を返す」という巨大なマッピングファイルを持っている形になります。
このマッピングファイルは「パラメータファイル」とも言われ、以下の特徴を持ちます
- LLM のプログラムからはファイルとして外出しされている
- 超巨大であるため、JSON などの可読性ある形式ではなくバイナリ形式となっている
- バイナリ形式の方が GPU などのプロセッサが直接読み込むことができる
- ローカルで LLM を稼働させる際に、ダウンロードサイズが巨大になるのはこのファイルサイズに起因しているということになります
ユーザのプロンプトに対して、LLM が自然言語で回答をすることができるのは、プロンプトを分解し、パラメータファイルを元に確率が最も高い応答文字列を探し出し、最終的に文章として組み立てているということなります。
また、パラメーターファイルにはいくつかのフォーマットがあるようです
- safetensors 形式
-
.safetensors拡張子を持つ(models.safetensorsなど) - Hugging Face などでは標準的に利用されている形式
-
- Pickle 形式
- PyTorch の
.binや.pt拡張子を持つ - Python の Pickle と呼ばれるライブラリでシリアライズされている形式
- PyTorch の
現実の LLM ではこのパラメータファイルはバイナリですが、今回の実装では構造理解を優先するために safetensors 形式のパラメータファイルを JSON 形式で模擬する形としてみたいと思います。
超簡易的なモデルの実装
まずはモデルクラスを実装する service ファイルを作成します。
% mkdir -p app/services/
% touch app/services/llm_service.rb
次に Tokenization 処理時に読み込まれる tokenizer.json を作成します。
% mkdir -p config/data/
% touch config/data/tokenizer.json
Tokenizers では JSON フォーマットが定まっているようで、tokenizer.json には以下のブロックが含まれるようです。
added_tokensnormalizerpre_tokenizermodelpost_processor
このうち、Tokenization を行い応答を確率予測するのに重要なのは model フィールドとなるようなので、今回はここのみを実装する形とします。
具体的には以下のような JSON 形式として、Ruby で読み込めるようにします。
// file: config/data/tokenizer.json
{
"version": "1.0",
"model": {
"type": "BPE",
"vocab": {
"save": 0,
"file": 1,
"search": 2,
"web": 3,
"now": 4,
"i": 5,
"tool": 6,
"code": 7,
"ruby": 8,
}
}
}
実際の tokenizer.json を見ると、分解した単語を数値に置き換えているに過ぎず、これは意味ベクトルへのポインタのような形になっていることがわかります。
例として、以下のようにマッピングされています。
-
saveという単語を受けた場合にはモデルの 0 番地にジャンプ -
fileという単語を受けた場合にはモデルの 1 番地にジャンプ - ...
model.type の値になっている BPE は Byte Pair Encoding のことで、トークン化の際のアルゴリズムとなります。
今回の Rails アプリでは、ホワイトスペースをセパレーターとして単語に分解する単純なアルゴリズムとしますが、
実際の Tokenization では様々なアルゴリズムを利用できることがわかります。
次にパラメータファイルを作成し、同様に読み込めるようにします。
% touch config/data/models.safetensors.json
safetensors 形式のパラメータファイルは、以下のような3つのフィールドから構成されているようです。
- Header Size
- JSON Header
- Tensor Data
具体的な形式としては以下のようになっているようです。
(実際にはバイナリデータであるため中身は見ることができない)
// file: config/data/models.safetensors.json
{
"header_size_bytes": 512,
"json_header_area": {
"model.embed_tokens.weight": {
"dtype": "F32",
"shape": [9, 3],
"data_offsets": [0, 27]
},
"lm_head.weight": {
"dtype": "F32",
"shape": [9, 3],
"data_offsets": [27, 54]
}
},
"raw_data_binary_area": [
// [0~27] は Header の `model.embed_tokens.weight` に対応するデータ
// 分解された単語の意味ベクトルが格納されている
0.9, 0.0, 0.0, // ID:0 (save)
0.8, 0.0, 0.0, // ID:1 (file)
0.0, 0.9, 0.0, // ID:2 (search)
0.0, 0.8, 0.1, // ID:3 (web)
0.1, 0.1, 0.1, // ID:4 (now)
0.1, 0.1, 0.1, // ID:5 (i)
0.5, 0.5, 0.0, // ID:6 (tool)
1.0, 0.0, 0.0, // ID:7 (code)
0.0, 1.0, 0.0, // ID:8 (ruby)
// [27~54] は Header の `lm_head.weight` に対応するデータ
// 実質的にはここが「入力となる単語」に対する「応答する単語」のマッピングとなる
0.1, 0.0, 0.0, // ID:0 (save)
0.1, 0.0, 0.0, // ID:1 (file)
0.0, 0.1, 0.0, // ID:2 (search)
0.0, 0.1, 0.0, // ID:3 (web)
0.0, 0.0, 0.1, // ID:4 (now)
0.0, 0.0, 0.1, // ID:5 (i)
0.5, 0.5, 0.0, // ID:6 (tool)
1.0, 0.0, 0.0, // ID:7 (code)
0.0, 1.0, 0.0 // ID:8 (ruby)
]
}
tokenizer.json と合わせて見た時に、tokenizer.json の model.vocab フィールドは raw_data_binary_area へのポインタとなっており、json_header_area のオフセットなどと合わせて3つの要素を取り出すことで意味ベクトルを取り出すことができるとわかります。
lm_head.weigh では単語に対応するベクトルが同様に配置されていますが、LLM は類似度の高いベクトル選択することでレスポンスの単語を確定しています。
これは、models.safetensors.json に対して
-
raw_data_binary_areaの前半部分(0-27 間の要素)はキーワードからベクトルを取り出すために利用される(LLM が入力を理解するために利用) -
raw_data_binary_areaの後半部分(27-54 間の要素)はベクトルからキーワードを取り出すために利用される(LLM が出力を作るために利用)
とも捉えることができます。
Tokenization とパラメータファイルの実装
LLM の処理概要が見えてきたところで、実装を進めて見ます。
tokenizer.json や パラメータファイルや llm_service.rb 内で読み込める必要があるので、Ruby で JSON を読み込む処理を実装します。
- 今回は単純にホワイトスペースでレスポンスボディを単語に分解する形とするため、そのためのメソッドも実装しておきます
-
@@dictionaryや@@model_headersなどは Ruby におけるクラス変数となりますが、今回は後述の学習機能を追加することなどを踏まえ、簡単に値を書き換えられる形とします
# file: app/services/llm_service.rb
class LlmService
@@dictionary = {}
@@model = {}
@@model_headers = {}
@@model_raw = {}
def split(user_prompt)
user_prompt.split()
end
def load_dictionary_file
puts("=> Reading tokenizer.json ...")
json_llm = "config/data/tokenizer.json"
if File.exist?(json_llm)
@@dictionary = JSON.parse(File.read(json_llm)).dig("model", "vocab")
end
end
def load_model_file
puts("=> Reading model.safetensors.json ....")
json_model = "config/data/model.safetensors.json"
if File.exist?(json_model)
@@model = JSON.parse(File.read(json_model))
@@model_headers = @@model["json_header_area"]
@@model_raw = @@model["raw_data_binary_area"]
end
end
end
RootController 側ではこの LlmService をインスタンス化しておきます。
# file: app/root_controller.rb
class RootController < ApplicationController
def index
render(json: {status: "ok"}, status: :ok)
end
def create
puts("=> Got user queries, Agents starting ...")
llm = LlmService.new
# tokenizer.json およびパラメータファイルを読み込み、
llm.load_dictionary_file
llm.load_model_file
# ユーザプロンプトを受け取り、
req = params[:prompt]
# ホワイトスペースで単語に単純分解する
words = llm.split(req)
render(json: {user_prompt: req}, status: :created)
end
end
Tokenization の処理では、分割した単語それぞれの意味ベクトルを求める必要があります。
これも LlmService クラスにメソッドを実装します。
# file: app/services/llm_service.rb
def vectorize(words)
tokens = []
unknowns = []
puts("=> Token counts: #{words.size}")
words.each do |w|
if @@dictionary.key?(w)
tokens << @@dictionary[w]
else
tokens << @@dictionary["UNKNOWN"]
unknowns << w
end
end
puts("=> #{tokens.size} words tokenized: #{tokens}")
puts("=> #{unknowns.size} words unknown: #{unknowns}")
[tokens, unknowns]
end
ユーザーからのプロンプトを tokenizer.json により数値化することができたら、models.safetensors.json を用いて実際に意味ベクトルを取得します。
簡便のため、このメソッドも LlmService に実装します。
# file: app/services/llm_service.rb
def generate_response(tokens, unknowns)
# パラメータファイルの `json_header_area` からオフセットを取得し `raw_data_binary_area` の意味ベクトル部分を取り出す
embed_start = @@model_headers["model.embed_tokens.weight"]["data_offsets"][0]
embed_dims = @@model_headers["model.embed_tokens.weight"]["shape"][1]
tokenized_prompt = []
tokens.each do |t|
target_index = embed_start + (t * embed_dims)
vector = @@model_raw[target_index, embed_dims]
tokenized_prompt << vector
end
end
重みづけ
LLM においては意味ベクトルへの変換時に「重みづけ」と呼ばれる処理が行われます。
これはプロンプトを分解した単語をそれぞれ単純にベクトル変換するだけではなく、単語間の関係性を踏まえて変換することで、プロンプト(文章)全体のコンテキストを把握する処理になります。
LLM においてはこの重みづけが複雑に行われていますが、今回は簡単のために、プロンプトを分解した各単語への重みを均等で実装します。
具体的には、以下のような処理になります。
- 各単語を
tokenizer.jsonで数値(ID)に変換する - ID から
models.safetensors.jsonの意味ベクトル部分を取り出す - 各単語が持つ意味ベクトルに均等な重みをつけて(=ベクトルの平均を取り)プロンプトを一つのベクトルにする
つまり、前述の例の場合、
- "I" = [0.9, 0.0, 0.2]
- "like" = [0.7, 0.9, 0.0]
- "Python" = [-0.5, 0.0, 1.0]
となった結果、"I" の重み、"like" の重み、"Python" の重みは同様として考え、"I like Python" を一つのベクトルに変換するという形になります。
実際の LLM ではこの「重みづけ」が文脈把握の正確性そのものであり、以下のような手法が利用されているようです。
- Scaled Dot-Product Attention (Q, K, V を用いた手法, このパターンが一番多い)
- Multi-Head Attention
- FlashAttention
重みづけのロジックはベクトルの平均算出となるので、メソッド化して実装しておき、generate_response メソッドから呼び出す形にします。
# file: app/services/llm_service.rb
def get_vector_average(vectors)
total = [0.0, 0.0, 0.0]
vectors.compact.each do |v|
total[0] += v[0]
total[1] += v[1]
total[2] += v[2]
end
avg = total.map do |t|
t / vectors.size
end
puts("=> Average of vector: #{avg}")
avg
end
先ほどの LlmService クラスの generate_response メソッドは以下のようになります。
# file: app/services/llm_service.rb
def generate_response(tokens, unknowns)
# パラメータファイルの `json_header_area` からオフセットを取得し `raw_data_binary_area` の意味ベクトル部分を取り出す
embed_start = @@model_headers["model.embed_tokens.weight"]["data_offsets"][0]
embed_dims = @@model_headers["model.embed_tokens.weight"]["shape"][1]
tokenized_prompt = []
tokens.each do |t|
target_index = embed_start + (t * embed_dims)
vector = @@model_raw[target_index, embed_dims]
tokenized_prompt << vector
end
# 追加: プロンプトを構成する各意味ベクトルの平均を算出
context_vector = get_vector_average(tokenized_prompt)
end
次に、パラメータファイルから応答レスポンスを予測するロジックを実装します。
前述の通り、LLM はパラメータファイルを元に最も類似度の高いベクトルおよび単語を返しているに過ぎません。
ベクトルの近似度を計算する方法は、ベクトルの内積和やコサイン類似度などいくつかの方法がありますが、今回はベクトルの内積和を利用する実装とします。
これも個別の関数として実装しておきます。
# file: app/services/llm_service.rb
def get_inner_product(v1, v2)
return (v1[0] * v2[0]) + (v1[1] * v2[1]) + (v1[2] * v2[2])
end
LLM の文脈において、2つのベクトルの内積和が大きいということは、2つのベクトルの特徴量が強く一致していることを意味しており、プロンプトに対する応答を組み立てる上では特徴量が最も一致するものを返すことに他なりません。
ユーザープロンプトに対して最も近い(内積和が大きい)意味ベクトルを探索するロジックは以下のようになるため、これも generate_response メソッドから呼び出せるように LlmController クラスにメソッドを追加します。
また、現時点では処理はないものの、未知の単語を受けた際のレスポンスもハンドリングできるようにしておきます。
# file: app/services/llm_service.rb
def generate_response(tokens, unknowns)
if not unknowns.empty?
return "LLM answer>>> learned unknowns, please try again"
end
# パラメータファイルの `json_header_area` からオフセットを取得し `raw_data_binary_area` の意味ベクトル部分を取り出す
embed_start = @@model_headers["model.embed_tokens.weight"]["data_offsets"][0]
embed_dims = @@model_headers["model.embed_tokens.weight"]["shape"][1]
tokenized_prompt = []
tokens.each do |t|
# calculate index-jump
target_index = embed_start + (t * embed_dims)
# Get matrix of token
vector = @@model_raw[target_index, embed_dims]
tokenized_prompt << vector
end
context_vector = get_vector_average(tokenized_prompt)
# 以下を追加: プロンプトに対する応答を組み立てるロジック
raw_start = @@model_headers["lm_head.weight"]["data_offsets"][0]
raw_dims = @@model_headers["lm_head.weight"]["shape"][1]
all_dict_scores = {}
# tokenizer.json のキーをループさせ、それぞれプロンプトとの類似度を計算する
@@dictionary.each do |word, token|
# パラメータファイルの raw_data_area 後半部からその単語のベクトル部分を取り出す
target_index = raw_start + (token * raw_dims)
vector = @@model_raw[target_index, raw_dims]
# 取り出したベクトルとプロンプトを表すベクトルとで内積和を取り、格納しておく
all_dict_scores[word] = get_inner_product(context_vector, vector)
end
puts("=> Score of dictionary: #{all_dict_scores}")
# 最も内積和の大きいベクトルを選択し、`tokenizer.json` のキーを返す
max_score = 0
predicted_text = nil
all_dict_scores.each do |word, score|
if score > max_score
max_score = score
predicted_text = word
end
end
predicted_text
end
ここまで来ればモデルへの問い合わせも最小限実装できそうです。
RootController 側で generate_response メソッドを呼ぶため、以下のような形になります。
# file: app/root_controller.rb
class RootController < ApplicationController
def index
render(json: {status: "ok"}, status: :ok)
end
def create
puts("=> Got user queries, Agents starting ...")
llm = LlmService.new
llm.load_dictionary_file
llm.load_model_file
req = params[:prompt]
words = llm.split(req)
vectors, unknowns = llm.vectorize(words)
resp = llm.generate_response(vectors, unknowns)
render(
json: {
status: "ok",
user_prompt: req,
tokenized: vectors,
unknonws: unknowns,
response: resp
},
status: :created
)
end
end
Hello, my model
上記の例では、tokenizer.json の model.vocabl には save, file, search, web, now, i, tool, code, cat, ruby というキーワードが含まれていました。
これは LLM が学習しているデータであるため、まずはプロンプトとして既知のデータを投げて見ます。
% curl -s -X POST localhost:3000/query -d '{"prompt": "file search ruby"}' -H "Content-Type: application/json" |jq .
Started POST "/query" for ::1 at 2026-07-03 16:45:24 +0900
Processing by RootController#create as */*
Parameters: {"prompt" => "file search ruby", "root" => {"prompt" => "file search ruby"}}
=> Got user queries, Agents starting ...
=> Reading tokenizer.json ...
=> Reading model.safetensors.json ....
=> Token counts: 3
=> 3 words tokenized: [1, 2, 8]
=> 0 words unknown: []
=> Average of vector: [0.26666666666666666, 0.6333333333333333, 0.0]
=> Score of dictionary: {"save" => 0.02666666666666667, "file" => 0.02666666666666667, "search" => 0.06333333333333334, "web" => 0.06333333333333334, "now" => 0.0, "i" => 0.0, "tool" => 0.44999999999999996, "code" => 0.26666666666666666, "ruby" => 0.6333333333333333}
Completed 201 Created in 1ms (Views: 0.1ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
{
"status": "ok",
"user_prompt": "file search ruby",
"tokenized": [
1,
2,
8
],
"unknowns": [],
"response": "ruby"
}
上記の HTTP レスポンスのうち、
-
tokenizedはfile,search,rubyがそれぞれtokenizer.jsonで ID に置き換えられた値 -
unknownsは未知の単語数
となります。
また、Rails アプリのログとして出力されている
=> Average of vector: [0.26666666666666666, 0.6333333333333333, 0.0]
は、"file search ruby" というプロンプトの単語がそれぞれ意味ベクトルに変換され、その平均を算出できていることがわかります。
この平均ベクトルと、tokenizer.json 内のキーワードのすべてのベクトルとを比較し、内積和が最大なものを response として返せている形になります。
tokenizer.json に含まれる単語であれば、意味ベクトルを元にレスポンスを返してくれることまで確認できました。
今回は「人間っぽさ」「ポジティブさ」「機械っぽさ」の3次元の意味ベクトルを LLM は持っており、ruby という単語がプロンプトの平均ベクトルに最も近似していると判断したと言えます。
意味ベクトルの値が近い単語を投げると、類似度も高くなることが確認できるかと思います。
これはモデルが受け取った自然言語(複数の単語)の類似度を計算していること、つまり文脈を計算し得ていることとなります。
% curl -s -X POST localhost:3000/query -d '{"prompt": "code code code"}' -H "Content-Type: application/json" |jq .
# ...
{
"status": "ok",
"user_prompt": "code code code",
"tokenized": [
7,
7,
7
],
"unknowns": [],
"response": "code"
}
逆に、tokenizer.json に含まれない単語をレスポンスに含めると、未知の単語がある旨をレスポンスとして返すことがわかります。
% curl -s -X POST localhost:3000/query -d '{"prompt": "code ruby python"}' -H "Content-Type: application/json" |jq .
# ...
{
"status": "ok",
"prompt": "code ruby python",
"tokenized": [
7,
8,
null
],
"unknowns": [
"python"
],
"response": "LLM answer>>> learned unknowns, please try again"
}
自己学習を実装する
実際の LLM は、未知の単語を受け取ったら処理を停止するということはありません。
自身のベクトル空間になかった単語を受け付けた際には学習をする機能を持ちます。
(RAG などを用いて最新の情報を問い合わせに行く形もありますが、今回は割愛します)
学習処理を今回のモデルで考えると、以下のような機能が必要となります。
-
tokenizer.jsonに存在しない単語を受けた際には、tokenizer.jsonおよびmodels.safetensor.jsonを更新する - 更新した JSON ファイルを再読み込みする
def self_update_model(unknowns)
unknowns.each do |unk|
next if @@dictionary.key?(unk)
# Update tokenizer.json
new_key = @@dictionary.values.max + 1
puts("=> Leanring #{unk} with new_key #{new_key} ...")
@@dictionary[unk] = new_key
# Update model.safetensors.json (self model updating)
# 実際はここはモデルに問い合わせ、簡易的にランダムな2桁の浮動小数点を持つ3次元配列を作る
random_vector = Array.new(3) { rand.round(2) }
random_vector_head = Array.new(3) { rand.round(2) }
# --- update raw data section
embed_end_offset = @@model_headers["model.embed_tokens.weight"]["data_offsets"][1]
# random_vector の各要素をすべて取り出し、そのまま insert(配列のまま追加されないように)
@@model_raw.insert(embed_end_offset, *random_vector)
# --- update header section
@@model_headers["model.embed_tokens.weight"]["shape"][0] += 1
@@model_headers["model.embed_tokens.weight"]["data_offsets"][1] += 3
@@model_headers["lm_head.weight"]["data_offsets"][0] += 3
@@model_headers["lm_head.weight"]["data_offsets"][1] += 3
# --- lm_head の末尾に追加する
@@model_raw.insert(@@model_headers["lm_head.weight"]["data_offsets"][1], *random_vector_head)
@@model_headers["lm_head.weight"]["shape"][0] += 1
end
# Update files (Model learning)
puts("=> Update files and reloading")
File.write("config/data/tokenizer.json", JSON.pretty_generate(
{
"version" => "1.0",
"model" => {
"type" => "BPE",
"vocab" => @@dictionary
}
}))
File.write("config/data/model.safetensors.json", JSON.pretty_generate(@@model))
# reloading
load_dictionary_file
load_model_file
end
最後にこの自己学習を行うメソッドを generate_response の中でも繋いでおくようにします。
def generate_response(tokens, unknowns)
if not unknowns.empty?
# ここを追加して、未知の単語が来た際に、tokenizer.json と models.safetensors.json を更新するように変更する
self_update_model(unknowns)
return "LLM answer>>> learned unknowns, please try again"
end
# ...
プロンプトとして未知の単語(例: python) を含めてみます。
% curl -s -X POST localhost:3000/query -d '{"prompt": "code ruby python"}' -H "Content-Type: application/json" |jq .
Started POST "/query" for ::1 at 2026-07-03 16:46:47 +0900
Processing by RootController#create as */*
Parameters: {"prompt" => "code ruby python", "root" => {"prompt" => "code ruby python"}}
=> Got user queries, Agents starting ...
=> Reading tokenizer.json ...
=> Reading model.safetensors.json ....
=> Token counts: 3
=> 3 words tokenized: [7, 8, nil]
=> 1 words unknown: ["python"]
=> Leanring python with new_key 9 ...
=> Update files and reloading
=> Reading tokenizer.json ...
=> Reading model.safetensors.json ....
Completed 201 Created in 2ms (Views: 0.1ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
{
"status": "ok",
"prompt": "code ruby golang",
"tokenized": [
7,
8,
null
],
"unknowns": [
"python"
],
"response": "LLM answer>>> learned unknowns, please try again"
}
この時点で、tokenizer.json と models.safetensors.json が更新されており、python に関わるデータが追加されていることがわかるかと思います。
diff --git a/config/data/model.safetensors.json b/config/data/model.safetensors.json
index 627041b..6a28a30 100644
--- a/config/data/model.safetensors.json
+++ b/config/data/model.safetensors.json
@@ -3,20 +3,87 @@
"json_header_area": {
"model.embed_tokens.weight": {
"dtype": "F32",
- "shape": [9, 3],
- "data_offsets": [0, 27]
+ "shape": [
+ 10,
+ 3
+ ],
+ "data_offsets": [
+ 0,
+ 30
+ ]
},
"lm_head.weight": {
"dtype": "F32",
- "shape": [9, 3],
- "data_offsets": [27, 54]
+ "shape": [
+ 10,
+ 3
+ ],
+ "data_offsets": [
+ 30,
+ 57
+ ]
}
},
"raw_data_binary_area": [
- 0.9, 0.0, 0.0, 0.8, 0.0, 0.0, 0.0, 0.9, 0.0, 0.0, 0.8, 0.1, 0.1, 0.1, 0.1,
- 0.1, 0.1, 0.1, 0.5, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0,
-
- 0.1, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.1, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.1,
- 0.0, 0.0, 0.1, 0.5, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0
+ 0.9,
+ 0.0,
+ 0.0,
+ 0.8,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.9,
+ 0.0,
+ 0.0,
+ 0.8,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.5,
+ 0.5,
+ 0.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ 0.0,
+ 0.66,
+ 0.56,
+ 0.88,
+ 0.1,
+ 0.0,
+ 0.0,
+ 0.1,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.1,
+ 0.0,
+ 0.0,
+ 0.1,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.1,
+ 0.0,
+ 0.0,
+ 0.1,
+ 0.5,
+ 0.5,
+ 0.0,
+ 1.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ 0.0,
+ 0.75,
+ 0.81,
+ 0.02
]
-}
+}
\ No newline at end of file
diff --git a/config/data/tokenizer.json b/config/data/tokenizer.json
index e53b419..5cddb9d 100644
--- a/config/data/tokenizer.json
+++ b/config/data/tokenizer.json
@@ -11,7 +11,8 @@
"i": 5,
"tool": 6,
"code": 7,
- "ruby": 8
+ "ruby": 8,
+ "python": 9
}
}
-}
+}
再び python を含めた同じプロンプトを投げてみると、学習したデータに応じてレスポンスが返ってくることがわかります。
% curl -s -X POST localhost:3000/query -d '{"prompt": "code ruby python"}' -H "Content-Type: application/json" |jq .
Started POST "/query" for ::1 at 2026-07-03 16:49:12 +0900
Processing by RootController#create as */*
Parameters: {"prompt" => "code ruby python", "root" => {"prompt" => "code ruby python"}}
=> Got user queries, Agents starting ...
=> Reading tokenizer.json ...
=> Reading model.safetensors.json ....
=> Token counts: 3
=> 3 words tokenized: [7, 8, 9]
=> 0 words unknown: []
=> Average of vector: [0.5533333333333333, 0.52, 0.29333333333333333]
=> Score of dictionary: {"save" => 0.05533333333333334, "file" => 0.05533333333333334, "search" => 0.052000000000000005, "web" => 0.052000000000000005, "now" => 0.029333333333333336, "i" => 0.029333333333333336, "tool" => 0.5366666666666666, "code" => 0.5533333333333333, "ruby" => 0.52, "python" => 0.8420666666666667}
Completed 201 Created in 0ms (Views: 0.1ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
{
"status": "ok",
"prompt": "code ruby python",
"tokenized": [
7,
8,
9
],
"unknowns": [],
"response": "python"
}
これは、3次元 x 9 vocabs x 2 = 54 パラメータを持つ LLM ということになります。
(python という未知の単語を学習した結果、3次元 x 10 vocabs になったということでもあります)
※ モデルのパラメータ数は、パラメータファイルの raw_data_area 部分の要素数となるので、tokenizer.json に含まれる vocab 数 x 意味ベクトルの次元数 x 2 として計算されます
実際の LLM と比較する
私たちが日々利用している Agentic システムは当然こんなに簡単な実装ではないはずです。
しかし、今回作成した簡易モデルと比較をすることで LLM の全体像などが見えて来るのではないかと思います。
- パラメーター数
- 言わずもがな今回はミニチュアなのでモデル数が異なっています。
- LLM では意味ベクトルの次元数やパラメーターファイルに格納されるデータ量も桁違いのものとなっています(故に models.safetensors.json のように JSON ではなくバイナリ形式となります)
- パラメーターファイルの分割
- LLM においてはパラメーターファイルが数百GB と巨大になるため、index ファイルを用いて分割するケースがあります
-
models.safetensors.index.jsonという index ファイルを用いて、複数のパラメーターファイルへの lookup を行います
- 重みづけのロジック
- 今回は意味ベクトルの「平均」を取ることでプロンプトを一つのベクトルに変換しましたが、実際にはプロンプトを構成する各要素に重みが付与されます
- 単純な平均ではなく、単語間の類似度などを高度に分析するアルゴリズムなどにより重み付けは非常に複雑に行われています
- MCP Server との連携
- LLM 自体はステートレスであり、ユーザーに代わり何かの処理を行うためにはオーケストレーター経由で MCP Server を利用します
- LLM はプロンプトに応じて「必要な MCP Server」を確定させ、オーケストレーターに実行してもらい、その結果の解析により実行結果やレスポンスを組み立てます
やってみて感じたこと、所感
- 慣れない言語でやるべきではなかった
- (Python は大好きですが)OOP の観点では Ruby の方が実装が美しい。Ruby もたのしい。
- Rails はその名の通り、「レール(規約)を理解していれば超高速にロジック実装ができる」
です。
機械学習といえばそのライブラリの豊富さから Python とはわかっていたものの、あえて Ruby で Hardway をやり Ruby (on Rails) へのリスペクトが生まれた点も多かったです。
実際に試してみようという物好きな方がいれば、得意な言語で実装することを強く推奨します…。
MCP Server も Sandbox 環境もないですが、54 パラメーターの自作 LLM を Ruby で実装し、学習ロジックも実装してみました。
Agentic なシステムの全体像およびその根幹となる LLM の内部挙動について、少しでもイメージが湧けば幸いです。