はじめに
ここ数か月、私が参加したプロジェクトにLLMエージェントを適用しました。ユーザーが自然言語で依頼すると、エージェントが必要な照会ツールを選択し、結果を確認したうえで回答を作成したり、追加の判断が必要な場合に再度質問したりする構成です。この記事では、その過程でモデルを包み込み、ツール呼び出しと会話の流れを制御するagent harnessを作り、複数のモデルを入れ替えて試した経験を整理します。
当初、私はharnessを「どのモデルを接続しても安定して動作する汎用制御層」だと考えていました。そのため、ローカルモデルがスキーマから逸脱したり、ツール呼び出しを誤ったりするたびに、パーサー、再試行、推論ルールを追加しました。当時は失敗を一つずつ防げていたため、合理的な対応に見えました。
しかし、同じharnessでモデルだけを変更して繰り返し実行すると、結果は異なりました。ローカルのオープンモデルでは、タスク全体を終える前に、形式エラー、誤った引数、空の応答、過度な遅延が繰り返し発生しました。一方、gpt-5.6は同じツールと同じ依頼に対して、最後まで最も安定して実行できました。その差はわずかではなく、実際に利用可能かどうかを分けるほど圧倒的でした。
|
補償的なharnessは汎用的な安全網ではなく、観察したモデルの失敗分布に合わせたコードでした。 モデルが向上すればそのコードは不要になり、むしろ以前のモデルに合わせた補償処理が、新しいモデルの単純な実行経路を妨げる可能性があります。 |
|---|
だからといって、harness全体が消えるわけではありません。モデルの不足を補う補償的(compensatory)harnessと、承認・権限・予算・監査のようにシステムが責任を負うべきharnessは区別する必要があります。この記事では、私が実際に作成した補償コードを振り返り、優れたモデルを選ぶことがコードの外側にあるコストの問題ではなく、アーキテクチャ上の決定である理由を説明します。
1. ローカルモデルを選択した背景とharnessの始まり
プロジェクト初期にローカルモデルを優先的に検討した理由は明確でした。業務データが外部に出ない構成が必要で、すでに利用可能な社内推論サーバーがあり、呼び出しコストも削減できました。モデルとproviderを設定で切り替えられるようにすれば、アプリケーションコードをそのまま維持できると判断しました。
外部に公開できないプロジェクト識別子を除いて単純化すると、設定は次のような形でした。
providerとモデルを切り替えるための設定
agent:
provider: ${LLM_PROVIDER:local}
endpoint: ${LLM_ENDPOINT}
model: ${LLM_MODEL}
capabilities:
tool-calling: true
structured-output: false
設定だけを見ると、モデルの切り替えは文字列を一つ変更するだけの作業に見えます。実際にはそうではありませんでした。最初のローカルモデルはスキーマで宣言したフィールド名を別の単語に置き換え、2番目のモデルは必要な照会を終える前に回答を作成したり、応答が長時間遅延したりしました。別のモデルはツール引数を誤ったり、内容もツール呼び出しもない空のターンを返したりしました。
失敗が起きるたびに、私はモデルが再び成功できるようコードを付け加えました。フィールド名の同義語を受け入れ、誤った型を選択肢の数から推測し、空の応答を再試行し、あるモデルの呼び出し習慣に合わせてステップ数を増やしました。個々のエラーは減りましたが、harnessは次第にモデルごとの知識を取り込むようになりました。
この過程には重要な落とし穴がありました。各修正はテストに合格し、実際のエラーを解決しました。そのため、コードだけを見ると、すべてが必要な防御ロジックのように見えました。しかし、その必要性は製品の契約から生じたものではなく、当時使用していたモデルの振る舞いから生じたものでした。モデルを変更すれば、根拠も同時に消える可能性のあるコードでした。
2. 実際に追加した補償的harness
私が追加した補償ロジックは、形が異なっていても同じ一文で説明できます。「モデルがXのように失敗したため、コードがYで修正する」という構造です。代表的な事例を、外部に公開しても問題ない一般化されたコードとして整理しました。
2.1 スキーマのフィールド名を同義語として吸収しました
ユーザーに追加の質問を送るツールは、questionId、question、inputTypeなどのフィールドを要求していました。しかし一部のモデルは、questionIdの代わりにidやkeyを、inputTypeの代わりにtypeやkindを送信しました。そのままパースすると質問全体が欠落するため、複数の名前を順番に読み取る関数を作りました。
フィールド名のバリエーションを受け入れるパーサー
private String firstText(Map<String, Object> values, String... names) {
for (String name : names) {
String value = text(values.get(name));
if (!value.isBlank()) {
return value;
}
}
return "";
}
String questionId = firstText(values, "questionId", "id", "key");
問題は、同義語のリストが仕様から導かれたものではない点です。実行ログで見た単語を一つずつ追加した結果でした。同じモデルでも実行ごとに異なる単語を作り、モデルを変更するとまた別のバリエーションが現れました。パーサーは広がりましたが、契約はさらに曖昧になりました。
2.2 誤った入力型をコードが推測しました
フィールド名を見つけた後も、値が問題でした。Text、Radio、Selectのような限定された値だけを許可していましたが、モデルはmultiple_choice、dropdown、checkboxといった表現を作りました。質問を破棄しないために文字列を正規化し、それでも判別できない場合は選択肢の数から画面の種類を決定しました。
モデル出力値を正規化して推測するロジック
String normalized = raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z]", "");
QuestionType declared = switch (normalized) {
case "text", "freetext", "string" -> QuestionType.Text;
case "radio", "singlechoice", "choice" -> QuestionType.Radio;
case "select", "dropdown", "list" -> QuestionType.Select;
case "multiselect", "multiplechoice", "checkbox" -> QuestionType.MultiSelect;
default -> null;
};
return declared != null ? declared : inferFrom(options);
このロジックにより画面を描画できるようになりましたが、モデルが作った曖昧さをアプリケーションが任意に解釈するというリスクが生じました。誤った推測が正常な入力として保存される可能性があり、新しいモデルが正確な値を送信しても、古いfallbackが残り続けます。短期的な復旧には有用でしたが、長期的な契約とするには適していませんでした。
2.3 構造化出力の代わりにプロンプトへ契約を繰り返し記述しました
一部のローカル実行環境では、構造化出力オプションを有効にすると、生成が正常に終了したと表示されるにもかかわらず、本文が空になる現象がありました。結局、ネイティブ形式の強制を無効にし、プロンプト内にJSONスキーマと「JSONのみを出力」という文を再度記述したうえで、パーサーが結果を検証するようにしました。
形式の強制をプロンプトとパーサーで迂回した例
request.disableNativeSchema();
request.addInstruction("Respond with JSON only.");
request.addInstruction(renderSchema(responseSchema));
Response parsed = parser.parse(modelResponse);
validator.validate(parsed);
この方法はすぐに機能しましたが、契約を3か所に複製することになりました。ツールスキーマ、プロンプトの文、パーサーの防御ルールが、同じ内容を異なる方法で保持していました。どれか一つを変更すると、残りも一緒に変更する必要があり、モデルが向上してもこの重複は自動的には消えませんでした。
2.4 空のターンと繰り返し発生するエラーを再試行で覆いました
ツールによる照会を数回成功させた後、contentとtool callsがどちらも空の応答が返る場合がありました。最後の1ターンのために、すでに得た観測結果をすべて失わないよう、空の応答を再試行し、残った情報で応答を完了する経路を追加しました。
空の応答の復旧と実行予算
private static final int MAX_EMPTY_RESPONSE_RETRIES = 2;
if (response.hasNoContent() && response.hasNoToolCalls()) {
retryOrFinishWithCollectedEvidence();
}
RunBudget budget = RunBudget.of(maxSteps, maxDuration, maxTokens);
再試行自体は必要な安全策になり得ます。しかし、何回再試行するか、1回の実行で何ステップを割り当てるかは、当時のモデルの失敗頻度と呼び出し習慣に合わせて決めました。あるモデルには不足し、別のモデルには過剰な数値となり、同じ定数でもモデルごとにまったく異なるコストを意味しました。
2.5 プロンプトも特定モデルの失敗記録になりました
コードだけでなく、システムプロンプトにも「識別子をそのままコピーすること」「プレースホルダーを作らないこと」「ツールの結果を確認する前に結論を出さないこと」といった文が増えていきました。ほとんどは、一度発生した実際の失敗から始まった文でした。
プロンプトが長くなるほど、どの指示が製品ポリシーで、どの指示が特定モデルへの補償なのかを区別しにくくなりました。新しいモデルがすでに得意としている動作まで繰り返し指示することで、重要なポリシーの優先順位が曖昧になりました。これも、harnessがモデルにfitした痕跡でした。
3. 同じharnessでモデルだけを変更した結果
3.1 比較方法
仮説を確認するため、同一のシステムプロンプト、同一のツールスキーマ、同一の照会データ、同一の実行予算を維持したまま、モデルだけを変更しました。実際の業務で使用したものと同じ複合的な依頼を、何度も繰り返しました。依頼内容は、必要な情報を複数のツールで確認し、ユーザーの決定が必要な項目について質問するというタスクでした。
タスク全体が中断せず、最後まで完了するかを確認しました。
ツール名と引数がスキーマに適合しているかを確認しました。
応答形式のエラー、空のターン、再試行が繰り返されるかを確認しました。
同じ依頼を再実行したとき、結果のばらつきが大きいかを確認しました。
harnessが介入しなくても正常な結果が維持されるかを確認しました。
最初に作成した草稿では、一部の下位指標を数値として整理しました。しかし改めて見ると、その値は実際の体感やタスク全体の成功可否を適切に表していませんでした。たとえば、質問JSONが1回形式に適合していても、それ以前のツール呼び出しに失敗してタスク全体が完了していなければ、成功とは言いにくいでしょう。今回の文書では、記憶に基づいて数値を新たに作るのではなく、繰り返し実行で実際に再現された結果だけを状態として整理しました。
3.2 実際の観測結果
表1. 同一のharnessでモデルだけを変更して繰り返し実行した観測結果
|
観測項目 |
gpt-oss:120b |
gemma4:31b |
qwen3.6:35b |
gpt-5.6 |
|---|---|---|---|---|
|
全タスク完了 |
途中でエラーが繰り返し発生し、安定した完了が困難でした。 |
ツール呼び出しエラーと空の応答が繰り返し発生しました。 |
応答の遅延と形式エラーにより、失敗が繰り返し発生しました。 |
繰り返し実行において、最も一貫して最後まで完了しました。 |
|
ツール呼び出し |
スキーマに準拠した呼び出しを続けて実行しました。 |
必要な呼び出しが欠落することが多くありました。 |
不完全な終了がありました。 |
スキーマに準拠した呼び出しを続けて実行しました。 |
|
応答形式 |
形式の検証がたびたび必要でした。 |
空の応答からの復旧が必要でした。 |
再試行と形式の検証が必要でした。 |
1~2回の再試行で応答が安定しました。 |
|
実行のばらつき |
結果と実行フローのばらつきが比較的少なかったです。 |
応答形式にばらつきがありました。 |
応答形式にばらつきがありました。 |
結果と実行フローのばらつきが最も小さかったです。 |
|
総合判断 |
現在の条件では運用への適用が困難でした。 |
現在の条件では運用への適用が困難でした。 |
現在の条件では運用への適用が困難でした。 |
唯一、実際の適用基準を安定して満たしました。 |
当時、各回の定量ログをすべてのモデルについて同じ形式で保存できなかったため、正確でない成功率や平均時間を新たに作成することはしませんでした。表には、繰り返し確認された失敗の形態と、実際の適用判断のみを記録しました。
結果はgpt-5.6が圧倒的でした。他のモデルでは、ある問題を補うと別の箇所で再びエラーが発生し、実行するたびに失敗の形態が変わりました。一方、gpt-5.6は同じharness上で、ツール呼び出しから最終質問までの流れを最も安定して完了しました。私が作成した報酬コードの量よりも、モデル自体のツール使用能力と契約遵守能力が結果を大きく左右しました。
特に重要な違いは、「一度か二度、形式に合った回答を作れたか」ではなく、「全体の作業を繰り返し完了できるか」でした。ローカルモデルでも部分的な成功はありました。しかし、ユーザーに提供できる機能は、全体のフローが完了して初めて成立します。この基準で見ると、モデル間の差ははるかに明確になりました。
harnessは弱いモデルを一定の水準まで引き上げましたが、失敗の分布をなくすことはできませんでした。同義語を追加すると新たな同義語が現れ、再試行を増やすと時間とコストが増加し、ステップ予算を引き上げると誤った呼び出しもより長く繰り返されました。報酬ロジックには効果がありましたが、限界も明らかでした。
3.3 harnessがモデルにfitするという意味
報酬型harnessの入力は製品要件ではなく、モデルで観測された失敗です。観測は、特定のモデル、特定のバージョン、特定のプロンプト、特定の推論サーバーから得られます。したがって、その観測をコードに移した報酬ロジックも、同じ条件に縛られます。
|
モデルがXのように失敗しました。→ harnessがYで補正します。→ harnessはXという失敗を示したモデルにfitします。 |
|---|
決定論的なコードで非決定論的な出力を包むと、安定性が生まれたように見えます。しかし、モデルが変われば出力分布も変わります。以前のモデルで頻発したエラーが新しいモデルでは発生しない可能性があり、新しいモデルの呼び出し方式は、以前の報酬ロジックが想定した順序と異なる可能性があります。結局、汎用層だと思っていたコードが、特定モデルの挙動を近似した経験的モデルになります。
この観点では、モデルの置き換えは単純なproviderの変更ではありません。harnessとモデルの結合を再評価する作業です。既存の報酬コードをそのままにして新しいモデルだけを接続すると、不要なfallbackが正確な値を変更したり、不要な再試行が遅延を生じさせたりする可能性があります。モデルをアップグレードした後は、コードの追加よりも削除を先に検討すべきです。
4. gpt-5.6に変更して消えたもの
gpt-5.6に置き換えた後に最も大きく変わった点は、以前のharnessをよりうまく通過したという事実だけではありませんでした。報酬が必要な状況そのものが大幅に減りました。正確なフィールド名と引数の形式を維持し、必要なツールを選択して全タスクを完了する割合が高まると、共通ループに含まれていた復旧コードの存在理由が弱まりました。
表2。モデル置き換え前後における報酬型harnessの変化
|
報酬項目 |
エラーが繰り返し発生していたモデル |
gpt-5.6適用後 |
|---|---|---|
|
フィールド名の同義語 |
複数の別名を順番に探索しました。 |
定められたスキーマ名をそのまま使用するため、ほとんど不要でした。 |
|
入力型の推論 |
未知の値を文字列規則と選択肢の数から推定しました。 |
許可された型を使用することで、推定fallbackを削除できました。 |
|
プロンプトにおけるスキーマの反復 |
契約を長い文章で再度説明しました。 |
ツールと応答スキーマ自体に契約を集中させることができました。 |
|
空の応答の復旧 |
再試行と観測維持の経路が頻繁に介入しました。 |
通常経路が安定したため、例外的な障害処理に縮小できました。 |
|
モデル別のステップ補正 |
呼び出しの習慣に合わせて定数を増やしました。 |
不要な呼び出しが減り、より単純な予算ポリシーを適用できました。 |
ここで「必要なくなった」というのは、コードを直ちにすべて削除したという意味ではありません。実際に削除する前に、新しいモデルで回帰テストを実行し、補償ロジックを一つずつ無効にした状態でも結果が維持されるかを確認する必要があります。重要な変化は、新しい補償をさらに追加する方向ではなく、既存の補償を削除できる方向へ検証の焦点が変わったことです。
優れたモデルは、単に正答率を高めるだけではありません。パーサーの分岐、再試行回数、モデル別の設定、失敗ログの分析、回帰テストの組み合わせを同時に減らします。呼び出し単価だけを見ればローカルモデルのほうが安いかもしれませんが、失敗の調査や補償コードの保守にかかる開発コストまで含めると、結論は変わります。実際のプロジェクトでは、gpt-5.6を選ぶほうが全体のコストとリスクを低減できました。
もう一つ学んだのは、モデル評価を単一の回答品質だけで終わらせてはいけないということです。エージェントでは、ツールの選択、引数の正確性、複数ターンにわたる状態維持、失敗後の復旧、最終応答までが一つの性能です。gpt-5.6の優位性は、文章をより上手に書くという水準ではなく、実行グラフ全体を最後まで完遂する点にありました。
5. それでも残すべきharness
優れたモデルが補償的なharnessを減らしてくれても、システムの責任まで代わりに担うわけではありません。モデルがどれほど正確でも、権限のないデータを読み取ってはならず、取り消しの難しい変更を承認なしに実行してはならず、使用した根拠と実行結果を追跡できなければなりません。
5.1 補償と責任を区別する質問
コードを分類する際、私は次の質問を使いました。「モデルが契約を完全に守っても、このコードは必要か?」答えが「いいえ」なら補償的なharnessであり、「はい」ならシステムの責任に近いものでした。
表3. 補償的なharnessと責任を担うharnessの境界
|
区分 |
補償的なharness |
責任を担うharness |
|---|---|---|
|
発生根拠 |
特定のモデルで観測したエラーです。 |
製品ポリシー、セキュリティ、運用上の責任です。 |
|
モデル依存性 |
高いです。モデルとバージョンによって異なります。 |
低いです。providerが変わっても維持されます。 |
|
代表例 |
同義語のパース、値の推論、空のターンの再試行、モデル別の定数です。 |
権限確認、承認ゲート、予算上限、監査記録です。 |
|
優れたモデルを適用した後 |
回帰検証後に削除するか、adapterに縮小します。 |
そのまま維持し、テストを強化します。 |
|
失敗処理 |
可能であれば黙って修正せず、明示的に表面化させます。 |
ポリシー違反時には実行を中断し、理由を記録します。 |
5.2 ユーザー承認と変更の境界は残します
読み取り専用の照会と実際の変更は、同じツール呼び出しとして扱いませんでした。エージェントが情報を確認して計画を説明する段階までは自動化しますが、データが変更されたり、外部に結果が公開されたりする時点では、ユーザーの明示的な承認を得るようにしました。この境界はモデルの性能とは無関係です。
内部名を削除して一般化した承認フロー
사용자 요청
-> 읽기 전용 조회
-> 변경 계획과 영향 제시
-> 사용자 승인
-> 실제 변경 실행
-> 결과와 근거 기록
質問や承認依頼をモデルに書き直させず、システムが定められた構造でユーザーに伝える仕組みも残しました。これは、モデルがフィールド名を間違えることを避けるための補償であるだけでなく、ユーザーの意思決定を生成テキストから分離するという製品原則でもあるためです。
5.3 予算、権限、監査記録は残します
一つの実行で使用できるステップ、時間、トークンに上限を設ける構造は必要です。ただし、数値自体はモデルの速度や呼び出し方式によって変わり得るため、設定として分離する必要があります。上限というポリシーは責任を担うharnessであり、特定のモデルに合わせた値は補償的な設定です。
ツールの許可リストと権限確認もモデルに任せません。照会権限がない状況と、照会結果が空の状況は、ユーザーにとってまったく異なる意味を持ちます。モデルがその違いをうまく説明できても、呼び出しの可否を決定する主体はシステムでなければなりません。
最後に、どのリクエストでどのツールが呼び出され、どの結果を根拠に応答したのかを記録する必要があります。運用中に問題が発生したとき、モデルの最終文だけが残っていては原因を再構成できません。監査記録は、モデルの品質が高くなるほど重要性が下がる機能ではなく、実際の利用範囲が広がるほど重要になる機能です。
6. モデル選択とharnessを同時に設計する方法
今回の経験以降、弱いモデルを先に選び、harnessで補う順序を変えました。まず最小限の契約と安全装置だけを備えた薄い実行器で候補モデルを評価し、代表的なシナリオを最後まで実行できるモデルを選びます。その後も繰り返されるエラーだけを、分離されたadapterで補償します。
6.1 全体のタスク成功を最初に測定します
JSONを1回パースできた成功率や、ツールを1回呼び出せた成功率だけを見ると、実際の使いやすさを過大評価しやすくなります。ユーザーが求める結果に到達したか、同じリクエストを繰り返したときに結果が維持されるか、失敗したときに原因が明確かを、併せて確認する必要があります。
代表的な業務シナリオを短い単位ではなく、開始から終了まで実行します。
モデルごとに同じプロンプト、ツール、データ、予算を使用します。
最終的な成功可否と併せて、ツール引数のエラー、空のターン、再試行、所要時間を記録します。
harnessを有効にした結果と、補償ロジックを無効にした結果を並べて比較します。
モデルを変更した後は、新しい補償を追加する前に、既存の補償を削除できるか確認します。
6.2 補償コードをadapter内に閉じ込めます
モデルごとの報酬ロジックが共通実行ループに入ると、コードがどのモデルのために存在するのか分かりにくくなります。報酬はproviderまたはモデルadapterの内側に置き、共通ループでは明確な契約が守られると仮定するほうがよいでしょう。契約を守れない場合は、静かに推論するのではなく、診断可能なエラーとして失敗させるほうが長期的には安全でした。
やむを得ず報酬を追加するときは、モデル名、バージョン、再現条件、削除条件をコメントとテストに残す必要があります。そうすれば、次のモデルに置き換える際に削除候補をすぐ見つけられます。報酬コードには機能の説明だけでなく、有効期限を判断する根拠も必要です。
6.3 モデルのコストに保守コストを含める
モデル選定表には、トークン価格と推論サーバーのコストだけが記載されがちです。しかし実際のプロジェクトでは、失敗の分析時間、報酬コードの実装とテスト、再試行による遅延、運用障害の可能性もコストです。ローカルモデルの呼び出しコストが低くても、エラー対応を繰り返さなければならないなら、総コストはかえって大きくなる可能性があります。
私の場合、gpt-5.6はモデルのコスト以上に、開発フローをシンプルにした効果が大きいものでした。失敗が再現されるたびに新しい分岐を追加する作業が減り、核心となるポリシーとツールの契約に集中できました。性能の高いモデルを選ぶことは、単なる品質向上ではなく、コードと運用の複雑さを減らす選択になりました。
7. 適用後に整理した実務チェックリスト
現在は、モデルを導入または交換するとき、次の順序で確認しています。
候補モデルが代表的なシナリオを最初から最後まで安定して実行できるか確認します。
失敗を、出力形式、ツール選択、引数の正確性、状態維持、遅延に分けて記録します。
モデル自体の問題を共通ビジネスロジックで補いません。
報酬ロジックはモデルごとのadapterと設定に隔離し、削除条件も併せて記録します。
承認、権限、実行予算、監査記録は、モデルに依存しないポリシーとして維持します。
モデルをアップグレードするときは、機能追加よりも不要なharnessの削除を先に検討します。
定量的なログがなければ、記憶に合わせた数字を作らず、再現可能な現象だけを共有します。
このチェックリストの目的は、harnessをなくすことではありません。モデルが責任を負うべきことと、システムが責任を負うべきことを区別することです。モデルの欠陥をコードが無限に吸収する状態にすると、harnessは際限なく厚くなり、どのモデルにとっても最適ではない中間層になります。
おわりに
最初は、優れたharnessがモデル間の違いを消してくれると考えていました。実際には、harnessのかなりの部分が、あるモデルの欠陥をコードに書き写したものでした。そのコードは当時は問題を解決しましたが、他のモデルでも常に必要となる汎用的なルールではありませんでした。
同じharnessを複数のモデルに接続してみると、gpt-5.6の性能は圧倒的で、他のモデルではエラーが発生し続けました。この経験から、モデルの性能は単なる部品の仕様ではなく、harnessの大きさと形を決めるものだと実感しました。弱いモデルを補うコードが積み重なるほど、システムはそのモデルにより強くfitします。
|
優れたモデルはharnessをすべてなくすわけではありません。 モデルの欠陥を埋めていた補償的なharnessを減らし、承認・権限・予算・監査のように、システムが最後まで責任を負うべきharnessだけを残します。 |
|---|
したがって、モデルを選ぶときは呼び出しコストだけを比較してはいけません。タスク全体の成功率、エラーの再現性、補償コードの量、運用リスク、開発者が対応に費やす時間まで、併せて見る必要があります。プロジェクトでgpt-5.6を選んだ理由も、同じ基準で説明できます。性能の高いモデルを使うことで結果が改善しただけでなく、システムがよりシンプルで説明可能になりました。
今後、モデルが再び変わるとしても、まず同じ質問を投げかけるつもりです。今追加しようとしているこのコードは、システムの責任でしょうか。それとも、現在のモデルの不足を埋める一時的な補償でしょうか。この区別を残しておくことが、次のモデルに移行するとき、削除すべきものと維持すべきものを最も早く知らせてくれる基準になりました。
IAN