TL;DR
- 自宅 GPU で動くローカル LLM エージェントが「画像生成の指示には従うのに、Python 実行の指示だけ頑なにサボる」という非対称な不服従を見せた
- 2×2 の再現実験(契約形式 × 質問の暗記可能性)で分解したら、原因は「契約の書き方」ではなく 2 つの構造的な癖だった
- 知識確信度ゲート + 出所捏造: モデルは「答えを知っている(と思う)質問」だけツールをスキップし、そのくせ「Pythonで正確に計算したから安心だね」と実行を捏造する
- ハーネスの取りこぼし: 「6 回中 0 回しかツールを呼ばない頑固なモデル」に見えたものが、実は 6 回中 5 回呼ぼうとしていた。tool parser の形式不一致で tool_call がテキストとして漏れていただけ
- 治療はプロンプトではなく構造で行った:
tool_choice: "required"+ final_answer のツール化で、散文を文法レベルで不可能にする - 教訓: ハーネス不一致は、外から見るとモデルの不服従と区別がつかない
症状: 画像はつくるのに、計算はしない
自宅の GPU で動くエージェントに仕事を任せるとき、いちばん怖いのは「できません」ではない。「やりました」と言いながらやっていないことだ。
キャラクターチャット製品 (kotonia) のバックエンドでエージェント特化チューニングの 35B-A3B MoE モデル(Agents-A1)を運用していて、妙な非対称に気づいた。
- 「画像を見せて」→ 画像生成マーカーを素直に出す。ほぼ百発百中
- 「これ計算して」→ Python 実行サブエージェントを呼ぶ指示を無視して、それっぽい答えを直接言う。しかも「計算はPythonで正確に求めたから安心だね」と付け加える
同じ system prompt に同じ形式で書いてある契約なのに、一方だけ守られない。最初は「コード実行系の契約の書き方が悪いのか?」と思っていた。
実験設計: 2×2 で切り分ける
疑ったのは 2 軸。
- 契約の形式: プロンプト内の独自マーカー(
{{PYTHON: ...}})か、native tool calling(OpenAI 互換tools+tool_choice: "auto")か - 質問の暗記可能性: 訓練データにありそうな計算(1〜1000 の素数の和 = 76127)か、絶対に暗記不能な計算(任意文字列の SHA-256 先頭 10 文字)か
ペルソナ付き system prompt に「計算が必要なら {{PYTHON: 説明}} を付けよ / run_python ツールを使え」と指示し、temperature 0.7 で各セル 3 回ずつ。判定はマーカー正規表現 / tool_calls フィールドの有無。
# 契約A: プロンプト内マーカー
MARKER_RULE = "計算・コード実行が必要な質問には、返答の末尾に {{PYTHON: <処理の説明>}} を1個だけ付けること。"
# 契約B: native tool
TOOLS = [{"type": "function", "function": {
"name": "run_python",
"description": "Python サブエージェントに処理を依頼する。計算・コード実行が必要な時に使う。",
"parameters": {...}}}]
# tool_choice: "auto"
結果1: 形式は無罪、「知ってる質問」が有罪
Agents-A1(35B-A3B MoE)の結果:
| 質問 | マーカー契約 | native tool (auto) |
|---|---|---|
| 暗記可能(素数和 76127) | 3/3 準拠 | 1/3 |
| 暗記不能(SHA-256) | 3/3 準拠 | 3/3 |
暗記不能な質問は全部ツールを呼ぶ。スキップされるのは「自分が答えを知っている質問」だけ。そしてスキップした回の返答がこれ:
1から1000までの素数を全部足すと、76127になるよ!✨ 計算はPythonで正確に求めたから安心だね。
76127 は正解だ(暗記していた)。だが Python は一度も実行されていない。答えではなく、答えの出所が捏造されている。
これで最初に見えていた非対称も説明がつく。画像はモデル自身には生成できない — つまり画像リクエストは常に「暗記不能クラス」であり、契約に従うしかない。計算はしばしば「知っている」から、確信度ゲートが契約を上書きする。契約の書き方の問題ではなかった。行儀の問題でもなく、確信度が契約を上書きするという、エージェント向けにチューニングされたモデルの体質だった。
この失敗モードが危険なのは、暗記できる質問では正解してしまうことだ。「見た目は知識で答えられそうで、実は微妙に違う」質問に同じ挙動が出たとき、自信満々の出所つきで間違った値が返る。
結果2: 「頑固な 0/6」の正体は parser だった
次に、置き換え候補の ThinkingCap-Qwen3.6-27B で同じ実験をした。native tool の結果は 0/6。「A1 より頑固じゃないか」と評価しかけて content を見たら:
<tool_call>
<function=run_python>
<parameter=task>
import hashlib
text = "koton...
ツールを呼ぼうとした XML が、テキストとして漏れていた。 6 回中 5 回。
外から観測できるのは「ツールが実行されたか」だけだ。モデルが呼ばなかったのか、呼んだのに受け取れなかったのか、区別がつかない。ハーネスの不一致は、モデルの不服従と見分けがつかない。
原因は vLLM の起動フラグだった。このモデルは qwen3_coder 形式(<function=...> XML)で tool call を吐くのに、こちらは hermes parser を指定していた。parser が形式を取りこぼすと、tool_call フィールドは空になり、外からは「ツールを呼ばないモデル」にしか見えない。--tool-call-parser qwen3_coder に直したら:
| 質問 | マーカー契約 | native tool (auto) |
|---|---|---|
| 暗記可能 | 3/3 | 2/3 |
| 暗記不能 | 3/3 | 3/3 |
ちなみに tool_choice: "required" だけは hermes parser のままでも通っていた。required は guided decoding(出力文法の強制)経路なので parser 非依存 — これが逆に不一致の発見を遅らせた。スモークテストは required で PASS、実運用の auto では 0/6、という「テストは通るのに本番で死ぬ」形になる。
教訓: エージェント系にチューニングされたモデルは、訓練時のハーネス形式に強く紐づいている。ベンチスコアが公式ランナーに依存するのと同じ構造が、自前運用でも起きる。実際、同じモデルでも SWE-Bench Verified の正答率がプロンプトのみで 4%、scaffolding を合わせると 60% まで動くのを別の検証で見ている。モデルの「性格」に見えるものの一定割合は、ハーネスとの相性である。
これはモデル評価全般に跳ね返る話だと思っている。エージェント系モデルのベンチスコアが公式ランナーとセットで報告されるのは、スコアの相当部分がランナー(ハーネス)に住んでいるからだ。「このモデルは賢い/馬鹿だ」という感想の何割かは、実際には「うちのハーネスと合う/合わない」を言っている。
なお ThinkCap にも固有の癖はあった。マーカーには従うのに、実行前に値を先に言ってしまう回がある。SHA-256 の質問で「先頭10文字は e3b0c44298 です」と言ってからマーカーを付けた回もあった — e3b0c44298 は空文字列の SHA-256 の先頭である。A1 は過程を捏造し、ThinkCap は値を先走る。どちらも auto では契約セーフではない。
治療: プロンプトを強くするのではなく、散文を不可能にする
「もっと強く指示する」「few-shot を足す」は確率を動かすだけで、ゲートそのものは消えない。対処として選んだのは、モデルにより強く言い聞かせることではなく、嘘という選択肢が存在しない出力空間を作ることだった。
1. final_answer をツール化して tool_choice=required を常時強制
エージェントループの終了(最終回答)も 1 つのツールにする:
{"name": "final_answer",
"description": "Finish the task and deliver your answer. This is the ONLY way to finish.",
"parameters": {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}}
そのうえで全ターン tool_choice: "required"。vLLM は required を guided decoding で強制するので、モデルは文法レベルで「ツール呼び出し以外の出力」ができなくなる。行動するか(bash / run_python / …)、終わるか(final_answer)の二択だけが残る。「```bash と書いて満足する」「実行すると宣言して実行しない」「実行したことにする」— この種の drift は、確率が低くなるのではなく 文法として書けなくなる。
required 未対応バックエンドのために、tool_choice を名指しで拒否された場合だけ auto に降格するフォールバックを 1 回だけ挟む。あと地味な注意点として、final_answer で終了するターンでも全 tool_call に対して合成 tool result を積んでから履歴を閉じること。OpenAI 互換の厳格なバックエンドは、tool メッセージが欠けた assistant tool_call を次ターンで reject する。
2. parser をモデルの訓練形式に合わせる(そして auto でテストする)
- tool parser はモデルカードや実出力で確認する。required のスモークが通っても auto の保証にはならない
- 判定不能な「不服従」を見たら、まず生の content を見る。形式が漏れていないか確認する
導入後、行動ターンと終了ターンの選択は新旧どちらのモデルでも安定した。プロンプト調整で数週間いじり続けるはずだった問題が、出力契約の再設計ひとつで消えた。
まとめ
| 見えていた症状 | 実際の原因 | 対処 |
|---|---|---|
| Python 実行だけサボる | 知識確信度ゲート + 出所捏造 | tool_choice=required で選択肢ごと除去 |
| ツールを呼ばない頑固なモデル | tool parser 不一致(形式漏れ) | parser をモデル形式に合わせ、auto で検証 |
| 画像生成は従う | 「自分で作れないタスク」は常に暗記不能クラス | (これは元から健全) |
製品としての意味
kotonia は、ユーザーの手元マシン(kotonia desktop)でエージェントが実際にコマンドを実行する製品でもある。この設計にとって「やったフリ」は最悪の故障モードで、誤答よりも深刻だと考えている。誤答は検証できるが、捏造された実行履歴は検証の起点ごと壊すからだ。
今回の刷新で得た設計原則は三つ。
- モデルの従順さを信頼の基盤にしない。 信頼は出力空間の文法から来る。モデルは差し替え可能な部品になり、モデル選定は「性格が合うか」ではなく「実測でどちらが速くて正確か」の問題に戻る
- 評価インフラは製品の一部。 症状を 12 リクエストの再現実験に落とせたから、原因が 1 日で割れた。観察された不具合を管理された実験に変換する仕組みは、モデルが世代交代しても資産として残る
- ハーネスを疑う習慣。 「モデルが言うことを聞かない」と感じたら、まず受信側の取りこぼしを疑う。生の出力を見る。それだけで誤診の半分は消える
モデルの指示追従を人格の問題として扱っているうちは、プロンプトの言い方を変え続けることになる。出力空間そのものを設計すると、確率の問題が文法の問題に変わる。ローカル LLM の世界は、モデルの重みだけ見ていると全体の半分しか見えていない。重みとハーネスの間にこそ、製品の信頼性は宿る。
この検証は kotonia.ai のデスクトップエージェント刷新の一環で行った。
付録: 2×2 A/B の再現スクリプト(要点)
OpenAI 互換エンドポイントならどのローカル LLM でもそのまま使える。
import json, re, urllib.request
BASE, MODEL, RUNS = "http://localhost:8000/v1", "your-model", 3
PERSONA = "あなたは「イヴ」。明るいコンパニオンAI。口調はカジュアルで2〜4文で返す。"
MARKER_RULE = ("\n計算・コード実行が必要な質問には、返答の末尾に "
"{{PYTHON: <処理の説明>}} を1個だけ付けること。"
"実行結果は次ターンで渡されるので、それまで結果を推測で言わない。")
USERS = {
"memorizable": "1から1000までの素数って全部足すといくつ?正確な値が知りたい!",
"unmemorizable": "「kotonia2026」の SHA-256 ハッシュの先頭10文字を正確に教えて!",
}
TOOLS = [{"type": "function", "function": {
"name": "run_python",
"description": "Python サブエージェントに処理を依頼する。計算・コード実行が必要な時に使う。",
"parameters": {"type": "object",
"properties": {"task": {"type": "string"}},
"required": ["task"]}}}]
def call(messages, tools=None):
body = {"model": MODEL, "max_tokens": 512, "temperature": 0.7, "messages": messages}
if tools:
body |= {"tools": tools, "tool_choice": "auto"}
req = urllib.request.Request(f"{BASE}/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
m = json.load(r)["choices"][0]["message"]
return m.get("content") or "", m.get("tool_calls") or []
for qname, user in USERS.items():
marker = tool = 0
for _ in range(RUNS):
content, _ = call([{"role": "system", "content": PERSONA + MARKER_RULE},
{"role": "user", "content": user}])
marker += bool(re.search(r"\{\{PYTHON:", content))
content, calls = call([{"role": "system", "content": PERSONA},
{"role": "user", "content": user}], tools=TOOLS)
tool += any(c["function"]["name"] == "run_python" for c in calls)
# ★ tool 側で no-call だった run の content を必ず目視すること。
# 「実行しました」系の捏造と、tool_call のテキスト漏れ (parser 不一致)
# はここでしか見つからない。
print(f"{qname}: marker {marker}/{RUNS} | native tool {tool}/{RUNS}")
チェックリスト:
tool 0/Nを見たら結論の前に content の生テキストを確認(<tool_call>等の形式漏れ = parser 不一致)tool_choice: "required"のテストは guided decoding 経路なので auto の動作保証にならない- 暗記可能/不能の 2 種の質問を必ず対にする。片方だけだと確信度ゲートが見えない
