~14 min · master-chief, context-compaction, summarization, growth
Level 0구경꾼
0 XP0/44 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete
상한도 자라고 있었어
레슨 1은 마지막 턴이 없는 대화에서 자라는 걸 여섯 개 꼽았고, 재생엔 압축이라는 상한을 줬어. 오래된 행들을 요약 하나로 바꾸는 거. 근데 그 요약 자체를 목록에 올린 사람은 없었어. 2026-10-22에 Master Chief 리뷰(Coop todo #597, M2 지적)가 올렸어. 요약이 턴마다 실리는 것 중에 꽤 큰 덩어리가 돼 있었거든.
이유는 그때 적혀 있던 레슨 3의 3단계에 있었어. 백그라운드 패스는 최신 40행보다 오래된 걸 전부 요약했는데, 그중 제일 오래된 행이 바로 앞 패스가 쓴 요약이었어. 그러니까 패스마다 앞 요약이 행 하나로 다시 청크 나누기에 들어간 거야. 청크는 통째의 행을 18,000자쯤까지 묶었어. 그보다 훨씬 긴 요약은 어디에도 같이 못 들어가니까 통째로 혼자 청크가 됐고, 모델은 그걸 새 행들의 청크 옆에서 또 요약했어. 복사본을 또 복사하면 앞서 묻은 얼룩이 다 따라오듯이, 요약마다 그 앞 요약들을 품고 다녔어.
리뷰가 잰 것
Chief의 요약 16개는 114 KB에서 524 KB까지 갔다가 150~330 KB에 자리 잡았어. 턴마다 깔고 들어가는 입력 약 281K 토큰의 4분의 1에서 3분의 1이야.
제일 새 요약 안에 앞선 두 세대 요약의 청크 제목이 22개나 남아 있었어.
식별자 목록은 1,562개 중 위치상 앞의 120개만 남겼어. 그래서 요약마다 머리엔 첫 주의 Wikipedia랑 Polygon URL이 앉아 있었고, 새 식별자는 전부 "…and N more"로 밀려났어.
도구 목록엔 heredoc 명령이 800자씩 통째로 실렸어. 턴마다 17 KB였어.
그리고 경쟁 조건도 하나 있었어(m4 지적). 다른 패스가 도는 중에 요청된 패스가 시작해 보니 살아 있는 행이 60개였고, 방금 그 패스가 쓴 요약을 또 요약했어.
토큰으로 보면 이래. 2026-10-22까지 한 주 동안 Chief에서 아빠 턴 하나의 입력은 평균 356.6K 토큰이었고, 그중 82K쯤이 압축 요약이었어. 턴마다 그만큼을 또 냈어.
합치기만 하고, 청크로 다시 안 돌려
고친 건(커밋 2e35ef04) 패스가 찾아낸 요약을 어떻게 다루느냐야.
요약할 행들 중 요약 행은 서 있는 요약이야. 다시는 청크 나누기에 안 들어가.
청크로 잘라 요약하는 건 새 행뿐이야. 레슨 3 그대로 넷씩 한꺼번에 돌리고, 청크마다 글의 해시를 이름 삼아 디스크에 캐시해.
_merge_standing이 유틸리티 요청 한 번으로 새 청크 요약들을 서 있는 요약에 합쳐. 프롬프트가 달라는 건 갱신된 요약 하나야. 뒤 턴에서 풀리거나 닫히거나 바뀐 건 결과만 한 줄로 접으라고 해. 아빠의 상시 결정, 남은 일, 거기 필요한 식별자는 지키고, 청크 제목이나 중복은 남기지 말라고 하고. 합친 결과도 청크처럼 디스크에 캐시돼. 패스가 하는 요청 중 제일 커서 시간 제한도 제일 길어 (300초).
모델이 합치기를 못 쓰면(오류든 빈 답이든) 잃는 건 없어. 서 있는 요약이랑 새 청크 요약들이 ## Since the last summary 아래 나란히 남고, 다음 패스가 합쳐.
식별자 목록과 도구 목록은 새 행만으로, 최신부터 다시 지어. 식별자는 최신 120개를 남기고 몇 개가 더 있었는지 적어. 도구 노트는 하나에 한 줄이야. 글은 240자에서 자르고, 그 뒤에 얼마나 뺐는지 덧붙여. 청크 프롬프트는 여전히 긴 발췌를 받고, 명령 전체는 행에 남아 있어.
직전 패스가 남긴 어림으로 재생이 이미 Chief의 선 아래면, 패스는 이 중 아무것도 안 하고 멈춰. m4 경쟁은 이걸로 닫혔어.
테스트 파일 tests/test_compaction_standing_summary.py가 하나하나 못 박아. 합치기만 하고 다시 안 자르기, 옛 목록 안 돌려주기, 최신부터 식별자, 나란히 두는 대체 경로, 한 줄 노트, 선 아래면 멈추는 패스.
한도를 걸면 안 돼?
요약에 크기 한도를 거는 건 코드 한 줄이야. 이 수정은 그 한 줄을 일부러 안 썼어. 토큰 한도에 대한 아빠 규칙이 있거든. 2026-09-20에 한 말이야.
왜 한도를 넣냐고? 알아서 쓸만큼 쓰라고 해. — 아빠
한도는 과거를 얼마나 남길지 미리 정해 버려. 게다가 위치로 잘라. 식별자 목록이 앞의 120개 뒤로 전부 잃어버린 게 바로 그 방식이었어. 합치기 프롬프트는 한도가 못 하는 걸 해 달라고 해. 아직 열린 거랑 아빠가 정한 건 지키고, 끝난 건 접고, 아무것도 되풀이하지 말라고. 재생에서 빠진 행은 대화록과 리콜에 그대로 남으니까 (레슨 5), 끝난 일의 세부를 버려도 다시 못 찾는 건 없어.
그래도 드는 값
값은 솔직하게 적자. 서 있는 요약을 만난 패스는 요청을 한 번 더 써. 그것도 제일 큰 걸로. 합치기는 뭘 접을지에 대한 모델의 판단이고, 판단엔 손실이 있어. 합치기가 연달아 실패하면 나란히 놓인 부분이 성공할 때까지 자라. 그리고 커밋은 고치기 전 크기만 적었고, 고친 뒤 크기는 안 적었어. 이제 지켜볼 숫자는 패스가 지날 때마다 턴 입력에서 요약이 차지하는 몫이야.
상한도 자랄 수 있어. 뭔가를 자기 요약으로 바꿔서 묶었다면, 다음 패스에서 그 요약이 어떻게 되는지 물어. "새것들이랑 같이 또 요약된다"가 답이면, 요약은 조상을 전부 업고 다녀. 거기에 합쳐. 새것들이랑 같이 다시 넣지 마.
Code
원문, backend/services/context_compaction_guard.py, 2026-10-27 기준: 서 있는 요약과 새 청크 요약을 요청 한 번으로 합치고, 모델이 못 하면 나란히 둬·python
async def _merge_standing(standing_rows: list[Any], chunk_summaries: list[str], *,
cache_dir: Path | None = None) -> list[str]:
"""The body of a pass that found an earlier summary: the standing summary
merged with the new chunk summaries by one model request, cached on disk
like a chunk. When the model is unavailable or answers nothing, the
standing summary and the new chunk summaries side by side: nothing is
lost, and the next pass merges them."""
standing = "\n\n".join(body for body in (_standing_body(m) for m in standing_rows) if body)
if not chunk_summaries:
return [standing] if standing else []
if not standing:
return chunk_summaries
prompt = _build_merge_prompt(standing, chunk_summaries)
cache_path = _cache_path(cache_dir, "merge\n" + prompt)
if cache_path is not None:
try:
cached = cache_path.read_text(encoding="utf-8").strip()
if cached:
return [cached]
except OSError:
pass
try:
merged = (await _call_utility_model_complete(
prompt,
system=(
"You keep the standing summary of a long conversation current. Merge, "
"fold what later turns resolved, and keep what is still open. Return only "
"the updated summary."
),
temperature=0.1,
timeout=MERGE_TIMEOUT_SECONDS,
)).strip()
except Exception as exc: # noqa: BLE001
logger.warning("compaction merge failed: %s", exc)
merged = ""
if not merged:
return [standing, "## Since the last summary", *chunk_summaries]
if cache_path is not None:
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(merged, encoding="utf-8")
except OSError:
logger.debug("compaction merge cache write failed", exc_info=True)
return [merged]
돌려볼 수 있는 최소 스케치: 옛 요약을 청크 나누기에 다시 넣는 패스 여덟 번과 거기에 합치는 패스 여덟 번 (모양은 진짜인 장난감 요약기, 표준 라이브러리, Python 3.9 이상)·python
"""A minimal sketch: a pass that hands the old summary back to the chunker
nests it; a pass that merges into it keeps one. Toy summarizer, real shape."""
CHUNK_CHARS = 60 # stands in for the real threshold of 18,000 characters
def chunks_of(rows: list[str]) -> list[list[str]]:
# Whole rows, a chunk closing before the row that would push it past the
# threshold; a row longer than that (an old summary, soon) rides alone, whole.
chunks: list[list[str]] = []
current: list[str] = []
size = 0
for row in rows:
if current and size + len(row) > CHUNK_CHARS:
chunks.append(current)
current, size = [], 0
current.append(row)
size += len(row)
if current:
chunks.append(current)
return chunks
def summarize_chunk(rows: list[str], n: int) -> list[str]:
# Like the real model: what already reads as a summary is kept, every
# event becomes a bullet, and chatter is dropped.
lines = [line for row in rows for line in row.splitlines()]
kept = [line for line in lines if line.startswith(("## ", "- "))]
events = [f"- {line}" for line in lines if line.startswith(("open", "closed", "Dad ruled"))]
return [f"## Chunk {n}"] + kept + events
def rechunk_pass(summary: list[str], rows: list[str]) -> list[str]:
# Before: the standing summary goes back in as one more row, the oldest.
material = (["\n".join(summary)] if summary else []) + rows
out: list[str] = []
for n, chunk in enumerate(chunks_of(material), start=1):
out += summarize_chunk(chunk, n)
return out
def merge_pass(summary: list[str], rows: list[str]) -> list[str]:
fresh: list[str] = []
for n, chunk in enumerate(chunks_of(rows), start=1): # only the new rows are chunked
fresh += summarize_chunk(chunk, n)
# The one merge request: fold what later turns resolved, keep what is
# open and what Dad ruled, one summary with no chunk headings.
items = [line for line in summary + fresh if line.startswith("- ")]
closed = {line.removeprefix("- closed ") for line in items if line.startswith("- closed ")}
items = [line for line in items if line.removeprefix("- open ") not in closed]
return ["## Standing summary"] + list(dict.fromkeys(items))
def rows_for(k: int) -> list[str]:
return [f"open #{k}", "chatter", f"closed #{k - 1}", "chatter", f"Dad ruled R{k}", "chatter",
"chatter", "chatter", "chatter", "chatter", "chatter", "chatter"]
nested: list[str] = []
merged: list[str] = []
print("pass re-chunked: lines / chunk headings merged: lines / chunk headings")
for k in range(1, 9):
nested = rechunk_pass(nested, rows_for(k))
merged = merge_pass(merged, rows_for(k))
heads = lambda summary: sum(line.startswith("## Chunk") for line in summary)
print(f"{k:>4} {len(nested):>13} / {heads(nested):<16} {len(merged):>11} / {heads(merged)}")
Exercise
굴러가는 요약을 들고 있는 게 있으면(채팅 기억, 변경 기록 요약, 이어 쓰는 보고서), 실제 입력이나 재생한 입력으로 스무 세대를 돌리면서 세대마다 요약 크기를 적어. 그다음 제일 새 요약 안에서 사건이 아니라 요약에서 온 글을 찾아봐. 제목들, "앞서" 같은 말, 오래전에 풀린 항목. 크기가 오르거나 그런 찌꺼기가 쌓이면, 옛 요약을 새 사건만의 요약이랑 합치는 식으로 단계를 바꾸고 스무 세대를 다시 돌려.
Hint
크기는 시간 말고 세대에 대고 그려. 패스마다 조금씩 크는 요약은 몇 주 동안 멀쩡해 보여. 합치기 단계엔 풀린 항목(결과만 한 줄로)과 열린 항목(통째로 유지) 규칙을 대놓고 줘. 그리고 합치기가 실패하면 두 조각을 나란히 저장하는 대체 경로를 둬. 그래야 운 나쁜 날에도 잃는 건 기억이 아니라 공간뿐이야.
Progress
Progress is local-only — sign in to sync across devices.