DeepSeek Harness · 컨텍스트 엔지니어링

Spill: 도구 출력이 너무 클 때

한도를 넘는 출력은 디스크에 보관하고, 모델에게는 회수 자격증명을 남깁니다. 핵심 소스:packages/spill/spill-policy/src/index.ts

강의 목표이 레슨을 마치면 세 가지를 말할 수 있어요: 2MB grep 결과가 들어왔을 때 DSH가 왜 컨텍스트에 쑤셔 넣지도, 잘라내지도 않고 전문을 디스크에 두고 컨텍스트에는 앞뒤 미리보기와 회수 자격증명만 남기는지; post-execute 다섯 단계가 각각 무엇을 판단하고 왜 read 도구를 건너뛰는지; 저장 쓰기가 실패해도 결과가 왜 성공으로 남는지.
인터랙티브 데모 · 대용량 출력 처리 파이프라인
모델 컨텍스트(모델이 보는 부분)
보관함 spillStore(세션 전용 파일)
POST-EXECUTE 5단계 결정(spill-policy/src/index.ts)
1 · next() 위임먼저 다운스트림이 결과를 정산하게 · L194
2 · 순수 텍스트 검사비텍스트 블록이 섞이면 통째로 건드리지 않음 · L200-201
3 · 바이트 임계값UTF-8 크기가 maxInlineBytes를 넘을 때만 동작 · L202-203
4 · saveText 디스크 저장전문을 그대로 세션 보관함에 기록 · L155
5 · 미리보기 + 자격증명으로 교체앞뒤 미리보기와 회수 힌트를 컨텍스트에 · L173-175
대조 · 하드 잘라내기
한도까지 자르고 앞부분만 남김잘린 부분은 더 이상 존재하지 않음
truncated 표시를 붙임모델에게 잘렸다고만 알림 — 그뿐
「재생」을 눌러 시나리오 A를 실행하거나, 여기로 스크롤하면 자동 재생됩니다.
수업용 시뮬레이션입니다: 카드·바이트 수·경로는 수업용 추상이고, 결정 로직은 packages/spill/spill-policy/src/index.ts 190–209행에 대응합니다. 데모의 maxInlineBytes는 50 KB로 Agent Note 예시 배포와 같습니다.
이 문제의 세 가지 후보 답

grep이 수만 줄을 맞히거나 web_fetch가 문서 한 페이지를 통째로 가져와 결과가 2MB입니다. 이 결과는 어디로 갈까요? 선택지는 세 가지뿐입니다.

선택지 1: 전부 컨텍스트에 쑤셔 넣기. 다음 모델 요청이 바로 가득 차서 지갑과 컨텍스트 윈도우가 함께 타격을 입어요. 선택지 2: 초과분을 자르기. 공간은 아끼지만, 나중에 모델이 필요한 게 잘린 그 에러 줄이면 작업이 멈춥니다. 선택지 3이 DSH 방식입니다: 전문을 디스크에 보관하고, 컨텍스트에는 앞뒤 미리보기와 경로·read/grep으로 되찾을 수 있다는 힌트만 남깁니다. 컨텍스트에서 나간 정보도 언제든 찾을 수 있어요 — 그게 Spill(오버플로 저장)입니다.

잘라내면 정보가 사라지고, Spill이면 되찾을 수 있어요.

이 일을 하는 플러그인이 dsh-spill-policy입니다. 도구 실행 파이프라인의 tools/post-execute 이벤트에 걸려, 도구 결과가 완전히 확정된 뒤에야 움직입니다. 결정은 다섯 단계뿐이고, Agent Note 2026-07-08에 분명합니다: 위임, 순수 텍스트 검사, 바이트 임계값, saveText, 교체. 데모의 체크리스트가 그 다섯 단계를 그대로 옮긴 것입니다.

핵심 시각 · 수업용 구조도
도구 결과 확정 tools/post-execute · next() 이후 순수 텍스트이면서 > maxInlineBytes? read 도구는 바로 건너뛰어 무한 루프 방지 아니오 · 그대로 통과 모델 컨텍스트 모델이 보는 부분 saveText() 전문 디스크 저장 ctx.spillStore · 실패 시 인라인 유지 보관 파일(0600 배타적 쓰기) session-<hash>/<random>-grep.txt 앞뒤 미리보기 + 회수 자격증명 locator + retrievalHint 모델 · read / grep 자격증명으로 언제든 전문 회수
수업용 구조도: 노드와 연결은 소스 관계를 설명하며, 내용은 수업에 맞게 정리했습니다.
가장 헷갈리기 쉬운 세 가지
순수 텍스트만 처리

결과에 비텍스트 블록(스크린샷 등)이 하나라도 섞이면 flattenPlainText가 undefined를 반환하고 결과 전체를 그대로 둡니다. 정책은 최종 포맷 텍스트만 알고 도구 내부 구조는 모르니, 차라리 건드리지 않습니다. 출처: index.ts 80–87행.

read는 건너뜀

모델을 향한 쪽은 read 도구를 명시적으로 건너뛰어, read 출력이 spill되어 파일이 되고 모델이 다시 read·spill하는 무한 루프를 막습니다. 로그 쪽은 건너뛰지 않습니다 — 로그 사본은 모델 컨텍스트에 들어가지 않아 루프가 성립하지 않아요. 출처: 195–197행과 219–222행 주석.

자격증명은 경로가 아님

locator는 불투명 핸들입니다: 로컬 백엔드는 파일 경로를, 원격 백엔드는 URI나 키를 줄 수 있어요. 소비자는 파싱하지 않고 백엔드가 붙인 retrievalHint로 회수 문구를 렌더하며, read가 항상 올바른 회수 방식이라고 가정하지 않습니다. 출처: docs/subsystems/spill.zh.md 70행.

다섯 단계 결정의 게이트키핑 로직

정책 입구는 통과 판단의 연쇄입니다. 건드리지 않을 이유 네 가지를 순서대로 봅니다: 다운스트림 리스너가 이 결과를 받지 않음, 다른 플러그인이 이미 값을 바꿈, 중첩 서브콜이거나 read 도구, 내용에 비텍스트 블록이 섞임 — 하나라도 맞으면 그대로 통과. 모두 아니면 바이트를 재고, maxInlineBytes 이하면 또 통과. 전부 통과해야 spill로 갑니다.

놓치기 쉬운 순서가 있어요: 판단의 첫걸음은 await next() — 먼저 위임. 다운스트림 리스너(내용을 바꾼 hook 등)가 결과를 완전히 정산한 뒤에야 spill이 확정본을 건드립니다. 그래서 다른 플러그인이 내용을 바꿔도, 바뀐 내용 역시 spill의 관리 아래 있어요.

출처: packages/spill/spill-policy/src/index.ts 194–209행, 확인일 2026-08-13. read를 건너뛰는 이유는 소스 주석에 있습니다: read 출력이 spill되어 파일이 되고 모델이 다시 read·spill하는 루프를 피하려는 것.

핵심 증거 · 저장 실패는 판정을 바꾸지 않음

개요에 있던 그 경계 조건: spill 저장 쓰기가 실패하면 이번 도구 호출은 성공일까요 실패일까요? 답은 catch 분기에 있어요 — 성공이고, 한 글자도 숨기지 않습니다.

packages/spill/spill-policy/src/index.ts153–161행 발췌
    let ref: SpillRef
    try {
      ref = await spillStore.saveText(save)
    } catch (error: unknown) {
      // Best-effort: a storage failure (permissions, ENOSPC, backend down) must
      // never fail the call or hide the content — keep the original inline.
      ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`)
      return undefined
    }
소스 스냅샷 안내:로컬 저장소 deepseek-harness-master 기준, 확인 파일 packages/spill/spill-policy/src/index.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.

디스크가 가득 차거나, 권한이 틀리거나, 백엔드가 마운트되지 않아도 warn 로그만 남기고 원본 결과는 컨텍스트에 인라인으로 남습니다. 설계 문서 원문: “spill 실패는 성공한 도구 호출을 isError 결과로 바꾸거나 인라인 결과를 숨기지 않는다”(Agent Note 2026-07-08 91행). 논리는 단순해요: spill은 비용을 아끼는 최적화이고, 실패해도 컨텍스트가 조금 커질 뿐 — 성공 호출을 실패로 만들거나 정보를 잃게 해서는 안 됩니다.

반대쪽 함정도 막혀 있어요: 설정 검증은 플러그인 로드 때이지 호출마다가 아닙니다. 음수나 소수 maxInlineBytes는 배포 시작을 바로 실패시킵니다(114–119행) — 잘못된 설정은 배포가 터져야지, 어떤 도구 호출이 책임을 져서는 안 됩니다.

자격증명은 어떻게 생겼나

교체 후 모델이 보는 텍스트는 세 부분입니다: 남긴 머리 미리보기, 생략 설명과 자격증명, 남긴 꼬리 미리보기. 자격증명 줄은 spillNotice가 만듭니다(104–108행). Agent Note 예시는 “(Omitted N bytes. Full formatted result stored at: /.../session-.../....txt. Use read with offset/limit, or grep this path to search within it.)”. 표현은 일부러 일반적입니다 — 정책은 최종 텍스트만 알고 도구 내부 자원은 모릅니다.

얼마나 빡센지 보여주는 디테일: 자격증명 자체의 바이트를 먼저 maxInlineBytes 예산에서 빼고 나서 미리보기 크기를 잡습니다(171–172행). 안 그러면 미리보기가 예산을 다 쓰고 자격증명을 붙였을 때 교체 텍스트가 한도를 넘을 수 있어요. 자격증명 한 줄이 한도 전체를 넘으면 정책은 spill을 포기하고 인라인을 유지합니다 — 스스로 선언한 한도를 절대 어기지 않아요(183–185행).

보관 파일 자체도 까다롭습니다. 로컬 백엔드는 <root>/session-<hash>/<random>-<safeName>에 쓰고, 루트는 비공개(0700), 쓰기는 open(path, 'wx', 0o600)으로 배타적이며 소유자만 읽을 수 있어 미리 심어 둔 심볼릭 링크로 쓰기를 돌릴 수 없습니다(docs/subsystems/spill.zh.md 85행). 같은 족의 설계로 첨부 시스템이 있어요: 로그에는 참조, 바이트는 외부 store — 본문에는 가벼운 참조만 남깁니다(docs/subsystems/attachment.zh.md).

가로 비교 · 세 곳이 대용량 출력을 다루는 법

Claude Code · 한도 + 디스크 저장

공식 블로그 원문: “For Claude Code, we restrict tool responses to 25,000 tokens by default”; 소스는 Tool.ts의 maxResultSizeChars(study/chapters/02-tool-system.md 664–666행). 670행은 한도를 넘는 도구 결과를 디스크에 둔 뒤 경로 설명을 붙인다고 하며 DSH와 방향이 같습니다. 블로그는 잘라낼 때 Agent에게 왜 잘렸는지, 전체 내용을 어떻게 얻는지 알려야 한다는 원칙도 덧붙입니다.

Grok Build · bash 전용 디스크 저장

도구 출력 기본 한도는 20,000바이트(DEFAULT_TOOL_OUTPUT_CHARS, crates/codegen/xai-grok-tools/src/lib.rs 11행)이고, 초과 시 잘라내며 truncated 플래그를 돌려줍니다. bash만 예외: 전체 출력을 먼저 세션 디렉터리의 terminal log에 씁니다(bash/mod.rs 379–381행). 잘린 부분은 파일에서 되찾을 수 있어요. 이 저장은 bash 전용이라, 다른 도구는 잘리면 그냥 사라진 겁니다.

비교의 초점은 범용성입니다. 세 곳 모두 대용량 출력을 모델에 다 넣을 수 없다고 인정하고, 차이는 버린 부분을 되찾을 수 있는지, 그 능력이 몇 도구를 덮는지예요. Grok은 bash만 디스크에 두고, Claude Code와 DSH는 범용 메커니즘으로 만들었습니다. DSH 쪽이 가장 잘게 나뉩니다: 미리보기는 output-retention 라이브러리, 저장은 spillStore seam(메서드 하나짜리 추상 서비스), 정책 플러그인은 언제 spill할지·자격증명을 어떻게 붙일지만 결정. 패키지 세 개가 구간을 나눠 가져, 원격 저장 백엔드를 바꿔도 정책 코드 한 줄 안 건드려도 됩니다. Agent Note 대안 절은 참조 대상까지 찍어 둡니다: 범용 기본 동작으로, “Claude Code식 범용 도구 결과 영속화”를 겨냥한다(187행).

수업 실습
01

출력 세 개의 운명을 손으로 추적

배포 설정 maxInlineBytes: 50000. 도구 결과 세 개가 차례로 들어옵니다: 60,000바이트 web_fetch 순수 텍스트; image 블록이 섞인 200,000바이트 브라우저 스크린샷 결과; spill 때 디스크가 가득 찬 80,000바이트 순수 텍스트. 194–203행 게이트 로직과 153–161행 catch 분기를 대조해, 각 결과가 모델 컨텍스트에 들어가는 최종 형태와 보관함에 파일이 몇 개 늘었는지 쓰세요.

Takeaway:Spill은 대용량 출력을 ‘쑤셔 넣거나 버리기’ 이분법에서 해방합니다: 전문은 디스크, 컨텍스트에는 미리보기와 자격증명; 모델은 기존 read/grep으로 언제든 되찾아요. 정책은 확정된 순수 텍스트만 다루고, 루프 방지를 위해 read를 건너뛰며, 저장 실패 시 인라인을 유지하고 isError를 뒤집지 않습니다. 잘라내면 정보가 사라지고 Spill이면 되찾을 수 있어요 — 두 가지 컨텍스트관입니다.