TL;DR
長い会話を全部 LLM に渡すことはできないので、古いところを要約して畳む。言葉にすると 1 行だが、Kotonia のキャラチャットでは畳み方を 5 回作り直した。
- 窓からあふれた応答は、エラーも出さずに文の途中で切れていた
- 古いターンをすぐに捨てると prompt cache が崩れ、畳もうとしたターンのほうが落ちた
- 要約を毎回書き直す方式は、古い出来事ほど 0 に近い率でしか答えられなかった。GC の世代別回収と同じ「章を積み、たまに古い世代へ畳む」形にした
- 要約器が台詞と地の文を区別できず、嘘をつくキャラの自己申告が記憶の中で事実になっていた
- 字数とトークン数を取り違え、中国語の会話だけ記憶の末尾が削れていた
この記事の対象読者
AI キャラクターや長い会話を扱うアプリを作っていて、「長く話すとキャラが昔のことを忘れる」問題に向き合っている人に向けて書く。要約のプロンプトより、要約をどこに置き、いつ作り、どう測るかの話が中心になる。
前提: 1 回の会話が、モデルの窓より長い
Kotonia はキャラクターとロールプレイで話すサービスで、関係が続くこと自体が商品になっている。最長の会話は 5,000 メッセージを超える。
モデルが一度に読める量 (コンテキスト窓) には限りがある。いまの本番はローカルの GLM-5.3-Flash (llama.cpp) で、1 会話あたりの枠は 64k トークン。ここにキャラ設定・ペルソナ画像・会話履歴・これから書く応答が全部入る。
そして、当時の履歴の読み込みには LIMIT が無かった。毎ターン、会話の全行を積み直していた。
先に、できあがった形を 1 枚で見せておく。古いメッセージは要約されて「あらすじ」と「章」になり、直近は原文のまま渡る。この記事の残りは、図の各部品がなぜこの形になったかの話だ。
1. あふれても誰も気付かなかった
それまでのモデルは窓が 131k あり、履歴が長くなっても当たらなかった。2026-08-31 に今のモデルに替えた瞬間、当たり始めた。
当たり方が悪かった。画像入力のための --mmproj を積んだ llama-server では、窓があふれたときに古いトークンを捨てて続ける機能 (context shift) が強制的に切られる。窓に触れた時点で、生成がトークン単位で止まる。
ある会話では入力が 13,475 トークンから 32,767 トークンまで伸び、それ以降は毎ターン応答が文の途中で切れていた。backend は finish_reason を 1 箇所も見ていなかったので、エラーもログも出なかった。気付いたのは、応答の形式チェックの通過率が前日の 153/153 から 45/67 に落ちていたからだった。
「古いターンを切り落とせばいい」は、このサービスでは選べなかった。関係が続くことが価値なのに、最初に出会った日のことを捨てることになる。だから溢れた分は要約して畳むことにした。
2. 畳もうとしたターンが落ちた
最初の実装は素直だった。履歴が予算を超えたら、そのターンで古い部分を切り、要約に置き換える。
これで別のものが壊れた。llama-server は、前のターンとプロンプトの先頭が一致する部分を再計算せずに使い回す (prompt cache)。履歴の前半を落とすと先頭が変わるので、この使い回しが丸ごと外れる。本番のログはこうなった。
slot get_availabl: selected slot by LCP similarity, f_sim_best = 0.280 ← ふだんは 0.97
slot release: stop processing: n_tokens = 29071, truncated = 0 ← 30 秒ちょうどで打ち切り
29,000 トークンを読み直すのに、空いていれば約 29 秒、混んでいると 58 秒かかる。タイムアウトは 30 秒だった。しかも、混ませていた相手は同時に走らせていた要約ジョブだった。助けようとした会話を、助けるための処理が落としていた。
直し方は 3 つ。
トリガを超えても、そのターンでは畳まない。 畳み始める基準 (48,000 字、約 30k トークン) は 64k の窓の半分ほどなので、要約ができあがるまでの数ターンはそのまま流しても溢れない。畳むのは要約ができた次のターンの 1 回だけにし、prompt cache が外れるのもその 1 回にした。要約が何度も失敗したときのために、ここを超えたら仕方なく切る、という天井 (56,000 字) だけは別に置いた。
要約はバックグラウンドで、30 秒遅らせて作る。 要約 1 回は実測 30 秒前後かかる。会話の途中で 30 秒黙るわけにはいかない。遅らせるのは、いま走っているターンと GPU を取り合わないためだ。
要約はシステムプロンプトに入れない。 システムプロンプトは同じキャラの全会話で共有していて、そこが prompt cache の土台になっている。会話ごとの要約をそこに足すと、土台が会話ごとに割れる。要約は履歴の先頭に「ここまでのあらすじ」という合成の往復として差し込んでいる。冒頭の全体図で、キャラ設定のすぐ後ろに記憶が並んでいるのはこのためだ。
おまけの失敗も書いておく。backend のタイムアウトを 150 秒に伸ばしたのに、フロント側が 40 秒で通信を切っていた。片方だけ直しても何も変わらない。
3. 毎回書き直す要約は、古いものから忘れる
最初の要約は rolling 方式だった。畳むたびに「前回の要約 + 新しく溢れた分」から、要約 1 本を書き直す。
しばらく運用して、要約の中身を追ってみた。最初の版の要約にあった言葉は、4〜5 回畳むと 20〜30%、18 回畳むと 5% しか残っていなかった。書き直すたびに、要約器は直近の出来事を優先し、序盤の前提から捨てる。消えるのは「誰が誰の義母か」のような、話の土台になる情報だった。
世代別にする
開発者の発案で、ガベージコレクションの世代別回収と同じ形にした。
- 章 (若い世代): 畳んだ区間だけを要約した章を、1 本ずつ積む。一度書いた章は書き直さない
- 古い世代: 章の合計が 6,000 字を超えたら、古い半分の章を 1 本のあらすじに畳み込む (major GC)
書き直し、つまり目減りが起きるのは major GC のときだけになる。
記憶が育つと、そのぶん予算を食う。原文の履歴を同じ量残したまま記憶だけ増やすと、畳む間隔が短くなり、prompt cache が外れる重いターンが増える。そこで畳んだ直後の余白を固定し、記憶が増えた分だけ原文の履歴を短くしている (ただし今の場面が痩せないよう、原文は最低 16,000 字残す)。
忘れ方を測る
「良くなった気がする」で終わらせないために、オフラインの評価を作った。
- 実際の長い会話を、本番と同じ位置で区間に切る
- 区間ごとに、その区間で起きた事実の問答を LLM に 8 問作らせる
- 会話の最後の時点の記憶だけを渡して答えさせ、採点する
区間を古い順に並べれば、どれだけ前のことをどれだけ覚えているかが曲線になる。
| 方式 | 記憶の長さ (会話 1 / 2) | 平均再現率 (会話 1 / 2) |
|---|---|---|
| 毎回書き直す要約 | 761 / 845 字 | 0.11 / 0.21 |
| 書き直す要約・長め | 1,823 / 2,131 字 | 0.30 / 0.49 |
| 章を積む (世代別) | 3,817 / 4,409 字 | 0.56 / 0.69 |
毎回書き直す要約は、一番新しい区間だけを覚えていて、それより前はほぼ 0 だった。章を積む方式は、古い区間も半分前後を答えられる。
ただし、この差のどこまでが構造の効果かは言い切れない。世代別の記憶は長い。「長さを揃えた rolling」と比べるつもりで用意した腕も、長さを指示しても世代別の 4 割強までしか伸びなかった (指示を強めると今度は 5,000 字近くまで暴れた)。言えるのは、長くした rolling でも、古い区間ほど落ちる曲線の形は変わらなかったことまでだ。
評価の限界もほかに書いておく。
- 日本語の会話 3 本だけで、うち 2 本は同じ利用者・同じキャラ
- 問題を作るのも、答えるのも、採点するのも、要約するのも同じ LLM
- 測っているのは事実を答えられるかで、会話の手触りは測っていない
- 要約は毎回揺れる。同じ会話を 3 回回すと、記憶の長さは 4,785〜7,016 字まで振れた
それでも、0 に張り付いた曲線を見れば方式を変える理由としては十分だった。評価を回すときは、テストのバイナリを固定しておく必要がある。cargo test に毎回ビルドさせると、評価中に作業ツリーを触った瞬間に評価対象が変わってしまう (実際に踏みかけた)。
4. 嘘つきのキャラは、記憶の中で正直者になる
世代別にして 1 週間後、本番の章を読み返していて気付いた。
57 本中 44 本に「確定した事実として」という言い回しが入っていた。 中身を見ると、キャラの自己申告 (年齢や経歴) がそのまま事実として書かれている。ロールプレイのキャラは嘘をつく。秘密を隠す役も、経歴を偽る役もある。その台詞が、記憶を経由すると「確定した設定」に変わっていた。
原因は 2 つあった。1 つは要約器への入力で、キャラの応答を本文だけ空白でつないで渡していた。Kotonia のテキストモードは応答を JSON で受け取り、地の文・台詞 (話者つき)・口に出していない本音を区別して持っている。その区別を要約器の手前で捨てていた。もう 1 つは指示で、「後から参照される確定した事実」を書かせていた。
入力と指示を、こう変えた (例は架空)。
指示のほうは、作中の人物の主張を「誰が誰に何と言ったか」で書かせ、地の文か本音で裏付けられない限り事実として書かせない。嘘や演技が分かるものは「表向き」と「実際」を並べる。記憶の見出しにも「人物の発言は、その人物がそう言ったという記録であって、真実とは限らない」と書いた。
やりすぎて、ユーザーの話を落とした
最初の修正案は「地の文で起きたこと」と「人物の主張」の 2 つに分けるものだった。評価にかけると、日常会話寄りの会話で再現率が 0.72 から 0.50 に落ちた。
その会話で大事な事実のほとんどは、ユーザー本人が自分について話したことだった。明日の予定、好きなもの、友人の名前。これは地の文でもキャラの主張でもないので、どちらの欄にも入らずに落ちていた。疑うべきなのは作中の人物の発言だけで、ユーザーが自分について語ったことは事実として扱う、と書き足した。
本番で数え直す
評価には「監査」も足した。章を元の区間と突き合わせ、事実として書いてしまった主張と、解決済みなのに未解決として残した話題を数える。日本語 3 本で、主張を事実として書いた文は 17 → 6、再現率は 0.68 → 0.73 だった。
本番の章でも数え直した (2026-10-27)。
| 修正前 (66 本) | 修正後 (67 本) | |
|---|---|---|
| 「確定した事実」を含む | 38 | 0 |
| 「解決:」で話題を閉じている | 0 | 33 |
| 「〜と言った / 語った / 主張」の帰属表現 | 5 | 16 |
ただし、解決済みの話題を未解決に残す問題は、評価で 10 → 11 とまったく直っていない。指示を書き足すだけでは効かなかった。
5. 細かい落とし穴
拒否を n=1 で説明した
要約器はセンシティブな会話で、ときどき「その記録は作成できません」と断る。最初に 1 件見たとき、「指示をプロンプトの末尾に置くと拒否される」と結論してコードのコメントにも書いた。数え直すと、拒否は 18 回中 2 回 (約 11%) で、指示の置き場所とは関係がなかった。同じプロンプトを一字一句再現しても 3 回とも通る。低い確率で引かれるだけだった。
なので、プロンプトではなくコードで受け止めている。短くて定型句を含む出力は拒否と見なして 1 回引き直す (実際の拒否は 101 字、通った要約の最短は 519 字だったので、境目を 400 字にした)。拒否文を要約として保存してはいけない。 保存すると、以後毎ターン、あらすじの欄に「お手伝いできません」が居座る。
ちなみに要約器には「物語の記録係」という作中の役を与えているが、これは拒否を減らすためではない。素直に「要約せよ」と頼むと、要約ではなく物語の続きが返ってくることがあったからだ。
字とトークンを取り違えた
Rust 側にはトークナイザを持っていないので、予算は字数で数えている。換算は実測で 1.57 字/トークン。ところがこれは、JSON を含む入力側の値だった。出力の要約では、日本語 1.2〜1.3、中国語 1.2、英語 4.7 字/トークンで、言語によって 4 倍違う。
major GC の出力上限を「目標 1,500 字 + 400」として 1,900 トークンにしていたので、中国語のあらすじは 11 本中 5 本が文の途中で切れていた。切れたあらすじは次の major GC の入力になる。あらすじの最後には未解決の話題を書かせているので、まだ終わっていない話から順に削れていく。上限は、切れないための天井 (4,096 トークン) に変えた。
窓の広いモデルには畳ませない
上の 48,000 字はローカルのモデルのための数字で、決めているのは窓の大きさより、prompt cache が外れたときに読み直す時間だ。窓が 1M あるクラウドのモデルに同じ基準を当てると、原文で持てる会話をわざわざ要約に置き換えることになる。DeepSeek / Gemini / Grok は約 200,000 字まで原文で持つようにした。
逆に、ローカルの要約器は大きな範囲を一度に読めない。クラウドで長く原文を持った会話がローカルに切り替わると、一度に 15 万字を畳むことになる。そのときは区間を 32,000 字ごとに割り、複数の章として古い順に積んでいる。
6. 本番での実績と、まだ直っていないこと
2026-10-10 から記録をつけている (10-27 時点)。
| 回数 | 所要時間の中央値 | 出力の平均字数 | |
|---|---|---|---|
| 章を書く | 133 | 27 秒 | 1,110 字 |
| major GC | 17 | 34 秒 | 4,123 字 |
| 天井で強制的に切った | 3 | — | — |
1 回で畳む量は平均で約 17,000 字。記録期間中、拒否で要約に失敗した回は 0 (引き直しで吸収できている)。
正直に言うと、まだ当たっている会話は多くない。コンパクションが走った約 40 の会話のうち、28 は開発者自身のものだ。長く話した人だけが当たる仕組みなので、利用者が育つほど効いてくる。
直っていないことも並べておく。
- 解決済みの話題が「未解決」に残る
- 英語と中国語のあらすじは、目標の長さを守らず major GC のたびに太っていく
- 評価は日本語だけで、会話の手触りは測っていない
- 事実だけを追記していく台帳や、決め台詞を原文のまま残す仕組みはまだ無い
おわりに
要約のプロンプトを磨く前に効いたのは、要約をどこに置くか (cache を割らない位置)、いつ作るか (ターンの外で、ずらして)、何を書き直さないか (章は積むだけ) という、置き方の判断だった。そして、その判断を変えられたのは、忘れ方を曲線で見たからだった。
0 に張り付いた線を一度見てしまうと、「要約しているから大丈夫」とはもう言えない。
