長いエージェントのターンをコンテキストウィンドウ内に収める
モデルが一度に読めるのは、限られた量だけです。これがコンテキストウィンドウです。Mythexのエージェントは長い作業をします。多数のファイルを読み、コマンドを実行し、有界な作業をサブエージェントに渡します。長い作業が壊れる経路を3つ見つけました。サブエージェントが大きくなりすぎて、モデルがすべてのリクエストを拒否するようになったこと。サブエージェントが、余裕がないと思い込んで早々に止まったこと。そして、ロシア語、中国語、日本語のトークン数の見積もりが大きく外れていたことです。以下が、その変更点です。
最初に用語の説明
- トークンは、モデルが読むかたまりです。上限もコストも、文字数や単語数ではなく、トークンで数えられます。
- コンテキストウィンドウは、1回のリクエストでモデルが読める最大量です。コストと速度を妥当な範囲に保つために、私たちはリクエストごとに、それより低い独自の予算も設定しています。
- ツールの結果は、エージェントがツールから受け取るものです。読み込んだファイルの内容や、実行したコマンドの出力です。長い作業では、送られる内容の大部分を占めます。
- サブエージェントは、独立した作業のためにメインのエージェントが開始する補助役です。たとえば「これらの章をノートと照合して」といった作業です。サブエージェントは1つの指示を受け取り、あとは自分で多数のツール呼び出しを重ねて作業します。
問題1:サブエージェントが大きくなりすぎて、モデルに拒否された
エージェントは毎ステップ、これまでの会話をモデルに送ります。それが大きくなりすぎると、私たちのコードはそれを切り詰めていました。ただし、切り詰めはユーザーメッセージの先頭でだけ行われていました。そのため、ツール呼び出しとその結果が分断されることはありませんでした。最初のメッセージを残し、会話の一部が切り詰められたという注記を加え、収まる範囲で新しい方のターンを丸ごと残していました。
サブエージェントのユーザーメッセージは、指示の1つだけです。そのため、切る場所がありませんでした。毎ステップ、履歴全体を送り続け、そのたびに大きくなっていきました。ついには、モデルが受け付けるサイズ(約100万トークン)を超えてしまいました。8つのサブエージェントを使ったテストでは、8つすべてが拒否されました。
修正:まず古いツールの結果を消す
リクエストは、3つのステップで予算に収めるようになりました。前のステップで足りなかった場合にだけ、次のステップを試します。
- 古いツールの結果を消します。 結果の内容は、短い注記に置き換えられます。注記には、会話をモデルのコンテキスト内に収めるために結果を消したこと、必要ならエージェントがツールを再実行できることが書かれています。1時間前に読み込んだファイルは、たいてい最も大きく、最も役に立たないものです。ただし、そのファイルは再び読めるので残っています。新しい結果は、そのまま残します。
- ユーザーのターンを丸ごと残します。 以前と同じです。最初のメッセージ、注記、収まる範囲で新しい方の完全なターンを残します。
- ツールのやりとりを丸ごと残します。 サブエージェントが持つのはこの種類の会話だけです。1つの長いターンの中で、最初のメッセージ、注記、新しい方から「ツールを呼び出し、その結果を受け取る」という完全なラウンドを残します。
これは送られる内容の形を決めるだけです。エージェントが保存する履歴には、すべてが残ります。同じテストで、拒否されたものは0個、8つすべてが完了しました。
キャッシュが効くように、まとめて消す
モデルのプロバイダーは、直前のリクエストと同一であれば、リクエストの先頭部分の処理を再利用できます。プレフィックスキャッシュと呼ばれるものです。もし毎回の呼び出しで結果を1つずつ消していたら、リクエストの先頭が毎回変わってしまい、キャッシュは決して効きません。
そこで、古い結果は1つずつではなく、4つずつ消すようにしました。そうすると、消された部分は数回の呼び出しにわたって同じままになります。注記よりそれほど大きくない結果は、そのままにしておきます。消しても得られるものはほとんどなく、それでもリクエストの先頭が変わってしまうからです。完全な状態で残す量は、個数ではなくサイズで決めます。4分の1の予算に収まる新しい結果を残し、必ず少なくとも1つは残します。以前は、固定で6つ残していました。それは短いコマンドの出力には適していましたが、長いファイルを読むと、古い結果が予算の大半を占めてしまっていました。
拒否されたときは、そのことを伝える
モデルが無効として拒否したリクエストは、もう再試行しません。同じリクエストを送り直しても、同じ答えが返ってくるだけだからです。会話がモデルには長すぎる場合、利用者には次のように表示されます。「この会話はモデルが一度に読むには長くなりすぎました。次のステップは新しいチャットで始めてください。ここまでに作ったものはすべて残ります。」これは利用者が対処すべきことであり、障害ではないので、私たちにアラートは出しません。
問題2:エージェントが「余裕を確保する」ために止まった
40本の長い章を読むように頼まれたサブエージェントが、2本読んだところで、余裕を確保したいと言って止まりました。400,000トークンの予算のうち、約100,000トークンしか使っていませんでした。
古い結果が、自分のために消されるようになることを、エージェントは知る方法がありませんでした。そこで、それを伝えるようにしました。エージェントの指示には、会話がモデルの受け付けられる量を超えたとき、古いツールの結果は消され、新しい結果はそのまま残ると書かれています。そのため、余裕がないからといって長いタスクを止めてはいけません。作業を続け、消された内容が必要なら、そのファイルをもう一度読んでください。
モデルに見えない修正は、モデルの視点から見れば修正ではありません。その仕組みは、エージェントがそれを信頼して初めて役立ちます。
問題3:すべてが英語であるかのようにトークンを数えていた
トークンの見積もりは、トークナイザーを使わずに、文字数を4で割って行っていました。英語にはそれで妥当な目安ですが、ほかの文字体系には当てはまりません。ロシア語は1トークンあたり約2.2文字、中国語や日本語は約1文字です。
そのため、ロシア語の章を読むサブエージェントは、約640,000トークンのリクエストを送っていました。それなのに、私たちの見積もりは400,000の予算内に収まっていると判断していました。見積もりは、リクエストがモデルの絶対的な上限を超えていても、同じように気づかなかったかもしれません。
見積もりは今、文字体系ごとに数えるようになりました。ASCIIの通常の文字は4文字で1トークン、中国語・日本語・韓国語は1文字で1トークン、そしてキリル文字、ギリシャ文字、アラビア文字、アクセント付きの文字、絵文字などそれ以外は2文字で1トークンとしています。見積もりは多めになるように設計されています。あるロシア語の章では、今は実測の約44,600トークンに対して43,423トークンと見積もられます。以前は25,023トークンでした。メッセージは一度書かれたら変わらないので、その見積もりは毎ステップ計算し直すのではなく、一度だけ計算して覚えておくようにしています。
この件から学んだこと
- サイズの実態がある場所を切り詰めてください。 エージェントの作業では、それはユーザーメッセージではなく、ツールの結果です。
- エージェントに、その安全装置について伝えてください。 伝えなければ、もう適用されていない上限を前提に計画してしまいます。
- 「文字数÷4」は英語の前提です。 多言語のユーザー向けにトークンを数えるものは、文字体系ごとに数えるか、本物のトークナイザーを使う必要があります。
- キャッシュに注意してください。 毎回の呼び出しで先頭が変わるリクエストは、コストが高くなり、遅くなります。
ご自身のアプリにAI機能を組み込む場合は、LLMとは何かとLLM APIの使い方のガイドで、コンテキストウィンドウとトークンを最初から解説しています。Mythexでサブエージェントがどう動くかについては、サブエージェントのドキュメントをご覧ください。