Kotonia
ログイン今すぐ始める

Kotonia Articles

キャラの記憶を要約したら、嘘つきが正直者になった

長く話すほどAIキャラが昔のことを忘れる。会話を要約して畳む「コンパクション」をキャラチャットに入れたら、要約の形式が5回壊れた。毎回書き直す要約は古いものから忘れ、嘘をつくキャラの台詞は記憶の中で事実になった。GCの世代別回収を持ち込んだ設計と、忘却を測った方法をまとめる。

著者 8分で読める
#llm#character-chat#memory#compaction#rust

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 字) だけは別に置いた。

履歴の字数は 48,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 のときだけになる。

毎回書き直す要約では序盤の区間の取り分が畳むたびに減る。章を積む方式では章が積み上がり、合計が 6,000 字を超えたときだけ古い半分があらすじに畳まれる

記憶が育つと、そのぶん予算を食う。原文の履歴を同じ量残したまま記憶だけ増やすと、畳む間隔が短くなり、prompt cache が外れる重いターンが増える。そこで畳んだ直後の余白を固定し、記憶が増えた分だけ原文の履歴を短くしている (ただし今の場面が痩せないよう、原文は最低 16,000 字残す)。

畳んだ直後の 48,000 字の内訳。記憶が育つと原文の履歴が短くなり、余白 16,000 字は常に同じ

忘れ方を測る

「良くなった気がする」で終わらせないために、オフラインの評価を作った。

  1. 実際の長い会話を、本番と同じ位置で区間に切る
  2. 区間ごとに、その区間で起きた事実の問答を LLM に 8 問作らせる
  3. 会話の最後の時点の記憶だけを渡して答えさせ、採点する

区間を古い順に並べれば、どれだけ前のことをどれだけ覚えているかが曲線になる。

区間ごとの再現率。毎回書き直す要約は古い区間ほど 0 に近く、章を積む方式は古い区間も 0.5 前後を保つ

方式記憶の長さ (会話 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 本)
「確定した事実」を含む380
「解決:」で話題を閉じている033
「〜と言った / 語った / 主張」の帰属表現516

ただし、解決済みの話題を未解決に残す問題は、評価で 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 時点)。

回数所要時間の中央値出力の平均字数
章を書く13327 秒1,110 字
major GC1734 秒4,123 字
天井で強制的に切った3——

1 回で畳む量は平均で約 17,000 字。記録期間中、拒否で要約に失敗した回は 0 (引き直しで吸収できている)。

正直に言うと、まだ当たっている会話は多くない。コンパクションが走った約 40 の会話のうち、28 は開発者自身のものだ。長く話した人だけが当たる仕組みなので、利用者が育つほど効いてくる。

直っていないことも並べておく。

  • 解決済みの話題が「未解決」に残る
  • 英語と中国語のあらすじは、目標の長さを守らず major GC のたびに太っていく
  • 評価は日本語だけで、会話の手触りは測っていない
  • 事実だけを追記していく台帳や、決め台詞を原文のまま残す仕組みはまだ無い

おわりに

要約のプロンプトを磨く前に効いたのは、要約をどこに置くか (cache を割らない位置)、いつ作るか (ターンの外で、ずらして)、何を書き直さないか (章は積むだけ) という、置き方の判断だった。そして、その判断を変えられたのは、忘れ方を曲線で見たからだった。

0 に張り付いた線を一度見てしまうと、「要約しているから大丈夫」とはもう言えない。

Kotonia は音声AI、AIチャット、画像生成、チーム共有をひとつにまとめたAIワークスペースです。

試してみる