前回までの実験で、文字列が Token に分割され、それぞれに Token ID が割り当てられていることを観察しました。
さらに、Token ID から対応する Token に戻せることも確認しました。
ここまで実験して、ひとつ気になることが出てきました。
LLM が回答を生成するときも、最初から「東京です。」という文字列を作っているのではなく、
Token ID を順番に生成し、最後に人間が読める文字へ戻しているのではないか?
という疑問です。
前回 /completion の出力を観察したとき、こんな値が出ていました。
tokens_predicted
tokens_evaluated
名前だけを見ると何となく意味を想像できます。
でも、名前から判断するのではなく、実際に値がどう変化するのかを観察してみます。
今回の疑問
今回確認したいのは、次のような流れです。
入力
↓
Token ID
↓
LLM
↓
Token ID ?
↓
文字
特に気になったのが、
tokens_predicted
tokens_evaluated
です。
これらが入力や生成に合わせて、どのように変化するのかを観察します。
なお、Token ID と Token の対応関係については、すでに前の実験で確認しているため、今回は実験しません。
実験1:生成する Token 数を変えてみる
まず、入力を固定します。
日本の首都は
temperature も 0 に固定し、n_predict だけを変えてみます。
n_predict = 1
curl -N http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "日本の首都は",
"stream": true,
"n_predict": 1,
"temperature": 0
}'
結果:
data: {"index":0,"content":"東","tokens":[102356],"stop":false,"id_slot":-1,"tokens_predicted":1,"tokens_evaluated":3}
生成されたのは、
content:"東"
tokens:[102356]
tokens_predicted:1
でした。
n_predict = 2
次は n_predict を 2 にします。
curl -N http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "日本の首都は",
"stream": true,
"n_predict": 2,
"temperature": 0
}'
結果:
data: {"index":0,"content":"東","tokens":[102356],"stop":false,"id_slot":-1,"tokens_predicted":1,"tokens_evaluated":3}
data: {"index":0,"content":"京","tokens":[46553],"stop":false,"id_slot":-1,"tokens_predicted":2,"tokens_evaluated":3}
今度は、
東 → tokens_predicted:1
京 → tokens_predicted:2
となりました。
tokens にも、それぞれ別の Token ID が出ています。
東 → 102356
京 → 46553
n_predict = 5
さらに増やしてみます。
curl -N http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "日本の首都は",
"stream": true,
"n_predict": 5,
"temperature": 0
}'
結果:
data: {"index":0,"content":"東","tokens":[102356],"stop":false,"id_slot":-1,"tokens_predicted":1,"tokens_evaluated":3}
data: {"index":0,"content":"京","tokens":[46553],"stop":false,"id_slot":-1,"tokens_predicted":2,"tokens_evaluated":3}
data: {"index":0,"content":"です","tokens":[37541],"stop":false,"id_slot":-1,"tokens_predicted":3,"tokens_evaluated":3}
data: {"index":0,"content":"。","tokens":[1773],"stop":false,"id_slot":-1,"tokens_predicted":4,"tokens_evaluated":3}
data: {"index":0,"content":" ","tokens":[22441],"stop":false,"id_slot":-1,"tokens_predicted":5,"tokens_evaluated":3}
並べてみると分かりやすくなります。
| 順番 | content | Token ID | tokens_predicted |
|---|---|---|---|
| 1 | 東 | 102356 | 1 |
| 2 | 京 | 46553 | 2 |
| 3 | です | 37541 | 3 |
| 4 | 。 | 1773 | 4 |
| 5 | |
22441 | 5 |
content がひとつ生成されるたびに、
tokens_predicted
が、
1 → 2 → 3 → 4 → 5
と増えていました。
同時に tokens には Token ID がひとつずつ現れています。
つまり今回の結果だけを見ると、
tokens_predicted は生成された Token の数に合わせて増えているように見えます。
実験2:今度は入力を変えてみる
生成側では tokens_predicted が変化しました。
一方、すべての結果で、
tokens_evaluated:3
は変わっていませんでした。
入力の、
日本の首都は
を固定していたからでしょうか?
今度は n_predict = 1 に固定して、入力だけを変えてみます。
「日本」
curl -s http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "日本",
"n_predict": 1,
"temperature": 0
}'
結果:
"content":"の"
"tokens_predicted":1
"tokens_evaluated":1
「日本の首都は」
curl -s http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "日本の首都は",
"n_predict": 1,
"temperature": 0
}'
結果:
"content":"東"
"tokens_predicted":1
"tokens_evaluated":3
「日本の首都はどこ?」
curl -s http://localhost:8080/completion \
-H "Content-Type: application/json" \
-d '{
"prompt": "日本の首都はどこ?",
"n_predict": 1,
"temperature": 0
}'
結果:
"content":"また"
"tokens_predicted":1
"tokens_evaluated":5
結果を並べます。
| 入力 | tokens_evaluated | tokens_predicted |
|---|---|---|
| 日本 | 1 | 1 |
| 日本の首都は | 3 | 1 |
| 日本の首都はどこ? | 5 | 1 |
今回は出力を 1 Token に固定しているため、
tokens_predicted:1
は変わりません。
一方で入力を長くすると、
tokens_evaluated
1 → 3 → 5
と増えました。
少なくとも今回の結果では、tokens_evaluated は入力側、tokens_predicted は出力側の Token 数と関係しているように見えます。
もうひとつ気づいたこと
今回、stream:true のときには、
content:"東"
tokens:[102356]
のように content と Token ID が一緒に出ていました。
一方、stream:false で実行した結果では、
content:"東"
tokens:[]
となっています。
つまり、tokens というフィールドが常に生成した Token ID を同じ形で返しているわけではなさそうです。
これは LLM 自体というより、llama.cpp の API が結果をどのように返しているかにも関係していそうです。
今回はここには深入りしません。
今回分かったこと
今回の実験では、フィールド名から意味を推測するのではなく、実際に入力と出力を変えて値を観察しました。
その結果、
入力を増やす
↓
tokens_evaluated が増える
生成を増やす
↓
tokens_predicted が増える
という動きを確認できました。
さらにストリーミング生成では、
tokens_predicted:1
tokens:[102356]
content:"東"
↓
tokens_predicted:2
tokens:[46553]
content:"京"
のように、生成が進むたびに Token ID と人間が読める文字列が一緒に現れました。
前の実験では、
Token ID と Token の対応関係
もすでに観察しています。
ここまでの結果をつなげると、最初に考えていた、
入力文字
↓
Token ID
↓
LLM
↓
Token ID ?
↓
人間が読める文字
という流れが、少しずつ見えてきた気がします。
ただし、今回観察しているのは llama.cpp が API として外に見せている結果です。
LLM 内部で本当に Token ID がどのように予測されているのかまでは、まだ観察できていません。
これは別の疑問として残しておきます。
ところで、これって……
今回の実験では、
入力を増やす
↓
tokens_evaluated が増える
生成を増やす
↓
tokens_predicted が増える
という動きを観察できました。
ここで、普段 ChatGPT などの LLM API で見かける料金を思い出しました。
LLM API の料金は「文字数」ではなく、Input Token と Output Token を単位として計算されています。
もしかすると、商用の LLM にも llama.cpp の tokens_evaluated や tokens_predicted のように、
入力で何 Token 使ったか
出力で何 Token 生成したか
を数える仕組みが存在していて、その値を使って料金を計算しているのではないでしょうか?
今のところ、これは今回の実験結果から思いついただけの仮説です。
実際に ChatGPT などの LLM API でも同じ手段で Token 数を数えることができるのか、まだ確認していません。