콘텐츠로 이동

핸드오프

컨텍스트가 거의 찼을 때, 지금 이 세션이 직접 사람이 읽고 고칠 수 있는 핸드오프 문서를 쓰게 하고, 새 세션을 열어 넘겨받게 한다. compact는 쓰지 않는다. 전 과정이 터미널에서 보이고, 문서는 디스크에 떨어진다. 고치고 싶으면 언제든 고쳐도 된다 —— 넘겨받는 세션이 읽는 것이 바로 그 파일이다.

핸드오프는 연속성이 아니다

핸드오프같은 런 내부에서세션으로 바꾸는 것이고, 연속성프로세스를 넘어 지난 에 이어 붙이는 것이다. 연속성을 보라.

둘은 자동으로 맞물리며 별도 배선이 필요 없다. 리니지가 기록하는 것은 그 스텝의 마지막 session_id이고, 그것이 바로 후임 세션이다 —— 그래서 다음 웨이크는 후임에 이어 붙지, 소진된 세대에 붙지 않는다.

무엇을 해결하는가

SDK에 내장된 auto-compact는 윈도 −33k 지점에서 발동하고(실측: 윈도가 200000일 때 임계값은 167000. 압축 알고리즘 자체는 harness 바이너리 안에 있어 바꿀 수 없고, 바꿀 수 있는 건 발동 여부뿐이다), 하는 일은 히스토리를 한 단락으로 요약하는 것이다. 이건 이 프레임워크의 나머지 부분과 어긋나 있다:

언제 결정하는가 무엇을 남기는가
spill_guard 툴이 반환하는 그 순간 큰 결과는 스필하고, 컨텍스트에는 경로 한 줄만 남김
동결물(브리프 / 목표) 그 스텝이 끝날 때 문서 하나. 다음 스텝은 그것만 읽음
auto-compact 컨텍스트가 찬 뒤에야 뒤돌아봄 모델이 스스로 쓴 요약 한 단락

flower가 처음부터 끝까지 하는 일은 그 자리에서 무엇을 남길지 결정하는 것이다. compact는 유일한 "사후 수습"이고, 그 산출물에는 네 가지 결함이 있다:

  • 모델이 생성한 것 —— 요약에 무엇을 쓸지는 그때 모델의 판단이 정한다. 당신은 관여하지 않는다
  • 읽을 수 없다 —— 다음 턴의 모델을 위해 쓰인 것이지 사람을 위해 쓰인 게 아니다
  • 고칠 수 없다 —— harness 안에 있고, 당신이 열어볼 파일이 하나도 없다
  • 무엇이 버려졌는지 모른다 —— 무엇이 버려졌는지도 보이지 않고, 버려지기 전에 "그건 버리지 마"라고 말할 수도 없다

핸드오프는 이 일을 같은 방식으로 되돌린다. 또 하나의 동결물이고, 브리프·목표와 같은 형태다 —— 구조화되어 있고, 디스크에 떨어지며, 열어서 한 줄 고치고 다시 돌릴 수 있다. 그리고 이건 이 프로젝트 자신이 쓰는 방식이기도 하다 —— 저장소 루트의 HANDOFF.md는 사람이 쓴 같은 종류의 물건이다.

어떻게 쓰나(최소 코드)

커맨드라인은 기본으로 핸드오프가 켜져 있다:

flower                       # 默认就带换代
flower --window 200000       # 判错了才需要给(默认 100 万)
flower --no-handoff          # 关掉 —— 退回 SDK 自带的 auto-compact

직접 코드를 쓸 때 핸드오프는 Runtime(handoff=…)가 제어하며 기본값은 True다:

from flower import HandoffPolicy, Runtime

# 기본값: HandoffPolicy(enabled=True, window=default_window(), headroom=50_000, max_generations=8)
rt = Runtime(workspace=".", run_dir="runs", workbench=True)

# 윈도를 명시적으로 설정(게이트웨이나 모델을 바꿀 때 가장 먼저 조정해야 하는 값)
rt = Runtime(
    workspace=".",
    run_dir="runs",
    workbench=True,
    handoff=HandoffPolicy(window=200_000, headroom=50_000, max_generations=8),
)

rt = Runtime(workspace=".", run_dir="runs", handoff=False)   # 끄면 auto-compact로 되돌아감

Runtime.__init__는 전부 keyword-only이고 workspace는 필수다. handoffHandoffPolicy 인스턴스나 bool을 받으며, bool을 주면 HandoffPolicy(enabled=…)와 같다.

핸드오프를 켜면 = auto-compact가 강제로 꺼진다

Runtime(handoff=True)기본값이고, 매 시도를 조립할 때 이 일을 한다. handoff.enabled이고 spec.compact is None이면 spec을 CompactPolicy(mode="no_summary")로 바꾼다 —— 즉 자식 프로세스에 DISABLE_AUTO_COMPACT=1을 주입한다.

이유는 두 메커니즘이 동시에 돌면 어느 시점의 컨텍스트 하락이 누구 짓인지 설명할 수 없기 때문이다. 대가는 안전망이 없다는 것이다. 핸드오프 문서를 쓰는 그 턴이 실패해도 멈출 수 없고, 아무 일 없는 척 하드 상한까지 버틸 수도 없다. 그래서 반드시 폴백 경로가 있어야 한다(아래 참조).

auto-compact를 최후의 보루로 남기려면 명시적으로 AgentSpec(compact=CompactPolicy(mode="auto"))를 줘야 한다 —— spec이 직접 값을 주면 그것을 존중하고 덮어쓰지 않는다. 이 경우 핸드오프 쪽의 가정을 조용히 이긴다는 점에 주의하라.

실제로 하는 일

발동 시점: 핸드오프로 들어가는 두 경로

하나, 수위가 임계값에 닿을 때. 판정은 _handoff_due다. handoff.enabled이고 핸드오프 문서를 쓰는 턴이 아니며 _ctx >= handoff.at이고 이 스텝이 이미 session_id를 받았을 것. _ctx메인 스레드가 마지막 턴에 실제로 본 컨텍스트 규모다 —— 메인 스레드만 본다. subagent의 컨텍스트는 자기 transcript의 문제고, 끝나면 흩어진다. 메인 스레드를 세대 교체시킬 이유가 없다.

warn_at 지점에서 먼저 접근 경고를 한 번 보낸다. 세대당 한 번만 보내고 화면을 도배하지 않는다.

둘, API가 직접 "못 담는다"고 알려올 때. 아래 is_overflow 절을 보라.

두 경로 모두 max_attempts의 제약을 받지 않고, 재시도 횟수도 소모하지 않는다(내부적으로 attempt -= 1) —— 핸드오프는 실패가 아니다.

핸드오프 문서: 다섯 섹션, 필수는 둘뿐

각 섹션은 "넘겨받은 사람이 저지를 실수" 한 종류씩을 막는다:

섹션 필드 무엇을 막는가
지금 무엇을 하고 있는가 doing 필수 자기가 어디 서 있는지 모르는 것
이미 정해진 것 decided 이미 정한 일을 다시 논의하는 것(를 함께 적을 것)
막힌 길 deadends 가장 비싼 섹션 —— 아래 참조
다음 스텝 next 필수 무엇을 할지 정하는 데 먼저 30분을 쓰는 것
현장 scene 핵심 파일과 산출물의 경로. 포인터이지 내용이 아님

Handoff.missing()REQUIRED = ("doing", "next") 이 둘만 검사하고, complete()는 그 부정이다. "막힌 길"을 반드시 비어 있지 않게 요구하면 지어내기를 유발한다 —— 태스크 초반에는 원래 비어 있는 게 맞다. 게다가 complete() 판정에는 결과가 따른다. 필수 섹션이 빠지면 이 핸드오프는 통째로 기계적으로 조립한 폴백 문서로 교체된다 (아래 참조). 그건 한 섹션 덜 쓴 진짜 핸드오프보다 훨씬 나쁘다. 그래서 나머지 세 섹션은 선택이다 —— 쓰면 쓸모가 있고, 안 써도 핸드오프를 막지 않는다.

to_markdown()에서 빈 섹션은 (空)으로 쓴다. prompt_block()의 머리말은 넘겨받는 쪽에게 "당신은 인계를 받는 중"이라고 분명히 알려서, 사람에게 배경을 되묻지 않게 한다. step 필드는 문서 머리말에만 쓰이고 파싱에는 관여하지 않는다.

"막힌 길"이 왜 가장 비싼가

넘겨받는 사람이 다시 발견하는 데 가장 돈이 많이 드는 것인데, 쓰는 사람이 가장 빠뜨리기 쉽기 때문이다.

워커에게는 체계적인 낙관 편향이 있다(목표 가드가 같은 것을 논증했다). 자기가 무엇을 해냈는지는 쓰지만, 무엇을 시도했다가 안 됐는지는 쓰지 않는다. 그리고 진짜 비싼 건 후자다 —— HT002에서는 컴파일 문제 하나로 한 시간을 돌았고, 그 한 시간의 결론을 적어두지 않았다면 넘겨받은 쪽이 똑같이 다시 한 바퀴 돌았을 것이다.

그래서 HANDOFF_PROMPT에는 이 점을 짚는 단락이 따로 있고, 그 실측 대가까지 함께 적혀 있다.

실제 핸드오프 문서는 이렇게 생겼다

# 上下文 130.0K/200K · 还有约 20K 到换代

# 上下文 152.0K/200K —— 写交接准备换代
  - 现在在做  在给 Makefile 加 macOS 垫片头,让 sigemptyset 宏不再展开成语法错误。
  - 已定的事  不改业务源码 —— 用户明确说过边界,所以走 Makefile 生成 shim 这条路。
  - 走不通的  -D_ANSI_SOURCE 会把别的宏一起关掉;改 include 顺序无效。
  - 下一步    在干净 clone 上跑一次 make 验证 shim 成立。
<- 交接写在 ~/proj/.flower/notes/交接-干活.md
<- 新会话接手,上下文从 152.0K 重新开始

완전 자동이고, 멈춰서 당신을 기다리지 않는다 —— 장기 실행이 사람이 밥 먹으러 갔다고 멈춰서는 안 된다.

이벤트는 Event("handoff")이고 payload["phase"]에는 값이 있다. near(접근), writing(작성 중. 핸드오프 문서 작성에는 십수 초가 걸려서, 이걸 안 보내면 UI가 멈춘 것처럼 보인다), done(교체 완료). done의 payload에는 context, window, degraded, path, sections도 실린다.

임계값 계산

at      = max(10_000, window - headroom)   # 핸드오프 라인, 하한 10k
warn_at = max(1_000, at - 20_000)          # 접근 경고 라인

at의 10k 하한은 필수다 —— 그보다 낮으면 핸드오프 문서조차 쓸 수 없다.

아래 눈금 그림은 --window 200000을 예로 든 것이다. 기본 윈도는 100만이다:

  0--------------------------------------|-----|--------------|
                                       130K  150K           200K
                                        경고  교체        하드 상한

window를 주지 않으면 default_window()모델 이름 문자열로 판단하며, ANTHROPIC_MODEL 또는 ANTHROPIC_DEFAULT_OPUS_MODEL 두 환경 변수만 본다:

모델 이름 판정
이름에 독립된 1m 토큰이 있음 1_000_000
이름에 haiku 포함 200_000
그 외, 그리고 두 변수가 모두 설정되지 않은 경우 1_000_000

순서에 주의하라. 1m이 먼저 매칭되므로 claude-haiku[1m]은 20만이 아니라 100만으로 판정된다.

headroom이 왜 50_000인가. auto-compact는 윈도 −33k에서 발동하므로 핸드오프는 그보다 먼저 도착해야 하고, "핸드오프 문서 쓰기" 자체도 한 턴을 더 돌아야 한다. 50k는 이 두 가지를 동시에 만족한다.

--window는 모델이나 게이트웨이를 바꿀 때 가장 먼저 조정해야 할 스위치다. SDK 쪽에서는 신뢰할 만한 윈도 크기를 얻을 수 없어서 이름으로 추측할 수밖에 없다. 실제 윈도가 더 크면 → 교체가 이르다(낭비지만 오류는 아님). 더 작으면 → 늦어서 반드시 조정해야 한다. 실측 하나가 언급할 만하다. 개발 머신의 게이트웨이 설정은 claude-opus-5[1m]이었다. 예전처럼 20만으로 계산했다면 15만마다 한 세대씩 교체했겠지만, 실제로는 95만까지 갈 수 있었다 —— 5배 차이다. 장기 실행 작업이 잘게 토막 난다.

flower -v로 실행 전에 현재 적용 중인 자격증명 설정(엔드포인트, 모델 이름, 토큰은 앞 4자리만 남기고 마스킹)을 볼 수 있다.

is_overflow: 하드 에러를 즉석 핸드오프로 바꾼다

이것이 default_window()의 기본값을 감히 100만으로 잡을 수 있는 전제다.

윈도를 크게 잘못 잡으면 임계값에 영원히 닿지 못하고, auto-compact도 꺼져 있으니 API에 그대로 들이받는다. is_overflow(*texts)가 이 신호를 인식한다. prompt is too long, context length exceeded, maximum context length, too many total text bytes, input length and max_tokens exceed 등이다.

인식한 뒤에는 같은 핸드오프 경로를 탄다. 다만 이 세대의 핸드오프는 필연적으로 폴백 버전이다 —— 그 세션은 이미 "핸드오프 문서를 한 턴 더 쓸" 여력이 없으므로, 기계적으로 조립한 폴백 문서를 바로 쓰고 평소처럼 새 세션으로 이어간다. 이 스텝은 실패하지 않는다.

그래서 윈도를 크게 잡은 대가는 "이 스텝이 실패한다"에서 "이 세대의 핸드오프가 폴백이다"로 낮아진다.

is_overflowHandoff의 메서드가 아니라 모듈 레벨 함수이며, 가변 인자를 받는다.

핸드오프 문서를 못 쓸 때: 멈추지 말고 폴백

핸드오프 문서를 쓰는 턴도 실패할 수 있다. 네트워크가 끊기거나, 모델이 이상해지거나, 파싱 결과에 필수 섹션이 빠지거나. auto-compact가 이미 꺼져 있으니 최후의 보루가 없고, 여기서 멈추면 윈도에 들이받는 것과 같다.

방식은 이렇다. 손에 있는 것만으로 불완전한 핸드오프 문서를 기계적으로 조립하고, doing[降级:交接没写成] (상수 DEGRADED)를 표시하고, scene에 원래 태스크의 앞 1200자를 넣은 다음, 그대로 세대를 교체한다. 넘겨받는 쪽은 자기가 받은 것이 불완전하니 직접 현장을 보라는 말을 분명히 듣는다. 동시에 StepResult.errors에 "交接降级(…)" 한 줄이 추가되고, manifest.json에서 이유를 확인할 수 있다.

대응하는 것은 모듈 레벨 함수 degraded(step, prompt, *, why="")이고, Handoff.degraded는 읽기 전용 property로 doing에 그 표시가 있는지를 판정한다.

불완전한 핸드오프가 윈도에 들이받는 것보다 훨씬 낫다.

핸드오프 문서를 쓰는 턴에는 의도적인 장치가 둘 더 있다. max_budget_usd=None으로 돌린다 —— 핸드오프는 반드시 쓸 수 있어야 하고 예산에 걸려서는 안 된다. 그리고 on_event=None —— 이 턴은 UI로 흘리지 않는다.

지뢰 하나: 핸드오프를 쓰는 턴은 반드시 임계값에서 면제되어야 한다

핸드오프 작성은 선을 넘은 뒤에 돌아간다 —— 그때 수위는 당연히 임계값 위에 걸려 있다. 면제하지 않으면 그 턴의 첫 메시지에서 또 "교체할 때다"라고 판정되고, 결국 한 글자도 못 쓴 채 끊겨서 매 세대가 폴백 문서를 뱉는다. 게다가 겉보기에는 아무 문제 없어 보인다(폴백 경로가 아주 잘 작동하니까).

실제로 당해봤다. tests/handoff_live.py를 처음 실제로 돌렸을 때 두 세대의 핸드오프가 전부 폴백 버전이었다. 오프라인 테스트는 잡지 못했다 —— 거기서는 _attempt를 통째로 갈아끼워서 가짜가 이 판정을 타지 않았다. 지금은 판정을 Runtime._handoff_due()로 끌어올려서 오프라인에서 직접 검증한다.

폭주 방지 차단기 하나

max_generations=8.

window를 작게 잡으면 무한 세대 교체로 돈을 태운다

위험은 이렇다. 임계값이 이 역할의 기동 바닥보다 낮으면(코디네이터는 실측 약 34k. 시스템 프롬프트와 워크벤치 인덱스만으로 그만큼 먹는다), 새 세션은 입을 떼자마자 선을 넘는다 → 핸드오프 작성, 세대 교체, 또 선 넘기, 영원히 멈추지 않는다. 그리고 핸드오프는 재시도 횟수를 소모하지 않는데 그건 의도된 것이므로, 유일한 차단기가 max_generations=8이다.

정상적인 장기 실행에서는 8세대까지 갈 일이 없다. 정말 걸렸다면 거의 확실히 window를 작게 잡은 것이다 —— 상한에 도달하면 에러 메시지가 그렇게 말해준다 ("임계값이 이 역할의 기동 바닥보다 낮을 가능성이 크다. window를 키우거나 --no-handoff").

한 번의 핸드오프 전 과정

작업(session A)
  |  메인 스레드 컨텍스트가 임계값을 넘음   <- 메인 스레드만 본다. subagent의 컨텍스트는 자기
  |                                          transcript의 일이고 끝나면 흩어진다. 메인 세션을
  |                                          교체시킬 이유가 없다
  |- 메시지 경계에서 끊는다               <- Ctrl-C 중단과 같은 원리. 깨끗하게 끊고 상태를 찢지 않는다
  |                                          (대가도 같다: 날고 있던 subagent는 잃는다. 50k 여유는 이걸 위한 것)
  |- 같은 session으로 한 턴 더: 핸드오프 문서 작성
  |     왜 스스로 쓰나 —— 그 컨텍스트를 가진 건 그것뿐이다. 누구를 데려와도 먼저 다 읽어야 하니 바꾸는 의미가 없다
  |- <工作台>/notes/交接-<步骤名>.md 로 동결, 이전 세대는 notes/archive/交接/ 로 이동
  |- 새 세션(resume=None, fork=False), prompt = 핸드오프 문서의 prompt_block()
작업(session B)이 이어서 진행

HANDOFF_PROMPT는 현재 세션에게 핸드오프 문서를 쓰게 하는 프롬프트이고, {used}{window} 두 자리표시자를 포함한다. 이건 새 역할이 아니다 —— 그 컨텍스트를 가진 건 지금 이 세션뿐이다.

핸드오프는 재시도가 아니다, 장부는 어떻게 적히나

필드 핸드오프 때 어떻게 변하나
attempts 늘지 않음 —— 실패한 시도를 세는 값이다
retired[] 이 스텝에서 소진된 session_id를 순서대로 기록
session_id 항상 마지막으로 인계받은 것. 소진된 것이 아니다
context 마지막 턴에 메인 스레드가 실제로 본 컨텍스트 규모
cost_usd / num_turns 재시도와 핸드오프에 걸쳐 누적

이 필드들은 모두 manifest.json에 들어가고, 사후에 "이 스텝이 몇 세대를 태웠고 세대마다 얼마를 썼는지"를 완전히 복원할 수 있다.

핸드오프 문서는 어디에 떨어지나

<工作台>/notes/交接-<步骤名去掉非法字符>.md. 이미 있던 이전 세대는 notes/archive/交接/<步骤名>-<时间戳>.md로 옮겨진다.

워크벤치가 없으면 디스크에 떨어지지 않는다 —— 그때 _handoff_pathNone을 반환하고, 문서는 그대로 prompt를 통해 인계자에게 전달되며 핸드오프는 평소대로 진행된다. 다만 사후에 사람이 그 파일을 찾을 수 없다. 사후에 보고 싶다면 워크벤치를 켜야 한다 (Runtime(workbench=True), 또는 워크플로가 직접 하나 붙여도 된다).

언제 쓰지 말아야 하나

  • 그냥 compact를 쓰고 싶을 때. flower --no-handoff, 또는 Runtime(handoff=False). 핸드오프는 auto-compact를 함께 끄므로, 그 부수 효과가 싫으면 켜지 마라.
  • 둘을 동시에 두고 싶을 때. 명시적으로 AgentSpec(compact=CompactPolicy(mode="auto"))를 주면 auto-compact를 지킬 수 있지만, 그 뒤로는 "어느 시점의 컨텍스트 하락이 누구 짓인지"를 설명할 수 없어 디버깅이 어려워진다. 핸드오프를 믿든 compact를 믿든 하나만 믿어라.
  • 짧은 태스크, 단일 턴 작업. 핸드오프는 영원히 발동하지 않으므로 설정할 의미가 없다 —— 다만 Runtime의 기본값 handoff=True가 여전히 auto-compact를 끈다는 점은 기억하라.
  • 워크벤치도 없이 사후에 핸드오프를 읽으려 할 때. 먼저 워크벤치를 켜라. 아니면 문서는 그 한 번의 런 컨텍스트에만 존재하고 끝난다.
  • window도 못 맞춘 채 장기 실행을 시작할 때. 실제 윈도가 기본값보다 작으면 처음 몇 세대의 핸드오프가 전부 폴백 문서가 되고, 폴백 문서야말로 가장 쓸모없는 종류의 핸드오프다. 먼저 --window로 맞추거나, 짧게 한 번 돌려 -v의 모델 이름을 확인하라.
  • 핸드오프를 컨텍스트 관리의 전부로 삼을 때. 이건 마지막 방어선이다. 그 자리에서 바로 쳐내는 층들 (스필, 트림, 프룬)이 더 싸다. 컨텍스트 경제학을 보라.

증상 대조: 어느 손잡이를 돌릴 것인가

증상 무엇을 돌리나
핸드오프가 너무 잦고 작업이 계속 끊긴다 --window를 모델의 실제 윈도로 맞춘다(-v로 적용된 모델 이름을 볼 수 있다)
시작하자마자 교체하고 "기동 바닥"이라고 뜬다 위와 같음. window를 작게 잡았다
핸드오프가 늘 폴백이다 runs/manifest.jsonerrors를 보라. 폴백 이유가 적혀 있다
인계받은 쪽이 이전 세대가 한 일을 자꾸 반복한다 핸드오프의 "막힌 길"이 너무 얇다. 그 파일을 직접 고쳐도 된다
사후에 핸드오프를 읽으려는데 파일이 없다 워크벤치를 안 켰다. 핸드오프가 디스크에 떨어지지 않고 prompt로만 갔다
그냥 compact를 쓰고 싶다 --no-handoff

이어서 읽을 것