0. はじめに
データセットを作っていて、ふと思ったんですよ。
Harmonyって何なんだっけ?
chat templateと何が違うの?
というか、正直、
「messagesって最終的にどういう文字列になってモデルに入ってるんだ?」
と急に気になったのが始まりです。
普段は、
messages = [
{"role": "user", "content": "こんにちは"}
]
みたいなのを書いて、
tokenizer.apply_chat_template(messages, tokenize=False)
終わり。
僕も今まで、「まあTokenizerがいい感じにしてくれるんだろう」くらいに思っていました。
ただ、最近reasoning付きの学習データを作って学習すると、なんだかモデルが崩れる時があるんですよね。
- モデルごとに形式が違うのは知ってるけど、Harmony形式って全然違うの?
- Harmonyってchannelってのがあるらしい
- channelって自分で作れるの?
という疑問が出てきました。
そして今回、
- llm-jp/llm-jp-4-33b-thinking
- Qwen/Qwen3.8-27B
のChat Templateを実際に眺めて、少し改造してみました。
さらに、llm-jp/llm-jp-4-8b-thinkingをベースに追加学習し、通常channelと今治弁用channelで出力を比較するところまで試しました。
先に結果を書くと、今回確認した出力では、analysis_imabariからfinal_imabariへ遷移し、方言表現を含む回答が出ました。ただし、通常のanalysis / finalでも方言が出るケースがあり、標準語と今治弁を完全に分離できたわけではありません。おそらく、analysis_imabariとfinal_imabariしか追加学習していないことが原因だと思います。
公開した学習済みモデルはこちらです。
なお、QwenについてはChat Templateの整形確認までです。この記事で紹介する追加学習と生成結果は、LLM-jp-4の8B版のものです。また、この記事のコードはllm-jp-4の素のモデルに対してチャットテンプレートを変える場合などを記載しています。
結論から書くと、思っていた以上に 面白かった です。
先人たちの苦労と工夫と、まだまだこれからの発展性がありそうです。
1. 今回やりたかったこと
僕は地元である、今治に関するLLMを作るため、データセットを作っています。
その流れで、
- 通常の思考
- 通常の回答
- 今治弁での思考
- 今治弁での回答
みたいなものを分離して学習できないかな、と考えました。
ま、かるいフォーマルな感じと、地元の友達と話す感じと言えばいいでしょうか?
例えばHarmonyに使えるchannelを固定させて、今治弁で回答させたり、標準語で回答させられればそんな雰囲気が出そうです。
つまり、Harmonyに
[analysis, final]
と渡したら、標準語で思考、回答し、
[analysis_imabari, final_imabari]
と渡したら、今治弁で思考回答する
みたいにできたら面白そうです。
ということで、Chat Templateを自分で変更して、こういう形式の学習データを作れるのかを試しました。
今回は、テンプレートの改造、学習用テキストの作成、LoRAによる追加学習、学習後の出力確認までを紹介します。
ただし、「独自channelを含む出力が得られること」と「channelだけで文体を安定して制御できること」は別です。実際に試してみると、この違いが見えてきました。
2. そもそもChat Templateって何?
僕自身、基本の「き」しか理解していませんでした。例えば、
messages = [
{
"role": "user",
"content": "今治市のおすすめ観光地を教えてください。",
},
{
"role": "assistant",
"content": "しまなみ海道がおすすめです。海の景色を楽しみながら走れます。",
}
]
というmessagesがあります。
これをそのままLLMへ渡しているような気になりますが、実際にはモデルごとの専用形式へ変換されています。
シンプルに理解するためにQwen系で見てみましょう。ざっくり、以下のようになっています。
<|im_start|>user
今治市のおすすめ観光地を教えてください。<|im_end|>
<|im_start|>assistant
しまなみ海道がおすすめです。海の景色を楽しみながら走れます。<|im_end|>
つまり、
Pythonのdict
↓
Chat Template
↓
モデルが読む文字列
↓
tokenize
↓
LLM
という流れ。
ここで、最初の疑問を整理します。Chat Templateはmessagesをモデル用の形式へ変換するルール、Harmonyは会話を表現する形式です。 同じものではなく、Chat TemplateでHarmony系の形式に整形する、という関係です。
apply_chat_template(..., tokenize=False)を使うと、この変換後の文字列を確認できます。TransformersのChat Template解説も参考になります。
なお、以下のQwenの例は区切りを説明するための模式例です。thinkingを含む実際の例は後半で扱います。
3. まずllm-jp-4を見てみる
次に、Harmony系の形式を使う llm-jp/llm-jp-4-33b-thinking を見ていきます。
このモデルでは、assistantの出力をchannelで分けるChat Templateを使っています。HarmonyはOpenAIが公開している会話の表現形式です。僕も最初、「Harmony?なんかとなんかが調和した感じ??共鳴か何かしてるの?」くらいでした。
Harmonyではassistantのメッセージに、例えば、
analysis
commentary
final
というchannelを持たせられます。channel名そのものは通常の文字列で、境界を示す特殊トークンとは別です。OpenAIのHarmony解説に構造が説明されています。
この記事の<|channel|>や<|return|>などの表記は、LLM-jp-4のテンプレート・出力に合わせています。OpenAIのHarmonyと、特殊トークンの表記まで同じものとして読まないようにしてください。
ここで思いつきました。
channelが文字列なら増やせるのでは?方言をしゃべるchannelと標準語でしゃべるchannelが共存できるのでは? という単純な発想です。
4. llm-jp-4の通常形式を出力してみる
最初にテンプレートを調べたのは33B版です。後半で学習する8B版とは分けて考えます。
from transformers import AutoTokenizer
import datetime
tokenizer = AutoTokenizer.from_pretrained("llm-jp/llm-jp-4-33b-thinking")
date = datetime.date.today()
messages = [
{"role": "user", "content": "今治市のおすすめ観光地を教えてください。"},
{
"role": "assistant",
"thinking": "サイクリングを楽しめるしまなみ海道を挙げる。",
"content": "しまなみ海道がおすすめです。",
},
]
print(tokenizer.apply_chat_template(
messages,
reasoning_effort="low",
add_generation_prompt=False,
tokenize=False,
))
注目したいのはassistant部分です。以下は、その構造を説明するための抜粋例です。モデルが生成した回答ではなく、上のmessagesに書いた文を整形しています。
<|start|>assistant<|channel|>analysis<|message|>サイクリングを楽しめるしまなみ海道を挙げる。<|end|>
<|start|>assistant<|channel|>final<|message|>しまなみ海道がおすすめです。<|return|>
これを見たとき、「あ、本当にchannelって書いてあるんだ」と思いました。
実際に文字列で見ると、かなり分かりやすいです。
assistant
├─ analysis:thinkingの内容
└─ final:contentの内容
LLM-jp-4のこの形式では、<|end|>がメッセージの終了、<|return|>がassistantターンの終了を表します。
5. じゃ、analysis_imabariを作れるんじゃね?
ここで悪い癖が出ます。「文字列なら変えられるのでは?」と思ってしまいました。
そこで、analysis finalに加えて、analysis_imabariとfinal_imabariを扱えるようChat Templateを書き換えてみました。
まず、仕組みを説明するための改造例です。長いのでトグルで閉じておきます。
この掲載例と、後半の学習で保存したテンプレートには日付や既定のchannel一覧などに差があります。学習済みモデルを試す際は、モデルと一緒に公開したTokenizerを使ってください。
改造版chat template
imabari_template = r'''
{%- macro build_system_message() -%}
{%- if model_identity is not defined %}
{%- set model_identity = "You are LLM-jp-4, a large language model trained by LLM-jp." %}
{%- endif %}
{{- model_identity + "\n" -}}
{%- if knowledge_cutoff is not defined %}
{%- set knowledge_cutoff = "2025-12" %}
{%- endif %}
{{- "Knowledge cutoff: " + knowledge_cutoff + "\n" -}}
{%- if conversation_start_date is not defined %}
{%- set conversation_start_date = strftime_now("%Y-%m-%d") %}
{%- endif %}
{{- "Current date: " + conversation_start_date + "\n\n" }}
{%- if reasoning_effort is not defined %}
{%- set reasoning_effort = "medium" %}
{%- endif %}
{{- "Reasoning: " + reasoning_effort + "\n\n" }}
{%- if valid_channels is not defined %}
{%- set valid_channels = ["analysis_imabari", "final_imabari", "commentary", "analysis", "final"] %}
{%- endif %}
{%- if "user" not in valid_channels %}
{%- set valid_channels = ["user"] + valid_channels %}
{%- endif %}
{%- if "commentary" not in valid_channels %}
{%- set valid_channels = valid_channels[:1] + ["commentary"] + valid_channels[1:] %}
{%- endif %}
{{- "# Valid channels: " + (valid_channels | join(", ")) + ". Channel must be included for every message." }}
{%- endmacro -%}
{{- "<|start|>system<|message|>" }}
{{- build_system_message() }}
{{- "<|end|>" }}
{%- if messages and (messages[0].role == "developer" or messages[0].role == "system") %}
{%- set developer_message = messages[0].content %}
{%- set loop_messages = messages[1:] %}
{%- else %}
{%- set developer_message = "" %}
{%- set loop_messages = messages %}
{%- endif %}
{%- if developer_message %}
{{- "<|start|>developer<|message|># Instructions\n\n" + developer_message + "\n\n<|end|>" }}
{%- endif %}
{%- for message in loop_messages %}
{%- if message.role == "user" %}
{{- "<|start|>user<|message|>" + message.content + "<|end|>" }}
{%- elif message.role == "assistant" %}
{%- if message.thinking is defined and message.thinking %}
{%- if message.thinking_channel is defined %}
{{- "<|start|>assistant<|channel|>" + message.thinking_channel + "<|message|>" + message.thinking + "<|end|>" }}
{%- else %}
{{- "<|start|>assistant<|channel|>analysis<|message|>" + message.thinking + "<|end|>" }}
{%- endif %}
{%- endif %}
{%- set response_channel = message.channel | default("final") %}
{{- "<|start|>assistant<|channel|>" + response_channel + "<|message|>" + message.content + "<|return|>" }}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{%- if generate_thinking is defined and generate_thinking %}
{{- "<|start|>assistant<|channel|>" + (thinking_channel | default("analysis")) + "<|message|>" }}
{%- else %}
{{- "<|start|>assistant<|channel|>" + (channel | default("final")) + "<|message|>" }}
{%- endif %}
{%- endif %}
'''
正直、Jinja って読みにくい。
Pythonならまだ追えるんですが、こいつらが大量に出てくると、一瞬どこを見ているのか分からなくなります。
{%- if
{{-
{%-
Djangoでもある程度使ってきましたが、ここまで長いと目がチカチカします。
6. 今治弁用channelを出力してみる
学習データ側を以下にしてみます。
training_messages_imabari = [
{
"role": "user",
"content": "今治市のおすすめ観光地を教えてください。",
},
{
"role": "assistant",
"thinking_channel": "analysis_imabari",
"thinking": "今治の代表的な観光地を選び、今治弁で説明する。",
"channel": "final_imabari",
"content": "今治なら、まずはしまなみ海道がおすすめじゃけん。景色がきれいで、サイクリングにも向いとるよ。",
},
]
ポイントはここです。
"thinking_channel": "analysis_imabari""channel": "final_imabari"
そして、chat_templateに渡して、出力を確認してみましょう。
training_text = tokenizer.apply_chat_template(
training_messages_imabari,
reasoning_effort="high",
knowledge_cutoff="2026-03",
conversation_start_date=date.strftime("%Y-%m-%d"),
valid_channels=["analysis_imabari", "final_imabari"],
chat_template=imabari_template,
tokenize=False,
add_generation_prompt=False,
)
print(training_text)
<|start|>system<|message|>You are LLM-jp-4, a large language model trained by LLM-jp.
Knowledge cutoff: 2026-03
Current date: 2026-09-15
Reasoning: high
# Valid channels: user, commentary, analysis_imabari, final_imabari. Channel must be included for every message.<|end|>
<|start|>user<|message|>今治市のおすすめ観光地を教えてください。<|end|>
<|start|>assistant<|channel|>analysis_imabari<|message|>今治の代表的な観光地を選び、今治弁で説明する。<|end|>
<|start|>assistant<|channel|>final_imabari<|message|>今治なら、まずはしまなみ海道がおすすめじゃけん。景色がきれいで、サイクリングにも向いとるよ。<|return|>
おお。出た。😍
テンションが上がってまいりました!
この段階では、Chat Templateとして、analysis_imabari final_imabariを持ったtraining textを作れています。
7. でも、ちょっと待てよ
ここで一度冷静になります。
Chat Templateで、analysis_imabariと書けたからといって、モデルが、analysis_imabariとは今治弁で考えるchannelですと理解したわけではありません。ただ文字列として出力できただけです。極端な話、analysis_udonでも、analysis_takoyakiでもChat Template上は書けます。
だから、テンプレートの整形確認だけで分かるのは、「独自形式の学習データを作れる」というところまでです。このあと実際に学習して、生成時にもその形式が出てくるかを確かめます。この辺、Chat Templateをいじっていると何でもできた気分になるので注意が必要ですね。
8. 学習時と推論時で少し違う
ここも最初ちょっと混乱しました。
学習データの場合は、assistantの正解まで全部含まれています。なので、add_generation_prompt=Falseです。
一方、推論では、
user
↓
ここからassistantが生成
という状態にしたい。
そこで、add_generation_prompt=Trueを使います。
例えば、
inference_messages = [
{
"role": "developer",
"content": "質問に正確かつ簡潔に回答してください。",
},
{
"role": "user",
"content": "今治市のおすすめ観光地を教えてください。",
},
]
に対して、
inference_prompt = tokenizer.apply_chat_template(
inference_messages,
chat_template=imabari_template,
valid_channels=["analysis_imabari", "final_imabari"],
thinking_channel="analysis_imabari",
channel="final_imabari",
generate_thinking=True,
tokenize=False,
add_generation_prompt=True,
)
print(inference_prompt)
とします。
最後は、
<|start|>assistant<|channel|>analysis_imabari<|message|>
になります。つまり、「ここからanalysis_imabariを書いてね」 という開始位置まではChat Template側で作れます。
ただし、
analysis_imabari
↓
final_imabari
へモデル自身が遷移できるかは別です。後半では、ここを実際の出力で確認します。
また、この改造テンプレートでは、generate_thinking=Trueのとき、channel="final_imabari"は後続の回答channelを強制しません。開始位置にはthinking_channelだけが使われ、その後の切り替えはモデルの生成に任せています。
valid_channelsも、システムプロンプト内の一覧を書き換える指定です。それだけで生成可能なトークンを制限する仕組みではありません。
9. 次にQwen3.8を見てみる
さて。llm-jp-4で、analysis_imabariができたので、僕はこの時点で、「Qwenも似たような感じだろう」と思っていました。
違いました。かなり違いました。
今回確認したQwen3.8のChat Templateは、Harmony型のchannelではなく、
<think>
...
</think>
でthinkingを表現しています。
つまり、
assistant
├─ <think>...</think>
└─ 通常回答
という形です。
llm-jp-4とは思想が違います。この辺を見ていると、「messagesという共通インターフェースのおかげで、裏側の違いを今まで意識せず使えてたんだな」と感じます。
Hugging Face、マジありがたい。
⸻
10. Qwen3.8の通常Chat Templateを確認
このQwenの整形確認では、モデル本体はロードせず、Tokenizerだけを使います。
from transformers import AutoTokenizer
from copy import deepcopy
tokenizer = AutoTokenizer.from_pretrained(
"Qwen/Qwen3.8-27B",
revision="1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0",
)
official_template = tokenizer.chat_template
今回はChat Template自体を調べたいので、revisionも固定しています。
後から公式側のChat Templateが変わって、「あれ?記事と違う」 となると困るためです。
messagesはこんな感じ。
messages = [
{
"role": "system",
"content": "質問に正確かつ簡潔に回答してください。",
},
{
"role": "user",
"content": "今治市でサイクリングを楽しめる場所を一つ教えてください。",
},
{
"role": "assistant",
"reasoning_content": "サイクリングが楽しめるしまなみ海道を挙げる。",
"content": "しまなみ海道がおすすめです。海の景色を楽しみながら走れます。",
},
]
Qwen3.8の場合は、reasoning_contentにthinkingを入れています。
実際に変換してみます。
print(
tokenizer.apply_chat_template(
messages,
reasoning_effort="medium",
preserve_thinking=True,
add_generation_prompt=False,
tokenize=False,
)
)
出力。
<|im_start|>system
質問に正確かつ簡潔に回答してください。<|im_end|>
<|im_start|>user
今治市でサイクリングを楽しめる場所を一つ教えてください。<|im_end|>
<|im_start|>assistant
<think>
サイクリングが楽しめるしまなみ海道を挙げる。
</think>
しまなみ海道がおすすめです。海の景色を楽しみながら走れます。<|im_end|>
なるほど。
これはこれで分かりやすい。
11. 今回のQwenテンプレートにはHarmony型channelがない
ここで問題発生です。
僕がやりたいのは、analysis_imabari final_imabariです。
でも、今回確認したQwenのテンプレートには、LLM-jp-4のような<|channel|>で分ける構造がありません。
じゃあどうするのか。
少し考えて、「普通のテキストとしてラベルを入れればいいのでは?」となりました。
つまり、
<think>
[analysis_imabari]
...
</think>
[final_imabari]
...
です。
かなり力技に見えます。でもこういうの、僕は嫌いじゃないです。😂
12. QwenのChat Templateを部分的に書き換える
とはいえ、公式Chat Template全部を自分でコピーして書き換えるのは少し怖いです。
公式側で細かい処理をたくさんしています。
なので、変更したい部分だけ置換する方法にしました。
from transformers import AutoTokenizer
from copy import deepcopy
tokenizer = AutoTokenizer.from_pretrained(
"Qwen/Qwen3.8-27B",
revision="1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0",
)
official_template = tokenizer.chat_template
def replace_once(source, old, new):
assert source.count(old) == 1, (
f"公式テンプレートの想定箇所と不一致: {old}"
)
return source.replace(old, new, 1)
このreplace_once()は、今考えると結構大事でした。
単純に、replace()するだけでもいいのですが、公式テンプレートが変更された場合、気付かないまま変な場所を書き換える可能性があります。
そこで、
assert source.count(old) == 1
として、想定した場所が1箇所だけ存在するときだけ変更するようにしています。
少し面倒ですが、こういうところで止まってくれた方が安心です。
13. analysis_imabariラベルを挿入
変更部分はこんな感じです。
imabari_template = official_template
imabari_template = replace_once(
imabari_template,
"{%- set reasoning_content = reasoning_content|trim %}",
"""{%- set reasoning_content = reasoning_content|trim %}
{%- if message.thinking_channel is defined and message.thinking_channel %}
{%- set reasoning_content = '[' + message.thinking_channel + ']\\n' + reasoning_content %}
{%- endif %}
{%- if message.channel is defined and message.channel %}
{%- set content = '[' + message.channel + ']\\n' + content %}
{%- endif %}""",
)
imabari_template = replace_once(
imabari_template,
"{{- '<think>\\n\\n</think>\\n\\n' }}",
"{{- '<think>\\n\\n</think>\\n\\n' + ('[' + channel + ']\\n' if channel is defined and channel else '') }}",
)
imabari_template = replace_once(
imabari_template,
"{{- '<think>\\n' }}",
"{{- '<think>\\n' + ('[' + thinking_channel + ']\\n' if thinking_channel is defined and thinking_channel else '') }}",
)
Chat Templateをいじる前は、「数行追加すれば終わるでしょ」くらいに思っていました。
実際は、学習時の出力と推論開始時の出力の両方を考えないといけないので、少し頭を使いました。この辺もやってみないと分からないものですね。
14. Qwen3.8で今治弁形式を出してみる
messages側を変更します。
training_messages_imabari = deepcopy(messages)
training_messages_imabari[0]["content"] = (
"思考の先頭に [analysis_imabari]、"
"回答の先頭に [final_imabari] を置き、"
"今治弁で回答してください。"
)
training_messages_imabari[-1].update(
thinking_channel="analysis_imabari",
channel="final_imabari",
reasoning_content="しまなみ海道を挙げ、今治弁で説明する。",
content="しまなみ海道がおすすめじゃけん。"
"海の景色を楽しみながら走れるよ。",
)
そして、
print(
tokenizer.apply_chat_template(
training_messages_imabari,
chat_template=imabari_template,
reasoning_effort="medium",
preserve_thinking=True,
add_generation_prompt=False,
tokenize=False,
)
)
結果。
<|im_start|>system
思考の先頭に [analysis_imabari]、回答の先頭に [final_imabari] を置き、今治弁で回答してください。<|im_end|>
<|im_start|>user
今治市でサイクリングを楽しめる場所を一つ教えてください。<|im_end|>
<|im_start|>assistant
<think>
[analysis_imabari]
しまなみ海道を挙げ、今治弁で説明する。
</think>
[final_imabari]
しまなみ海道がおすすめじゃけん。海の景色を楽しみながら走れるよ。<|im_end|>
できました。
ただ、ここでllm-jp-4とはかなり違います。llm-jp-4の場合、
<|channel|>analysis_imabari
です。
つまりChat Templateの構造上、channelとして存在しています。
一方Qwenでは、
[analysis_imabari]
はただのテキストです。LLMからすると普通のToken列です。これは同じように見えて結構重要な違いだと思います。
15. Qwenの推論時はどうなる?
推論時も確認します。
print(
tokenizer.apply_chat_template(
training_messages_imabari[:-1],
chat_template=imabari_template,
reasoning_effort="medium",
thinking_channel="analysis_imabari",
channel="final_imabari",
enable_thinking=True,
add_generation_prompt=True,
tokenize=False,
)
)
すると最後が、
<|im_start|>assistant
<think>
[analysis_imabari]
になります。
ここまではChat Templateで準備できます。でもこの後、
</think>
[final_imabari]
をモデルが生成してくれる保証はありません。
つまり、[analysis_imabari]まではこちらから置けるけど、[final_imabari]はモデル自身に覚えてもらう必要があります。
この辺を見ると、「Chat Templateを変えたら全部解決!」という話ではないことがよく分かります。
16. HarmonyとQwen、結構違う
整理するとこんな感じです。
| 項目 | llm-jp-4 / Harmony系 | Qwen3.8 |
|---|---|---|
| reasoning | channel=analysis |
<think>...</think> |
| final | channel=final |
<think>後の本文 |
| thinking用key | thinking | reasoning_content |
| 独自channel | Template上は作成可能 | Harmony型channelは存在しない |
| 今回の形式 | analysis_imabari | [analysis_imabari] |
| 推論開始 | 独自channelから開始可能 |
<think>内のラベルから開始 |
| 学習・出力確認 | 8B版で実施。結果は後半で紹介 | 今回の独自ラベル形式での学習は未実施 |
最初は、「reasoning modelなら大体似たような形式なんだろう」と思っていました。
全然違いますね。むしろ、「モデルごとにChat Templateを確認せず学習データを作る方が怖い」と思うようになりました。
17. ここで少し脱線:特殊Tokenなのか普通の文字列なのか
今回調べながら気になったことがあります。
例えば、
<|start|>
<|channel|>
<|message|>
みたいなものを見ると、「なんか全部特殊Tokenっぽい」と思います。
一方、[analysis_imabari]は普通の文字列です。さらに、LLM-jp-4で<|channel|>の後ろに書いたanalysis_imabariも、名前自体は通常の文字列です。
つまり今回の違いは、「名前が特殊トークンかどうか」よりも、メッセージのヘッダーに置くか、本文中のラベルとして置くかです。独自channel名を書くことと、Tokenizerに新しい特殊トークンを追加することは別の操作です。
例えばQwenで、[analysis_imabari]とすると、Tokenizerによって複数Tokenへ分割される可能性があります。でも、それが悪いのか?というと、必ずしもそうでもなさそうです。LLMは普通の自然言語の文字列だって学習します。例えば、### Instructionや、### Responseを使ったSFTデータも昔からあります。
なので、[analysis_imabari]でも十分学習できる可能性はありますが、special tokenにした方がいいのか、普通の文字列の方がいいのか、ここは実験してみるしかないんでしょうね。
18. 実際に8Bモデルを追加学習した
さて、ここまではテンプレートの話でした。
ここからは実際に学習した話です。
ベースモデルにはllm-jp/llm-jp-4-8b-thinkingを使いました。最初にテンプレートを眺めた33B版ではなく、8B版です。
学習には、今治に関するQAとthinkingを持つデータセットを使いました。
ikedachin/imabari_wiki_qa_v4_reasoning_effort_llmjp4
学習用13,262件、検証用1,473件です。question、thinking、answerを会話形式へ変換し、thinkingをanalysis_imabari、回答をfinal_imabariに割り当てました。
ここは誤解しやすいのですが、通常channelと今治弁用channelの両方を均等に学習させた比較実験ではありません。今回の追加学習では今治弁用channelに割り当てたデータを使っています。
主な学習設定は次のとおりです。学習ノートブックとモデルカードに記載した設定をまとめています。
| 項目 | 設定 |
|---|---|
| ベースモデル | llm-jp/llm-jp-4-8b-thinking |
| 学習方法 | LoRAによるSFT |
| ライブラリ | Transformers / TRL / PEFT |
| Trainer | SFTTrainer |
| エポック数 | 1 |
| 学習率 | 2e-4 |
| 最大系列長 | 8,192 tokens |
| デバイスごとのバッチサイズ | 2 |
| 勾配累積 | 8 steps |
| LoRA rank / alpha / dropout | 8 / 16 / 0.0 |
| LoRA対象 | q / k / v / o projection、gate / up / down projection |
| Packing | 無効 |
学習後はLoRAアダプターをベースモデルへマージし、改造したChat Templateを持つTokenizerと一緒に保存しました。
公開したものはマージ済みモデルなので、推論時に別途LoRAアダプターを読み込む必要はありません。
reasoning_effortについて、今回の学習でできていなかったこと
ここは、結果を読む前に書いておきます。
データセットにはlow、medium、highのラベルがあります。しかし今回の学習用テキストへの変換処理では、各レコードのreasoning_effortやchat_template_kwargsをapply_chat_templateへ渡していませんでした。
そのため、学習プロンプトはテンプレートの既定値である、
Reasoning: medium
になっています。元のデータにlowやhighのラベルがあっても、そのラベルに対応したヘッダーで学習したわけではありません。
後半では推論時にlow、medium、highを変えますが、これは推論プロンプトを変えたときの観察です。「今回の追加学習で3段階のreasoning effortを学習できた」という評価にはなりません。
JSONに情報が入っていることと、モデルへ渡す文字列に反映されていることは違う。Chat Templateを調べていたはずなのに、自分の学習処理でもこの確認が必要でした。
19. 学習済みモデルの出力を確認する
確認に使った質問は、次の一つです。
今治西高ってどんな高校か教えてください。
これに対して、次の6条件の出力を見ました。
| channelの組み合わせ | reasoning_effort |
|---|---|
analysis → final
|
low / medium / high
|
analysis_imabari → final_imabari
|
low / medium / high
|
質問本文には「今治弁で答えてください」とは書いていません。channel一覧と生成開始channel、reasoning effortを指定しています。
公開モデルで同じ6種類のプロンプトを試すためのコード例です。以下の生成設定は再実行用の例であり、後述する既存ログと完全に同一の出力を保証するものではありません。既存ログには全生成設定が記録されていないためです。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "ikedachin/llm-jp-4-8b-thinking_imabari_qa_v4_reasoning_effort_v1"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
dtype=torch.float16,
device_map="auto",
)
model.eval()
messages = [
{"role": "user", "content": "今治西高ってどんな高校か教えてください。"}
]
channel_settings = {
"standard": {
"valid_channels": ["analysis", "final", "commentary"],
"thinking_channel": "analysis",
"channel": "final",
},
"imabari": {
"valid_channels": ["analysis_imabari", "final_imabari", "commentary"],
"thinking_channel": "analysis_imabari",
"channel": "final_imabari",
},
}
for mode, settings in channel_settings.items():
for effort in ("low", "medium", "high"):
chat_kwargs = {
**settings,
"reasoning_effort": effort,
"add_generation_prompt": True,
"generate_thinking": True,
}
print(f"\n=== {mode} / {effort} ===")
print(tokenizer.apply_chat_template(
messages, tokenize=False, **chat_kwargs
))
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True,
return_tensors="pt",
return_dict=True,
**chat_kwargs,
)
inputs = {key: value.to(model.device) for key, value in inputs.items()}
with torch.inference_mode():
output_ids = model.generate(
**inputs,
max_new_tokens=1024,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
# 入力も含めて、開始channelと生成後の遷移を確認する
print(tokenizer.decode(output_ids[0], skip_special_tokens=False))
# 生成した部分だけも確認する
input_length = inputs["input_ids"].shape[1]
generated_ids = output_ids[0, input_length:]
print(tokenizer.decode(generated_ids, skip_special_tokens=False))
FP16のモデルを動かせるだけのメモリがある環境を想定しています。また、公開モデルに保存したテンプレートの日付は2026-09-19で固定されています。
このコードで使うvalid_channels、generate_thinking、thinking_channel、channelは、今回の改造テンプレートが解釈する引数です。どのモデルでも同じように使えるTransformers共通の設定ではありません。
20. 結果:独自channelへの遷移は確認できた
手元の出力確認.mdに記録した6条件を整理すると、こうなりました。
| channel指定 | effort | ログ上の遷移 | 文体・出力の様子 |
|---|---|---|---|
| 通常 | low |
analysis → final
|
短い説明。標準語の文体 |
| 通常 | medium |
analysis → final
|
thinkingに見出しが付き、思考・回答とも方言表現あり |
| 通常 | high |
analysis → final
|
thinkingに見出しが付き、思考・回答とも方言表現あり |
| 今治弁用 | low |
analysis_imabari → final_imabari
|
思考・回答とも方言表現あり。最終回答は1文 |
| 今治弁用 | medium |
analysis_imabari → final_imabari
|
思考・回答とも方言表現あり |
| 今治弁用 | high |
analysis_imabari → final_imabari
|
思考・回答とも方言表現あり |
「思考」は、ここではanalysis側に生成されたテキストを指します。モデル内部の処理を直接観察した、という意味ではありません。
通常channel・low
まず、通常channelのlowです。ログからassistant部分を抜粋します。
<|start|> assistant<|channel|> analysis<|message|> 今治西高は愛媛県今治市にある公立の高等学校で、1902年に創立された歴史ある学校です。<|end|><|start|> assistant<|channel|> final<|message|> 今治西高は、愛媛県今治市にある公立の高等学校です。1902年に創立された歴史ある学校として知られています。<|return|>
この例は、analysisもfinalも標準語の文体でした。
今治弁用channel・medium
次に、今治弁用channelのmediumです。同じくassistant部分の抜粋です。
<|start|> assistant<|channel|> analysis_imabari<|message|> ## 思考プロセス
### 1. 質問の整理
今治西高がどんな高校かを聞かれとるけん、学校の概要や歴史、特徴を整理するんよ。
### 2. 答えへの接続
愛媛県今治市にある公立の高等学校や。1902年に創立され、2022年に創立120周年を迎えたんよ。今治市を代表する進学校として知られとるけん、この事実が答えにつながるんよ。<|end|><|start|> assistant<|channel|> final_imabari<|message|> 今治西高は、愛媛県今治市にある公立の高等学校なんよ。1902年に創立され、2022年に創立120周年を迎えたんよ。今治市を代表する進学校として知られとるんよ。<|return|>
おお。出た。😍
テンプレートで正解文を並べただけのときと違い、今回は学習後のモデルが本文と後続のchannelヘッダーを生成しています。
ただし、analysis_imabariの開始ヘッダーは入力側で用意したものです。モデルが生成した遷移として注目するのは、その後の<|end|>からfinal_imabari、そして<|return|>までの部分です。
今回の今治弁用3条件では、いずれもこの遷移を確認できました。
これは嬉しい結果です。ただし、一つの質問のログなので、一般的な成功率を示したものではありません。
21. でも、文体の切り替えはまだきれいに分離できていない
通常channelなら標準語、今治弁用channelなら今治弁。
最初にやりたかったのは、こういう使い分けでした。
ところが、通常channelのmediumやhighでも、
今治西高がどんな高校かを聞かれとるけん、学校の概要や歴史、特徴を整理するんよ。
というthinkingが出ています。回答にも「〜なんよ」「〜しとる」などの表現が出ました。
つまり今回の結果は、channelの形式は指定に対応していても、文体がそのchannelだけに限定されるわけではない、というものです。
そもそも、通常のanalysisという名前が「標準語専用」を意味するわけではありません。今回はこちらがそう使い分けたいと考えた、という話です。
追加学習で今治弁のデータを使った影響が通常channelにも出た可能性はあります。ただし、追加学習前のベースモデルを同条件で比較していないので、原因はまだ切り分けられていません。
また、「方言らしい語尾が出ること」と「自然な今治弁になっていること」も別です。今回のログから言えるのは、方言表現を含む出力が得られたというところまでです。
reasoning_effortを上げれば良くなる、とは言えない
通常channelのlowは短い説明で、mediumとhighでは見出し付きのthinkingになりました。
一方、今治弁用channelではlowでも見出し付きのthinkingが出ています。
このログだけで「low → medium → highの順に深く考えられる」とは言えません。長い文章や見出しが出たことと、推論の質が上がったことは別だからです。
加えて、今回の追加学習は先ほど書いたとおりReasoning: mediumで行われています。3段階の制御を評価するには、学習時の条件付けから見直す必要があります。
回答内容の正確さは、別に確認が必要
出力には、学校の沿革や野球部の成績について具体的な年や数字が含まれます。しかしログ間では、全国制覇したとする年などの記述が食い違っています。
例えば通常channelのmediumでは「1975年に全国制覇」、highでは「1962年と1963年に全国制覇」と出ています。これらは生成文の観察であり、学校の事実として紹介しているものではありません。
この記事の出力例は、学校紹介の資料として裏取りしたものではなく、形式と文体を確認するためのログです。channelが狙った形になっていても、内容が正しいとは限りません。
今回の確認では、未学習の質問だけを使った評価や、正答率の測定までは行っていません。
22. 出力確認では特殊トークンを残した方が分かりやすい
今回、出力を読むうえで役に立ったのが、
tokenizer.decode(output_ids[0], skip_special_tokens=False)
です。
skip_special_tokens=Trueにすると区切りの特殊トークンは取り除かれますが、最終回答だけを抽出してくれるわけではありません。thinkingの本文や、通常の文字列であるassistant、final_imabariなどが残ることがあります。
また、生成部分だけをdecodeすると、入力として置いた最初のanalysis_imabariヘッダーは出てきません。
そのため、確認時には次の両方を見ておくと整理しやすかったです。
- 入力と生成を含む全体:どのchannelから始めたかを確認する
- 新しく生成した部分:モデル自身がどの境界やchannelを出したかを確認する
「モデルが独自channelを書いた」と思ったら、実はプロンプト側で置いていた、という読み違いを防げます。
23. 独自channelは本当に必要なのか?
学習して出力が出たことで、むしろ次の疑問がはっきりしました。
そもそも、独自channelを作る方がよいのでしょうか?
普通のanalysis / finalに「今治弁で回答してください」と指示する方法でも、似たことはできるかもしれません。
今回の実験では、独自channelの方が優れているとまでは分かっていません。
次は、例えば以下を同じ条件で比較したいです。
| 比較したいもの | 確認したいこと |
|---|---|
| 通常channel+今治弁の指示 / 独自channel | 独自channelにする効果があるか |
| 標準語と今治弁のデータを混ぜた学習 | 通常channel側の標準語を保てるか |
| thinkingだけ今治弁 / 回答だけ今治弁 / 両方今治弁 | 思考文と回答文のスタイルを分けられるか |
| effortをレコードごとに正しく渡した学習 | 3段階の条件付けが出力に反映されるか |
| LLM-jp-4 / Qwenの独自ラベル形式 | 形式の違いが学習後の出力にどう現れるか |
同じ質問だけでなく、学習に使っていない複数の質問で、channelの遷移、文体、正確さを分けて確認したいところです。
……また実験が増えました。😂
24. 今回わかったこと
今回試して分かったことをまとめます。
- Chat Templateはmessagesを整形するルールで、Harmonyは会話の表現形式。役割が違う。
- LLM-jp-4では、改造テンプレートで独自channelを含む学習テキストを作れる。
- Qwenの今回のテンプレートでは、thinking内や回答前へテキストラベルを追加する方法を試せた。ただし追加学習は未実施。
- LLM-jp-4の8B版を実際に追加学習し、今回の今治弁用3条件では
analysis_imabariからfinal_imabariへの遷移を確認できた。 - 通常channelでも方言表現が出たため、channelだけで文体を完全に分離できたわけではない。
- 今回の学習プロンプトは
Reasoning: mediumで、3段階のreasoning effortを条件付けて学習できたとは言えない。 - channelの形式、方言の自然さ、回答の正確さは、それぞれ評価する必要がある。
- 学習前の整形テキストと、学習後の特殊トークンを含む生成結果の両方を見ることが大事。
個人的には、最後の8番が一番大きかったです。
25. おわりに
最初は「Chat Templateを変えたら、今治弁用channelを作れるのでは?」という軽い思いつきでした。
実際にやってみると、テンプレートで学習用の文字列を作ることはできました。さらに8Bモデルを追加学習し、独自channelから回答へ進む出力も確認できました。
ここまでは嬉しいです。
でも、通常channelにも方言が出る。おそらく、今回は追加channelでしか学習をしていないせいだと推測しています。標準語のデータセットも作らないといけないですね。
学習してみたことで、「文字列を作れる」と「狙いどおりに制御できる」の間にある課題が、少し具体的になりました。
Chat Templateを単なる前処理だと思っていた頃より、JSONのその先にある、モデルが実際に読む文字列を見るようになった気がします。
公開したモデルを再度掲載します。
まだ実験は続きますが、まずは学習して出力を見るところまで進められました。また、chat_templateの理解が非常に深くなったことは私自身得る物が非常に多かったです。
最後まで読んでいただきありがとうございました。😊