Harness Engineering Lab
Build, break, measure, and evolve a coding harness from scratch.
이 가이드는 coding agent의 harness를 처음부터 직접 만들어 보는 과정이다. harness는 LLM 바깥에서 model을 반복 호출하고, tool을 실행하고, context를 관리하고, 위험한 동작을 막는 프로그램이다.
Lab에서는 아주 작은 harness에서 시작해 실패를 만들고, 그 실패를 설명하는 가설을 세우고, 최소한의 구현을 진행한 뒤, 같은 조건에서 다시 측정하는 작업을 반복하면서 harness의 내부 구조와 작동 방식을 이해하고, 더 나아가 개인의 워크플로우에 적합한 harness를 효과적으로 구현할 수 있는 기초 체력을 얻는 것을 목표로 한다.
Lab의 진행 방식
- Rust로 작은 harness를 직접 구현한다. model API를 직접 호출하는 loop에서 시작해 tool, repository 탐색, context 관리, 권한, sandbox, session, 확장, subagent로 넓혀 간다.
- 매 단계마다 변경 전과 후를 같은 task, 같은 model, 같은 budget으로 비교한다.
- 같은 측정 절차로 Claude Code 같은 외부 harness도 함께 측정해, 우리가 만든 구조와 production harness의 결과를 함께 비교한다.
- 각 단계 끝에서 production harness는 같은 문제를 어떻게 다루는지, 그리고 그 복잡성이 어떤 engineering pressure 때문에 생겼는지 살펴본다.
읽는 순서
- Introduction을 먼저 읽는다. 이 가이드의 방식, 준비물, 측정 방법, 비용과 정책을 다룬다.
- Lab은 H0부터 순서대로 진행한다. 각 Lab은 이전 Lab의 결과물을 시작 상태로 삼는다.
- Appendix는 필요할 때 찾아본다.
각 Lab은 글 한 편과 FAQ로 구성된다. 글은 같은 흐름을 따른다.
| 절 | 내용 |
|---|---|
| 들어가며 | 필요한 개념, 논문과 참고 자료가 다루는 내용 |
| 이번에 해볼 것 | 그중 직접 만들어 확인할 부분 |
| 결과 확인 | 핵심 코드와 실제로 돌려서 확인한 결과 |
| 돌아보기 | 달라진 것, 대가, 논문 또는 다른 harness와 비교 |
FAQ에는 본문에 넣지 않았지만 이후 Lab과 이어지는 이야기를 남긴다. 각 글 맨 위에 시작 tag와 다루는 논문 절을 적는다.
목차
Note
목차에서 흐리게 보이는 항목은 아직 작성되지 않은 페이지다.
| Part | Lab | 핵심 질문 |
|---|---|---|
| I. Foundations | H0 Hello Harness | LLM을 agent로 만드는 최소 구조는 무엇인가? |
| H1 Tool Loop | bash 하나만으로 어디까지 가능한가? | |
| H2 Editing | coding agent의 edit는 왜 실패하는가? | |
| II. Repository Intelligence | H3 Repository Context | agent가 매번 repository를 다시 발견하지 않게 하려면? |
| H4 Repository Search | 전용 파일명·본문 검색 tool은 bash 검색의 탐색 비용을 어떻게 바꾸는가? | |
| III. Context | H5 Context Budget | Codex와 DeepSeek Harness는 context budget을 어떻게 측정·배분하고, 한도에 가까워지면 무엇을 남기고 버리는가? |
| H6 Compaction | 기존 작업과 같은 환경에서 compaction을 일찍 일으킨 뒤 이어서 작업하면, 작업은 계속되고 호출마다 cache hit는 다시 올라가는가? | |
| IV. Safety and Runtime Reliability | H7 Permissions | 접근 레벨에 따라 tool 호출을 어떻게 제어하는가? |
| H8 Sandbox | 허용한 실행의 접근 범위를 어떻게 제한할 것인가? | |
| H9 Sessions & Checkpoints | 종료한 대화를 어떤 상태에서 다시 시작할 것인가? | |
| V. Extensibility and Orchestration | H10 Skills / Hooks / MCP | 핵심 loop를 계속 고치지 않고 기능을 어떻게 확장할 것인가? |
| H11 Subagents | 작업 위임과 별도 문맥은 언제 도움이 되는가? | |
| H12 Parallelism | 여러 작업을 동시에 실행하면 작업 완료에 도움이 되는가? | |
| VI. Evaluation | H13 Harness Evaluation | harness를 어떤 기준으로 평가하고 개선할 수 있는가? |
Lab의 이름과 순서는 실험을 진행하며 바뀔 수 있다.
대상 독자와 전제
누구를 위한 가이드인가
이 가이드는 다음과 같은 사람을 위해 쓴다.
- 프로그래밍 경험이 있다. 명령행, git, HTTP와 JSON 같은 기본 도구가 익숙하다.
- LLM을 써 본 적이 있다. ChatGPT, Claude 같은 서비스나 Claude Code, Codex 같은 coding agent를 사용해 봤다.
- 그 도구들이 안에서 어떻게 동작하는지 직접 만들어 보며 이해하고 싶다.
Harness란
Note
Harness(하네스)는 model(LLM)을 agent(에이전트)로 동작하게 만들어 주는 프로그램을 말한다. model은 텍스트를 입력하면 텍스트를 생성하는 역할만 담당한다. 파일을 읽고, 명령을 실행하고, 결과를 다시 model에 보여주고, 언제 멈출지 정하는 것은 모두 harness가 한다.
이 프로젝트는 harness를 다음 7개 영역으로 나눠 본다. 이 분류는 production coding agent 11개의 소스 코드를 분석한 논문에서 가져왔다(참고 자료).
| 영역 | harness가 하는 일 |
|---|---|
| Agent loop | model 호출 → tool 실행 → 결과 전달을 반복. 중단 관리 |
| LLM integration | model API를 호출. 응답 형식과 오류 관리 |
| Tools & actions | 파일 읽기, 명령 실행, 편집 같은 tool을 정의하고 실행 |
| Memory & context | model에게 보여줄 기록과 맥락 |
| Safety & permissions | 위험 동작의 허용 여부와 권한 위임. 샌드박스 격리 |
| Orchestration | 작업 분배 및 Agent 병렬 실행 |
| Extensibility | core loop 변경 없는 기능 추가 |
구현 언어: Rust
이 가이드의 코드는 Rust로 작성한다. harness가 하는 일을 감추는 계층 없이 전부 눈으로 보기 위해 외부 프레임워크나 SDK 없이 HTTP로 직접 호출하는 것을 지향한다.
Rust가 아닌 익숙한 프로그래밍 언어로 작성해도 상관없다. 각 단계의 핵심은 언어보다 무엇을 왜 바꾸고, 어떻게 측정하는가에 있다. 다만 측정 도구와 기록 형식은 Rust 코드와 함께 제공되므로, 다른 언어를 쓴다면 결과를 같은 형식으로 남기는 부분을 직접 맞춰야 한다(측정 방법).
다루지 않는 것
- Model 학습과 fine-tuning. model은 주어진 것으로 보고, 바꾸는 것은 harness뿐이다.
- 완성된 제품. Claude Code 같은 도구를 그대로 복제하는 것이 목표가 아니다.
- Production 운영. 배포, 과금, 다중 사용자 같은 서비스 운영 문제는 다루지 않는다.
접근 방식
Lab의 진행 방식
모든 Lab은 다음 사이클을 거친다.
flowchart TB
subgraph before["구현 전"]
direction LR
B[Baseline] --> F[Failure] --> M[Measurement] --> H[Hypothesis]
end
subgraph after["구현과 검증"]
direction LR
I[Minimal Implementation] --> S[Same Experiment] --> C[Comparison] --> T[Interpretation] --> D{Adopt / Revise / Reject}
end
H --> I
D -- Revise --> H
| 단계 | 하는 일 |
|---|---|
| Baseline | 바꾸기 전의 harness를 commit이나 tag로 고정한다 |
| Failure | baseline으로 task를 실행해 실제 실패를 재현한다 |
| Measurement | 그 실패를 어떤 지표로 관찰할지 정하고 측정한다 |
| Hypothesis | 무엇을 바꾸면 무엇이 좋아질지, 어떤 결과가 나오면 가설을 버릴지 적는다 |
| Minimal Implementation | 가설을 검증하는 데 필요한 최소한만 바꾼다 |
| Same Experiment | baseline과 같은 조건으로 다시 실행한다 |
| Comparison / Interpretation | 무엇이 달라졌는지, 그것으로 무엇을 말할 수 있고 무엇은 아직 모르는지 정리한다 |
| Adopt / Revise / Reject | 변경을 채택하거나, 가설을 고쳐 다시 하거나, 버린다 |
Important
가설이 틀려도 실험 결과와 해석은 문서에 그대로 남긴다.
가설에는 기각 조건이 있어야 한다
가설은 구현하기 전에 적는다. 최소한 다음 네 가지가 들어가야 한다.
- 무엇을 바꾸는가
- 무엇이 개선될 것으로 예상하는가
- 어떤 지표로 판단하는가
- 어떤 결과가 나오면 가설을 기각하는가
예를 들어 “전용 검색 tool이 bash만 쓸 때보다 관련 파일에 도달하기까지의 tool 호출 수와 context 사용량을 줄일 것이다“라는 가설이라면, “tool 호출 수가 줄지 않거나 task 성공률이 떨어지면 기각한다“까지 적는다.
같은 조건으로 다시 측정한다
가능한 한 다음을 그대로 유지하고 baseline과 비교한다.
- model과 설정
- task와 repository 상태
- budget (최대 turn 수, 시간 제한 등)
- 판정 규칙
실험 조건은 구현을 시작하기 전에 Lab 정의 파일(evals/labs/hXX.yaml)로 고정한다. 결과를 본 뒤에 조건을 바꿨다면 무엇을 왜 바꿨는지, 바뀐 결과를 어떻게 해석할지 다시 기록한다.
시작하기
준비물
| 도구 | 용도 | 비고 |
|---|---|---|
| macOS | H8부터 bash 등 외부 명령을 macOS sandbox 안에서 실행 | 다른 OS에서는 H8 이후의 hel이 외부 명령을 실행하지 않는다 |
Rust toolchain (rustup, cargo) | harness와 측정 도구 빌드 | stable 최신 버전 |
| git | 소스 받기, Lab checkpoint 이동 | |
ripgrep (rg, H4부터) | 파일명·본문 검색 tool과 검색 측정 | PATH에서 실행 가능해야 함. rg --version으로 확인 |
| DeepSeek API key | 실험 model 호출 | 비용, 정책, 윤리 참고 |
| Claude Code (선택) | 외부 harness와 비교할 때 | 없어도 우리 harness의 실험은 모두 진행할 수 있다 |
Rust 설치
Rust는 공식 설치 도구인 rustup으로 설치한다. 설치 방법은 rustup.rs를 따른다. 설치한 뒤 다음 명령이 버전을 출력하면 된다.
cargo --version
DeepSeek API key
이 가이드의 실험은 DeepSeek의 deepseek-flash model을 쓴다. 이 model을 고른 이유와 그 한계는 측정 방법에서 다룬다.
- DeepSeek Platform에서 계정을 만들고 API key를 발급한다.
- key를 저장소 루트의
.env파일에 적는다.evals는 환경 변수DEEPSEEK_API_KEY가 없으면 이 파일을 읽는다.
echo 'DEEPSEEK_API_KEY=<발급받은 key>' > .env
hel을 직접 실행할 때는 환경 변수로 넣는다.
export DEEPSEEK_API_KEY="<발급받은 key>"
Warning
.env는 저장소의.gitignore에 들어 있어 commit되지 않는다. key를 코드나 다른 설정 파일에 적어 commit하지 않는다.
소스 받기
소스 코드와 이 가이드의 원본은 jammer-droid/HEL에 있다.
git clone https://github.com/jammer-droid/HEL.git
소스 트리
저장소의 구조는 다음과 같다.
| 경로 | 내용 |
|---|---|
crates/hel/ | 이 가이드에서 만드는 harness. 이름은 hel(Harness Engineering Lab)이다. H0에서 만든다(h00에는 없다) |
crates/record/ | 실행 기록(record)의 데이터 타입. harness와 측정 도구가 함께 쓴다 |
crates/evals/ | 측정 도구 evals: task 실행, 기록 수집, 판정, 집계 |
evals/labs/ | Lab별 test set, 기본 model과 budget, 채점 기준 |
evals/tasks/ | 실험에 쓰는 task와 그 입력 파일(fixture) |
evals/harnesses/ | 측정 대상 harness별 특성과 실행 방법(profile) |
evals/schema/ | 실행 기록의 형식 정의 |
results/ | evals 실행 결과가 생기는 곳. git으로 관리하지 않는다 |
guide/ | 지금 읽고 있는 이 가이드의 원본 |
Lab checkpoint 사용하기
각 Lab의 시작 상태는 hXX tag로 제공한다. Lab의 시작 상태는 이전 Lab을 완성한 상태다.
| tag | 내용 |
|---|---|
h00 | 기록 형식(record)과 측정 도구. harness는 없음 |
h01 | H0 완성 상태. 대화형 hel 포함 |
h02 | H1 완성 상태. hel에 bash tool 포함 |
h03 | H2 완성 상태. hel에 편집 tool 포함 |
h04 | H3 완성 상태. hel이 OS·shell·작업 경로와 HEL.md를 시스템 프롬프트로 보냄 |
h05 | H4 완성 상태. 파일명·본문 검색 tool glob, grep |
h06 | H5 완성 상태. 실행 기록에 cache hit와 요청별 context 크기 |
h07 | H6 완성 상태. hel이 context를 기본으로 압축함 |
h08 | H7 완성 상태. 접근 레벨과 호출별 승인 |
h09 | H8 완성 상태. macOS sandbox와 인스턴스별 저장 공간 |
h10 | H9 완성 상태. 세션 snapshot·재개·세션별 spill |
h11 | H10 완성 상태. Skills 목록·Hooks·지연 로딩 MCP tool |
h12 | H11 완성 상태. subagent에게 작업을 순차 위임하는 delegate_task |
h13 | H12 완성 상태. 읽기 전용 tool과 subagent를 동시에 실행하는 --parallel |
h14 | H13 완성 상태. tool 호출의 exit code·실패 결과 기록, evals failures와 pass@k·pass^k |
Lab을 시작할 때는 해당 tag에서 branch를 만든다. 예를 들어 H1은 h01에서 시작한다.
git checkout -b my-h01 h01
한 Lab에서 무엇이 바뀌었는지는 다음 tag와 비교해 본다. 예를 들어 H0에서 바뀐 것은 이렇게 본다.
git diff h00..h01
tag는 각 Lab이 끝날 때 만든다.
명령 설치
이 가이드에서 쓰는 명령은 두 가지다.
| 명령 | 역할 |
|---|---|
hel | 직접 만드는 harness. 실행한 폴더를 작업 디렉터리로 쓴다 |
evals | task를 실행하고 채점하는 측정 도구. 저장소 안에서 실행한다 |
저장소 루트에서 설치한다.
cargo install --path crates/evals --locked --target-dir target
hel은 각 Lab에서 직접 만들고, 고칠 때마다 다시 설치한다.
cargo install --path crates/hel --locked --target-dir target
--locked는 저장소의 Cargo.lock 버전 그대로 설치하고, --target-dir target은 저장소의 빌드 폴더(target/)를 재사용해 다시 설치할 때 바뀐 crate만 컴파일한다.
evals는 설치된 hel을 사용한다. 설치되어 있지 않으면 crates/hel을 빌드해서 사용한다. 설치하지 않고 작업 중인 코드를 바로 실행하려면 --build를 붙인다. 설치된 hel이 소스보다 오래되었으면 경고가 나온다.
task 실행과 채점
Lab을 진행하면서 task 하나를 바로 실행해 볼 때는 evals try를 쓴다.
evals try read-echo-01
입력, tool 호출, 출력, 채점 결과, token 사용량이 터미널에 나온다. 결과는 results/try/에 남는다. 지시문만 바꿔서 시험하려면 --instruction을 붙인다.
evals try read-echo-01 --instruction "Read hello.txt and print it in uppercase."
Lab을 마칠 때는 그 Lab의 test set 전체를 실행하고 채점한다. Lab 정의(evals/labs/<lab>.yaml)에는 비교할 조건이 있다. baseline은 Lab을 시작한 상태의 hel, variant는 그 Lab에서 고친 hel로 실행하는 조건이다. baseline은 코드를 고치기 전에 먼저 실행한다.
evals run h00 --conditions baseline
evals run h00 --conditions variant
이미 실행한 결과를 다시 채점하고 report만 볼 때는 evals report h00을 쓴다. Lab ID를 생략하면 가장 최근 Lab을 쓴다. report는 results/<lab>/report.md에 저장된다.
h14부터는 실행 중 실패한 tool 호출을 evals failures로 모아 볼 수 있다. 호출마다 exit code, 실패 종류, 결과의 일부가 한 줄로 나온다. 이 명령과 실패 종류의 판정 방법은 H13에서 만든다.
evals failures h13
이 가이드를 로컬에서 보기
이 가이드는 mdBook으로 만들고, 한국어 검색은 Pagefind로 제공한다. 로컬에서 빌드하는 방법은 저장소의 guide/README.md에 있다.
측정 방법
한눈에 보기
flowchart LR
T[Task<br/>instruction + fixture + check] --> R1[우리 harness<br/>hel]
T --> R2[외부 harness<br/>예: Claude Code]
R1 -->|직접 기록| REC[(Run record)]
R2 -->|session 기록 수집| REC
REC --> V[Validate<br/>형식 검사]
V --> C[Check<br/>판정]
C --> REP[Report<br/>집계]
- Task는 harness 바깥에 있다. 어떤 harness든 같은 task를 받는다.
- 각 harness를 실행한 결과는 같은 형식의 run record로 남는다.
- record의 형식 검사를 진행한 뒤, task에 정의된 기준으로 판정하고, 여러 run을 집계한다.
Task
task는 세 부분으로 이루어진다.
| 구성 | 내용 |
|---|---|
| instruction | harness에 그대로 전달하는 자연어 지시 |
| fixture | run마다 새 임시 디렉터리로 복사되어 harness의 작업 디렉터리가 되는 파일들 |
| checks | 결과를 판정하는 규칙 |
H0의 첫 task는 이렇게 생겼다.
id: read-echo-01
instruction: >
Read the file hello.txt and print its contents exactly as they are.
Do not add anything else.
fixture: fixture/ # 안에 hello.txt가 있다
checks:
- id: output-exact # 최종 출력이 hello.txt 내용과 같은가
type: output_exact_match
expected_file: fixture/hello.txt
- id: single-read # 읽기 tool을 정확히 한 번, hello.txt에 대해 호출했는가
type: tool_calls
category: read
count: 1
path: hello.txt
not_applicable: [baseline]
Lab 별로 확인할 규칙이 달라진다. H0에서는 두 가지뿐이다.
| check | 판정 |
|---|---|
output_exact_match | 최종 출력이 기대 파일과 같다. 양쪽 끝의 줄바꿈 하나만 무시하고, 공백 정리나 code fence 제거 같은 다른 보정은 하지 않는다 |
tool_calls | 지정한 범주의 tool 호출이 정확히 N회이고, 대상 경로가 맞다 |
비교 조건
한 실험에는 보통 세 종류의 조건이 있다.
| 조건 | 의미 |
|---|---|
baseline | 이번 Lab에서 바꾸기 전의 harness |
variant | 이번 Lab에서 바꾼 harness. 가설 검증 대상 |
external-<이름> | 외부 harness. 같은 task를 같은 절차로 실행해 직접 만든 harness와 비교 |
H0를 예로 들면 baseline은 tool 없이 model을 한 번 호출하는 프로그램이고, variant는 loop와 파일 읽기 tool을 붙인 harness다. external은 같은 model로 실행한 Claude Code다.
Run record
run 하나가 끝나면 다음이 남는다.
results/<lab>/<run-id>/
├── record.json # 무슨 일이 있었는가 (수집 결과)
├── verdict.json # 판정 결과
└── raw/ # 원본 기록 사본
record에는 사실만 담고, 판정은 따로 둔다. record에 담기는 주요 항목은 다음과 같다.
| 항목 | 예 |
|---|---|
| 실행 식별자 | 실험 ID, task, 조건, 반복 번호, 시작과 종료 시각 |
| harness | 이름, 버전(commit), 비교 등급 |
| model | 요청한 model, 실제로 응답한 model, 설정값 |
| 결과 | 최종 출력, 종료 사유(완료, 최대 turn, 시간 초과, 오류) |
| 비용 | 입력/출력 token, model 호출 수, 걸린 시간 |
| tool 호출 | 순서, 공통 범주(read / search / edit / exec / other), 원래 이름, 인자, 성공 여부 |
모르는 값은 모른다고 적는다
외부 harness에서는 어떤 값을 얻을 수 없을 때가 있다. 그럴 때 0이나 빈 값을 넣으면 “token을 하나도 안 썼다“처럼 잘못 읽힌다. 그래서 수치 항목에는 값과 함께 상태를 붙인다.
"input_tokens": { "value": 812, "status": "measured" }
| 상태 | 의미 |
|---|---|
measured | 기록이나 API 응답에서 직접 얻은 값 |
derived | 다른 값에서 계산한 값 |
unavailable | 이 harness에서는 얻을 수 없는 값 (value는 null) |
외부 harness와 비교하기
외부 harness는 우리 코드가 아니므로 내부를 바꿀 수 없다. 대신 harness마다 collector를 하나 만들어, 그 harness가 남긴 session 기록을 같은 형식의 record로 바꾼다. 새 harness를 추가할 때 바뀌는 부분은 실행 방법(driver)과 collector뿐이고, 판정과 집계는 모든 harness가 같은 코드를 쓴다.
외부 harness의 결과는 어떤 model로 실행했는지에 따라 다르게 해석한다.
| 등급 | 의미 | 해석 |
|---|---|---|
same-model | 우리 실험과 같은 model로 실행 | harness의 차이 확인 가능 |
reference | 다른 model로만 실행 가능 | 참고용으로만 사용 |
외부 harness는 실행할 때마다 별도의 설정 디렉터리를 쓰고, 사용자의 설정, plugin, memory를 읽지 않게 격리해서 실행해야 한다.
비용, 정책, 윤리
harness를 만들고 실험하는 과정에서 알아 둬야 할 내용을 미리 정리했다.
API 비용
- model 호출은 token 단위로 과금된다. 실험은 같은 task를 여러 번 반복하므로, 반복 횟수 × 조건 수 × task 수만큼 호출이 쌓인다.
- DeepSeek의 가격 안내 페이지를 통해 사용할 model의 호출 비용을 확인할 수 있다.
- 이 가이드는 비용을 token 수로 기록하고, 달러 환산은 그 시점의 가격으로 계산한다.
API key와 기록 관리
- key는 환경 변수로만 주입하고 저장소에 commit하지 않는다.
- 원본 기록(raw)에 key가 남지 않게 한다. harness가 HTTP 요청을 기록할 때
Authorization같은 인증 header는 저장하지 않는다.
외부 API로 보내는 데이터
model API를 호출하면 instruction, 파일 내용, tool 결과가 모두 provider의 서버로 전송된다.
Claude Code를 다른 model로 실행하는 것에 대해
이 가이드는 외부 비교를 위해 Claude Code를 DeepSeek model로 실행한다. 이에 대해 확인한 내용은 다음과 같다(2026-10 기준).
- DeepSeek는 Claude Code를 연결하는 방법을 공식 문서로 안내한다.
- 연결해서 사용할 수는 있지만, Anthropic은 Claude Code를 Claude가 아닌 model과 함께 쓸 때 생기는 문제에 대해 동작이나 지원을 보장하지 않는다. 자세한 내용은 Claude Code의 LLM gateway 문서에서 확인할 수 있다.
- 이 가이드는 Claude Code 바이너리를 수정하거나 분석하지 않는다. 실행 결과로 로컬에 남는 session 기록만 읽는다.
Agent 실행 환경
agent는 파일을 읽고, 명령을 실행하고, 파일을 고칠 수 있다. 직접 만든 harness와 외부 harness 모두 해당한다.
- 실험은 task fixture를 복사한 임시 디렉터리에서 실행한다.
- 외부 harness는 사용자 설정, plugin, memory를 읽지 않도록 설정을 격리해서 실행한다.
- 설정을 격리해도 프로세스는 사용자 권한으로 파일 시스템과 네트워크에 접근할 수 있다. 이를 막으려면 container 같은 sandbox가 필요하다.
- sandbox 밖에서는 권한 확인을 건너뛰는 실행 모드(예: Claude Code의
--dangerously-skip-permissions)를 쓰지 않는다.
permission과 sandbox의 차이는 H7(Permissions)과 H8(Sandbox)에서 다룬다.
Part I 개요
Note
- 시작 상태:
h00· 완료 상태:h03- 논문: §2.4 The Minimal Harness, §6 Agent Loop Design, §8 Tool and Action Systems
현재 구조
사용자와 model만 있다. 그리고 model이 할 수 있는 일은 텍스트를 입력 받아 텍스트로 답을 하는 것 뿐이다.
flowchart LR
U[사용자 지시] --> M[model] --> A[텍스트 답]
- model은 파일을 읽거나 명령을 실행하지 못한다. 파일 내용을 물으면 읽을 수 없다는 답이나 추측한 답을 만든다.
- 사용자가 결과를 보고 다음 지시를 직접 다시 써야 한다.
이 Part에서 다루는 내용
이번 Part에서는 아무것도 없는 상황에서 agent loop와 기초적인 tool을 만들어 가장 기본적인 유형의 harness를 만들 것이다. 이를 통해 model이 harness를 통해 사용자의 작업 디렉터리에서 작업할 수 있는 기초적인 능력을 얻게 될 것이다.
| Lab | 질문 | 더하는 구조 |
|---|---|---|
| H0 Hello Harness | LLM을 agent로 만드는 최소 구조는 무엇인가? | agent loop, read_file tool, 실행 기록 |
| 대화형 도구의 기본 틀 | 사람과 주고받는 대화형 모드 | |
| H1 Tool Loop | bash 하나만 주면 model은 어디까지 스스로 해낼 수 있을까? | bash tool, --tools로 tool 고르기 |
| H2 Editing | bash만으로 파일을 고칠 때와 전용 편집 tool을 줄 때, 비용과 작업 범위는 어떻게 달라질까? | write_file, search_replace tool |
이 Part를 마치면
flowchart TB
U[사용자 지시<br/>--instruction 또는 대화형 입력] --> L
subgraph H[hel]
L[agent loop<br/>max_turns까지 반복]:::new
T[tool<br/>bash · read_file<br/>write_file · search_replace]:::new
R[실행 기록]:::new
end
L <--> M[model]
L <--> T
L --> R
T <--> W[(작업 디렉터리)]
classDef new fill:#fff3bf,stroke:#e8590c,color:#000
hel이 앞으로 만들어 갈 harness의 이름이다.- harness는 model이 요청한 tool을 실행하고, 결과를 대화에 붙여 model을 다시 부른다. model이 tool 호출 없이 답하면 loop가 끝난다.
- model이 받는 것은 사용자 지시, 사용 가능한 tool 목록, 대화 기록뿐이다. 어떤 OS에서 실행되는지, 작업 디렉터리가 어디인지, 그 저장소에 어떤 규칙이 있는지는 model이 tool을 호출해서 알아내야 한다. 이 문제는 Part II에서 다룬다.
H0 — Hello Harness!
Note
- 시작 상태:
h00· 완료 상태:h01- 논문: §6 Agent Loop Design, §7 LLM Integration and Model–Agent Co-design
git checkout -b my-h00 h00
LLM을 agent로 만드는 최소 구조는 무엇인가?
들어가며
tool calling과 agent loop
model은 텍스트를 받아 텍스트를 만든다. 이게 model이 할 수 있는 전부다. 파일을 읽거나 명령을 실행하진 못한다. 이러한 model이 바깥세상에 영향을 주려면 harness가 그 일을 대신 해 줘야 한다. 이때 쓰는 방식이 tool calling이다.
sequenceDiagram
participant H as harness
participant M as model
H->>M: 지시 + 사용할 수 있는 tool 목록
M-->>H: tool 호출 요청 (read_file, "hello.txt")
H->>H: 파일을 읽는다
H->>M: 지금까지의 대화 + tool 실행 결과
M-->>H: 최종 답
- model은 tool을 실행하지 않는다. “이 tool을 이 인자로 호출해 달라“는 요청을 harness에게 전달한다.
- harness는 요청받은 tool을 실행하고, 결과를 대화에 붙여 model을 다시 부른다.
- model은 이전 호출을 기억하지 못한다. 그래서 harness는 이전 대화 정보를 누적해 다시 보낸다.
그리고 이 반복을 agent loop라고 부른다. 이번 Lab에서는 model이 tool 호출 없이 답하면 loop가 종료되도록 설정할 것이다.
tool 목록과 tool 호출 요청을 주고받는 형식은 공식 표준이 없다. provider마다 API 형식은 다르지만, OpenAI Chat Completions 형식을 다른 provider도 지원(“OpenAI 호환”)하면서 사실상 많이 쓰이는 형식이 되었다(2026-10 기준).
| OpenAI Chat Completions | Anthropic Messages | |
|---|---|---|
| tool 목록 | tools[].function (name, description, parameters) | tools[] (name, description, input_schema) |
| tool 호출 요청 | assistant message의 tool_calls. 인자는 JSON 문자열 | content의 tool_use block. 인자는 JSON 객체 |
| tool 결과 | role: "tool" message | user message 안의 tool_result block |
사용 가능한 tool 목록에는 각 tool의 이름, 설명, 인자 형식이 들어간다. 두 형식 모두 tool의 인자를 JSON Schema로 정의한다. model 내부에서 사용 가능한 tool 목록과 호출이 어떤 텍스트로 바뀌는지는 provider가 처리하고 API 밖으로 드러나지 않는다. 이 Lab의 hel은 OpenAI 호환 형식을 쓴다. DeepSeek는 두 형식을 모두 제공해서(OpenAI 호환, Anthropic 호환), Anthropic 형식을 쓰는 Claude Code도 같은 model로 실행할 수 있다.
agent loop란?
Harness Engineering 논문은 coding harness 11개를 소스 코드로 분석했다. agent loop를 다루는 §6의 내용은 이렇다.
- agent loop는 model 추론과 action 실행을 번갈아 하고, 언제 멈출지와 실패했을 때 어떻게 할지를 정하는 harness의 뼈대다.
- loop의 형태는 세 가지로 나뉜다.
| 형태 | 내용 | 예 |
|---|---|---|
| 반복형(action-observation) | 행동하고 결과를 보고 다음 행동을 정함 | 11개 중 9개 |
| 되돌아보기 추가형(reflection) | 결과를 검토하는 단계를 loop에 추가 | Aider |
| 조정자-작업자형(coordinator-worker) | 한 agent가 작업을 나눠 다른 agent에게 맡김 | Claude Code, Codex |
- 같은 반복형 안에서도 구현 차이가 크다. Mini-SWE-Agent는 bash 위의 단순한 while loop이고, OpenHands는 대화를 event 기록으로 남기며, Claude Code는 응답을 streaming으로 받으면서 tool을 동시에 실행하고, Codex는 비동기 state machine으로 만들었다.
- 논문은 loop가 정교하다고 task를 더 잘 끝내지는 않는다고 본다. 약 100줄인 Mini-SWE-Agent가 훨씬 복잡한 OpenHands와 비슷한 결과를 낸다고 했다.
model 호출을 다루는 §7에는 이번 Lab과 관련된 내용이 두 가지 있다.
- prompt를 만드는 방식은 템플릿 하나로 한 번에 만드는 것(Mini-SWE-Agent)부터 여러 단계로 조립하고 cache 경계를 두는 것(Claude Code)까지 다양하다.
- model provider와 묶이는 정도도 다르다. 한 provider에 맞춰 만든 harness가 있고, 여러 provider를 추상화한 harness가 있다.
참고 자료
- Mini-SWE-Agent는 약 100줄이다. 기본 설정은 tool calling API로
bashtool 하나를 보낸다. model이 쓴 텍스트에서 bash 명령을 찾아 실행하는 방식도 설정으로 고를 수 있다(2026-10 기준). 대화는 순서대로 쌓기만 한다.
이번에 해볼 것
h00에는 실행 기록 형식(crates/record)과 측정 도구(crates/evals)만 있고 harness는 없다. 여기서부터 직접 harness hel을 처음부터 만든다.
먼저 만드는 것은 model을 한 번만 부르는 프로그램이다. model에게 지시를 보내고 받은 답을 출력하고 끝난다.
이 프로그램에 “hello.txt를 읽고 내용을 그대로 출력하라“고 지시하면 model은 파일을 읽을 방법이 없다. 실제로 실행하면 model은 파일에 접근할 수 없다고 답한다.
이 Lab에서 확인할 수 있는 내용은 이렇다.
- model API를 직접 호출하는 방법과 응답 구조
- 사용 가능한 tool 목록, tool 호출 요청, tool 결과가 오가는 형식
- agent loop의 시작과 끝을 정하는 조건
- loop가 token 사용량을 어떻게 바꾸는가
논문의 분류로 보면 가장 단순한 반복형 loop이고, Mini-SWE-Agent 쪽 끝에서 출발한다. tool calling API를 쓰는 것은 Mini-SWE-Agent의 기본 설정과 같고, bash 대신 파일 읽기 전용 tool read_file 하나만 둔다는 점이 다르다. tool 등록 구조는 만들지 않는다. tool이 하나뿐이라 아직은 필요가 없다.
실습
1. model을 한 번 호출하기
workspace에 hel crate를 만든다. workspace 안에서는 --vcs none을 붙여야 crate 폴더에 .git이 생기지 않는다.
cargo new --vcs none crates/hel
crates/hel/Cargo.toml에 쓰는 의존성은 이렇다.
[dependencies]
record = { path = "../record" }
reqwest = { version = "0.13", features = ["blocking", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
humantime = "2"
DeepSeek Chat Completions API에 지시 하나를 보내고 답을 출력하는 프로그램을 만든다. 요청은 이렇게 생겼다.
POST https://api.deepseek.com/chat/completions
Authorization: Bearer $DEEPSEEK_API_KEY
{
"model": "deepseek-flash",
"messages": [{ "role": "user", "content": "<지시>" }],
"max_tokens": 8192
}
응답에서 쓰는 필드는 네 가지다.
| 필드 | 용도 |
|---|---|
choices[0].message.content | 최종 답 |
choices[0].finish_reason | 종료 사유. length면 출력 token 한도에 걸림 |
usage.prompt_tokens, usage.completion_tokens | 사용한 token |
model | 실제로 응답한 model |
Warning
reqwest의 blocking client는 기본 timeout이 30초다. reasoning이 긴 응답은 30초를 넘을 수 있으므로 timeout을 직접 지정한다.
다 만들었으면 직접 실행해 본다.
cd evals/tasks/read-echo-01/fixture
cargo run -p hel -- --instruction "Read the file hello.txt and print its contents exactly as they are."
hel은 실행한 폴더를 작업 디렉터리로 쓴다. 이 폴더에는 hello.txt가 있지만, 지금의 hel은 model에게 지시만 전달하므로 model은 파일에 접근할 수 없다고 답한다.
2. evals로 지금 상태 확인하기
evals는 task를 실행하고 채점하는 도구다. 저장소 루트에서 설치한다.
cargo install --path crates/evals --locked --target-dir target
task 하나를 실행해 본다. --build는 설치된 hel 대신 작업 중인 코드를 빌드해서 쓴다는 뜻이다.
evals try read-echo-01 --build
출력은 이런 순서로 나온다.
| 항목 | 내용 |
|---|---|
task | task ID, 실행한 harness와 버전, model |
input | harness에 전달한 지시 |
tool calls | harness가 실행한 tool 호출. 지금은 (none) |
output | 최종 출력 |
checks | 채점 결과. output-exact는 출력이 파일 내용과 같은지, single-read는 읽기 tool을 정확히 1번 호출했는지 본다 |
usage | input/output token, model 호출 수, 걸린 시간 |
saved | 기록이 저장된 폴더 (results/try/) |
지금은 output-exact가 실패한다. 코드를 고치기 전에 이 상태를 기록해 둔다. baseline은 Lab을 시작한 상태를 가리키는 이름이다.
evals run h00 --conditions baseline --build
같은 task를 3번 실행해 results/h00/에 기록한다. 코드를 고친 뒤에는 이 상태로 돌아가 다시 기록할 수 없으므로 지금 해 둔다.
3. read_file tool 붙이기
요청에 tools를 붙이면 model이 이 tool을 호출할 수 있다. tool을 따로 등록하는 절차는 없다. provider가 사용 가능한 tool 목록을 model 입력에 넣고, model은 tool을 부를지 직접 답할지 정한다. model이 tool을 부르면 provider가 그 출력을 tool_calls로 바꿔 돌려준다. 실행은 harness가 한다.
아래 예시는 OpenAI Chat Completions 양식을 바탕으로 tools[].function을 작성한 것이다.
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a UTF-8 text file in the working directory and return its full contents.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path of the file, relative to the working directory." }
},
"required": ["path"]
}
}
}
tool을 실행할 때는 경로를 작업 디렉터리 기준으로 정규화하고, 작업 디렉터리 밖이면 거부한다. model이 요청한 파일 내용은 그대로 API로 전송되기 때문이다. 오류도 text로 model에 돌려준다.
4. agent loop 만들기
messages = [user 지시]
최대 turn 수만큼 반복:
응답 = model 호출(messages, tools)
messages에 응답의 assistant message를 그대로 추가
tool 호출이 없으면 → content가 최종 답, 종료
tool 호출마다:
결과 = tool 실행
messages에 { role: "tool", tool_call_id, content: 결과 } 추가
최대 turn 수에 닿으면 → 종료
- tool 호출의
function.arguments는 JSON을 문자열로 담은 값이다. 한 번 더 parse해야 한다. - assistant message는 고치지 않고 그대로 다시 보낸다. thinking이 켜진 model은
reasoning_content도 함께 온다.
5. 실행 기록 남기기
evals가 채점하려면 hel이 실행 기록(record)을 남겨야 한다. evals는 --context로 실행 정보를 넘기고, --record로 기록할 경로를 준다. 형식은 evals/schema/record.md, 실행 규약은 evals/harnesses/hel/PROFILE.md에 있다.
- tool 호출마다
events에 이름, 인자, 성공 여부를 남긴다. - 원본 요청과 응답은
raw/에 남기되Authorizationheader는 저장하지 않는다.
read_file처럼 결과를 미리 정할 수 있는 부분은 test로 확인한다.
cargo test -p hel
결과 확인
같은 task(read-echo-01)를 조건마다 3번씩 실행했다. 단발 호출은 실습 2단계에서 남긴 기록이고, loop + read_file은 5단계까지 마친 hel이다. Claude Code도 같은 model(deepseek-flash)로 실행했고 읽기 tool(Read)만 열어 두었다.
evals run h00 --conditions variant --build
evals run h00 --conditions external-claude-code
evals report h00
| 단발 호출 | loop + read_file | Claude Code | |
|---|---|---|---|
output-exact | 0/3 | 3/3 | 3/3 |
| 읽기 tool 호출 | 0회 | 3번 모두 1회 | 3번 모두 1회 |
| model 호출 | 1 | 2 | 2 |
| input / output token (평균) | 50 / 528 | 755 / 94 | 1274 / 104 |
| 걸린 시간 (평균) | 3.4초 | 1.8초 | 2.0초 |
단발 호출
3번 모두 파일에 접근할 수 없다고 답했다.
I don't have access to the file hello.txt.
I can’t access local files.
I can't access the file hello.txt.
출력 token은 368–721이었고, 거의 전부가 reasoning이었다(예: 721 중 709).
loop + read_file
3번 모두 같은 순서로 끝났다. 첫 번째 run의 요청과 응답 원본(raw/requests.jsonl)을 들어가며의 다이어그램 순서대로 보면 이렇다. 반복되는 사용 가능한 tool 목록은 생략했다.
① harness → model: 지시 + 사용할 수 있는 tool 목록
{
"model": "deepseek-flash",
"max_tokens": 8192,
"messages": [
{ "role": "user", "content": "Read the file hello.txt and print its contents exactly as they are. Do not add anything else.\n" }
],
"tools": [{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a UTF-8 text file in the working directory and return its full contents.",
"parameters": {
"type": "object",
"properties": { "path": { "type": "string", "description": "Path of the file, relative to the working directory." } },
"required": ["path"]
}
}
}]
}
② model → harness: tool 호출 요청
model은 답(content) 대신 read_file을 고르고 인자를 채웠다. finish_reason이 "tool_calls"이고, arguments는 JSON이 문자열에 담겨 온다.
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "The user wants me to read hello.txt and print contents exactly. Let me read the file. Note: I should call the tool first.",
"tool_calls": [{
"id": "call_00_C5bVwDYo0rT2WV3hSk0T4242",
"type": "function",
"function": { "name": "read_file", "arguments": "{\"path\": \"hello.txt\"}" }
}]
}
}
③ harness: 파일을 읽는다
hel이 name으로 read_file을 찾고, arguments를 parse해 hello.txt를 읽었다. 결과는 Hello, harness!\n이다.
④ harness → model: 지금까지의 대화 + tool 실행 결과
"messages": [
{ "role": "user", "content": "Read the file hello.txt and print its contents exactly as they are. Do not add anything else.\n" },
{ "role": "assistant", "content": "", "reasoning_content": "The user wants me to ...", "tool_calls": [ ...②와 같음... ] },
{ "role": "tool", "tool_call_id": "call_00_C5bVwDYo0rT2WV3hSk0T4242", "content": "Hello, harness!\n" }
]
②의 assistant message를 그대로 붙이고, tool 결과는 tool_call_id로 어떤 호출의 결과인지 표시한다. tool 목록도 다시 보낸다.
⑤ model → harness: 최종 답
{
"finish_reason": "stop",
"message": { "role": "assistant", "content": "Hello, harness!", "reasoning_content": "" }
}
tool_calls가 없으므로 loop가 끝났다.
tool이 하나뿐이라 model이 고를 수 있는 것은 read_file을 부르거나 바로 답하는 것 두 가지였다. 여러 tool 중에서 고르는 모습은 tool이 늘어나는 이후 Lab에서 볼 수 있다.
두 번째 호출의 reasoning은 3번 중 2번이 0 token이었고, 1번은 46 token이었다.
Claude Code
3번 모두 Read를 1번 부르고 Hello, harness!를 출력했다. hel과 다른 점은 두 가지다.
- 경로를 절대 경로로 넘겼다.
hel에서는 3번 모두hello.txt였다. - 읽기 결과에 줄 번호와 탭이 붙어 있었다. 파일 끝의 줄바꿈 때문에 빈 2번째 줄도 함께 왔다(
\t는 탭).
1\tHello, harness!
2\t
model은 번호를 떼고 내용만 출력했다.
같은 model이지만 Claude Code는 Anthropic 형식으로 주고받았다. tool 호출 요청은 tool_use block으로 오고 인자는 JSON 객체다.
{ "type": "tool_use", "id": "call_00_...", "name": "Read", "input": { "file_path": "/.../hello.txt" } }
{ "type": "tool_result", "tool_use_id": "call_00_...", "content": "1\tHello, harness!\n2\t" }
돌아보기
변경 사항
harness를 달아주니 model이 파일을 읽을 수 있게 됐다. model의 능력은 그대로이고, 바뀐 것은 harness가 model 대신 파일을 읽어 준다는 점이다.
트레이드오프
token 사용량을 호출별로 보면 이렇다(run 하나 기준).
| 호출 | input | output (그중 reasoning) | |
|---|---|---|---|
| 단발 호출 | 1 | 50 | 721 (709) |
loop + read_file | 1 | 333 | 67 (28) |
| 2 | 416 | 5 (0) |
- input은 늘었다. 매 호출에 사용 가능한 tool 목록(약 280 token)가 붙고, 두 번째 호출에는 첫 응답과 tool 결과까지 다시 보낸다. loop가 길어질수록 다시 보내는 대화도 길어진다.
- output은 줄었다. 단발 호출에서 model은 파일을 읽을 수 없는 상황에서 어떻게 답할지 709 token 동안 고민했다. tool이 있으면 28 token 만에
read_file을 부르기로 했고, 결과를 받은 뒤에는 고민 없이 내용을 출력했다.
지금은 호출 두 번이라 비용이 작지만, tool 호출이 많아지면 매번 다시 보내는 대화가 비용의 대부분이 된다. Production 레벨의 harness는 이러한 문제를 고민하고 해결하기 위해 메모리 관리와 compaction을 제공한다. 우리도 추후에 이를 다룰 것이다.
논문의 내용 또는 다른 harness와 비교하면
- Claude Code: §7의 여러 단계 prompt 조립이 첫 호출 크기에 보인다.
hel | Claude Code | |
|---|---|---|
| 첫 호출 input token | 333 | 581 |
| 두 번째 호출 input token (그중 cache) | 407–444 (256) | 690–698 (512) |
| 읽기 tool 결과 | 파일 내용 그대로 | 줄마다 번호와 탭을 붙여 전달 |
- 시스템 프롬프트와 tool 설명이 길어서 첫 호출이 더 크다. 실제 개발 작업 전반을 다루려면 model에게 알려줄 것이 많다.
- 줄 번호는 파일의 특정 위치를 가리킬 때 유용한 출력 형식이 될 것이다.
- 두 harness 모두 두 번째 호출의 앞부분을 provider cache에서 읽었다. DeepSeek API는 앞부분이 같은 요청을 자동으로 cache한다(2026-10 기준).
hel은 cache를 따로 요청하지 않았다. 대화가 길어질수록 의미가 커진다.
FAQ
Claude Code의 줄 번호와 cache
Claude Code의 읽기 tool은 줄마다 번호와 탭을 붙여 돌려준다. 그대로 출력하는 task에는 방해가 되지만, model이 “12번째 줄“처럼 위치를 말하거나 편집할 범위를 정할 때는 사용자 입장에서 도움이 될 수 있다.
두 번째 호출에서는 Claude Code가 앞부분 512 token, hel이 256 token을 provider cache에서 읽었다. hel은 cache를 따로 요청하지 않았다. DeepSeek API가 앞부분이 같은 요청을 자동으로 cache한 것이다.
reasoning도 출력 token에 포함된다
baseline을 시작하는 시점에서 출력 token의 한도를 2048로 제한했다. 그리고 이 상황에서 model에게 요청을 보냈다.
model은 자신이 가진 tool이 없는 상태였기에 ’파일에 접근할 수 없는데, 이를 어떻게 하지. 내용을 지어내야 하나?(추측)’와 같은 reasoning을 약 9천여 자 동안 고민하다가 token 제한 한도를 모두 reasoning에 쓰고 빈 답으로 끝났다.(finish_reason: length) 이를 통해 thinking이 켜진 model은 reasoning도 출력 token에 포함됨을 확인할 수 있었다.
출력 제한에 답변이 잘리지 않도록 한도를 8192로 늘리고, 같은 요청을 6번 더 보냈다. 그러나 이번에 출력 token은 368~721 수준에서 끝났다. API로 호출하는 model이 내부에서 이 데이터를 확인한건지 아닌지 모르겠으나 같은 지시에서도 매번 다른 출력을 하는 것을 확인할 수 있다.
reasoning 설정은 harness가 결정한다. 이 설정이 task 결과와 비용에 어떤 영향을 주는지는 아직 확인하지 않았다. Harness Evaluation에서 확인해볼만한 내용이다.
hel은 지시 하나만 받는다
지금의 hel은 --instruction으로 지시 하나를 받아 끝까지 실행하고 끝난다. 사람과 주고받으며 쓰는 대화형 CLI는 아직 없다. 측정은 이 방식으로 충분하지만, 직접 써 보면서 harness를 키우려면 대화형 틀이 필요하다. H1 전에 대화형 기본 틀에서 만든다.
전용 tool이 아니라 범용 bash를 쓴다면?
Mini-SWE-Agent는 bash 하나로 모든 일을 한다. 파일 읽기도 cat이면 된다. 전용 tool은 할 수 있는 일이 좁아서 경로 검사 같은 제한을 걸기 쉽고, bash는 무엇이든 할 수 있는 대신 제한하기 어렵다.
대화형 도구의 기본 틀
Note
- 이 페이지의 코드는 H0 완료 상태
h01에 들어 있다.
H0의 hel은 --instruction으로 지시 하나를 받아 loop를 끝까지 돌리고 종료한다. 측정에는 이 방식으로 충분하다. 하지만 H1부터 harness를 키우면서 직접 써 보려면 사람과 주고받는 틀이 필요하다. 이 페이지에서는 그 틀만 만든다. 새 tool이나 loop 변경은 없다.
무엇을 바꾸나
H0의 agent loop(run_loop)는 대화 기록 messages를 받아, 그 안에 assistant 응답과 tool 결과를 덧붙인다. 대화형 모드는 이 loop를 한 번 더 감싸기만 하면 된다.
사람 입력 한 줄
→ messages에 user message 추가
→ run_loop (model 호출 ↔ tool 실행, 최대 max_turns번)
→ 최종 답 출력
→ 다음 입력 대기 (messages는 그대로 유지)
model이 이전 호출을 기억하지 못하지만, 앞 대화를 기억하는 것처럼 보이는 이유는 messages에서 전체 내용을 매번 다시 보내는 것 덕분이다. 기존 loop 안에서 전체 내용을 누적해 전달하는 것과 같은 원리이다.
CLI의 사용 방법은 다음과 같다.
| 항목 | 동작 |
|---|---|
| 진입 | --instruction 없이 hel 실행 |
| 기존 실행 | --instruction, --context, --record는 그대로. --context는 --instruction과 함께일 때만 허용 |
| 입력 | 한 줄씩. 빈 줄은 무시 |
| 종료 | /exit 또는 입력 끝(Ctrl-D) |
| turn 한도 | 입력 하나마다 max_turns(기본 5)번까지 model 호출 |
| tool 호출 표시 | 입력 하나가 끝난 뒤 [tool] 이름 인자 형식으로 stderr에 출력 |
| 실행 기록 | 남기지 않음 |
만들기
1. --instruction을 선택 인자로
Args.instruction을 Option<String>으로 바꾸고, 인자 해석에서 필수 검사를 뺀다. 대화형 모드는 record를 쓰지 않으므로 --context만 있고 --instruction이 없는 조합은 막는다.
#![allow(unused)]
fn main() {
struct Args {
instruction: Option<String>,
context: Option<PathBuf>,
record: Option<PathBuf>,
}
}
#![allow(unused)]
fn main() {
if context.is_some() && instruction.is_none() {
return Err("--context needs --instruction".to_string());
}
}
run()에서는 client와 사용 가능한 tool 목록을 만든 뒤 지시가 없으면 대화형 함수로 넘긴다.
#![allow(unused)]
fn main() {
let Some(instruction) = &args.instruction else {
return chat(&client, &workdir, &tool_defs, ctx.budget.max_turns);
};
}
2. 입력 loop
messages는 loop 밖에서 한 번만 만들고, 실행 기록용 RunLog는 입력마다 새로 만든다. RunLog의 종료 사유와 최종 답이 입력 하나 단위로 맞게 된다.
#![allow(unused)]
fn main() {
fn chat(
client: &api::Client,
workdir: &Path,
tool_defs: &Value,
max_turns: u32,
) -> Result<(), Box<dyn Error>> {
let stdin = io::stdin();
let mut messages = Vec::new();
eprintln!("hel — type /exit or press Ctrl-D to quit");
loop {
print!("> ");
io::stdout().flush()?;
let mut line = String::new();
if stdin.read_line(&mut line)? == 0 {
println!();
return Ok(());
}
let input = line.trim();
if input.is_empty() {
continue;
}
if input == "/exit" {
return Ok(());
}
messages.push(json!({ "role": "user", "content": input }));
let mut log = RunLog::new();
run_loop(client, workdir, tool_defs, &mut messages, max_turns, &mut log);
// 3. 결과 출력
}
}
}
print!은 줄바꿈이 없어서 바로 화면에 나오지 않는다.flush()로 내보내야>가 입력 전에 보인다.read_line은 읽은 byte 수를 돌려준다. 0이면 입력이 끝난 것(Ctrl-D, 또는 pipe가 닫힘)이다.
3. 결과 출력
#![allow(unused)]
fn main() {
for event in &log.events {
let status = if event.ok == Some(false) { " (failed)" } else { "" };
eprintln!("[tool] {} {}{status}", event.name, event.args);
}
if let Some(output) = &log.final_output {
println!("{output}");
}
if let Some(error) = &log.error {
eprintln!("hel: {error}");
}
if log.termination == Termination::MaxTurns {
eprintln!("hel: stopped after {max_turns} model calls");
}
}
답은 stdout, tool 호출과 오류는 stderr로 나눈다. H0의 단발 실행과 같은 규칙이다. 오류가 나도 프로그램을 끝내지 않고 다음 입력을 받는다.
실행 결과
저장소 루트에서 실행했다. 작업 디렉터리가 저장소 루트이므로 read_file은 저장소 안의 파일을 읽을 수 있다. (아래 기록의 저장소 경로는 /path/to/HEL로 바꿔 적었다.)
cargo run -q -p hel
1. tool 없이 답하기
hel — type /exit or press Ctrl-D to quit
> hi
Hi! What can I help you with today?
If you have a file in the working directory you'd like me to read or look over, just let me know its path.
> 안녕
안녕하세요! 무엇을 도와드릴까요?
작업 디렉터리에 읽어볼 파일이 있다면 경로를 알려주세요.
> 너가 사용할 수 있는 도구가 뭔데
제가 사용할 수 있는 도구는 딱 하나입니다:
- **read_file** : 작업 디렉터리에 있는 UTF-8 텍스트 파일을 읽어서 전체 내용을 반환합니다.
**사용 방법**
- 파라미터: `path` (작업 디렉터리를 기준으로 한 상대 경로)
- 예: `read_file(path="notes.txt")`
**할 수 없는 것**
- 파일 쓰기/수정/삭제
- 디렉터리 목록 조회나 파일 검색
- 명령어 실행
그래서 파일 이름을 정확히 알려주시면 제가 내용을 읽어서 요약, 번역, 분석, 코드 리뷰 등을 도와드릴 수 있습니다. 어떤 파일을 열어볼까요?
- 세 입력 모두 tool을 호출하지 않았다.
hel은 시스템 프롬프트를 보내지 않는다. 그런데 “hi“에 대한 첫 답부터 파일 경로를 묻는다. model이 받은 것은 대화와 사용 가능한 tool 목록뿐이고, 그것만 보고 자기 역할을 파일을 읽어 주는 쪽으로 잡았다.- 도구 설명은
read_file정의의description(“Read a UTF-8 text file in the working directory and return its full contents.”)과path설명을 거의 그대로 옮겼다. “할 수 없는 것” 목록은 정의에 없는 내용이다. 다른 tool이 없다는 데서 model이 추론했다.
2. 파일 읽기
> /path/to/HEL/evals/tasks/read-echo-01/fixture/hello.txt 내용 읽고 알려줘
[tool] read_file {"path":"/path/to/HEL/evals/tasks/read-echo-01/fixture/hello.txt"}
[tool] read_file {"path":"hello.txt"} (failed)
읽었습니다. ✅
**파일:** `/path/to/HEL/evals/tasks/read-echo-01/fixture/hello.txt`
**내용:**
```
Hello, harness!
```
한 줄짜리 텍스트 파일이고, 내용은 `Hello, harness!` 입니다. (참고로 `hello.txt` 같은 짧은 상대 경로로는 찾지 못했고, 알려주신 전체 경로로는 정상적으로 읽혔습니다.)
- 사용 가능한 tool 목록에서
read_file의 인자 설명은 상대 경로를 요구하지만 model은 받은 절대 경로를 그대로 넘겼다. 그래도 읽혔다.read_file은 작업 디렉터리에 경로를 이어 붙인 뒤 실제 경로로 바꿔서 작업 디렉터리 안인지 확인한다. 절대 경로를 이어 붙이면 절대 경로가 그대로 남고, 이 파일은 저장소 안에 있어서 통과했다. 저장소 밖의 절대 경로였다면 거부된다. - 첫 호출로 이미 내용을 읽었는데
hello.txt로 한 번 더 시도했다. 이 호출은 작업 디렉터리(저장소 루트)에hello.txt가 없어 실패했다. model은 실패도 답에 덧붙여 알렸다. [tool]줄은 답보다 먼저 나오지만, 실제로는 입력 하나가 끝난 뒤 한꺼번에 tool이 호출된 순서대로 출력된 것이다.
3. 앞 대화에 이어서 읽기
> README.md 내용도 확인
[tool] read_file {"path":"README.md"}
[tool] read_file {"path":"/path/to/HEL/README.md"}
[tool] read_file {"path":"/path/to/HEL/evals/tasks/read-echo-01/README.md"} (failed)
[tool] read_file {"path":"/path/to/HEL/evals/tasks/read-echo-01/fixture/README.md"} (failed)
`README.md`를 찾았습니다. (작업 디렉터리가 저장소 루트라 `README.md` 상대 경로로도 읽혔고, `evals/tasks/...` 하위에는 없었습니다.)
**파일:** `/path/to/HEL/README.md` — 저장소 루트의 README
## 요약
(중략: 구성, Lab 진행, 준비물, 설치, hel·evals 사용법 요약)
즉, 이 저장소는 **"read_file tool 하나만 있는 최소 harness를 만들고 → task(`read-echo-01` 등)로 측정하고 → 결과를 기록"** 하는 구조입니다. 앞서 읽은 `hello.txt`(`Hello, harness!`)는 바로 `read-echo-01` task의 fixture 입력 파일이네요.
- “README.md“만 말했는데 model은 앞 입력에서 다룬
read-echo-01폴더의 README까지 찾아보았다. 마지막 문장도 앞에서 읽은hello.txt와 이어서 설명한다. 대화 기록이 다음 입력에 함께 간다는 것이 이 두 곳에서 보인다. - 입력 하나에 tool 호출이 4번 일어났다. 같은 파일을 상대 경로와 절대 경로로 두 번 읽었고, 없는 경로 두 개를 추측해서 시도했다. 파일 목록을 보는 tool이 없어서 model은 경로를 추측할 수밖에 없다.
- README 전체가 tool 결과로
messages에 들어갔다. 같은 내용이 두 번 들어갔고, 이후 입력마다 다시 보내진다.
돌아보기
- 대화가 길어질수록 매 호출에 보내는
messages가 커진다. 입력 token과 비용이 대화 길이에 따라 늘어나고, 현재 줄이는 장치는 없다. - 입력 하나에서
max_turns를 다 쓰거나 API 오류가 나면, 대화 기록이 tool 결과나 user message로 끝난 상태에서 다음 입력이 붙는다. 이러한 오류와 예외는 실험을 진행하며 하나씩 고쳐 나간다. - 대화형 실행은 record를 남기지 않아 테스트에는
evals를 그대로 사용한다. - harness는 model의 tool 호출을 그대로 실행한다. 같은 파일을 다시 읽거나 경로를 추측해 여러 번 시도해도 막지 않고, 그만큼 model 호출과 token을 쓴다. 파일 목록을 보는 tool이 없는 것도 추측을 늘린다. H1에서는 전용 tool 대신 bash 하나를 주고 어디까지 되는지 본다.
다른 harness와 비교하면
Mini-SWE-Agent의 mini CLI도 대화를 순서대로 쌓기만 한다(2026-10 확인). 다른 점은 사람이 끼어드는 위치다. 기본 모드에서는 model이 제안한 명령을 사람이 확인한 뒤에 실행하고, agent가 끝났다고 하면 새 task를 이어서 줄 수 있다. 지금의 hel은 tool 실행 전에 묻지 않는다. 읽기 전용 tool 하나뿐이라 아직은 필요가 없고, 이 문제는 H7 Permissions에서 다룬다.
Claude Code는 응답을 streaming으로 받아 tool 실행 중에도 진행 상황을 보여주고, 대화가 길어지면 요약해서 줄이며, 대화를 저장했다가 다시 이어 갈 수 있다. 대화 길이는 H5 Context Budget과 H6 Compaction, 저장과 재개는 H9 Sessions & Checkpoints에서 다룬다.
FAQ
입력 하나마다 turn 한도를 새로 준다
max_turns(기본 5)는 대화 전체가 아니라 입력 하나에 적용한다. 대화 전체의 한도를 두면 대화가 길어질수록 남은 호출 수가 줄어 마지막 입력이 갑자기 끊긴다. 대신 대화 전체에서 얼마나 쓰는지는 제한하지 않는다. 대화 단위의 token과 비용 관리는 H5 Context Budget에서 다룬다.
H1 — Tool Loop
Note
- 시작 상태:
h01· 완료 상태:h02- 논문: §8 Tool and Action Systems, §16.3 Tool Design
git checkout -b my-h01 h01
bash 하나만 주면 model은 어디까지 스스로 해낼 수 있을까?
들어가며
전용 tool과 bash tool
H0의 hel에는 read_file tool 하나만 있다. model이 파일 경로를 넘기면 harness가 그 파일을 읽어 내용을 돌려준다. 이렇게 한 가지 일만 하도록 만든 tool을 이 글에서는 전용 tool이라고 부른다.
bash tool은 명령 문자열 하나를 받는다. harness는 그 문자열을 shell에서 실행하고 종료 코드와 출력을 돌려준다. 파일을 읽을지(cat), 목록을 볼지(ls), 파일을 찾을지(find)는 model이 명령을 쓰면서 정한다.
전용 tool (read_file) | bash tool | |
|---|---|---|
| model이 넘기는 것 | 파일 경로 | 명령 문자열 |
| 할 수 있는 일 | tool을 만들 때 정한 일 하나 | shell에서 되는 모든 일 |
| harness가 아는 것 | 하려는 일(읽기)과 대상 경로 | 문자열뿐. 하려는 일은 명령을 해석해야 앎 |
| 결과 형식 | harness가 정함 | 명령마다 다름. 종료 코드, stdout, stderr |
| 실패했을 때 | harness가 정한 오류 메시지 | 명령이 낸 오류와 종료 코드 |
bash tool 하나를 주면 tool을 더 만들지 않아도 할 수 있는 일이 늘어난다. 그 대신 harness는 명령이 무엇을 하는지 모르고, 결과의 크기와 형식도 정하지 못한다.
논문은 tool을 어떻게 보나
Harness Engineering 논문의 §8은 tool을 agent가 실제로 할 수 있는 일을 정하는 부분으로 보고, 성숙한 harness일수록 여기에 engineering을 많이 쓴다고 했다.
- harness마다 tool 수 차이가 크다.
| Harness | 기본 tool 수 |
|---|---|
| Mini-SWE-Agent | 1 (bash) |
| Pi | 7 (기본 노출 4) |
| OpenCode | 17 |
| Claude Code | 43 |
| Hermes | 69 |
- tool을 감싸는 정도도 다르다. Mini-SWE-Agent는 모든 action을 shell 호출 하나로 보내고, 시간이 넘으면 프로세스를 정리하는 것만 감싼다. Claude Code는 tool마다 입력 검증, 권한 확인, 동시 실행 가능 여부, 화면 표시를 따로 정의한다. 논문은 감싸는 범위가 넓을수록 안전 장치는 늘지만 만드는 비용도 커진다고 정리했다.
- model에게 주는 지시도 갈린다(§7.3). Claude Code는 전용 tool이 있으면 Bash를 쓰지 말라고 지시한다. 사용자가 작업을 이해하고 검토하기 쉽다는 이유다. Mini-SWE-Agent는 응답마다 bash 명령 하나만 쓰게 한다.
§16.3의 권고는 bash tool 하나로 시작하고, 실패를 관찰했을 때만 tool을 더하라는 것이다. 근거로 Mini-SWE-Agent가 bash 하나로 SWE-bench Verified에서 74% 이상을 보고한 것을 든다(Mini-SWE-Agent 측의 자체 보고 수치). tool을 더하는 순서도 적었다.
Note
h01이후에는 논문의 권고 사항을 반영하여hel의 기본 사용 도구를bash로 설정할 것이다.
- bash 출력이 잘리는 것이 문제가 되면
read_file,write_file find나rg로 찾기가 불편하면 grep, glob- 파일 전체를 다시 쓰느라 token이 낭비되면
search_replace
§15.5는 harness 구조를 더할수록 task 성공률이 처음에는 빠르게 오르다가 평평해진다는 직관을 제시한다. 빠르게 오르는 구간의 예로 bash tool과 읽기·쓰기 tool을 든다. 이번 Lab에서는 이 직관을 간단하게 테스트해볼 것이다.
참고 자료
- Mini-SWE-Agent의 기본 설정은 tool calling API로
bashtool 하나(인자command)를 보낸다. (텍스트에서 명령을 찾는 방식은 선택 설정으로 남아 있다. 2026-10 기준.)- 명령은 호출마다 새 shell에서 실행한다. 그래서
cd가 다음 호출로 이어지지 않는다. - 종료 코드와 출력을 함께 돌려준다. 출력이 10,000자를 넘으면 앞뒤 5,000자만 보내고
head,tail,sed로 일부만 보라고 안내한다. - 기본 timeout은 30초이고, 넘으면 명령이 만든 프로세스를 모두 종료한다.
- 명령은 호출마다 새 shell에서 실행한다. 그래서
- Claude Code(Tools reference, 2026-10 기준)는 Bash와 전용 Read를 함께 둔다.
- Read는 줄 번호를 붙여 돌려주고, 큰 파일은 일부만 보여 주며 이어 읽는 방법을 알려 준다. 디렉터리는 읽지 못해서
ls는 Bash로 한다. - macOS와 Linux에서는 파일 찾기(Glob)와 내용 검색(Grep) 전용 tool을 기본 목록에서 빼고 Bash의
find,grep을 쓰게 했다. - Bash 결과는 약 30,000자까지 그대로 보내고, 넘으면 파일로 저장한 뒤 앞부분만 보여 준다.
- Read는 줄 번호를 붙여 돌려주고, 큰 파일은 일부만 보여 주며 이어 읽는 방법을 알려 준다. 디렉터리는 읽지 못해서
이번에 해볼 것
출발 상태는 h01의 hel이다. tool은 read_file 하나이고, 대화형 모드와 --instruction 실행을 모두 지원한다.
여기에 bash tool을 만들고, model에게 주는 tool 구성을 실행할 때 고를 수 있게 한다(--tools). 같은 task를 세 가지 구성으로 실행해 비교한다.
| 구성 | 주는 tool | 확인할 것 |
|---|---|---|
| 시작 상태 | read_file | 비교 기준 |
| bash만 | bash | bash로 읽기와 찾기를 해내는가 |
| bash + 읽기 tool | bash, read_file | 둘 중 무엇을 고르는가 |
세 구성 모두 비용(token, model 호출 수, 시간)을 함께 기록하고, 결과를 보고 전용 읽기 tool이 무엇을 더해 주는지 판단한다.
task는 두 개다. 각 구성에서 task마다 3번씩 실행한다.
| task | 지시 | 확인할 것 |
|---|---|---|
path-echo-01 | memo.txt를 읽고 내용을 그대로 출력 | 경로를 알 때의 읽기 |
find-echo-01 | 이름이 target.txt인 파일을 찾아 내용을 그대로 출력. 위치는 알려 주지 않음 | 파일 찾기. 실제 위치는 archive/2024/q3/kv9/target.txt |
채점은 H0와 같이 최종 출력이 파일 내용과 정확히 같은지만 본다. 어떤 tool을 어떤 명령으로 썼는지는 채점하지 않고 실행 기록으로 확인한다.
결과 확인
시작 상태로 실행
먼저 h01의 hel(tool은 read_file 하나)로 두 task를 3번씩 실행했다. evals에서는 이 구성을 baseline 조건이라고 부른다.
evals run h01 --conditions baseline
| task | 통과 | input / output token (평균) | model 호출 (평균) | tool 호출 (평균) |
|---|---|---|---|---|
path-echo-01 | 3/3 | 772 / 95 | 2 | read_file 1.0 |
find-echo-01 | 0/3 | 3520 / 1342 | 4 | read_file 27.3 |
(hel 696916f, model deepseek-flash, 최대 model 호출 5번.)
find-echo-01의 tool 호출을 보면 경로를 추측하고 있다. 첫 번째 실행은 이렇게 시작했다.
read_file target.txt (error)
read_file ./target.txt (error)
read_file data/target.txt (error)
read_file files/target.txt (error)
read_file docs/target.txt (error)
read_file src/target.txt (error)
read_file tmp/target.txt (error)
- 세 번 모두 파일을 찾지 못했다. 시도한 경로는 7개, 53개, 22개였다. model은 한 번의 응답에
read_file호출 여러 개를 한꺼번에 보냈다. - 두 번째 실행은
archive,docs,src같은 이름을 파일처럼 읽으려 했다. 실제로 있는 폴더라 “Is a directory” 오류를 받았지만, 그 안으로 내려가기 전에 model 호출 5번을 모두 써서 멈췄다. - 내용을 지어내지는 않았다. 세 번 모두 목록을 보거나 검색할 수단이 없어 찾지 못했다고 답했다. 세 번째 실행은 “28개 경로를 시도했다“고 답했지만 기록된 호출은 22개였다.
- 경로를 알려 준
path-echo-01은read_file을 한 번 호출하고 끝났다.
bash tool 만들기
bash tool은 명령 문자열을 bash -c로 작업 디렉터리에서 실행하고, 종료 코드와 출력을 문자열 하나로 돌려준다.
#![allow(unused)]
fn main() {
fn bash(workdir: &Path, args: &Value) -> Result<String, String> {
let command = args
.get("command")
.and_then(Value::as_str)
.ok_or("missing string argument: command")?;
let output = Command::new("bash")
.args(["-c", command])
.current_dir(workdir)
.stdin(Stdio::null())
.output()
.map_err(|e| format!("could not start bash: {e}"))?;
let code = output
.status
.code()
.map_or("killed".to_string(), |c| c.to_string());
let text = format!(
"exit={code}\n{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if output.status.success() { Ok(text) } else { Err(text) }
}
}
- 결과의 첫 줄은
exit=<종료 코드>이고, 그 뒤에 stdout과 stderr를 이어 붙인다. - stdin을 닫아서, 인자 없는
cat처럼 입력을 기다리는 명령도 바로 끝난다. 명령마다 시간 제한을 따로 두지 않고, run 전체 시간 제한(120초)에 맡긴다. - 출력은 자르지 않는다. 이번 task는 출력이 짧다.
- 종료 코드가 0이 아니면 오류로 돌려준다. model은 어느 쪽이든 종료 코드와 출력을 모두 받는다.
model에게 줄 tool은 --tools로 고른다. 주지 않은 tool을 model이 호출하면 실행하지 않고 unknown tool로 답한다. --tools를 생략하면 bash 하나만 준다.
hel --tools bash,read_file
bash만 줬을 때, bash와 읽기 tool을 함께 줬을 때
evals run h01 --conditions variant-bash,variant-bash-read
variant-bash는 bash만 준 구성, variant-bash-read는 bash와 read_file을 함께 준 구성이다. 시작 상태의 결과와 함께 적었다.
| task | 구성 | 통과 | input / output token (평균) | model 호출 | tool 호출 (평균) |
|---|---|---|---|---|---|
path-echo-01 | 시작 상태 (read_file) | 3/3 | 772 / 95 | 2 | read_file 1.0 |
| bash만 | 3/3 | 810 / 143 | 2 | bash 1.0 | |
bash + read_file | 3/3 | 949 / 121 | 2 | read_file 1.0 | |
find-echo-01 | 시작 상태 (read_file) | 0/3 | 3520 / 1342 | 4 | read_file 27.3 |
| bash만 | 3/3 | 1447 / 179 | 3 | bash 2.0 | |
bash + read_file | 2/3 | 1604 / 290 | 3 | bash 1.7, read_file 0.3 |
(hel 091ee2c, model deepseek-flash, 구성마다 task당 3번.)
bash만 준 find-echo-01은 세 번 모두 같은 순서로 풀었다.
bash find . -name target.txt -type f 2>/dev/null
bash cat ./archive/2024/q3/kv9/target.txt
- 경로를 알 때는
cat memo.txt한 번, 모를 때는find로 찾은 뒤cat으로 읽었다. 실행마다find에 붙인 옵션(-type f,2>/dev/null)만 조금 달랐다. - 시작 상태와 비교하면
find-echo-01의 tool 호출은 평균 27.3번에서 2번, input token은 3520에서 1447, 걸린 시간은 7.1초에서 2.5초로 줄었다. - 경로를 알 때는 bash 쪽이 조금 더 비쌌다. input token이 772에서 810으로 늘었다. bash tool 설명이 더 길고, 결과 앞에
exit=0줄이 붙는다.
bash와 read_file을 함께 주면 model은 상황에 따라 나눠 썼다.
- 경로를 알려 준
path-echo-01은 세 번 모두read_file을 골랐다. find-echo-01은 세 번 모두 bashfind로 위치를 찾았다. 찾은 파일은 두 번은 bashcat, 한 번은read_file로 읽었다.- 실패한 한 번은
read_file로 정확한 내용을 읽었지만, 최종 답을 code block(```)으로 감싸서 출력이 파일 내용과 달라졌다. - 사용 가능한 tool 목록에 두 tool이 포함돼 input token이 가장 많았다(
path-echo-01949).
돌아보기
변경 사항
hel이 bash tool을 갖게 됐다. model은 tool 하나로 파일 읽기(cat)와 찾기(find)를 스스로 골라 썼다. 읽기 전용 도구만 가지고 있는 상태에서 파일 이름만 전달하면 한 번도 찾지 못했지만, bash를 주자 세 번 모두 찾았다.- 두 tool을 함께 주면 model은 경로를 알 때
read_file, 찾을 때 bash를 골랐다. 찾은 파일을 읽을 때는 세 번 중 두 번 bash를 이어 썼다. - model에게 전달할 tool을 실행할 때
--tools로 고른다. 아무것도 주지 않으면 bash 하나로 시작한다.
트레이드오프
- 경로를 아는 읽기는 조금 비싸졌다. bash만 주면 input token이 772에서 810으로, 두 tool을 함께 주면 949로 늘었다. 길어진 사용 가능한 tool 목록이 매 호출에 붙는다. 다만, 현재 수준으로는 유의미한 비용 차이가 발생했다고 보긴 어렵다.
- harness가 명령의 내용을 모른다.
read_file은 작업 디렉터리 밖을 막았지만 bash에는 그런 검사가 없다.bash와 같은 범용 도구를 사용하는 경우 발생하는 작업 범위와 권한의 문제는 H7, 실행을 가두는 문제는 H8에서 다룬다. - 출력 크기를 정하지 못한다. 큰 파일을
cat하면 출력 전체가 대화에 들어간다. 이번 task는 출력이 짧아서 차이가 없었다.
논문의 내용 또는 다른 harness와 비교하면
논문 §16.3의 권고대로 bash 하나로 시작해도 읽기와 찾기는 충분했다. 논문에서 말하는 “tool을 추가해야 하는 신호”(출력이 잘리는 문제, 찾기가 불편한 문제)는 이번 task에서 나타나지 않았다.
Mini-SWE-Agent는 이번 hel과 같은 구성(tool calling으로 bash 하나)이다. 다른 점은 출력이 10,000자를 넘으면 앞뒤만 보내고, 명령마다 30초 시간 제한을 둔다는 것이다. 지금의 hel은 둘 다 없다.
Claude Code는 macOS와 Linux에서 찾기(Glob, Grep)를 bash로 넘기고 읽기(Read)는 전용 tool로 남겼다. 이번에 두 tool을 함께 준 hel에서 model이 나눠 쓴 방식과 같다. Claude Code는 전용 tool을 쓰면 사용자가 작업을 이해하고 검토하기 쉽다고 설명하고, Read에는 큰 파일 일부 읽기와 이미지·PDF 처리 같은 기능을 따로 두었다.
FAQ
bash로는 작업 디렉터리 밖도 읽을 수 있다
H0의 read_file은 작업 디렉터리 밖의 경로를 거부한다. bash tool에는 이런 검사가 없어서 cat ../다른파일이나 cat ~/.ssh/... 같은 명령도 그대로 실행된다. 이번 실행에서는 model이 작업 디렉터리 밖으로 나간 명령을 쓰지 않았다. 명령 문자열만으로 무엇을 하는지 판단해야 하는 문제는 H7 Permissions에서, 실행 자체를 가두는 문제는 H8 Sandbox에서 다룬다.
긴 출력은 그대로 model에게 간다
bash tool은 출력을 자르지 않는다. 큰 파일을 cat하거나 넓은 범위를 find하면 출력 전체가 대화에 들어가 token을 쓴다. Mini-SWE-Agent는 10,000자가 넘으면 앞뒤만 보내고, Claude Code는 약 30,000자가 넘으면 파일로 저장한 뒤 앞부분만 보낸다. 이번 task는 출력이 짧아서 차이가 없었다. context가 커질 때의 문제는 H5 Context Budget에서 다룬다.
전용 읽기 tool의 이점은 아직 보이지 않았다
작은 파일을 읽는 task에서는 read_file이 bash cat보다 token을 조금 덜 썼을 뿐이고, 두 tool을 함께 주면 사용 가능한 tool 목록이 늘어 오히려 더 비쌌다. 논문과 Claude Code가 드는 전용 tool의 이점(큰 파일 일부 읽기, 권한 판단, 사용자가 작업을 검토하기 쉬움)은 이번 task로는 드러나지 않는다. 파일을 고치는 task에서는 전용 tool과 bash의 차이가 더 클 수 있어서 H2 Editing에서 다시 본다.
H2 — Editing
Note
- 시작 상태:
h02· 완료 상태:h03- 논문: §8.4 File Editing Strategies, §16.4 File Editing
git checkout -b my-h02 h02
bash만으로 파일을 고칠 때와 전용 편집 tool을 줄 때, 비용과 작업 범위는 어떻게 달라질까?
들어가며
논문이 본 편집 방식
Harness Engineering 논문의 §8.4는 파일 편집을 SWE agent의 핵심 action으로 보고, 11개 harness가 8가지 방식으로 나뉜다고 정리했다. 논문은 편집 방식이 코드 수정 정확도를 가장 크게 좌우하는 요인 중 하나라고 본다.
| Harness | 편집 방식 | 바꾸지 못했을 때 |
|---|---|---|
| Mini-SWE-Agent | bash의 sed, awk | shell 오류 |
| Claude Code | 정확한 문자열 치환, 파일 안에서 한 번만 나와야 함 | 오류와 맥락 |
| Mistral Vibe | 정확한 문자열 치환(전부 바꾸기 선택 가능), 새 파일 전용 write_file | 여러 곳 일치 오류 |
| OpenCode | model에 따라 patch 형식이나 문자열 치환. 9단계로 점점 느슨하게 맞춰 봄 | 일치 없음과 여러 곳 일치를 구분 |
| Hermes | 9단계로 느슨하게 맞춰 봄 | 가장 가까운 줄 제안, 3번 실패하면 write_file로 넘어감 |
| Gemini CLI | 문자열 치환, 정확히 맞지 않으면 공백·정규식·유사도 순으로 맞춰 봄 | LLM을 한 번 더 불러 치환 문자열을 고침 |
- 바꿀 부분을 정확하게 찾는 쪽과 느슨하게 찾는 쪽으로 갈린다. Mistral Vibe는 2026년 4월에서 7월 사이에 느슨하게 맞추는 SEARCH/REPLACE tool을 지우고 Claude Code처럼 정확히 맞추는 방식으로 옮겼다. 논문은 model이 강해질수록 tool이 오차를 흡수하기보다 엄격하게 맞추는 편이 낫다는 증거로 해석한다.
- §16.4는 model 수준에 따라 고르라고 권한다. 최신 상용 model에는 정확한 문자열 치환, 공개 model이나 약한 model에는 느슨한 매칭을 쓴다. 줄 번호로 위치를 지정하는 편집은 피하라고 한다. model은 주변 내용으로 위치를 맞출 때보다 줄 번호를 쓸 때 더 자주 어긋난다는 이유다.
- §16.3은 tool을 더하는 순서로 “bash →
read_file,write_file→ 파일 전체를 다시 쓰느라 token이 낭비되면search_replace“를 든다. §16.10의 최소 harness 예시에도write_file과search_replace가 있다.search_replace는 바꿀 문자열이 한 번만 나오지 않으면 몇 번 나왔는지 알려 주고 바꾸지 않는다.
이번 Lab에서 다룰 편집 방법
§16.3에서 나온 순서대로 bash, write_file, search_replace 세 단계를 hel에서 따라가 본다.
H1의 hel에는 read_file과 bash가 있다. 파일을 고치는 전용 tool은 없어서, 지금 model이 파일을 고치려면 bash 명령을 써야 한다.
- bash로 고치기:
sed -i 's/old/new/' file처럼 명령으로 바꾸거나,cat <<'EOF' > file로 파일 전체를 다시 쓴다. harness는 명령 문자열만 받으므로 어느 파일의 어디를 바꾸려는지 모른다. 명령이 아무것도 바꾸지 못해도sed는 종료 코드 0을 돌려준다. - 전체 쓰기(
write_file): model이 경로와 파일의 새 내용 전체를 넘기면 harness가 그대로 저장한다. 한 줄만 바꿔도 파일 전체를 출력해야 하고, 옮겨 적다가 다른 줄이 바뀌어도 harness는 알 수 없다. - 문자열 치환(
search_replace): model이 바꿀 부분(search)과 새 내용(replace)을 넘긴다. harness는search가 파일에 정확히 한 번 나올 때만 바꾸고, 없거나 여러 번 나오면 바꾸지 않고 오류를 돌려준다.
| bash | write_file | search_replace | |
|---|---|---|---|
| model이 넘기는 것 | 명령 문자열 | 경로, 파일 전체 내용 | 경로, 바꿀 문자열, 새 문자열 |
| 출력해야 하는 양 | 명령 길이 | 파일 크기 | 바뀌는 부분과 그 주변 |
| harness가 아는 것 | 문자열뿐 | 대상 파일 | 대상 파일과 바뀌는 위치 |
| 바꾸지 못했을 때 | 명령에 따라 다름. 오류 없이 끝나기도 함 | 해당 없음(항상 덮어씀) | 일치 없음, 여러 곳 일치 오류 |
bash만 있는 hel에 write_file을 더하고, 이어서 search_replace를 더하면서 단계마다 model이 파일을 어떻게 고치는지, token을 얼마나 쓰는지, 작업 디렉터리 밖에 접근하는지 본다.
참고 자료
- Mini-SWE-Agent에는 편집 전용 코드가 없다. 시스템 프롬프트에 편집 명령 예시를 넣어 가르친다. (2026-10 기준.)
- 새 파일은
cat <<'EOF' > newfile.py, 고치기는sed -i 's/old/new/g'과 줄 번호·줄 범위를 지정한sed예시, 보기는nl -ba file | sed -n '10,20p'. - macOS에서는
sed -i ''를 써야 한다는 안내를 prompt에 넣는다. macOS의sed는 Linux의sed와-i옵션 사용법이 다르다.
- 새 파일은
- Claude Code(Tools reference, 2026-10 기준)는 부분 수정에 Edit, 새로 만들거나 전체를 다시 쓸 때 Write를 쓴다.
- Edit는
old_string을new_string으로 바꾼다. 정규식이나 유사도 매칭은 쓰지 않는다. 공백이나 들여쓰기가 한 글자만 달라도 실패한다. old_string이 여러 번 나오면 주변 내용을 더 넣어 한 곳으로 좁히거나replace_all로 전부 바꾼다.- 이번 대화에서 읽지 않은 파일은 고치지 못하게 한다(model에 따라 조건부로 완화). 읽은 뒤 파일이 바뀌었으면 현재 내용에 정확히 맞을 때만 적용하고 다른 변경이 있다고 알린다.
- Edit는
이번에 해볼 것
출발 상태는 h02의 hel이다. 기본 tool은 bash 하나이고, --tools로 bash와 read_file 중에서 고를 수 있다.
여기에 write_file과 search_replace tool을 만들고 --tools로 고를 수 있게 한다. 같은 task를 세 가지 구성으로 실행해 비교한다. read_file은 세 구성 모두 주지 않는다. 파일은 bash의 cat 등으로 읽는다.
| 구성 | 주는 tool | 확인할 것 |
|---|---|---|
| 시작 상태 | bash | bash 명령만으로 파일을 고칠 때의 비용과 작업 범위 |
| 전체 쓰기 추가 | bash, write_file | write_file을 고르는가, 고르면 비용이 얼마나 느는가 |
| 문자열 치환 추가 | bash, write_file, search_replace | search_replace를 고르는가, 비용이 줄어드는가 |
task는 두 개다. 각 구성에서 task마다 3번씩 실행한다. 실행마다 model 호출은 10번까지 허용한다.
| task | 지시 | 확인할 것 |
|---|---|---|
edit-line-01 | 237줄짜리 settings.py에서 REQUEST_TIMEOUT_SECONDS를 30에서 60으로 바꾸고 나머지는 그대로 둠 | 긴 파일 한 줄 수정의 비용. 값 30이 다른 줄에도 있어 값만 바꾸면 다른 줄이 바뀜 |
edit-ambiguous-01 | services.ini의 [database] 섹션에서만 retries를 3에서 5로 바꿈 | 같은 줄 retries = 3이 다섯 섹션에 있을 때 의도한 곳만 바꾸는가 |
채점은 실행이 끝난 뒤의 파일이 기대한 내용과 정확히 같은지만 본다. 끝 줄바꿈 한 개의 차이만 무시하므로, 다른 줄이 하나라도 바뀌면 실패다. model의 최종 답변은 채점하지 않고, 어떤 tool을 어떤 명령으로 썼는지와 token은 실행 기록으로 확인한다.
결과 확인
시작 상태로 실행
먼저 h02의 hel(tool은 bash 하나)로 두 task를 3번씩 실행했다. evals에서는 이 구성을 baseline 조건이라고 부른다.
evals run h02 --conditions baseline
| task | 통과 | input / output token (평균) | model 호출 (평균) | tool 호출 (평균) |
|---|---|---|---|---|
edit-line-01 | 3/3 | 6110 / 1028 | 5 | bash 3.7 |
edit-ambiguous-01 | 3/3 | 7806 / 1376 | 5 | bash 4.7 |
(hel 091ee2c, model deepseek-flash, 최대 model 호출 10번, macOS.)
bash만으로도 여섯 번 모두 파일을 정확히 고쳤다. 실행마다 편집에 쓴 명령은 이렇다.
| 실행 | edit-line-01 | edit-ambiguous-01 |
|---|---|---|
| 1 | bash 안에서 python으로 ^REQUEST_TIMEOUT_SECONDS = 30$를 정규식 치환, 바뀐 곳이 1개인지 assert | python으로 줄을 돌면서 현재 섹션이 [database]일 때만 바꿈 |
| 2 | sed -i '35s/.../.../' 실패 → python으로 다시 고침 | python으로 섹션을 따라가며 바꿈 |
| 3 | python으로 s.count(old) == 1을 확인한 뒤 replace | awk로 섹션을 따라가며 바꿈 |
edit-line-01의 두 번째 실행에서 쓴 sed는 이렇게 끝났다.
exit=0
REQUEST_TIMEOUT_SECONDS = 30
--- diff ---
sed: 1: "settings.py": unterminated substitute pattern
- macOS의
sed는-i다음 인자를 백업 파일 확장자로 읽는다. 그래서'35s/.../'를 확장자로,settings.py를 편집 명령으로 해석해 실패했다. Linux의sed라면 성공했을 명령이다. - 같은 명령 문자열 안의 마지막 명령(
echo)이 성공해서 첫 줄은exit=0이다. model은 바로 뒤에 출력한 35번째 줄이 그대로인 것을 보고 실패를 알아챘고, python으로 다시 고쳤다. - 여섯 번 중 다섯 번은 처음부터
sed를 쓰지 않았다. 최종 편집은 다섯 번이 python, 한 번이awk였다. python으로 고친 다섯 번은 모두 바뀐 곳이 정확히 하나가 아니면assert로 멈추게 했다. bash 안에서search_replace와 같은 검사를 스스로 만들어 쓴 셈이다. - 고친 뒤에는 모두
diff,grep,sed -n으로 결과를 다시 확인했다. tool 호출이 2~6번으로 실행마다 달랐던 것은 이 확인 단계의 길이 차이다.
편집 결과와 별개로, bash로 고칠 때의 작업 범위 문제가 보였다.
- 여섯 번 중 네 번은 원본 백업을 작업 디렉터리 밖의
/tmp에 만들었다. edit-ambiguous-01의 두 번째 실행은 파일을 이미 읽은 뒤에find / -name "services.ini"로 디스크 전체를 검색했다. 이 명령에만 약 100초가 걸렸고(제한은 120초), 결과에는 이전 실행들이 남긴 작업 디렉터리의services.ini도 섞여 있었다.- bash tool은 명령이 어디를 읽고 쓰는지 harness가 알 수 없다. 작업 디렉터리 밖 접근과 실행 사이의 격리는 H7 Permissions와 H8 Sandbox에서 다룬다.
편집 tool 만들기
두 tool은 모두 경로를 받고, 작업 디렉터리 밖이면 거부한다. read_file은 이미 있는 파일만 다루므로 경로 전체를 실제 위치로 풀어서 확인하면 됐다. write_file은 아직 없는 파일도 만들어야 해서, 상위 디렉터리를 실제 위치로 풀어 확인한다. 이미 있는 파일이 작업 디렉터리 밖을 가리키는 symlink이면 그것도 거부한다.
#![allow(unused)]
fn main() {
fn writable_target(workdir: &Path, path: &str) -> Result<PathBuf, String> {
let root = fs::canonicalize(workdir).map_err(|e| format!("working directory: {e}"))?;
let joined = root.join(path);
let (Some(parent), Some(name)) = (joined.parent(), joined.file_name()) else {
return Err(format!("{path}: not a file path"));
};
let parent = fs::canonicalize(parent).map_err(|e| format!("{path}: {e}"))?;
let target = parent.join(name);
let resolved = fs::canonicalize(&target).unwrap_or_else(|_| target.clone());
if !resolved.starts_with(&root) {
return Err(format!("{path}: outside the working directory"));
}
Ok(target)
}
}
write_file은 이 경로에 내용을 그대로 쓰고 wrote N bytes to <path>를 돌려준다. search_replace는 논문 §16.10의 예시와 같은 계약이다.
#![allow(unused)]
fn main() {
fn search_replace(workdir: &Path, args: &Value) -> Result<String, String> {
let path = string_arg(args, "path")?;
let search = string_arg(args, "search")?;
let replace = string_arg(args, "replace")?;
if search.is_empty() {
return Err("search must not be empty".to_string());
}
let target = writable_target(workdir, path)?;
let text = fs::read_to_string(&target).map_err(|e| format!("{path}: {e}"))?;
let count = text.matches(search).count();
if count != 1 {
return Err(format!("search string occurs {count}x in {path}; must be unique"));
}
fs::write(&target, text.replacen(search, replace, 1)).map_err(|e| format!("{path}: {e}"))?;
Ok(format!("replaced 1 occurrence in {path}"))
}
}
search가 한 번도 없거나 여러 번 나오면 파일을 건드리지 않고 등장 횟수를 돌려준다. 빈 문자열은 모든 위치에 일치하므로 따로 거부한다.- 두 tool 모두 실행 기록에는 편집(
edit) 범주로 남는다.
tool은 지금처럼 --tools로 고른다.
hel --tools bash,write_file,search_replace
편집 tool을 줬을 때
evals run h02 --conditions variant-write,variant-write-replace
variant-write는 bash와 write_file, variant-write-replace는 bash, write_file, search_replace를 준 구성이다. 시작 상태의 결과와 함께 적었다.
| task | 구성 | 통과 | input / output token (평균) | model 호출 (평균) | tool 호출 (평균) |
|---|---|---|---|---|---|
edit-line-01 | 시작 상태 (bash) | 3/3 | 6110 / 1028 | 5 | bash 3.7 |
| 전체 쓰기 추가 | 3/3 | 7742 / 1116 | 7 | bash 6.3 | |
| 문자열 치환 추가 | 3/3 | 6422 / 777 | 6 | bash 4.7, search_replace 1.0 | |
edit-ambiguous-01 | 시작 상태 (bash) | 3/3 | 7806 / 1376 | 5 | bash 4.7 |
| 전체 쓰기 추가 | 3/3 | 6111 / 954 | 5 | bash 5.0 | |
| 문자열 치환 추가 | 3/3 | 8218 / 887 | 5 | bash 3.7, search_replace 0.7 |
(hel 90c8058, model deepseek-flash, 최대 model 호출 10번, macOS.)
write_file은 한 번도 쓰지 않았다. 전체 쓰기를 준 6번과 문자열 치환까지 준 6번, 모두 12번 중 write_file 호출은 0번이다. 전체 쓰기만 더한 구성에서 model은 시작 상태와 같이 bash 안에서 python, awk, sed로 고쳤다. 그래서 237줄 파일을 통째로 다시 쓰는 비용은 이번 실행에서 나타나지 않았다.
search_replace는 여섯 번 중 다섯 번 썼다. edit-line-01은 세 번 모두, edit-ambiguous-01은 두 번이다. edit-ambiguous-01에서 넘긴 search는 이렇다.
실행 2 search='[database]\nhost = db.internal\nport = 5432\nretries = 3\ntimeout = 10\npool_size = 20'
실행 3 search='port = 5432\nretries = 3'
- 다섯 번 모두 첫 호출에 성공했다. “여러 곳 일치” 오류를 받고 고친 경우는 없었다.
edit-ambiguous-01에서는 파일을 읽은 뒤 처음부터[database]섹션에만 있는 줄을 붙여 넘겼다. 실행 2는 섹션 전체를, 실행 3은 바로 앞줄(port = 5432)만 붙였다. - 남은 한 번(
edit-ambiguous-01실행 1)은search_replace를 두고 bash 안에서 python으로 섹션을 따라가며 고쳤다. search_replace를 쓴 실행도 bash 호출은 3~6번이었다. 편집 전에grep,sed -n으로 위치를 확인하고, 편집 뒤에 다시 확인하는 흐름은 그대로였다.
output token은 문자열 치환을 준 구성이 두 task 모두 가장 적었다. 실행마다 차이가 커서 차이를 단정하기는 어렵다.
| task | 구성 | 실행별 output token |
|---|---|---|
edit-line-01 | 시작 상태 | 659, 1955, 469 |
| 문자열 치환 추가 | 670, 1010, 652 | |
edit-ambiguous-01 | 시작 상태 | 2102, 1359, 668 |
| 문자열 치환 추가 | 909, 1222, 531 |
- 시작 상태에서 output token이 컸던 실행(1955, 2102)은 python 스크립트를 길게 쓰거나
sed실패 뒤 다시 고친 실행이다. 문자열 치환을 쓰면 이런 긴 편집 명령이 사라져서 위쪽 값이 낮아졌다. 짧게 끝난 실행끼리는 차이가 거의 없다. - input token은 줄지 않았다(6110 → 6422, 7806 → 8218). tool이 셋으로 늘어 요청마다 사용 가능한 tool 목록이 길어졌고, 확인용 bash 호출 수가 비슷해서 대화 길이도 비슷했다.
작업 디렉터리 밖에 접근한 실행 수는 이렇다. 실행 기록의 명령에 /tmp나 / 같은 작업 디렉터리 밖 경로가 나오는지로 셌다.
| 구성 | 작업 디렉터리 밖 접근 | 내용 |
|---|---|---|
| 시작 상태 | 4/6 | /tmp 백업 4번, 그중 한 번은 find /도 실행 |
| 전체 쓰기 추가 | 4/6 | /tmp 백업이나 임시 파일 4번 |
| 문자열 치환 추가 | 1/6 | /tmp 백업 1번 |
- 백업은 bash로 직접 고치기 전에 만든다.
search_replace로 고친 다섯 번 중 백업을 만든 실행은 한 번이었다. search_replace자체는 작업 디렉터리 밖 경로를 거부하지만, bash가 함께 있으므로 model이 bash로 밖에 접근하는 것까지 막지는 못한다.
새 파일을 만들 때
write_file을 한 번도 쓰지 않은 것이 tool을 고르는 기준 때문인지 확인하려고, 새 파일을 만드는 task를 하나 더 만들어 구성마다 2번씩 실행했다. 지시는 $, 백틱, 따옴표가 섞인 20줄짜리 shell script를 그대로 deploy.sh로 만드는 것이다.
evals try write-new-01 --lab h02 --condition variant-write
| 구성 | 통과 | 파일을 만든 방법 | input / output token (실행별) |
|---|---|---|---|
시작 상태 (bash) | 2/2 | 따옴표로 감싼 heredoc(cat > deploy.sh <<'EOF') | 5284 / 1225, 5354 / 1182 |
| 전체 쓰기 추가 | 2/2 | write_file | 6773 / 1790, 3913 / 1266 |
| 문자열 치환 추가 | 2/2 | write_file | 3890 / 1001, 11485 / 962 |
write_file이 있으면 네 번 모두 첫 호출에서write_file로 만들었다. 기존 파일의 한 줄을 고칠 때는 12번 중 한 번도 쓰지 않았으므로, model은 파일을 새로 만들 때만 전체 쓰기를 고른 것으로 보인다.- bash만 있을 때도 두 번 모두 heredoc 구분자를 따옴표로 감싸서
$와 백틱이 해석되지 않게 했다. - 만든 뒤에는 모든 실행이 내용을 다시 확인했다. 그중 다섯 번은 기대 내용 전체를 heredoc이나 python 문자열로 한 번 더 써서
diff로 비교했다. 그래서 파일 내용을 두 번 출력하는 셈이 되어 output token이 구성과 상관없이 1000 안팎이었다. 시작 상태의 두 번은 비교용 사본을/tmp에 만들었다.
macOS 환경에서 생긴 bash 명령 실패도 있었다. macOS의 cat에는 -A 옵션이 없고, 측정 도구가 hel을 PATH=/usr/bin:/bin으로 실행해 /sbin에 있는 md5sum을 찾지 못해서, 여러 실행이 확인 명령에서 오류를 받고 다른 명령으로 바꿨다(PATH는 H3에서 /usr/sbin:/sbin을 더하도록 고쳤다). 전체 쓰기를 준 첫 실행은 시작 상태의 두 번째 실행과 같은 sed -i '35s/.../' 실패를 겪고 python으로 다시 고쳤다.
돌아보기
변경 사항
- 편집 tool을 주자 model은 작업에 맞춰 골라 썼다. 기존 파일의 한 줄을 고칠 때는
search_replace를 썼고(6번 중 5번), 새 파일을 만들 때는write_file을 썼다(4번 중 4번). 기존 파일을 고치는 데write_file을 쓴 적은 없다. - 작업 디렉터리 밖에 접근한 실행이 6번 중 4번에서 1번으로 줄었다. bash로 직접 고칠 때 만들던
/tmp백업이 필요 없어졌고, 전용 tool을 통해 model의 행동 예측과 결과 관리를 harness 내부로 숨길 수 있다는 것도 확인할 수 있었다. - 정확성은 달라지지 않았다. bash만 있을 때도 model은
sed대신 bash 안에서 python으로 고쳤고, 바뀐 곳이 하나인지 스스로 검사해서 모두 정확히 고쳤다. --tools에write_file,search_replace를 넣어 준다.--tools를 생략하면 지금처럼 bash 하나로 시작한다.
트레이드오프
- input token은 새로운 tool에 대한 설명으로 늘어났다(6110 → 6422, 7806 → 8218). 사용 가능한 tool 목록이 길어진 만큼 매 호출에 붙고, 편집 전후의 확인 단계는 bash로 그대로 한다. output token은 줄었지만 실행마다 차이가 커서 지금 규모로는 의미 있는 차이로 보기 어렵다.
search_replace는 공백이나 들여쓰기가 한 글자만 달라도 일치하지 않는다. 이번에는 다섯 번 모두 첫 호출에 성공했지만, 들여쓰기가 깊은 코드 파일에서는 실패할 수 있다. 느슨하게 맞추는 방법은 FAQ에 남겼다.write_file은 기존 파일도 통째로 덮어쓴다. 읽지 않은 파일을 덮어쓰거나 다른 내용을 잃는 것을 harness가 막지 않는다. 읽은 파일만 고치게 하는 정책은 H7 Permissions와 H10 Skills / Hooks / MCP에서 다룬다.- bash가 함께 있는 한 작업 디렉터리 밖 접근은 남는다. 전용 tool은 밖을 거부하지만 model이 bash로
/tmp에 쓰는 것까지 막지는 못한다. 명령 단위의 허용 여부는 H7, 실행을 가두는 문제는 H8에서 다룬다.
논문의 내용 또는 다른 harness와 비교하면
논문 §16.3은 “파일 전체를 다시 쓰느라 token이 낭비되면 search_replace를 더하라“고 했다. 이번 model은 처음부터 기존 파일을 통째로 다시 쓰지 않아서 그 신호가 생기지 않았다. search_replace가 바꾼 것은 token보다 작업 범위였다. bash로 고칠 때 따라오던 백업과 임시 파일이 줄었다.(추가적으로 저장소 외부에 대한 접근도 줄었다.)
§16.4는 공개 model이나 약한 model에는 느슨한 매칭을 권한다. 이번에 쓴 model은 공개 model이지만 정확 매칭만으로 다섯 번 모두 첫 호출에 성공했다. 여러 섹션에 같은 줄이 있을 때도 고유한 앞줄을 붙여 넘겼다. 이번 두 task 범위에서는 Mistral Vibe가 옮겨 간 쪽인 엄격한 계약으로 충분했다.
Mini-SWE-Agent는 시스템 프롬프트에 sed 예시와 macOS용 sed -i '' 안내를 넣는다. hel은 이런 안내 없이 tool 설명만 준다. model은 sed를 18번 중 세 번만 썼다. 그중 두 번은 macOS sed의 -i 차이로 실패한 뒤 python으로 다시 고쳤고, 한 번은 처음부터 sed -i ''로 썼다.
Claude Code는 부분 수정에 Edit, 새 파일과 전체 쓰기에 Write를 쓰게 한다. 이번 model이 두 tool을 나눠 쓴 방식과 같다. Claude Code는 여기에 읽기 전 편집 금지와 읽은 뒤 바뀐 파일 감지를 더한다. 지금의 hel에는 둘 다 없다.
FAQ
읽지 않은 파일을 고치지 못하게 하려면
논문 §7은 Claude Code와 Mistral Vibe가 prompt로 “읽지 않은 코드는 고치지 말라“고 지시하고, OpenCode는 tool 설명에 “읽지 않고 편집하면 오류“라고 적었지만 실제로 막는 코드는 없다고 지적한다. prompt에 쓴 문장은 model에게 바라는 것일 뿐 harness가 강제하는 장치가 아니다. H2는 편집 tool의 형태만 비교하고 이 정책은 다루지 않는다. 읽기 기록을 남겨 편집을 막는 방식은 H7 Permissions나 H10 Skills / Hooks / MCP에서 다시 본다.
엄격한 매칭에 실패하면
search_replace는 search가 글자 그대로 한 번 나와야 바꾼다. 이번 task에서는 실패하지 않았지만, 들여쓰기가 깊은 코드나 공백이 섞인 파일에서는 model이 옮겨 적은 문자열이 한 글자 달라 실패할 수 있다. 논문 §8.4의 다른 harness는 이런 경우 공백을 정규화하거나(Pi), 여러 단계로 느슨하게 맞춰 보거나(OpenCode, Hermes), LLM을 한 번 더 불러 치환 문자열을 고친다(Gemini CLI). model마다 이 실패가 얼마나 나오는지는 H13 Harness Evaluation에서 여러 model로 비교할 때 다시 본다.
확인 단계가 출력 token을 쓴다
model은 파일을 고친 뒤 거의 매번 결과를 다시 확인했다. 새 파일을 만들 때는 기대 내용 전체를 한 번 더 써서 diff로 비교해, 같은 내용을 두 번 출력했다. 편집 tool이 결과(예: 바뀐 줄 주변)를 함께 돌려주면 이 확인을 줄일 수 있다. tool 결과와 대화 길이가 비용에 미치는 영향은 H5 Context Budget에서 다룬다.
이전 실행의 작업 디렉터리가 보였다
한 실행이 find /로 디스크 전체를 검색했을 때, 결과에 이전 실행들이 남긴 작업 디렉터리의 같은 파일이 섞여 있었다. 측정 도구는 실행마다 새 작업 디렉터리를 만들지만, bash는 그 밖을 볼 수 있다. 실행끼리 서로 보지 못하게 가두는 문제는 H8 Sandbox에서 다룬다.
macOS와 Linux의 명령 차이
macOS의 sed -i, cat -A는 Linux와 달라서, md5sum은 측정 환경의 PATH에 /sbin이 없어서 bash 명령이 여러 번 실패했다. model은 오류를 보고 다른 명령으로 바꿨지만 호출이 늘었다. Mini-SWE-Agent는 시스템 프롬프트에 OS 정보와 macOS용 안내를 넣는다. OS·작업 경로를 시스템 프롬프트에 어떻게 담을지는 H3 Repository Context에서 다룬다.
Part II 개요
Note
- 시작 상태:
h03· 완료 상태:h05- 논문: §9.7 Repository Context, §13.2 The Twin Absences, §16.5 Memory and Context
현재 구조
Part I을 마친 hel의 구조는 다음과 같다.
flowchart TB
U[사용자 지시<br/>--instruction 또는 대화형 입력] --> L
subgraph H[hel]
L[agent loop<br/>max_turns까지 반복]:::new
T[tool<br/>bash · read_file<br/>write_file · search_replace]:::new
R[실행 기록]:::new
end
L <--> M[model]
L <--> T
L --> R
T <--> W[(작업 디렉터리)]
classDef new fill:#fff3bf,stroke:#e8590c,color:#000
- harness는 매 호출에 사용자 지시, 사용 가능한 tool 목록, 대화 기록을 model에게 보낸다. 시스템 프롬프트는 없다.
- 실행 환경(OS, shell, 작업 디렉터리 위치)과 저장소의 규칙은 model이 tool을 호출해서 알아내거나 추측해야 한다.
Part I에서 드러난 문제
Part I을 마치면서 model은 harness를 통해 사용자가 원하는 작업을 할 수 있게 됐다. 파일을 찾고 읽고 고친다. 하지만 그 작업이 실행되는 환경과 저장소의 규칙은 model이 알 수 없었다.
- H2 Editing에서 model은
sed를 18번 중 세 번 썼다. 그중 두 번은 macOSsed의-i차이로 실패한 뒤 python으로 다시 고쳤다. model은 자신이 작업하게 될 운영체제를 모르는 상태로 Linux 기준의 명령을 먼저 사용했다. - 같은 Lab에서
cat -A처럼 macOS에서 다르게 동작하는 명령과, 실행 환경의 PATH에 없는md5sum도 여러 번 실패했다(H2 FAQ). model은 오류를 보고 다른 명령으로 바꿨지만 그만큼 호출과 token이 늘었다. - 실행할 때마다 model은 처음부터 다시 시작한다. 이전 실행에서 알아낸 환경과 파일 구조는 다음 실행에 남지 않는다.
이런 상황에서 작업 디렉터리가 커지고 지켜야 할 규칙이 생기면, 이 탐색과 시행착오가 작업마다 반복되며 프로젝트의 오버헤드가 될 것이다.
논문이 보는 Repository Intelligence
논문은 model이 저장소를 아는 방법을 두 갈래로 다룬다.
미리 알려 주기 (§9.7, §16.5): repository context를 다루는 시스템은 모두 AGENTS.md, CLAUDE.md 같은 Markdown 파일을 자동으로 찾아 prompt에 넣는다. 찾는 범위와 합치는 방식만 다르다. 시스템 프롬프트에 OS, 작업 디렉터리, git 상태를 넣는 시스템도 있다(§7.2). 논문은 이 방식을 권고로 정리한다(Recommendation 6).
필요할 때 찾기 (§13.2, §16.5): 코드 검색에 embedding 기반 검색(RAG)을 쓰는 시스템은 조사한 11개 중 하나도 없다. 모두 ripgrep, glob, 파일 시스템 탐색을 쓰고, Aider만 tree-sitter로 symbol 지도를 만든다. 논문은 코드에는 경로와 구문 구조 같은 결정적인 정보가 있고 코드가 자주 바뀌어 미리 만든 index가 금방 낡는다는 이유로 “코드에 RAG를 만들지 말라“고 권한다(Recommendation 8).
이 Part에서 다룰 것
| Lab | 질문 | 더하는 구조 |
|---|---|---|
| H3 Repository Context | OS·shell·작업 경로를 시스템 프롬프트로 주고, 작업 디렉터리의 HEL.md를 읽어 넣으면, model이 환경과 저장소 규칙을 알아내는 데 드는 호출과 실패는 어떻게 달라질까? | 시스템 프롬프트, HEL.md 읽기 |
| H4 Repository Search | 파일명·본문 검색을 전용 tool로 제공하면 필요한 코드를 찾는 재시도와 context 사용량이 줄어드는가? | glob·grep, 경로 검사·결과 한도 |
이 Part를 마치면
flowchart TB
U[사용자 지시] --> L
subgraph H[hel]
P[시스템 프롬프트<br/>OS · shell · 작업 디렉터리]:::new
C[HEL.md 읽기]:::new
L[agent loop]
T[tool<br/>bash · read_file<br/>write_file · search_replace]
S[검색 tool<br/>glob · grep<br/>경로 검사 · 결과 한도]:::new
end
P --> L
C --> L
L <--> M[model]
L <--> T
L <--> S
S <--> RG[ripgrep]
RG <--> W
T <--> W[(작업 디렉터리)]
W --> C
classDef new fill:#fff3bf,stroke:#e8590c,color:#000
- harness는 매 호출에 시스템 프롬프트, 사용자 지시, 사용 가능한 tool 목록, 대화 기록을 model에게 보낸다.
- model은 첫 호출부터 실행 환경을 알고, 작업 디렉터리에
HEL.md가 있으면 그 규칙을 함께 받는다. HEL.md는 harness가 실행된 작업 디렉터리에서만 읽는다.- 검색 tool을 제공하면 model은 pattern과 path로 파일명·본문 검색을 요청할 수 있다. harness는 작업 디렉터리 경계를 검사하고, 경로와 일치 내용으로 결과를 돌려준다.
H3 — Repository Context
Note
- 시작 상태:
h03· 완료 상태:h04- 논문: §9.7 Repository Context, §16.5 Memory and Context, §7.2 Prompt Engineering Architectures
git checkout -b my-h03 h03
OS·shell·작업 경로를 시스템 프롬프트로 주고, 작업 디렉터리의 HEL.md를 읽어 넣으면, model이 환경과 저장소 규칙을 알아내는 데 드는 호출과 실패는 어떻게 달라질까?
들어가며
Part II 개요에서 본 것처럼 지금의 hel은 시스템 프롬프트 없이 사용자 지시와 사용 가능한 tool 목록만 model에게 보낸다. 이번 Lab에서는 model이 저장소와 실행 환경을 미리 알도록 harness가 정보를 넣어 주는 방법을 다룬다.
논문이 본 repository context
논문 §9.7은 대화 기록과 별개로 각 시스템이 저장소 정보를 어떻게 model에게 주는지 정리한다. 대부분은 사람이 쓴 Markdown 파일을 harness가 찾아 prompt에 넣는다.
- Codex는 project root에서 작업 디렉터리까지 내려오며 찾은
AGENTS.md를 모두 이어 붙인다. - Claude Code는 조직·사용자·프로젝트 범위의
CLAUDE.md를 찾아 넣고, 하위 디렉터리의 파일은 그 디렉터리의 파일을 읽을 때 넣는다. - Aider는 tree-sitter로 symbol 지도를 만들어 대화와 관련된 순서로 넣는다. 이런 지도를 쓰는 시스템은 Aider 하나다.
§16.5의 Recommendation 6은 이 방식 사용을 권장한다. 프로젝트·사용자 범위의 Markdown context 파일을 계층적으로 자동 탐색하고, 다른 도구가 쓰는 파일 이름도 읽고, 최상위 내용은 시스템 프롬프트 위쪽에 넣으라고 한다. 논문의 최소 harness 예시(§16.10)는 작업 디렉터리에서 루트까지 AGENTS.md를 찾아 이어 붙이는 함수 하나로 이를 구현한다.
OS·작업 경로 등을 시스템 프롬프트에 넣는 방식은 §7.2에서 다룬다. OpenCode와 Pi는 작업 디렉터리와 날짜를, Mistral Vibe는 git log와 git status 출력을 시스템 프롬프트에 넣는다. Mini-SWE-Agent는 platform.uname()으로 얻은 OS 정보를 넣고, macOS에서는 sed -i ''를 쓰라는 안내를 덧붙인다.
이번 Lab에서 다룰 repository context
hel에는 다음 두 가지를 추가할 예정이다.
- 시스템 프롬프트: harness가 시작할 때 OS, shell, 작업 디렉터리를 알아내 system message로 보낸다. “macOS에서는 이렇게 하라” 같은 지시는 넣지 않는다. model이 환경을 안 상태에서 명령을 스스로 맞춰 고르는지 보기 위해서다.
HEL.md읽기: 작업 디렉터리에HEL.md가 있으면 그 내용을 시스템 프롬프트에 붙인다. 파일 이름은 다른 도구가 쓰는AGENTS.md,CLAUDE.md와 겹치지 않게 정했다. 이미AGENTS.md를 쓰는 저장소에서hel을 실행해도 다른 도구용 규칙이 섞이지 않는다. 상위 디렉터리로 올라가며 찾지 않고 작업 디렉터리 하나만 본다.
HEL.md에 쓴 규칙은 model에게 주는 정보일 뿐이고, harness는 model이 그 규칙을 지키는지 검사하지 않는다. Claude Code 문서도 CLAUDE.md를 강제되는 설정으로 보지 말고, 반드시 막아야 하는 동작은 hook으로 처리하라고 안내한다. 규칙을 강제하는 방법은 H7 Permissions와 H10 Skills / Hooks / MCP에서 다룬다.
참고 자료
- Mini-SWE-Agent
mini.yaml: 첫 user message의<system_information>에 OS 정보를 넣고, macOS면sed안내를 더한다. context 파일은 읽지 않는다. - Codex
agents_md.rs: project root(기본.git이 있는 곳)에서 작업 디렉터리까지의AGENTS.md를 이어 붙이고, 합계 32KiB에서 자른다. root를 못 찾으면 작업 디렉터리만 본다. - Claude Code 메모리 문서:
CLAUDE.md의 범위와 로드 순서,AGENTS.md를 읽는 조건, 파일당 200줄 이하 권장(2026-10 기준).
이번에 해볼 것
출발 상태는 h03의 hel이다. tool은 기본값 bash 하나이고(--tools로 read_file, write_file, search_replace를 고를 수 있다), model에게는 사용자 지시와 사용 가능한 tool 목록만 보낸다.
여기에 두 가지 실행 옵션을 만든다. 옵션을 주지 않으면 지금과 같이 동작한다.
--env: OS, shell, 작업 디렉터리를 알아내 system message로 보낸다.--context-file HEL.md: 작업 디렉터리에HEL.md가 있으면 그 내용을 system message에 붙인다.
같은 task를 세 가지 구성으로 실행해 비교한다. 시스템 프롬프트를 먼저 만들어 확인하고, 그다음 HEL.md 읽기를 더한다.
| 구성 | 옵션 | 확인할 것 |
|---|---|---|
| 시작 상태 | 없음 | 환경과 규칙을 model이 스스로 알아낼 때의 호출과 실패 |
| 시스템 프롬프트 | --env | 실패한 명령이 줄어드는가, system message만큼 비용이 얼마나 느는가 |
시스템 프롬프트 + HEL.md | --env --context-file HEL.md | 파일에만 적힌 규칙을 지키는가, 규칙을 찾는 호출이 줄어드는가 |
task는 세 개다. 각 구성에서 task마다 3번씩 실행하고, 실행마다 model 호출은 10번까지 허용한다.
| task | 지시 | 확인할 것 |
|---|---|---|
edit-line-01 | H2에서 쓴 task. 237줄짜리 settings.py에서 값 하나를 바꿈 | system message가 더하는 비용. 정확성은 그대로인지 |
env-checksum-01 | data.txt의 MD5 값을 checksum.txt에 씀 | 실행 환경에 없는 명령의 실패와, 환경을 모를 때의 대비 명령. OS와 PATH에 따라 md5sum, md5, openssl, python 중 쓸 수 있는 것이 다르다 |
rule-config-01 | “cache_ttl을 300으로 설정하라”. config/default.ini에 현재 값이 있고 config/local.ini도 있다 | 작업 디렉터리의 HEL.md에만 적힌 규칙(“default.ini는 고치지 않고 local.ini에 쓴다”)을 지키는가 |
rule-config-01의 HEL.md는 세 구성 모두 작업 디렉터리에 들어 있다. 달라지는 것은 harness가 그 내용을 미리 넣어 주는지다. 옵션이 없으면 model이 ls로 파일을 보고 직접 읽어야 한다.
채점은 실행이 끝난 뒤의 파일로 한다. edit-line-01과 env-checksum-01은 파일이 기대한 내용과 정확히 같은지 본다. rule-config-01은 default.ini가 그대로인지와, local.ini의 [cache] 섹션에 cache_ttl = 300이 있는지 본다. 새 키를 쓸 때 = 주변 공백이나 빈 줄은 model마다 다르게 쓰므로 비교하지 않는다. 실패한 bash 명령 수와 token은 실행 기록으로 확인한다.
결과 확인
1. 시작 상태
옵션 없이 h03의 hel로 세 task를 3번씩 실행했다.
evals run h03 --conditions baseline --build
baseline, variant-env, variant-env-hel은 Lab 정의(evals/labs/h03.yaml)에 적은 세 구성의 이름이다. 각각 시작 상태, 시스템 프롬프트, 시스템 프롬프트 + HEL.md에 해당한다.
(처음 측정에서는 evals가 hel을 PATH=/usr/bin:/bin으로 실행해서, macOS의 /sbin에 있는 md5와 md5sum을 찾지 못했다. 실제 사용 환경과 같도록 PATH에 /usr/sbin:/sbin을 더하고 다시 측정한 결과를 적었다.)
| task | 통과 | 평균 tool 호출 | 평균 input / output token | 실패한 bash 호출 |
|---|---|---|---|---|
edit-line-01 | 3/3 | 5.7 | 5907 / 1032 | 2 |
env-checksum-01 | 3/3 | 4.3 | 3906 / 843 | 0 |
rule-config-01 | 3/3 | 5.7 | 5049 / 731 | 2 |
모두 통과했지만 model은 자신이 어떤 OS에서 실행되는지 모르는 채로 시작했다. 실행 기록에서 이렇게 드러났다.
env-checksum-01은 세 실행 모두 값을 구하기 전에 명령이 있는지부터 확인했다.
#1 command -v md5sum; command -v md5; md5 -q data.txt 2>/dev/null; md5sum data.txt 2>/dev/null
#2 md5sum data.txt 2>&1 || echo "md5sum failed/absent"
#3 which md5sum md5 openssl python3; echo $SHELL
- 두 실행은 reasoning에서 OS를 추측했다. #1은
ls -la출력의 group 이름을 보고 “The environment seems macOS (staff group)“라고 판단했고, #3은 “macOS-ish … GNU md5sum at /sbin/md5sum (busybox?)“처럼 OS를 정하지 못했다. edit-line-01과rule-config-01에서는 Linux 기준 명령이 실패했다. macOS의cat에는-A옵션이 없어 세 번 실패했고,sed -i '35s/.../'는 macOSsed가-i뒤의 값을 백업 확장자로 읽어 실패했다. model은 오류를 보고 “this is BSD sed“라고 판단한 뒤 python으로 다시 고쳤다.rule-config-01은 세 실행 모두ls나grep으로 파일을 훑다가HEL.md를 발견하고 스스로 읽었다. 규칙은 모두 지켰다.
2. 시스템 프롬프트 추가
--env를 주면 harness가 시작할 때 다음 system message를 만든다.
#![allow(unused)]
fn main() {
pub fn environment(workdir: &Path) -> String {
let kernel = output("uname", &["-sr"]).unwrap_or_else(|| "unknown".to_string());
let shell = output("bash", &["-c", "echo $BASH_VERSION"])
.map(|version| format!("bash {version}"))
.unwrap_or_else(|| "bash (version unknown)".to_string());
format!(
"Environment:\n- OS: {} ({kernel}, {})\n- Shell: {shell}\n- Working directory: {}",
std::env::consts::OS,
std::env::consts::ARCH,
workdir.display()
)
}
}
실제로 보낸 내용은 이렇다(작업 디렉터리 경로는 /path/to/tmp로 바꿔 적었다).
Environment:
- OS: macos (Darwin 24.1.0, aarch64)
- Shell: bash 3.2.57(1)-release
- Working directory: /path/to/tmp/hel-lab/<run-id>
evals run h03 --conditions variant-env --build
| task | 통과 | 평균 tool 호출 | 평균 input / output token | 실패한 bash 호출 |
|---|---|---|---|---|
edit-line-01 | 3/3 | 3.0 (시작 상태 5.7) | 8475 / 713 | 0 (2) |
env-checksum-01 | 3/3 | 3.0 (4.3) | 3110 / 660 | 0 (0) |
rule-config-01 | 3/3 | 4.7 (5.7) | 4515 / 596 | 0 (2) |
env-checksum-01의 명령은 이렇게 바뀌었다.
#1 ls -la && md5 -q data.txt 2>/dev/null || md5sum data.txt
#2 md5 -q data.txt 2>/dev/null || md5sum data.txt
#3 ls -la; cat data.txt; md5 -q data.txt 2>/dev/null || md5sum data.txt
- 명령이 있는지 확인하는 호출(
which,command -v)이 사라졌고, 세 실행 모두 macOS의md5 -q를 먼저 썼다. reasoning에서 OS를 추측하는 문장도 없어졌다. || md5sum을 붙이는 fallback은 남았다. 이 형태는 시스템 프롬프트가 없던 처음 측정에서도 세 실행 중 두 번 나왔다. 시스템 프롬프트와 상관없는 model의 습관으로 보이고, 같은 명령 안에 붙어서 tool 호출을 늘리지 않는다.edit-line-01은 세 실행 중 두 번 처음부터 macOS 형식의sed -i ''를 썼고 실패하지 않았다.cat -A도 더 이상 쓰지 않았다.rule-config-01은 여전히 세 실행 모두HEL.md를 직접 찾아 읽었다. 이 구성의 시스템 프롬프트에는 저장소 규칙이 없으니 예상한 결과다.- system message는 첫 호출 input을 364에서 454 token으로 약 90 token 늘렸다.
edit-line-01의 평균 input이 늘어난 것은 호출이 줄었는데도 세 실행 모두 첫 호출에서 237줄 파일 전체를cat했기 때문이다. 실행마다 차이가 커서 지금 규모로는 의미 있는 차이로 보기 어렵다.
3. HEL.md 추가
--context-file HEL.md를 주면 작업 디렉터리의 HEL.md를 읽어 시스템 프롬프트의 OS·shell·작업 경로 뒤에 붙인다. 작업 디렉터리 밖은 찾지 않고, 파일이 없으면 아무것도 더하지 않는다.
#![allow(unused)]
fn main() {
if let Some(name) = context_file
&& let Ok(text) = fs::read_to_string(workdir.join(name))
&& !text.trim().is_empty()
{
parts.push(format!(
"Contents of {name} in the working directory:\n\n{}",
text.trim_end()
));
}
}
evals run h03 --conditions variant-env-hel --build
| task | 통과 | 평균 tool 호출 | 평균 input / output token | 실패한 bash 호출 |
|---|---|---|---|---|
edit-line-01 | 3/3 | 3.0 (시스템 프롬프트 3.0, 시작 상태 5.7) | 7797 / 647 (8475 / 713, 5907 / 1032) | 1 |
env-checksum-01 | 3/3 | 3.0 (3.0, 4.3) | 2727 / 500 (3110 / 660, 3906 / 843) | 0 |
rule-config-01 | 3/3 | 3.7 (4.7, 5.7) | 4481 / 1134 (4515 / 596, 5049 / 731) | 0 |
(edit-line-01의 실패 1은 diff가 차이를 찾아 exit 1을 돌려준 것이다. 실행 기록은 exit code만 보므로 실패로 남았다.)
rule-config-01을 더 자세히 보면 이렇다.
rule-config-01 | 시작 상태 | 시스템 프롬프트 | 시스템 프롬프트 + HEL.md |
|---|---|---|---|
| 실행별 tool 호출 | 6, 7, 4 | 4, 4, 6 | 2, 3, 6 |
HEL.md를 직접 읽은 실행 | 3/3 | 3/3 | 1/3 |
| 첫 호출 input token | 346 | 434 | 492 |
#1 ls -la; cat config/default.ini; cat config/local.ini
config/local.ini에 [cache] 섹션과 cache_ttl = 300을 추가
#2 ls -la && cat HEL.md && cat config/default.ini && ...
grep -rn "cache_ttl" .
config/local.ini에 [cache] 섹션과 cache_ttl = 300을 추가
#3 ls -la; ls -la config
cat config/default.ini; cat config/local.ini
local.ini 백업 후 [cache] 섹션 추가, diff로 확인, 백업 삭제 (3번)
- 두 실행은
HEL.md를 찾지 않고 바로 설정 파일을 열었다. 한 실행(#2)은 내용을 이미 받았는데도 첫 명령에cat HEL.md를 넣었다. - 세 실행 모두 규칙을 지켰고, 최종 답에 “per the project rules”, “Per HEL.md“처럼 규칙을 근거로 적었다.
- #3은
HEL.md를 찾지 않았지만 백업을 만들고 diff로 확인한 뒤 백업을 지우느라 호출이 6번이 됐다. 호출 수에는 파일을 찾는 비용과 결과를 확인하는 습관이 함께 들어 있다. HEL.md(4줄)는 첫 호출 input을 약 58 token 늘렸다. 평균 input은 호출이 줄어 시스템 프롬프트만 있을 때와 비슷했다(4515 → 4481). 평균 output이 1134로 늘어난 것은 #3 한 실행이 2374 token을 쓴 영향이다.
edit-line-01과 env-checksum-01에는 HEL.md가 없어서 이 구성의 시스템 프롬프트는 --env만 사용했을 때와 같다. 같은 내용으로 다시 실행한 셈인데, env-checksum-01의 || md5sum fallback은 세 실행 중 세 번에서 한 번으로 줄었다. 실행마다 나타났다 말았다 하는 습관이다.
돌아보기
변경 사항
- model이 환경과 규칙을 알아내려고 harness와 주고받던 요청-응답이 사라졌다. 명령이 있는지 확인하는 호출(
which,command -v), OS에 맞지 않아 실패한 뒤 다시 시도한 호출(cat -A,sed -i), reasoning에서 OS를 추측하는 문장이 시스템 프롬프트를 준 뒤 모두 없어졌다. HEL.md를 넣어 주자 규칙 파일을 찾아 읽은 실행이 세 번에서 한 번으로 줄었다.- 결과의 정확성은 그대로였다. 세 구성 모두 테스트를 통과했다. 규칙 task도 시작 상태에서 model이
HEL.md를 스스로 찾아 지켰다. 이번에 달라진 것은 결과보다 그 결과에 이르기까지의 탐색이다. - 이제
hel은 OS·shell·작업 경로와 작업 디렉터리의HEL.md를 담은 시스템 프롬프트를 기본으로 보낸다. 보내지 않으려면--no-env,--no-context-file을 준다.
트레이드오프
- system message는 매 호출에 붙는다. OS·shell·작업 경로만 담은 시스템 프롬프트는 약 90 token, 4줄짜리
HEL.md는 약 58 token이었다.HEL.md가 길어질수록 모든 호출의 input이 그만큼 늘어난다. Claude Code가 파일당 200줄 이하를 권하는 이유이기도 하다. - 평균 tool 호출과 token은 줄어든 task도 있고 늘어난 task도 있었다. 파일 전체를
cat할지, 결과를 백업하고 diff로 다시 확인할지 같은 model의 작업 습관이 실행마다 달라서, 세 번 실행으로는 정보를 넣은 효과와 구분하기 어렵다. 이번에는 이 값을 참고로만 두었다. HEL.md는 작업 디렉터리에서만 읽는다. 하위 디렉터리에서hel을 실행하면 저장소 루트의HEL.md를 받지 못한다. 상위 디렉터리를 찾는 방식은 FAQ에 남겼다.- 넣어 준 규칙은 model에게 주는 정보일 뿐이다. 이번에는 세 번 모두 지켰지만 harness가 막지는 않는다. 규칙을 강제하는 방법은 H7 Permissions와 H10 Skills / Hooks / MCP에서 다룬다.
논문의 내용 또는 다른 harness와 비교하면
논문 §16.5의 Recommendation 6은 context 파일을 자동으로 찾아 prompt 위쪽에 넣으라고 권한다. 이번 task에서는 그 효과가 정확성보다 탐색에서 드러났다. 작은 작업 디렉터리에서는 model이 규칙 파일을 스스로 찾았고, 미리 넣어 주자 그 탐색이 사라졌다.
Codex는 .git이 있는 project root에서 작업 디렉터리까지 내려오며 AGENTS.md를 모두 이어 붙이고, 합계 32KiB에서 자른다. 내용은 user message로 보낸다. 작업 디렉터리, shell, 날짜, 시간대는 <environment_context>라는 user message로 보낸다. hel은 둘 다 system message 하나에 넣고, 작업 디렉터리 하나만 본다.
Claude Code는 CLAUDE.md를 조직, 사용자, 프로젝트 범위로 나눠 찾고, 하위 디렉터리의 파일은 그 디렉터리의 파일을 읽을 때 넣는다. hel은 범위를 나누지 않고 하위 디렉터리 파일도 읽지 않는다. 이번 task처럼 디렉터리 하나짜리 작업에서는 이런 구분이 필요 없었다.
Mini-SWE-Agent는 OS 정보와 함께 macOS에서는 sed -i ''를 쓰라는 안내를 넣는다. hel은 안내 없이 OS 이름과 버전만 주었는데, model은 세 번 중 두 번 처음부터 sed -i ''를 썼다.
FAQ
시스템 프롬프트를 받은 model이 사실과 다른 내용을 보고하면
--env로 실행한 env-checksum-01의 한 실행은 최종 답에 “macOS/Darwin has no md5sum“이라고 적었다. 이 Mac에는 /sbin/md5sum이 있다. model은 OS 이름을 받은 뒤 명령이 있는지 확인하지 않고, macOS에 대해 알고 있던 내용을 보고에 섞었다. 작업에 쓴 md5 -q가 성공해서 결과에는 영향이 없었다. 확인하지 않은 일반 지식이 작업 선택까지 바꾸는 경우(실제로 있는 명령을 피하는 등)는 이번 task에서 나타나지 않았다. model의 보고를 실행 기록과 대조하는 방법은 H13 Harness Evaluation에서 다룬다.
상위 디렉터리의 context 파일도 읽으려면
hel은 작업 디렉터리의 HEL.md만 읽는다. Codex는 .git이 있는 project root까지 올라가 그 사이의 AGENTS.md를 모두 이어 붙이고, Claude Code는 작업 디렉터리 위의 CLAUDE.md를 모두 읽는다. 저장소의 하위 디렉터리에서 실행할 일이 많아지면 이런 탐색이 필요하다. 하위 디렉터리의 규칙을 그 디렉터리 파일을 읽을 때 붙이는 방식(Claude Code)은 tool 결과에 내용을 더하는 구조라, 저장소 탐색을 다루는 H4 Repository Search나 확장 지점을 다루는 H10 Skills / Hooks / MCP에서 다시 본다.
실패한 명령을 세는 방법
실행 기록은 bash 명령의 exit code가 0이 아니면 실패로 센다. 그래서 md5 -q data.txt || md5sum data.txt처럼 앞 명령이 실패해도 뒤 명령이 성공하면 실패로 남지 않고, 반대로 diff가 차이를 찾아 exit 1을 돌려주면 실패로 남는다. 이번 Lab에서는 명령 내용을 직접 읽어 구분했다. 실행 기록에서 이런 차이를 자동으로 구분하는 방법은 H13 Harness Evaluation에서 다룬다.
같은 명령에 fallback을 붙이는 습관
env-checksum-01에서 model은 md5 -q data.txt 2>/dev/null || md5sum data.txt처럼 두 OS에 모두 대비한 명령을 자주 썼다. 시스템 프롬프트가 없던 첫 측정에서도, 시스템 프롬프트를 준 뒤에도 나타났고, 같은 system message로 다시 실행하면 세 번 중 세 번에서 한 번으로 바뀌기도 했다. 시스템 프롬프트와 상관없이 나타나는 model의 습관으로 보인다. 같은 명령 안에 붙어서 tool 호출을 늘리지 않으므로 이번 Lab에서는 비용으로 보지 않았다.
H4 — Repository Search
Note
- 시작 상태:
h04· 완료 상태:h05- 논문: §16.3 Tool Design, §13.2 The Twin Absences, §16.5 Memory and Context
git checkout -b my-h04 h04
파일명·본문 검색을 전용 tool로 제공하면, bash로 검색할 때보다 필요한 코드를 찾는 과정의 재시도와 context 사용량이 줄어드는가?
들어가며
논문이 본 코드 검색
Part II 개요에서는 저장소의 정보를 미리 주는 방법과 필요할 때 찾는 방법을 나누어 보았다. H3에서 실행 환경과 HEL.md를 미리 주었고, 이번에는 작업에 필요한 파일과 코드를 찾는 과정을 살펴본다.
논문의 Table 13은 shell 검색과 전용 검색 tool을 함께 다룬다. Mini-SWE-Agent는 shell에서 grep을 실행한다. Claude Code는 ripgrep과 파일 읽기를 사용하고, Gemini CLI는 ripgrep과 glob 도구를 제공한다. Recommendation 3은 bash에서 시작해 실제 문제가 관찰되면 도구를 더하라고 권한다. 검색에서는 bash와 find/rg를 함께 쓰는 것이 불편해질 때 grep/glob을 추가하는 예를 든다.
이 권고가 어떤 차이를 만드는지 확인하려면 실제 탐색 과정을 봐야 한다. model은 검색 범위를 정하고, 검색어를 고르고, 결과에서 읽을 파일을 선택한다. 전용 tool이 명령 구성을 줄여 줄 수 있지만, 잘못 고른 검색어나 불필요한 파일 읽기는 그대로 남을 수 있다.
이번 Lab에서 다룰 검색 tool
파일명 검색은 이름이나 경로 패턴에 맞는 파일을 찾는다. 본문 검색은 파일 안에서 문자열이나 정규식에 맞는 내용을 찾는다. 이 두 작업을 전용 tool로 제공하고, bash로 검색할 때와 필요한 코드를 찾아가는 과정을 비교한다.
두 방식에서 같은 검색 엔진을 사용할 수 있게 둔다. bash로 검색과 읽기를 한 번에 조합하는 방법도 유지한다. 확인할 것은 잘못된 검색을 고치는 호출, 필요 이상으로 반환된 내용, 필요한 코드에 도달할 때까지 사용한 context다. 검색 tool의 설명과 인자 정의가 매 요청에 더하는 token도 함께 본다.
결과를 적게 반환하면 읽을 내용이 줄지만, 필요한 항목이 잘려 다시 검색할 수도 있다. 전용 tool로 나눈 동작이 bash 명령 하나보다 많은 호출을 요구할 가능성도 있다. 그래서 작업 완료 결과와 함께 이런 복합적인 작동을 함께 고려해야 한다.
참고 자료
Codex의 model prompt는 본문에 rg, 파일 탐색에 rg --files를 우선 쓰도록 한다. 별도의 Rust file-search는 파일 경로를 fuzzy matching하며, TUI의 @파일 선택에서도 사용된다. 사용자 입력창의 파일 선택과 model이 수행하는 코드 검색을 구분해서 본다(확인한 commit 8f82b8a).
Claude Code의 현재 공식 문서는 macOS·Linux·WSL에서 기본적으로 Bash 검색을 사용한다고 설명한다. Glob/Grep은 설정에 따라 제공되며, Grep은 파일 경로·일치 내용·개수 중 반환 형식을 선택할 수 있다. 논문의 관찰 시점과 현재 제품의 기본 도구 구성을 구분해야 한다(2026-10-04 확인).
이번에 해볼 것
세 가지 검색 작업을 해 본다. bash로 검색할 때와 파일명·본문 검색 tool을 더했을 때를 비교한다. 시스템 프롬프트와 HEL.md를 주는 동작은 H3와 같게 두고, 파일을 읽거나 여러 명령을 조합할 때는 두 방식 모두 bash를 사용할 수 있다.
| 작업 | 저장소에서 찾아야 할 것 | 확인할 차이 |
|---|---|---|
| 파일명 검색 | 위치를 알려 주지 않은 target.txt의 내용 | 간단한 탐색에서 더해지는 사용 가능한 tool 목록과 호출 비용 |
| 본문 검색 | 문서·테스트에도 같은 설정명이 있는 저장소에서 webhook 구현의 RETRY_LIMIT | 검색 범위 선택, 불필요한 결과량, 다시 검색하는 과정 |
| 단서 연결 검색 | HTTP route의 handler, handler가 가리키는 설정 파일, 그 안의 storage_bucket | 여러 파일의 단서를 잇는 검색·읽기 과정 |
파일명 검색에는 H1에서 쓴 파일을 그대로 사용한다. 본문 검색은 37개 파일, 단서 연결 검색은 43개 파일로 만든 작은 저장소를 쓴다. 본문 검색에는 과거 문서와 테스트용 설정값이 섞여 있고, 단서 연결 검색에는 여러 서비스의 경로와 설정이 있다. 특정 명령의 오류를 유발하는 특수한 경로나 문법은 넣지 않는다.
판정은 찾은 내용이 정답과 같은지로 한다. 새 작업의 답은 정수 하나나 짧은 문자열 하나다. 어떤 tool을 몇 번 썼는지는 정답 조건에 넣지 않는다. bash 명령 하나로 검색과 읽기를 마쳤다면 그 과정도 그대로 비교한다.
검색 결과가 없었던 경우와 명령 자체가 잘못된 경우는 실행 기록에서 구분한다. 필요한 정보를 얻기까지의 재검색과 반환된 내용을 보고, 전체 호출 수와 input/output token을 함께 확인한다. 검색 tool의 정의가 더하는 token도 총사용량에 포함된다.
결과 확인
1. 기본 실행 결과
시스템 프롬프트와 HEL.md를 제공하는 H3 코드로 먼저 실행했다. 아래 명령의 baseline은 전용 검색 tool을 추가하기 전 조건이다. 검색 엔진은 run마다 같은 ripgrep 15.2.0을 PATH에 두었다.
cargo run --quiet -p evals -- run h04 --conditions baseline --build
각 작업을 세 번 실행했고, 아홉 번 모두 정답을 냈다. tool 오류로 기록된 호출은 없었다. 표는 평균이며, 반환량은 model에게 전달한 tool 결과를 호출마다 한 번씩 센 UTF-8 bytes다. 같은 결과가 이후 대화에 다시 포함된 것은 중복해서 합산하지 않았다. input token에는 다시 보낸 대화 기록도 포함된다.
| 작업 | 정답 | Tool 호출 | Input / output token | 반환량 bytes |
|---|---|---|---|---|
| 파일명 검색 | 3/3 | 2.0 | 1,823 / 252 | 199 |
| 본문 검색 | 3/3 | 3.7 | 14,984 / 382 | 16,287 |
| 단서 연결 검색 | 3/3 | 4.7 | 6,544 / 465 | 2,726 |
파일명 검색은 세 번 모두 find로 경로를 찾고 cat으로 내용을 읽었다. 한 번은 같은 명령 안에서 xxd로 파일 bytes까지 확인했다. 전용 검색을 더했을 때 줄일 수 있는 동작이 많지 않은 task라고 볼 수 있다.
2. 정답을 읽은 뒤에도 이어진 검색
본문 검색의 첫 실행은 파일 목록을 본 뒤 src/delivery/webhook.py를 읽었다. 그 안에 RETRY_LIMIT = 7이 있었다. 이후 저장소 전체를 다시 grep하고 webhook 경로도 재확인했다.
(아래 명령의 작업 디렉터리는 /path/to/HEL로 바꿨다.)
cat /path/to/HEL/src/delivery/webhook.py
이 호출이 돌려준 내용은 140 bytes였다. 뒤이어 실행한 명령은 다음과 같다.
grep -rn "RETRY_LIMIT" /path/to/HEL --include="*.py" --include="*.md" --include="*.json"
이 결과는 25,247 bytes였다. 과거 문서와 테스트의 값까지 포함했고, 결과마다 긴 절대 경로가 붙었다. 최종 답은 7로 맞았지만 전체 input은 29,366 token이었다.
다른 실행에서는 grep -rn "RETRY_LIMIT" . 2>/dev/null | head -50을 썼다. 잘린 결과에는 테스트와 문서만 있었고, 정답 소스는 빠졌다. model은 다음 호출에서 src 아래 파일들을 한꺼번에 읽은 뒤, 다시 src와 docs를 검색했다. 결과를 자르는 것만으로 탐색이 짧아지지는 않았다.
본문 검색 세 번의 반환량은 30,044 / 10,463 / 8,354 bytes, input은 29,366 / 9,462 / 6,125 token이었다. 모두 정답은 맞추었지만, 정답 파일을 이미 읽은 상태에서도 다시 저장소 전체를 검색했다. 전용 검색 tool을 제공했을 때, 이 비용을 줄일 수 있는지 확인하기 좋은 task라 할 수 있겠다.
3. 검색어와 파일 구조
단서 연결 검색은 route에서 handler, 설정 파일로 이어지는 경로를 모두 찾아냈다. 한 실행에서는 POST /reports/export라는 문자열로 검색했지만 JSON에는 method와 path가 서로 다른 필드에 있어 일치하지 않았다. 같은 bash 호출에 이어 붙인 cat으로 route를 읽어 다음 단계로 진행했다.
이 검색어는 전용 정규식 검색 tool에서도 그대로 일치하지 않는다. 검색 도구의 입력·출력 형식과 model이 검색어를 고르는 판단을 함께 보되, 어느 쪽에서 차이가 생겼는지 구분해야 한다.
아홉번의 실행에서 model은 제공된 rg를 직접 쓰지 않고 find와 grep을 골랐다. rg는 bash에서 호출하는 명령어의 개념으로 봐야하기 때문에 전용 tool을 추가했을 때의 행동 변화를 관찰하는 것이 더 유의미한 결과를 가져올 것으로 예상된다.
4. 전용 검색 tool 추가
hel에 glob과 grep을 더했다. --tools bash,glob,grep으로 model에게 제공한다. PATH에 있는 rg를 실행하므로 ripgrep이 필요하다.
| Tool | 입력 | 돌려주는 내용 |
|---|---|---|
| glob | 파일명 pattern, 검색할 path(생략하면 작업 디렉터리) | 정렬된 상대 경로 목록 |
| grep | 정규식 pattern, path, 선택적 파일명 include | 상대 경로·줄 번호·일치한 줄 |
먼저 model이 넘긴 검색 경로를 실제 위치로 풀어 작업 디렉터리 안에 있는지 확인한다.
#![allow(unused)]
fn main() {
let root = fs::canonicalize(workdir).map_err(|e| format!("working directory: {e}"))?;
let target = fs::canonicalize(root.join(path)).map_err(|e| format!("{path}: {e}"))?;
if !target.starts_with(&root) {
return Err(format!("{path}: outside the working directory"));
}
}
glob은 디렉터리를, grep은 파일이나 디렉터리를 받는다. 검사한 경로를 작업 디렉터리 기준의 상대 경로 relative로 바꿔 검색에 쓴다.
검색할 때는 각 인자를 분리해 rg에 전달한다. glob은 파일 목록을, grep은 일치한 내용을 JSON으로 받는다.
#![allow(unused)]
fn main() {
let mut command = Command::new("rg");
command.args([
"--no-config",
"--no-follow",
"--sort",
"path",
"--color",
"never",
]);
if name == GLOB {
command.args(["--files", "--null", "--glob", pattern]);
} else {
command.args(["--json", "--regexp", pattern]);
if let Some(include) = include {
command.args(["--glob", include]);
}
}
let output = command
.arg("--")
.arg(relative)
.current_dir(&root)
.stdin(Stdio::null())
.output()
.map_err(|e| format!("could not run ripgrep: {e}"))?;
}
경로순으로 정렬하고 검색 중 symlink를 따라가지 않도록 옵션을 고정했다. -- 뒤에는 검색 경로를 넘긴다. 종료 코드 1은 일치한 결과가 없다는 뜻이므로 정상 결과로 돌려주고, 잘못된 정규식이나 실행 실패는 오류로 돌려준다.
받은 결과는 상대 경로 목록이나 경로:줄 번호:내용으로 정리한다. 각 항목을 SearchOutput::push에 넣으면서 최대 100건(MAX_MATCHES)·10,000 UTF-8 bytes(MAX_BYTES)까지 담는다.
#![allow(unused)]
fn main() {
fn push(&mut self, line: &str) -> bool {
if self.matches == MAX_MATCHES {
self.truncated = true;
return false;
}
self.matches += 1;
let remaining = MAX_BYTES - self.text.len();
if line.len() + 1 > remaining {
self.text.push_str(utf8_prefix(line, remaining));
self.truncated = true;
return false;
}
self.text.push_str(line);
self.text.push('\n');
true
}
}
utf8_prefix는 UTF-8 문자 경계를 찾아 한글 같은 문자가 중간에서 잘리지 않게 한다. 한도를 넘으면 truncated를 표시하고, 마지막 finish에서 범위를 좁히라는 안내를 붙인다. 안내문을 포함한 응답 전체가 10,000 bytes 안에 들도록 끝부분을 다시 조정한다.
한도는 model에게 보내는 응답에 적용한다. 현재 구현은 rg의 출력 전체를 받은 뒤 응답을 줄인다.
5. 검색 tool을 사용한 결과
같은 작업을 각각 세 번 더 실행했다. variant-search는 bash에 glob·grep을 추가한 조건이다.
cargo run --quiet -p evals -- run h04 --conditions variant-search --build
| 작업 | 정답 판정(추가 전 → 후) | 평균 tool 호출 | 평균 input token | 평균 반환 bytes |
|---|---|---|---|---|
| 파일명 검색 | 3/3 → 3/3 | 2.0 → 2.0 | 1,823 → 2,776 | 199 → 89 |
| 본문 검색 | 3/3 → 3/3 | 3.7 → 3.7 | 14,984 → 7,140 | 16,287 → 6,971 |
| 단서 연결 검색 | 3/3 → 2/3 | 4.7 → 5.0 | 6,544 → 6,410 | 2,726 → 2,885 |
아홉 번 중 여덟 번에서 전용 검색 tool을 썼다. glob은 여섯 번, grep은 다섯 번 호출했다.
파일명 검색은 세 번 모두 glob으로 경로를 찾고 bash에서 cat을 실행했다. 호출 수는 두 번으로 같았다. 첫 요청 input은 459에서 830 token으로 늘었다. 반환 내용이 조금 줄어도 사용 가능한 tool 목록이 늘어난 요청과 대화 기록을 보내는 비용 때문에 전체 input은 증가했다.
본문 검색은 비용이 줄었다. 세 번 중 한 번은 처음부터 src로 범위를 좁혔다.
{"pattern":"RETRY_LIMIT","path":"src"}
이 grep은 1,132 bytes를 반환했고, 그 안에 다음 줄이 있었다.
src/delivery/webhook.py:3:RETRY_LIMIT = 7
이후 glob으로 소스 목록을 확인하고 webhook.py를 읽었다. 이 실행의 전체 반환량은 1,478 bytes, input은 3,853 token이었다.
나머지 두 번은 처음에 작업 디렉터리 전체를 grep했다. 경로순으로 앞에 있는 문서에서 100건이 나와 결과가 잘렸다. 잘림 안내를 받은 뒤 src로 범위를 좁히거나 파일을 직접 읽었다. 전용 도구를 줘도 넓게 검색하거나 정답을 본 뒤 추가 확인하는 동작은 그대로 존재했다.
본문 검색의 반환량 중앙값은 10,463에서 8,986 bytes로 14.1%, input 중앙값은 9,462에서 8,555 token으로 9.6% 줄었다. 호출 수는 두 조건 모두 4 / 3 / 4번이었다. 전용 tool을 추가하면 model은 전용 tool을 더 우선적으로 선택하는 경향을 여기에서도 확인할 수 있었다.
- 실패한 실행은 올바른 값을 찾고도 설명과 code block을 붙여 형식이 맞지 않아 실패했다. 이 실행에서는 전용 검색 tool을 사용하지 않았다.
돌아보기
변경 사항
전용 검색 tool을 제공한 아홉 번 중 여덟 번에서 model이 이를 사용했다. 파일명 검색은 세 번 모두 glob을, 본문 검색은 세 번 모두 grep을 선택했다. 이번 task와 model에서는 검색을 전용 tool에 맡기는 성향을 확인할 수 있었다.
이 선택을 바탕으로 glob과 grep을 유지한다. model은 검색할 pattern과 path를 넘기고, harness가 명령 인자를 구성하고 결과를 정리한다. 경로 검사, 일치 없음과 오류의 구분, 응답 한도는 코드와 결정적 test로 확인할 수 있는 동작이 됐다. 사용하려면 --tools bash,glob,grep을 지정한다.
harness는 model이 요청한 작업을 실행하고 다음 단계로 이어 주는 역할을 한다. 전용 tool은 여기에 더해, model이 매번 명령으로 구성하던 동작의 일부를 harness가 통제할 수 있는 영역으로 옮긴다. model에게 남은 검색어와 범위의 선택을 보면서도, 선택한 작업을 어떤 규칙으로 실행하고 결과를 돌려줄지는 정할 수 있다.
트레이드오프
같은 task에서도 model의 탐색 순서와 최종 표현은 달라졌다. 한 번은 올바른 값을 찾고도 설명과 code block을 붙였다. 이 양식 불일치는 실행 기록에 남기되, 전용 검색 tool의 기능적 실패와 구분해서 보았다.
총 token도 매 실행에서 일정하게 만들 수 있는 값은 아니다. 본문 검색에서는 줄었고, 파일명 검색에서는 사용 가능한 tool 목록이 늘어난 비용으로 늘었다. 그러나 이번 실험에서 확인한 수치만으로 비용의 증감이 항상 이런 경향으로 진행된다고 판단할 수는 없다.
harness가 직접 정한 것은 각 tool의 인자와 응답 형식, 반환 한도다. 여러 호출에 걸쳐 쌓이는 context는 FAQ에서 이어서 본다.
출력 한도 때문에 필요한 결과가 잘려 다시 검색하는 대가도 있다. 실제로 두 번은 넓게 검색한 결과가 잘린 뒤 src로 범위를 좁혔다. 현재 구현은 rg 출력을 모두 받은 뒤 반환량을 제한하므로, 이 한도가 검색 과정의 메모리나 실행 시간까지 제한하지는 않는다.
논문의 내용 또는 다른 harness와 비교하면
논문의 Recommendation 3은 bash로 시작해 관찰한 문제에 맞춰 도구를 더하라고 권한다. 이번에는 넓은 검색 결과와 추가 확인을 보고 전용 검색을 더했다. 이 경로를 model이 실제로 사용하면서, 검색 동작의 일부를 harness가 정한 규칙으로 처리할 수 있었다.
Codex의 rg·rg --files 사용 지침과 Claude Code의 플랫폼별 Bash·Grep·Glob 구성은 검색 기능을 model에게 제공하는 방식이 다양함을 보여준다.
FAQ
Aider의 symbol 지도는 어떤 방식인가
논문의 §9.7은 Aider가 tree-sitter로 코드의 symbol을 추출하고, 대화와의 관련도로 순위를 매겨 token 예산 안에 repository map을 제공한다고 설명한다. 조사한 시스템 중 순위를 매긴 repository map을 제공하는 사례는 Aider 하나다.
Recommendation 8은 코드에 embedding 기반 RAG를 만드는 것을 피하라고 권하며, 대안에는 tree-sitter symbol 추출도 포함한다.
H4에서는 bash 검색과 전용 파일명·본문 검색 tool을 비교한다. symbol 추출과 repository map은 구현하거나 측정하지 않고 참고로 남긴다.
응답에 한도를 두면 전체 token도 고정되는가
한도는 전용 검색 tool 한 번이 돌려주는 내용에 적용된다. model이 검색을 몇 번 반복할지, bash로 다른 파일을 얼마나 읽을지, 최종 답을 얼마나 길게 쓸지는 이 한도로 정해지지 않는다. 사용 가능한 tool 목록도 매 요청에 포함되고, 앞서 받은 결과가 대화 기록에 남으면 다음 요청에서 다시 input이 된다.
H4에서는 개별 검색 응답을 제한했다. 누적된 대화 전체의 크기를 다루는 방법은 H5 Context Budget과 H6 Compaction에서 이어서 본다.
Part III 개요
Note
- 시작 상태:
h05· 완료 상태:h07- 논문: §9 Memory and Context Management, §16.5 Memory and Context
현재 구조
Part II를 마친 hel의 구조는 다음과 같다.
flowchart TB
U[사용자 지시] --> L
subgraph H[hel]
P[시스템 프롬프트<br/>OS · shell · 작업 디렉터리]
C[HEL.md 읽기]
L[agent loop<br/>대화 기록을 그대로 쌓음]
T[tool<br/>bash · read_file<br/>write_file · search_replace]
S[검색 tool<br/>glob · grep]
R[실행 기록]
end
P --> L
C --> L
L <--> M[model]
L <--> T
L <--> S
L --> R
T <--> W[(작업 디렉터리)]
S <--> W
W --> C
- harness는 매 호출에 시스템 프롬프트, 사용 가능한 tool 목록, 사용자 지시, 지금까지의 대화 기록 전체를 model에게 보낸다. 대화형 모드에서는 입력이 바뀌어도 기록을 이어서 보낸다.
- bash와 파일 읽기 결과는 길이 제한 없이 대화 기록에 들어간다. 검색 tool만 응답 하나에 100건·10,000 bytes 한도가 있다.
- harness는 지금 보내는 요청이 model의 context window(한 번의 호출에서 model이 받을 수 있는 최대 token) 중 얼마를 쓰는지 모른다. 실행 기록에는 호출마다 받은 input token의 합계만 남는다.
Part II에서 드러난 문제
Part II에서 model은 실행 환경과 저장소 규칙을 미리 받고, 전용 검색 tool로 필요한 코드를 찾을 수 있게 됐다. 하지만 한 번 대화에 들어온 내용은 작업이 끝날 때까지 매 호출에 다시 보내진다.
- H4 Repository Search의 본문 검색 task에서 한 실행은 25,247 bytes의 검색 결과를 받았고, 정답은 맞았지만 전체 input은 29,366 token이었다. 같은 task의 다른 실행은 6,125 token이었다.
- 검색 tool에 응답 한도를 둬도 여러 호출에 걸쳐 쌓이는 크기는 정해지지 않는다(H4 FAQ). bash 출력에는 한도가 없다(H1 FAQ).
- model은 파일을 고친 뒤 결과를 다시 출력해 확인했다(H2 FAQ). 이 출력도 대화 기록에 남는다.
Lab 환경에서의 task는 몇 번의 호출로 끝나기 때문에 context window에 가까이 가지 않았다. 그러나 실제 작업 환경에서는 대화가 길어지면서 model에게 전달되는 입력 데이터는 점점 커지고, 이에 따라 누적되는 input token 역시 빠르게 증가하게 된다. 또한 입력 데이터가 window를 벗어나면 요청 자체도 실패할 수 있다.
논문이 보는 Context
논문은 조사한 모든 agent가 결국 대화가 context window를 넘는 문제를 만난다고 정리한다(§9). 11개 시스템의 방식은 네 갈래다(§9.1).
- 관리하지 않기: Mini-SWE-Agent는 기록을 줄이지 않고 model의 window에 맡긴다. 재현성과 실행 분석을 위한 선택이다(§9.2).
- 요약: Aider는 기록이 한도를 넘으면 앞쪽 절반을 요약한다(§9.3).
- 교체 가능한 condenser: OpenHands는 기록을 줄이는 방식을 바꿔 끼울 수 있게 만들었다(§9.4).
- 한도 기반 compaction(압축): Claude Code, Codex, Gemini CLI 등 7개 시스템은 context 사용량이 정해 둔 지점에 닿으면 model에게 대화를 요약하게 하고, 최근 기록 일부를 원문으로 남긴다(§9.5).
압축을 쓰는 시스템은 지금 context를 얼마나 쓰는지 측정을 해야 한다. API가 돌려준 token 수를 쓰기도 하고, 글자 수로 추정하기도 한다. 혹은 큰 tool 출력을 자르거나 파일로 빼서 기록에 들어가는 양 자체를 줄이기도 한다.
논문은 window보다 일정량 아래에서 압축을 시작하고, 최근 기록은 원문으로 남기고, 요약은 이전 요약에 이어 붙이라고 권한다(Recommendation 7).
이 Part에서 다룰 것
Codex와 DeepSeek Harness가 context를 어떻게 재고 나누는지, 한도에 가까워지면 무엇을 남기고 버리는지 살펴보고, 그중 한 방식을 hel에 직접 만들어 본다. H5에서는 두 harness의 방식을 조사하고 hel이 호출마다 context 크기를 기록하게 만든다. H6에서는 조사한 방식을 구현하고, window 한도보다 이른 시점에 압축을 일으켜 그 뒤에도 작업을 이어 갈 수 있는지 확인한다.
| Lab | 질문 | 더하는 구조 |
|---|---|---|
| H5 Context Budget | Codex와 DeepSeek Harness는 context budget을 어떻게 측정·배분하고, 한도에 가까워지면 무엇을 남기고 버리는가? 각 방식의 장단점은 무엇인가? | context 측정(호출별 크기, cache 적용 token) |
| H6 Compaction | 기존 작업과 같은 환경에서 compaction을 일찍 일으킨 뒤 이어서 작업하면, 작업은 계속되고 호출마다 cache hit는 다시 올라가는가? | 요청 전 크기 추정, 호출 결과가 정한 크기 범위를 넘으면 파일 저장, 압축(이전 tool 결과 줄이기 · model 요약 · 최근 원문 유지) |
이 Part를 마치면
flowchart TB
U[사용자 지시] --> L
subgraph H[hel]
P[시스템 프롬프트]
C[HEL.md 읽기]
L[agent loop]
T[tool · 검색 tool]
B[context 측정<br/>호출별 크기 · cache]:::new
K[context 관리<br/>요청 전 크기 추정 · 압축]:::new
S[호출 결과가 정한 범위를<br/>넘으면 파일 저장]:::new
R[실행 기록]
end
P --> L
C --> L
L <--> M[model]
L <--> T
T --> S
S --> L
L --> B
B --> R
K <--> L
K <--> M
T <--> W[(작업 디렉터리)]
W --> C
classDef new fill:#fff3bf,stroke:#e8590c,color:#000
- harness는 호출마다 보낸 context의 크기와 그중 cache가 적용된 token을 기록한다.
- 요청을 보내기 전마다 context 크기를 추정하고, 기준(기본 약 800K token)을 넘으면 압축한다. 이전 tool 결과의 가운데를 잘라 보고, 그래도 넘으면 system 다음의 오래된 구간을 model에게 따로 보내 요약을 받아 바꾼다. 최근 구간은 원문으로 둔다.
- 12,500 token을 넘는 tool 결과는 작업 디렉터리 밖의 임시 파일에 저장하고, model에게는 앞·뒤와 경로만 보낸다.
- 압축으로 바뀐 지점부터는 cache hit에 실패하고, 요약 요청만큼 model 호출이 늘어난다. 작업 디렉터리 밖의 파일을 model에게 읽게 하는 범위는 H7 Permissions와 H8 Sandbox에서 다시 정한다.
H5 — Context Budget
Note
- 시작 상태:
h05· 완료 상태:h06- 논문: §9.5 Threshold Compaction, §16.5 Memory and Context, §16.10 A Minimum Viable Harness
git checkout -b my-h05 h05
Codex와 DeepSeek Harness는 context budget을 어떻게 측정·배분하고, 한도에 가까워지면 무엇을 남기고 버리는가? 각 방식의 장단점은 무엇인가?
들어가며
논문이 본 context budget
Part III 개요에서 본 것처럼 논문이 조사한 시스템의 대부분은 context 사용량이 정해 둔 제한에 도달하면 compaction(컨텍스트 압축)을 한다. 오래된 대화를 model이 쓴 요약으로 바꾸는 방식이다. 그 지점을 정하려면 두 가지가 먼저 필요하다. 지금 context를 얼마나 쓰는지 측정하는 방법과, model의 context window 중 어디까지를 대화 기록에 쓸지 정한 기준이다.
§9.5에 정리된 기준은 시스템마다 다르다. Claude Code는 window보다 13,000 token 아래, Gemini CLI는 window의 50%, OpenCode는 input 한도에서 출력 몫을 뺀 지점에서 압축을 시작한다. 사용량을 재는 방법도 다르다. OpenCode는 tokenizer 없이 글자 수를 4로 나누고, Gemini CLI는 API가 돌려준 token 수로 추정값을 보정한다. Codex는 기준과 별개로 thread마다 token budget을 두고 남은 양을 model에게 알린다(현재 Codex 소스에서는 기본으로 꺼진 실험 기능이다).
Recommendation 7은 window보다 일정량 아래에서 압축을 시작하고, 최근 기록은 원문으로 남기라고 권한다. §16.10의 최소 harness 예시는 tool 출력을 25,000자에서 자르고, 글자 수로 추정한 token이 120,000을 넘으면 압축하며 최근 30%를 남긴다. 이 수치들은 각 시스템이 정한 값이고, 논문은 어떤 값이 더 나은지 비교하지 않았다. 그래서 이번 Lab에서는 실제 harness가 이 값을 어떻게 정하고 무엇을 남기는지 소스에서 직접 확인한다.
이번 Lab에서 다룰 context budget
context budget(컨텍스트 예산)은 model의 context window를 어디에 얼마나 사용할지 분배하는 예산을 의미한다. 매 요청에는 시스템 프롬프트와 사용 가능한 tool 목록처럼 고정된 부분, 지금까지의 대화 기록, model이 이번에 쓸 출력 자리가 함께 들어간다. 대화 기록이 늘어나면 남은 공간이 줄고, 한도에 도달하면 harness는 기록 일부를 줄이거나 압축해야 한다.
이번에는 Codex와 DeepSeek Harness의 소스를 읽고 다음을 비교한다.
- 지금 사용 중인 context를 어떻게 측정하는가
- window 중 어디까지를 쓰고, 어디서 압축을 시작하는가
- 큰 tool 출력을 어떻게 줄이는가
- 압축할 때 무엇을 원문으로 남기고 무엇을 버리는가
두 harness는 모두 소스가 공개되어 있다. Codex는 Rust로 작성되어 hel과 구조를 비교하기 쉽고, DeepSeek Harness는 hel과 같은 DeepSeek API를 사용한다. 압축을 실제로 구현하고 실행해 보는 것은 H6에서 하고, 이번에는 hel이 호출마다 사용한 context 크기와 cache가 적용된 token을 기록하도록 만든다. H6에서 압축을 구현한 뒤 그 전과 같은 기준으로 비교하려면 이 기록이 먼저 필요하다.
참고 자료
- Codex의 context budget: 측정, 압축 기준과 시점, 남기는 것, tool 출력 상한, cache (확인한 commit
afb436d) - DeepSeek Harness의 context budget: 같은 항목 (확인한 commit
5badb15) - Claude Code는 소스가 공개되어 있지 않아 공식 문서로만 확인했다. 한도 근처에서 자동으로 압축하고,
/compact에 남길 내용을 지시할 수 있으며,/context와/usage로 사용량과 cache 통계를 보여 준다(2026-10-04 확인).
이번에 해볼 것
다른 harness의 방식을 보기 전에 hel이 지금 어떤 상태인지부터 확인한다. hel은 DeepSeek API의 deepseek-flash model을 쓴다. 이 model의 context window와 가격, API가 돌려주는 token 정보를 확인하고, 실제 실행 기록에서 호출마다 context가 어떻게 늘어나는지 본다.
그다음 hel이 호출마다 사용한 context 크기와 cache가 적용된 token을 실행 기록에 남기도록 만든다. 마지막으로 Codex와 DeepSeek Harness가 같은 문제를 어떻게 다루는지 비교한다.
결과 확인
1. deepseek-flash의 사양과 가격
DeepSeek 가격 페이지에 나온 deepseek-flash의 사양은 다음과 같다(2026-10-04 기준).
| 항목 | 값 |
|---|---|
| model 버전 | DeepSeek-V4.1-Flash |
| context window | 1M token |
| 최대 출력 | 384K token |
| thinking mode | 기본 사용, 끌 수 있음 |
| tool 호출 | 지원 |
가격은 1M token 단위이고, input은 cache(캐시) 적용 여부에 따라 나뉜다.
| 구분 | 할인 시간대 | 기본 시간대 |
|---|---|---|
| input (cache hit) | $0.003 | $0.006 |
| input (cache miss) | $0.15 | $0.3 |
| output | $0.6 | $1.2 |
- 기본 시간대는 평일 UTC 01:00–04:00, 06:00–10:00이고, 나머지 시간과 주말은 할인 시간대다.
- cache hit input은 cache miss input의 50분의 1 가격이다. 같은 대화 기록을 매 호출 다시 보내는 agent loop에서는 앞부분이 cache에 hit했는지가 비용을 크게 바꾼다.
- 최대 context window가 1M token이라 Lab에서 진행하는 task는 이 한도에 한참 못 미친다.
2. 요청과 응답의 구조
H4에서 저장한 실행 기록에서 요청과 응답 하나를 꺼내 실제 DeepSeek이 어떤 응답을 하는지 확인할 필요가 있다. 아래는 본문 검색 작업의 첫 호출이다. (긴 문자열은 줄였고, 실행 디렉터리 경로는 /path/to/run으로 바꿔 적었다.)
hel이 보내는 요청에는 model 이름, 출력 한도, 사용 가능한 tool 목록, 메시지 목록이 들어간다.
{
"model": "deepseek-flash",
"max_tokens": 8192,
"tools": [{ "type": "function", "function": { "name": "bash", "...": "..." } }],
"messages": [
{ "role": "system", "content": "Environment:\n- OS: macos ..." },
{ "role": "user", "content": "Find the default RETRY_LIMIT used by ..." }
]
}
응답은 다음과 같다.
{
"model": "deepseek-flash",
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": "",
"reasoning_content": "Let me explore the repository.", // model이 추론한 내용
"tool_calls": [
{ "function": { "name": "bash", "arguments": "{\"command\": \"cd /path/to/run && ls -la && echo \\\"---\\\" && ls src/\"}" } },
{ "function": { "name": "bash", "arguments": "{\"command\": \"cd /path/to/run && grep -rn \\\"RETRY_LIMIT\\\" . 2>/dev/null\"}" } }
]
}
}],
"usage": {
"prompt_tokens": 462, // 요청에 들어간 input 전체
"prompt_cache_hit_tokens": 0, // cache hit에 성공한 token
"prompt_cache_miss_tokens": 462, // cache hit에 실패한 token
"prompt_tokens_details": { "cached_tokens": 0 }, // OpenAI 형식으로 cache hit token 제공
"completion_tokens": 186, // 출력에 들어간 token
"completion_tokens_details": { "reasoning_tokens": 6 }, // 출력에 사용된 reasoning token
"total_tokens": 648
}
}
reasoning_content는 thinking mode에서 model이 답하기 전에 쓴 내용이다.(= 추론 과정) 이 token은completion_tokens안에reasoning_tokens로 포함되어 output 가격으로 계산된다.prompt_tokens는 이번 요청에 들어간 input 전체이고,prompt_cache_hit_tokens와prompt_cache_miss_tokens로 나뉜다.prompt_tokens_details.cached_tokens는 OpenAI 형식으로 같은 값을 한 번 더 준다.- 매 요청에는 대화 기록 전체가 들어가므로
prompt_tokens가 그 호출 시점의 context 크기다.
3. 호출마다 늘어나는 context와 cache
같은 실행의 세 호출을 이어서 보면 다음과 같다.
| 호출 | 보낸 메시지 | prompt_tokens | cache hit | cache miss | 출력 |
|---|---|---|---|---|---|
| 1 | system, user | 462 | 0 | 462 | 186 |
| 2 | + assistant, tool 결과 2개 | 4,409 | 640 | 3,769 | 125 |
| 3 | + assistant, tool 결과 1개 | 4,591 | 4,352 | 239 | 2 |
- 첫 호출에서 model은 디렉터리 목록과 저장소 전체
grep을 한 번에 요청했다.grep결과가 9,983 bytes여서 두 번째 호출의 context가 462에서 4,409 token으로 늘었다. - 두 번째 호출에서는 앞부분 640 token만 cache hit에 성공했고, 새로 붙은 tool 결과는 모두 cache miss였다. 세 번째 호출에서는 두 번째 호출까지의 내용 4,352 token이 cache hit에 성공했다. H4 기록 65개 호출의 cache hit 값은 모두 128의 배수였다.
- 세 번째 호출의 출력은
7하나였고, 이 실행은 정답으로 끝났다.
4. context window의 구성
deepseek-flash의 context window는 1M token이고, 한 요청의 input과 output을 합친 길이가 이 안에 들어가야 한다(API 문서의 max_tokens 설명). max_tokens는 output의 상한이고 1에서 384K 사이로 정한다. 정하지 않으면 thinking mode에서 64K가 기본이다. hel은 매 요청에 max_tokens: 8192를 보낸다.
한 요청이 window를 쓰는 모양은 다음과 같다.
| context window 1M token | ||
|---|---|---|
| input (prompt_tokens) | output 자리 | |
| 사용 가능한 tool 목록 · system · user | assistant · tool 결과 · … | reasoning + 답 |
| 매 요청 같은 앞부분 | 호출마다 붙는 대화 기록 | max_tokens 8,192 |
- 앞부분: 사용 가능한 tool 목록, 시스템 프롬프트(실행 환경,
HEL.md), 사용자 지시. 한 실행 안에서는 매 요청 같다. - 대화 기록: model의 응답(assistant)과 tool 결과가 호출마다 뒤에 붙는다. tool을 쓰는 요청에서는 이전 응답의
reasoning_content도 context에 다시 들어간다(thinking mode 문서).hel은 응답 message를 그대로 돌려보내므로 추론 내용도 다음 input이 된다. - output 자리: 이번 응답의 reasoning과 답이 쓰는 자리다. input이 커져서 input +
max_tokens가 window를 넘으면 요청이 거절된다.
§3의 세 번째 호출을 이 그림에 넣으면 input 4,591 token에 output 2 token이고, 그중 앞부분은 사용 가능한 tool 목록·system·user를 합친 첫 호출의 462 token이다. 나머지 4,129 token은 두 번의 호출에서 붙은 대화 기록이다.(window 1M에 비하면 0.5%도 쓰지 않았다.)
5. harness가 조절할 수 있는 것
window의 크기와 가격은 model이 정한다. harness가 조절할 수 있는 것은 매 요청에 무엇을 얼마나 넣는지와, 그 내용이 cache hit에 성공하는지다.
cache hit는 앞부분이 그대로일 때만 성공한다. DeepSeek의 context caching은 기본으로 켜져 있고, 요청마다 user 입력이 끝나는 지점과 model 출력이 끝나는 지점을 cache 단위로 저장한다. 그리고 다음 요청이 그 단위 전체와 앞부분부터 똑같아야 hit로 계산된다. 저장에는 몇 초가 걸리고, 쓰지 않으면 몇 시간에서 며칠 사이에 지워진다. 조건이 맞아도 hit가 항상 보장되지는 않는다.
§3의 두 번째 호출 cache hit 640 token은 첫 호출의 input 462와 output 186을 합친 648과 비슷하다. 앞 요청의 응답까지 하나의 cache 단위로 저장되고, 대화 기록을 뒤에 덧붙이기만 하면 그 단위가 다음 요청의 앞부분과 일치한다. 반대로 앞쪽의 한 글자라도 바뀌면 그 지점부터 다시 cache miss가 된다.
H4에서 기록한 65개 호출로 보면 다음과 같다.
| 호출 | input 합계 | cache hit | hit 비율 |
|---|---|---|---|
| 실행의 첫 호출 (18개) | 11,697 | 0 | 0% |
| 두 번째 이후 호출 (47개) | 107,334 | 69,888 | 65% |
| 전체 | 119,031 | 69,888 | 59% |
첫 호출은 18번 모두 cache hit에 실패했다. 시스템 프롬프트에 들어가는 작업 디렉터리 경로가 실행마다 달라서 앞부분이 실행끼리 같지 않은 것이 원인 중 하나로 보인다. 같은 실행 안의 다음 호출들은 앞 요청을 이어 붙였기 때문에 input의 65%가 cache hit였다.
새로 붙는 내용은 한 번은 cache miss다. tool을 호출한 결과는 처음 대화에 들어갈 때 miss 가격으로 계산되고, 그 뒤 호출에서는 hit 가격으로 계속 실려 간다. hit 가격이 miss의 50분의 1이어도 window는 그대로 차지한다. 그래서 harness가 다룰 지점은 두 가지다.
- cache hit 비율: 앞부분을 바꾸지 않고 뒤에 덧붙인다. 실행마다 달라지는 정보를 앞쪽에 두면 실행 사이의 cache를 잃는다.
- input 크기: 한 번에 들어오는 tool 결과를 제한하고, 쌓인 기록을 줄일 시점을 정한다. 다만 오래된 기록을 지우거나 압축하면 바뀐 지점부터 cache를 다시 만들어야 한다.
두 지점은 서로 부딪친다. input을 줄이려고 기록을 고치면 cache hit 비율이 떨어진다. Codex와 DeepSeek Harness가 이 둘을 어떻게 조절하는지는 다음에 비교한다.
6. hel의 실행 기록에 context와 cache 남기기
§3의 값은 raw log를 직접 열어서 계산했다. 현재 실행 기록(record.json)에는 input 합계만 있어서, 실행끼리 cache hit나 context 크기를 비교하려면 매번 raw log를 다시 읽어야 했다. 그래서 실행 기록의 usage에 세 값을 추가했다.
| 필드 | 값 |
|---|---|
cached_input_tokens | 응답마다 받은 prompt_cache_hit_tokens의 합 |
peak_context_tokens | 가장 큰 prompt_tokens. 실행 중 한 번에 쓴 최대 context |
last_context_tokens | 마지막 호출의 prompt_tokens. 실행이 끝났을 때의 context |
세 값은 사용자 지시 하나를 처리하는 실행 안에서 계산한다. 실행 하나는 model 호출 여러 번으로 이루어지고, 호출마다 prompt_tokens와 cache hit를 받는다.
호출 1 호출 2 호출 3 실행 기록 usage
prompt_tokens 462 4,409 4,591
├ cache hit 0 640 4,352 ─ 합 ──▶ cached_input_tokens 4,992
└ cache miss 462 3,769 239
prompt_tokens 합계 ──────────────────────────────▶ input_tokens 9,462
prompt_tokens 최댓값 ────────────────────────────▶ peak_context_tokens 4,591
마지막 호출의 prompt_tokens ─────────────────────▶ last_context_tokens 4,591
input_tokens와 cached_input_tokens는 비용을 보는 값이다. 같은 앞부분을 호출마다 다시 보낸 양까지 모두 더하므로 호출이 늘수록 커진다. cached_input_tokens를 input_tokens로 나누면 그 실행의 cache hit 비율이 된다(위 실행은 53%).
peak_context_tokens와 last_context_tokens는 window를 얼마나 차지했는지 보는 값이다. peak는 실행 중 input이 가장 컸던 순간이고, peak + max_tokens가 window 안에 들어야 요청이 거절되지 않는다. last는 마지막으로 성공한 요청의 input이다. 마지막 응답은 들어 있지 않으므로, 대화를 이어 가면 다음 요청은 last에 마지막 응답과 새 입력을 더한 크기가 된다. 실패한 호출은 token 수를 받지 못해 계산에서 빠진다.
지금 hel은 기록을 뒤에 덧붙이기만 하므로 마지막 호출이 가장 크고 peak와 last가 같다. 둘이 갈라지는 것은 기록을 줄일 때다.
호출 1 2 3 4 (압축 뒤) 5
input 462 4,409 4,591 1,200 1,350
▲ peak ▲ last
(압축 뒤의 수치는 설명을 위한 예시다.) peak로는 압축 전에 window를 얼마나 썼는지, last로는 압축 뒤 얼마나 줄어든 상태로 끝났는지 본다.
hel은 응답을 받을 때마다 prompt_tokens와 cache hit를 순서대로 쌓는다.
#![allow(unused)]
fn main() {
pub fn exchange(&mut self, exchange: &Exchange) {
self.model_calls += 1;
match &exchange.response.usage {
Some(usage) => {
self.input_tokens += usage.prompt_tokens;
self.output_tokens += usage.completion_tokens;
self.contexts.push(Context {
tokens: usage.prompt_tokens,
cache_hit: usage.prompt_cache_hit_tokens,
});
}
None => self.usage_complete = false,
}
// (중략: 응답 model 기록, raw log 저장)
}
}
prompt_cache_hit_tokens는 DeepSeek가 주는 필드라서 Option으로 받는다. 한 응답이라도 이 값이 없거나 호출이 실패하면 합계를 믿을 수 없으므로 cached_input_tokens를 unavailable로 남긴다.
#![allow(unused)]
fn main() {
fn cached_metric(&self) -> Metric {
let hits: Option<u64> = self.contexts.iter().map(|c| c.cache_hit).sum();
match hits {
Some(hits) if self.usage_complete => measured(hits as f64),
_ => Metric::unavailable(),
}
}
}
호출별 값은 실행 기록에 넣지 않고 raw log에 둔다. evals report가 raw log를 읽어 실행마다 한 줄로 보여 준다. H4의 기록에 실행하면 §3의 실행 아래에 다음 줄이 붙는다.
- context per call: 462 → 4409 → 4591 · cache hit: 0 → 640 → 4352
H4의 실행 기록에는 새 필드가 없으므로 표의 cached · peak ctx 열은 —로 나온다. 이전 기록은 그대로 읽히고, 새 필드는 없으면 unavailable로 본다.
대화형 모드에서는 입력 하나가 끝날 때마다 마지막 호출의 context를 stderr에 한 줄로 보여 준다.
[context: 4,591 tokens · cache hit 4,352 (94%)]
같은 값을 넣은 결정적 test로 기록과 표시를 확인했다.
test output::tests::records_cache_hits_and_context_sizes ... ok
test output::tests::peak_context_can_come_before_the_last_call ... ok
test output::tests::cache_hits_are_unavailable_when_a_response_omits_them ... ok
test output::tests::a_failed_call_makes_cache_hits_unavailable ... ok
test output::tests::no_context_before_the_first_successful_call ... ok
test output::tests::formats_thousands ... ok
7. Codex와 DeepSeek Harness의 context budget
두 harness의 자세한 동작은 하위 페이지(Codex, DeepSeek Harness)에 정리했다. 여기서는 §5의 두 지점, budget을 어떻게 나누는지와 cache hit를 어떻게 지키는지만 비교한다.
| Codex | DeepSeek Harness | |
|---|---|---|
| 현재 context | 마지막 응답의 token 수 + 그 뒤 추가분 추정(bytes ÷ 4) | session 기록으로 계산, 추정은 글자 수 ÷ 4 |
| 압축 시작 | window의 90% (설정값이 더 작으면 그 값) | window의 80% 또는 window − 출력 예약 − 65,536 중 작은 값 |
| tool 출력이 들어올 때 | 10,000 bytes를 넘으면 가운데를 자름 | 12,500 token을 넘으면 파일로 저장하고 앞·뒤만 보냄 |
| 압축할 때 남기는 것 | 최근 user 메시지(최대 20,000 token) + 요약 | 최근 구간(window의 약 16%) 원문 + 오래된 구간의 요약 |
budget 설계. 두 harness 모두 window 전체를 대화 기록에 쓰지 않는다. 출력 자리와 여유분을 먼저 떼어 두고, 그보다 앞에서 압축을 시작한다. 큰 tool 출력은 기록에 들어가는 순간 상한 안으로 줄인다. 차이는 압축할 때 드러난다. Codex는 model의 응답과 tool 결과를 모두 버리고 사용자 메시지와 요약만 남긴다. DeepSeek Harness는 오래된 구간만 요약하고 최근 작업은 tool 결과까지 원문으로 둔다.
cache를 지키는 방법. 두 harness 모두 기록을 뒤에 덧붙이기만 하고, 앞부분은 압축할 때까지 고치지 않는다. tool 출력을 들어올 때 미리 줄여 두면 나중에 그 결과를 고칠 일도 줄어든다. Codex는 설정이나 실행 환경이 바뀌어도 앞의 메시지를 고치지 않고 바뀐 부분만 새 메시지로 덧붙인다. DeepSeek Harness는 요약 요청 자체가 cache hit에 성공하도록 원래 대화와 같은 앞부분으로 보내고, 오래된 tool 결과를 줄이는 작업도 압축 기준을 넘었을 때만 한다.
압축하는 순간에는 두 harness 모두 cache를 잃는다. Codex는 기록 전체를 다시 만들어서 cache를 처음부터 쌓고, DeepSeek Harness는 바뀐 구간부터 다시 쌓는다. 기록을 많이 줄일수록 window는 넉넉해지지만 다시 계산할 부분이 커진다.
돌아보기
변경 사항
hel의 실행 기록만으로 실행마다 cache hit 비율과 window를 가장 많이 쓴 순간을 볼 수 있게 됐다. 전에는 raw log를 열어 호출마다 더해야 했다. evals report는 표에 cache hit 합계와 최대 context를 보여 주고, 실행마다 호출별 context와 cache hit를 한 줄로 적는다. 대화형 모드에서는 입력 하나가 끝날 때마다 지금 대화가 차지하는 context가 stderr에 나온다. model에게 보내는 요청은 바뀌지 않았다.
조사한 내용을 바탕으로 H6에서 만들 context 관리 구조를 정했다. budget은 DeepSeek Harness의 기준을 따르고, 요청은 다음 순서로 구성한다.
| context window | |||
|---|---|---|---|
| system · 사용 가능한 tool 목록 · HEL.md | 요약 | 최근 구간 원문 | output 자리 |
| 고치지 않음 (cache 유지) | 오래된 구간 대체 | window의 약 16% | max_tokens |
| 단계 | 언제 | 하는 일 | 누가 |
|---|---|---|---|
| 1. 정한 크기 범위를 넘은 호출 결과 저장 | tool 결과가 들어올 때 | 12,500 token을 넘으면 전체를 파일로 저장하고, 앞·뒤와 파일 경로만 기록에 넣는다 | harness 코드 |
| 2. 기준 확인 | 매 요청 전 | context가 min(window × 0.8, window − 출력 예약 − 65,536)을 넘었는지 본다 | harness 코드 |
| 3. 이전 tool 결과 줄이기 | 기준을 넘었을 때 | 8,192자를 넘는 이전 tool 결과를 앞 4,096자와 뒤 1,024자만 남긴다. 기준 아래로 내려가면 여기서 끝낸다 | harness 코드 |
| 4. 오래된 구간 요약 | 그래도 기준을 넘을 때 | 오래된 구간을 model에게 따로 보내 요약을 받고, 그 구간을 요약 하나로 바꾼다 | model |
1번과 3번은 harness 코드가 정해진 길이에서 문자열을 자르는 일이다. 4번의 요약은 model이 대화를 읽고 새로 쓰는 글이다. compaction(컨텍스트 압축)은 1~4번 전체를 가리키고, 그중 model이 글을 새로 쓰는 단계가 4번의 요약이다. harness는 시스템 프롬프트, 사용 가능한 tool 목록, 요약할 구간을 원래 대화와 똑같이 보내고 마지막에 요약 지시를 붙인다. model이 돌려준 답(content)이 요약이 되고, 대화를 이어 가는 model은 이 요청을 보지 못한다. 그래서 요약에는 API 호출 한 번의 비용과 시간이 들고, 무엇을 남길지는 model이 판단하며, 같은 대화라도 실행마다 다른 요약이 나올 수 있다.
deepseek-flash의 window(1M)로 계산하면 압축 기준은 약 800K token이어서 지금까지의 작업은 닿지 않는다. H6에서는 기준을 낮춰 압축을 일찍 일으키고, 그 뒤에도 작업을 이어 갈 수 있는지 확인한다.
트레이드오프
오래된 구간을 요약으로 바꾸면 그 뒤의 최근 구간은 내용이 같아도 앞이 바뀌었기 때문에 한 번은 cache miss로 다시 계산된다. 요약 요청 자체도 비용이다. 최근 구간을 원문으로 남기는 만큼 한 번에 줄어드는 양이 작아서, Codex처럼 거의 모두 버리는 방식보다 압축이 자주 일어날 수 있다.
논문의 내용 또는 다른 harness와 비교하면
논문의 Recommendation 7은 window보다 일정량 아래에서 압축을 시작하고, 최근 기록은 원문으로 남기고, 이전 요약에 이어 붙이라고 권한다. 정한 구조는 이 권고와 같다. 논문은 기준값을 비교하지 않았으므로, 이번에는 같은 DeepSeek API를 쓰는 harness가 cache를 기준으로 정한 값을 따랐다.
Codex는 압축할 때 대화 전체를 model에게 보내고, 사용자 메시지와 요약만 남긴 채 나머지를 버린다. 기록이 크게 줄고 구조가 단순하지만, 최근에 읽은 파일 내용도 함께 사라지고 cache를 처음부터 다시 쌓는다.
DeepSeek Harness는 오래된 구간만 요약하고, 요약 요청도 기존 cache hit에 성공하도록 보낸다. 요약 전에 tool 결과를 줄여 보고, 그것으로 충분하면 model을 부르지 않는다. 대신 구간을 고를 때 tool 호출과 결과를 떼지 않아야 하고, 이전 요약과 합치는 지시까지 다뤄야 한다.
Claude Code도 공식 문서상 한도 근처에서 자동으로 압축하고 /compact로 남길 내용을 지시할 수 있지만, 기준과 남기는 범위는 소스로 확인할 수 없었다.
Codex의 context budget
Note
확인한 소스: openai/codex commit
afb436d(2026-10-04). Rust로 작성된codex-rs기준이다.
현재 context 측정
마지막 API 응답이 알려 준 token 수에, 그 응답 뒤에 추가된 기록(tool 결과 등)의 크기를 더한다. 추가분은 tokenizer 없이 model에게 보이는 bytes를 4로 나눠 추정한다(history.rs get_total_token_usage).
window와 압축 시작 기준
| 값 | 기본 | 출처 |
|---|---|---|
| 사용 가능 window | model의 context window × 95% (effective_context_window_percent) | openai_models.rs |
| 자동 압축 기준 | 설정값 model_auto_compact_token_limit과 window의 90% 중 작은 값 | openai_models.rs |
| model 정보가 없을 때 | window 272,000 token | model_info.rs |
기준은 대화 전체에 적용하거나, 앞선 압축 이후에 늘어난 부분에만 적용하도록 고를 수 있다(context_window.rs).
압축하는 시점
- turn을 시작하기 전에 기준을 넘었으면 압축한다.
- model이 tool을 호출해 다음 요청이 이어져야 하는데 기준을 넘었으면, 그 사이에 압축한다.
- turn이 끝날 때 window의 N%를 넘었으면 압축하도록 설정할 수 있다(기본은 꺼짐).
- 사용 가능 window에 닿으면 기준과 상관없이 압축한다.
(turn.rs)
압축할 때 남기는 것
대화 전체 뒤에 요약 지시를 붙여 같은 model에 보내고, 마지막 응답을 요약으로 쓴다. 지시는 진행 상황과 결정, 제약, 남은 일, 이어서 작업하는 데 필요한 데이터를 담은 인계 요약을 요구한다.
새 기록은 다음으로 다시 만든다(compact.rs).
[초기 context] [최근 user 메시지들 (최신부터 최대 20,000 token)] [요약]
model의 응답과 tool 결과는 남기지 않는다. 요약 앞에는 “다른 model이 작업을 시작했고 이 요약을 남겼다“는 안내문을 붙인다. OpenAI provider에서는 서버의 /responses/compact로 압축하고, 다른 provider에서는 위의 방식을 쓴다.
tool 출력 상한
model마다 정한 상한(기본 10,000 bytes)을 넘으면 가운데를 잘라 앞과 뒤를 남기고, 원래 token 수와 전체 줄 수를 앞에 적는다(output-truncation).
Warning: truncated output (original token count: N)
Total output lines: M
cache
prompt_cache_key에 session ID를 넣어 같은 대화의 요청이 같은 cache를 쓰게 한다(client.rs).- 실행 환경이나 설정이 바뀌면 앞쪽 메시지를 고치지 않고, 바뀐 부분만 새 메시지로 뒤에 덧붙인다(
context/world_state). - tool 출력은 기록에 들어갈 때 이미 상한 안으로 줄어 있어서, 그 뒤로는 고칠 일이 없다.
- 압축하면 기록 전체를 다시 만들기 때문에 cache도 처음부터 다시 쌓인다.
실험 기능: token budget
features.token_budget을 켜면 압축까지 남은 token이 정한 값 아래로 내려갈 때 model에게 알리고, model이 get_context_remaining tool로 남은 양을 묻거나 new_context_window tool로 새 window를 요청할 수 있다. 현재는 개발 중인 기능이고 기본으로 꺼져 있다(features, config).
DeepSeek Harness의 context budget
Note
확인한 소스: deepseek-ai/deepseek-harness commit
5badb15(2026-10-04). 기능마다 plugin으로 나뉘어 있고, 아래 값은 기본 구성(base cordis.patch.yml)에서 켜지는 plugin 기준이다.
현재 context 측정
dsh-token-meter가 session 기록을 다시 읽어 계산하며, model을 호출하지 않는다. provider가 알려 준 token 수는 요청 내용이 완전히 같을 때만 다시 쓰고, 나머지는 글자 수를 4로 나눈 값에 구조 overhead를 더해 추정한다. 이 추정은 한중일 문자와 JSON Schema를 실제보다 적게 센다는 한계가 문서에 적혀 있다(token-meter).
window와 압축 시작 기준
DeepSeek adapter의 기본 window는 1,000,000 token, 출력 예약은 256,000 token이다(defaults.ts).
압축 시작 기준 = min(W × 0.8, W − O − 65,536) W: window, O: 출력 예약
원문으로 남길 최근 구간 = (W − O) × 0.16
model별로 비율과 남길 양을 바꿀 수 있다(compaction-basic, config.ts).
압축하는 시점
- model을 호출하기 전마다 기준을 넘었는지 확인한다.
- provider가 window 초과 오류를 돌려주면 기준과 상관없이 최대한 줄이고 한 번 다시 요청한다.
- 사용자가
/compact로 바로 압축할 수 있다.
압축할 때 남기는 것
가장 오래된 구간을 골라 요약 하나로 바꾸고, 최근 구간은 원문 그대로 둔다. 시스템 프롬프트는 고르지 않으며, tool 호출과 그 결과는 떼어 놓지 않는다. 요약은 고정된 Markdown 절(요청과 의도, 기술 개념, 파일과 코드, 오류와 해결, 남은 일, 현재 작업, 다음 단계, 중요한 맥락)로 쓰고, 이전 요약이 있으면 새 내용과 합친다. 요약이 원래 구간보다 줄지 않으면 쓰지 않는다.
[system] [<compacted-summary> 오래된 구간의 요약] [최근 구간 원문 ...]
tool 출력 상한
두 단계로 나뉜다.
| 단계 | 언제 | 하는 일 |
|---|---|---|
| spill | tool 결과가 들어올 때 | 12,500 token을 넘으면 전체를 파일로 저장하고, 앞·뒤와 파일 경로, 그 파일을 read나 grep으로 보라는 안내만 보낸다(spill-policy) |
| pruning | 압축 기준을 넘었을 때만 | 8,192자를 넘는 이전 tool 결과를 앞 4,096자 + [... tool result middle pruned ...] + 뒤 1,024자로 바꾼다. 원본은 session 기록에 남는다. 줄인 결과가 기준 아래면 요약을 하지 않는다(tool-result-pruner) |
cache
- 요약 요청을 보낼 때 시스템 프롬프트, 사용 가능한 tool 목록, 요약할 구간을 원래 대화와 byte 단위로 같게 보낸다. 그래서 요약 요청 자체가 기존 cache hit에 성공하고, 마지막 요약 지시와 요약 출력만 새로 계산된다.
- 오래된 구간만 바꾸므로 그 앞의 시스템 프롬프트 부분은 cache가 유지된다. 바뀐 지점부터는 cache를 다시 만든다.
- pruning은 압축 기준을 넘었을 때만 실행해서, 기준 아래에서는 이전 기록을 고치지 않는다.
- 각 plugin 문서에 “KV Cache effect” 절을 두어 그 기능이 cache를 어디서부터 깨는지 적는다.
FAQ
추론 내용도 context를 차지한다
tool을 쓰는 요청에서는 이전 응답의 reasoning_content를 돌려보내야 하고, DeepSeek는 이것을 context에 넣는다. hel은 응답을 그대로 돌려보내므로 model이 추론에 쓴 token이 다음 요청부터 input으로 계속 실려 간다. 요약할 구간을 고를 때 이 부분을 어떻게 다룰지는 H6 Compaction에서 정한다.
요청을 보내기 전에는 크기를 모른다
hel은 응답을 받은 뒤에야 그 요청의 prompt_tokens를 알게 된다. 압축 기준을 넘었는지는 요청을 보내기 전에 판단해야 하므로, 마지막 응답의 token 수에 그 뒤 추가된 내용의 추정치를 더하는 방법이 필요하다. Codex는 bytes를 4로 나누고, DeepSeek Harness는 글자 수를 4로 나눈다. 어느 쪽을 쓸지는 H6 Compaction에서 정한다.
H6 — Compaction
Note
- 시작 상태:
h06· 완료 상태:h07- 논문: §9.5 Threshold Compaction, §16.5 Memory and Context
git checkout -b my-h06 h06
기존 작업과 같은 환경에서 compaction을 일찍 일으킨 뒤 이어서 작업하면, 작업은 계속되고 호출마다 cache hit는 다시 올라가는가?
들어가며
논문이 본 compaction
§9.5에 정리된 시스템들은 context가 기준에 닿으면 compaction(컨텍스트 압축)을 한다. model에게 오래된 대화를 요약하게 하고 최근 기록 일부를 원문으로 남기는 방식이다. Recommendation 7은 이 방식에 더해 이전 요약에 새 내용을 합치고, window를 넘는 오류가 나면 같은 압축을 실행하라고 권한다. 근거는 여러 시스템이 같은 구조로 모였다는 관찰이고, 압축 뒤에 작업이 이어지는지나 cache 비용이 얼마인지는 측정하지 않았다.
이번 Lab에서 다룰 compaction
H5에서 정한 압축 구조를 hel에 만든다.
압축하면 그 뒤의 기록은 앞이 바뀌었기 때문에 cache hit에 실패한다. 이번에는 이 하락을 확인하는 데서 멈추지 않고, 압축 뒤에 작업을 이어 가면서 호출마다 cache hit가 어떻게 다시 쌓이는지 본다. 실제 window(1M token)까지 채우지 않고, 압축 기준을 낮춰 기존 작업 도중에 압축이 일어나게 한다.
참고 자료
이번에 해볼 것
지금까지의 작업은 지시 하나에 호출 3~6번으로 끝나서, 압축이 일어난 뒤 cache hit가 다시 오르는 과정을 볼 구간이 없었다. 그래서 한 세션에서 지시 6개를 차례로 보내는 작업을 새로 만들었다. 작업 디렉터리에는 서비스 설정 파일 4개(alpha, bravo, charlie, delta)가 있고, 파일 하나가 6KB 정도라 읽을 때마다 context가 크게 늘어난다.
| 순서 | 지시 | 확인할 것 |
|---|---|---|
| 1~3 | alpha, bravo, charlie 파일을 읽고 port만 답하라 | 요청이 이어질수록 cache hit가 오르는지 |
| 4 | 파일을 다시 읽지 말고 alpha의 owner를 답하라 | 압축 뒤에도 앞에서 읽은 내용이 남아 있는지 |
| 5 | delta 파일을 읽고 port만 답하라 | 압축 뒤 cache hit가 다시 오르는지 |
| 6 | 파일을 다시 읽지 말고 OWNER:SUM 형식으로 alpha의 owner와 port 4개의 합을 답하라 | 마지막 답이 mira:29900과 같은지 |
지시마다 tool 호출과 답으로 요청이 1~2번 생겨서 한 세션은 요청 10번 정도가 된다. 압축 기준을 낮춰 3번 지시 뒤에 압축이 일어나게 하고, 압축 없이 같은 세션을 실행했을 때와 요청 순서별 cache hit와 context 크기를 그래프로 비교한다. 정답 여부는 마지막 답으로만 판정하고, 4번 지시의 답과 파일을 다시 읽었는지는 실행 기록으로 확인한다.
hel에는 지시 목록을 JSON 파일로 받아 한 세션에서 차례로 실행하는 --turns-file 옵션을 더했다. 실행 기록은 세션 하나에 하나만 남고, raw log에는 요청마다 몇 번째 지시였는지 함께 적는다.
결과 확인
1. 압축 없이 세션을 이어 갔을 때
먼저 지금의 hel(압축 없음)로 세션을 세 번 실행했다.
cargo run -q -p evals -- run h06 --conditions baseline --build
condition task pass output-exact in/out tokens cached · peak ctx calls tools
baseline session-recall-01 3/3 3/3 33749 / 310 27477 · 5351 10 bash 4.0
세 번 모두 마지막 답이 mira:29900으로 맞았고, 세 번 모두 요청이 10번이었다. 첫 번째 실행의 요청을 차례로 보면 다음과 같다.
| 요청 | 지시 | 보낸 내용 | prompt_tokens | cache hit | hit 비율 |
|---|---|---|---|---|---|
| 1 | 1 | cat services/alpha.toml 요청 | 445 | 0 | 0% |
| 2 | 1 | alpha 파일 내용 → 7310 | 1,916 | 384 | 20% |
| 3 | 2 | cat services/bravo.toml 요청 | 1,963 | 1,792 | 91% |
| 4 | 2 | bravo 파일 내용 → 7420 | 3,424 | 1,920 | 56% |
| 5 | 3 | cat services/charlie.toml 요청 | 3,454 | 3,200 | 93% |
| 6 | 3 | charlie 파일 내용 → 7530 | 4,904 | 3,456 | 70% |
| 7 | 4 | 파일을 읽지 않고 → mira | 4,927 | 4,736 | 96% |
| 8 | 5 | cat services/delta.toml 요청 | 4,966 | 4,736 | 95% |
| 9 | 5 | delta 파일 내용 → 7640 | 6,404 | 4,992 | 78% |
| 10 | 6 | 파일을 읽지 않고 → mira:29900 | 6,453 | 6,272 | 97% |
세 번의 평균을 요청 순서대로 그리면 다음과 같다(evals report가 그린 그래프).
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#e8590c"}}}}%%
xychart-beta
title "cache hit % by request"
x-axis "request" [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y-axis "cache hit %" 0 --> 100
line [0, 20, 91, 65, 94, 76, 94, 95, 81, 96]
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#e8590c"}}}}%%
xychart-beta
title "context tokens by request"
x-axis "request" [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y-axis "tokens" 0 --> 6000
bar [445, 1918, 1974, 3064, 3094, 4172, 4195, 4234, 5302, 5351]
- cache hit 비율은 톱니 모양으로 올라간다. 파일 내용(약 1,450 token)이 새로 붙는 요청(2, 4, 6, 9번)은 그만큼 cache miss가 되고, 그다음 요청은 앞 요청 전체가 cache hit에 성공해 90%를 넘는다. 새로 붙는 양이 일정하면 context가 커질수록 miss 비율이 작아져서, 같은 종류의 요청끼리 비교하면 hit 비율이 오른다(파일 내용이 붙는 요청: 20% → 56% → 70% → 78%).
- 실행 전체로는 input의 81~83%가 cache hit였다.
- 두 번째 실행은 파일 전체 대신
sed -n '1,20p'로 앞 20줄만 읽어서 최대 context가 3,098 token이었다. 나머지 두 실행은cat으로 전부 읽어 6,453, 6,502 token까지 커졌다. 평균 그래프의 context가 실제 실행보다 낮은 것은 이 실행이 섞였기 때문이다. - 4번과 6번 지시는 세 번 모두 파일을 다시 읽지 않고 앞의 대화에서 답했다.
2. 압축 기능 만들기
hel에 압축 기준을 정하는 --compact-at과 --keep-recent 옵션을 더했다. 옵션을 주지 않으면 기본 기준(돌아보기 참고)으로 압축하고, --no-compaction으로 끈다. 이번 측정에서는 압축이 일찍 일어나도록 압축 기준을 4,000 token, 원문으로 남길 최근 구간을 1,500 token으로 두었다.
model을 호출하기 전마다 지금 context를 추정한다. 마지막 응답이 알려 준 prompt_tokens에 그 뒤 붙은 메시지의 글자 수를 4로 나눠 더한다.
prompt_tokens는 직전에 보낸 요청의 input 크기다. 그 요청에 대한 model의 응답과, 그 응답을 보고 hel이 실행한 tool의 결과는 그 뒤에 생겨서 아직 한 번도 보낸 적이 없으므로 token 수를 모른다. 이렇게 새로 붙은 메시지만 글자 수로 어림해 더한다.
① 요청 1 보냄: [system·사용 가능한 tool 목록] [user: alpha 읽어라]
└─────────── 445 token ───────────┘ ← 응답 1의 prompt_tokens
② 응답 1: assistant: cat services/alpha.toml (tool 호출)
③ tool 실행: tool 결과 = alpha 파일 내용
④ 요청 2 직전: [system·사용 가능한 tool 목록] [user] [assistant: tool 호출] [tool: 파일 내용]
└─── 445 (알고 있음) ───┘ └──── ②+③ 6,665글자 ÷ 4 ≈ 1,667 ────┘
추정 2,112 token (실제로 보낸 요청 2는 1,916 token)
요청을 보내고 응답을 받으면 정확한 값을 다시 알게 되므로, 어림하는 부분은 항상 마지막 요청 이후에 붙은 것뿐이다. 이번 작업에서 파일 내용의 어림값은 실제보다 11~13% 컸다. 글자 수에 JSON의 따옴표와 role 같은 구조가 포함되기 때문이다.
이 추정이 압축 기준을 넘으면 다음 순서로 줄인다. 기준은 지시가 바뀔 때만 확인하지 않고 요청마다 확인하므로, 한 지시 안에서도 tool을 호출한 결과가 대화에 추가된 직후에 압축이 일어날 수 있다.
#![allow(unused)]
fn main() {
pub fn before_request(messages: &mut Vec<Value>, policy: &Policy, meter: &mut Meter,
log: &mut RunLog, summarize: &mut dyn FnMut(&[Value]) -> Result<Exchange, ApiError>) {
let before = meter.estimate(messages);
if before <= policy.at {
return;
}
// 1. 8,192자를 넘는 이전 tool 결과를 앞 4,096자와 뒤 1,024자만 남긴다
if prune_tool_results(messages) > 0 { /* (중략: 다시 추정해 기준 아래면 끝) */ }
// 2. system 다음부터 최근 구간 앞까지를 model에게 따로 보내 요약을 받는다
let first = first_compactable(messages);
let Some(tail) = tail_start(messages, first, policy.keep_recent) else { return };
let exchange = summarize(&summary_request(messages, tail))?; // (중략: 실패 기록)
log.auxiliary(&exchange, "compaction");
let summary = summary_text(&exchange)?; // tool 호출·빈 응답이면 쓰지 않음
// 3. 그 구간을 요약 메시지 하나로 바꾼다
messages.splice(first..tail, [checkpoint(&summary)]);
meter.reset();
}
}
(설명을 위해 오류 처리를 줄였다.) 압축 한 번은 다음 네 단계로 진행된다. 숫자는 §3의 첫 번째 실행에서 처음 압축이 일어났을 때의 기록이다.
① 원문으로 남길 구간 고르기. 대화 기록은 메시지의 목록이고, 메시지마다 누가 만든 것인지(role)가 붙어 있다.
| role | 만드는 쪽 | 예 |
|---|---|---|
| system | hel | 실행 환경(OS, shell, 작업 디렉터리) |
| user | 사용자 | “alpha 파일을 읽고 port만 답하라” |
| assistant | model의 응답 | tool 호출(cat services/alpha.toml) 또는 답(7310) |
| tool | hel이 tool을 실행한 결과 | alpha 파일 내용 |
model이 tool을 부르면 assistant 메시지에 호출마다 id가 붙고, hel은 실행 결과를 tool 메시지로 돌려주며 어느 호출의 결과인지 그 id를 적는다. 그래서 tool 메시지는 항상 자기를 부른 assistant 메시지 바로 뒤에 짝으로 붙어 있다. 첫 압축 직전의 기록은 다음과 같았다.
0 system 실행 환경
1 user alpha 읽어라
2 assistant 호출 call_A (cat alpha)
3 tool call_A 결과: alpha 파일 내용
4 assistant 답: 7310
5 user bravo 읽어라
6 assistant 호출 call_B (cat bravo)
7 tool call_B 결과: bravo 파일 내용
8 assistant 답: 7420
9 user charlie 읽어라
10 assistant 호출 call_C (cat charlie)
11 tool call_C 결과: charlie 파일 내용 ← 대화의 맨 끝
압축은 이 목록을 한 위치에서 둘로 나눠, 앞쪽(1번부터)은 요약으로 바꾸고 뒤쪽은 원문으로 둔다. tail_start는 맨 끝에서부터 거슬러 올라가며 메시지 크기를 더하고, 나눌 위치는 user나 assistant 메시지(10, 9, 8, …)에서만 고른다. tool 메시지(11, 7, 3)는 후보에서 뺀다. 호출과 결과의 짝이 요약 쪽과 원문 쪽으로 갈라지지 않게 하기 위해서다. 후보 중 남길 구간이 --keep-recent 안에 들어오는 가장 앞 위치를 고르고, 그런 위치가 없으면 맨 끝에 가장 가까운 후보를 고른다. 이번 압축에서는 10번부터 남기면 추정 1,635 token(호출 58 + charlie 파일 내용 1,577)으로 1,500을 넘었지만, 더 뒤에 고를 후보가 없어 10번에서 나눴다.
- 11에서 나누면 1~10이 요약으로 바뀌고 원문에는
call_C 결과만 남는다. 그 결과를 만든 10번 호출이 요약 속으로 사라져, model과 API가 보기에는 하지 않은 호출의 결과가 들어온 셈이 되고 요청 형식이 깨진다. - 10에서 나누면 1~9가 요약이 되고 원문에는 10번 호출과 11번 결과가 함께 남는다.
② 요약 요청 보내기. summary_request는 요약할 구간을 다시 쓰지 않고 대화에 있던 그대로 보내고, 마지막에 요약 지시를 user 메시지로 붙인다. 사용 가능한 tool 목록도 대화 요청과 같게 넣는다. 그래서 요약 요청의 앞부분은 직전 대화 요청과 같고, input 3,720 token 중 3,456 token이 cache hit에 성공했다.
직전 대화 요청: [system·사용 가능한 tool 목록] [user: alpha] ... [user: charlie]
요약 요청: [system·사용 가능한 tool 목록] [user: alpha] ... [user: charlie] [user: 요약 지시]
요약의 형식은 hel이 이 지시문으로 정해 준다. DeepSeek Harness의 지시를 줄여 쓴 것으로, 절 제목과 규칙을 적어 두었다.
Output EXACTLY the Markdown structure below: keep every section, in order. ...
## Primary Request and Intent
## Files and Code
## Errors and Fixes
## Pending Jobs
## Current Work
## Next Step
## Critical Context
Rules:
- Preserve exact file paths, commands, identifiers, numeric values and every value the user was
told or may ask about again.
- Do NOT mention this summarization request or that the context was compacted.
- Output only the checkpoint text: do not call any tool.
...
(지시문 일부를 옮겼다.)
③ 요약이 도착하는 형식. 요약 요청의 응답은 대화 요청과 같은 Chat Completions 응답으로 온다. 요약 글은 content에 Markdown 문자열로 들어 있다.
{
"choices": [{
"finish_reason": "stop",
"message": {
"role": "assistant",
"reasoning_content": "",
"content": "## Primary Request and Intent\n- User requests reading TOML config files ...\n\n## Files and Code\n- `services/alpha.toml` — `[service]` section: ... `port = 7310`, `owner = \"mira\"` ..."
}
}],
"usage": { "prompt_tokens": 3720, "prompt_cache_hit_tokens": 3456, "completion_tokens": 480 }
}
(긴 문자열과 일부 필드를 줄였다.) 절 제목은 지시문에서 정해 준 것이고, 각 절 아래의 내용은 model이 대화를 읽고 쓴 것이다. 위의 Files and Code 절에 port와 owner를 적은 것도 model의 판단이다. hel은 이 응답에서 네 가지만 확인한다. tool 호출이 섞였는지, 출력 한도에 걸려 잘렸는지(finish_reason: length), 비어 있는지, 요약이 원래 구간보다 긴지다. 하나라도 해당하면 요약을 버리고 기록을 그대로 둔다. 절 제목이 모두 있는지는 확인하지 않으므로, model이 형식을 어기면 어긴 요약이 그대로 쓰인다. 이번 측정의 요약 5번은 모두 지시한 절을 지켰다.
④ 다음 요청에 넣기. 확인을 통과한 요약은 앞에 안내문을 붙이고 <compacted-summary> 태그로 감싸 user 메시지 하나로 만든다. 이 메시지가 요약할 구간 자리에 들어간다.
This is an automatically generated checkpoint condensing an earlier span of the conversation
to free up context. ... Continue the task directly from the messages that follow, without
acknowledging this checkpoint.
<compacted-summary>
## Primary Request and Intent
...
</compacted-summary>
그 뒤 대화 요청(요청 6)은 다음 네 메시지로 보낸다.
| 순서 | 메시지 | 글자 수 |
|---|---|---|
| 1 | system (실행 환경) | 211 |
| 2 | user: 안내문 + <compacted-summary> 요약 | 1,903 |
| 3 | assistant: cat services/charlie.toml 호출 | |
| 4 | tool: charlie 파일 내용 | 6,085 |
model은 이 요청을 받고 7530으로 답했다. 요청 6의 input은 2,415 token이었고, cache hit는 system과 사용 가능한 tool 목록에 해당하는 384 token뿐이었다. 2번 메시지부터는 처음 보내는 내용이기 때문이다. 다음 요청부터는 이 요청 뒤에 대화가 덧붙으므로 다시 cache hit에 성공한다(§3).
요약 요청은 raw log에 purpose: compaction으로 남는다. 실행 기록의 비용(input, output, cache hit)에는 들어가지만 context 크기(peak_context_tokens, last_context_tokens)에는 들어가지 않는다. 요약 요청의 input(3,720)은 대화 요청 중 가장 컸던 요청 5(3,514)보다 크지만, 대화가 window를 얼마나 차지했는지와는 다른 값이기 때문이다.
tool을 호출한 결과가 hel에서 정한 크기 범위(12,500 token)를 넘는 경우에는 압축과 별도로 처리한다. 결과를 처음 받았을 때 전체를 파일로 저장하고 앞·뒤와 파일 경로만 대화에 넣는다(저장 위치와 정리는 FAQ). 이번 작업의 파일은 6KB 정도라 이 저장도, 압축 직전에 이전 tool 결과의 가운데를 자르는 단계도 일어나지 않았다.
3. 압축이 포함된 결과(task 진행을 위해 빠르게 압축 진행)
cargo run -q -p evals -- run h06 --conditions variant-compaction --build
condition task pass output-exact in/out tokens cached · peak ctx calls tools
baseline session-recall-01 3/3 3/3 33749 / 310 27477 · 5351 10 bash 4.0
variant-compaction session-recall-01 3/3 3/3 30122 / 1497 21589 · 3497 12 bash 4.0
세 번 모두 마지막 답이 맞았다. 세 번 모두 5번째 요청 뒤(charlie 파일 내용이 붙은 뒤)에 압축이 일어났고, 두 번은 delta 파일 내용이 붙은 뒤 한 번 더 일어났다. 첫 번째 실행의 요청을 차례로 보면 다음과 같다.
| 요청 | 지시 | 보낸 내용 | prompt_tokens | cache hit | hit 비율 |
|---|---|---|---|---|---|
| 1 | 1 | cat services/alpha.toml 요청 | 446 | 0 | 0% |
| 2 | 1 | alpha 파일 내용 → 7310 | 1,922 | 384 | 19% |
| 3 | 2 | cat services/bravo.toml 요청 | 2,023 | 1,792 | 88% |
| 4 | 2 | bravo 파일 내용 → 7420 | 3,484 | 2,048 | 58% |
| 5 | 3 | cat services/charlie.toml 요청 | 3,514 | 3,328 | 94% |
| 요약 요청 | 요청 5까지의 대화 + 요약 지시 | 3,720 | 3,456 | 92% | |
| 6 | 3 | 요약 + charlie 파일 내용 → 7530 | 2,415 | 384 | 15% |
| 7 | 4 | 파일을 읽지 않고 → mira | 2,438 | 2,304 | 94% |
| 8 | 5 | cat services/delta.toml 요청 | 2,525 | 2,304 | 91% |
| 요약 요청 | 이전 요약 + 그 뒤 대화 + 요약 지시 | 2,731 | 2,432 | 89% | |
| 9 | 5 | 요약 + delta 파일 내용 → 7640 | 2,555 | 384 | 15% |
| 10 | 6 | 파일을 읽지 않고 → mira:29900 | 2,604 | 2,432 | 93% |
세 번의 평균을 그리면 다음과 같다(요약 요청은 그래프에서 뺐다).
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#e8590c"}}}}%%
xychart-beta
title "cache hit % by request"
x-axis "request" [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y-axis "cache hit %" 0 --> 100
line [0, 20, 90, 58, 95, 15, 93, 93, 39, 94]
%%{init: {"themeVariables": {"xyChart": {"plotColorPalette": "#e8590c"}}}}%%
xychart-beta
title "context tokens by request"
x-axis "request" [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
y-axis "tokens" 0 --> 6000
bar [446, 1925, 1998, 3467, 3497, 2512, 2535, 2625, 2758, 2807]
- 압축 직후 요청의 cache hit는 384 token(14~15%)이었다. 앞부분 중 시스템 프롬프트와 사용 가능한 tool 목록만 그대로이고, 요약과 남긴 최근 구간은 처음 보내는 내용이기 때문이다.
- 바로 다음 요청에서 hit 비율이 89~94%로 돌아왔다. 압축 뒤에는 다시 뒤에 덧붙이기만 하므로, 직전 요청까지의 앞부분이 그대로 cache hit에 성공한다. 9번 요청의 평균이 39%인 것은 세 번째 실행에서 두 번째 압축이 일어나지 않았기 때문이다(그 실행은 delta 파일을
sed -n '1,20p'로 앞 20줄만 읽었다). - 요약 요청 자체는 92~94%(두 번째 요약 요청은 89~90%)가 cache hit였다. 요약할 대화가 직전 요청의 앞부분과 같기 때문이다.
- context는 압축 뒤 2,400~2,600 token에서 다시 쌓였다. 파일 전체를 읽은 실행끼리 비교하면 가장 컸던 context가 약 6,500에서 3,500 token으로 줄었다.
- 4번 지시(alpha의 owner)는 세 번 모두 파일을 다시 읽지 않고
mira로 답했다. 요약의 “Files and Code” 절에 port와 owner가 남아 있었다.
## Files and Code
- `services/alpha.toml` — `[service]` section: `name = "alpha"`, `port = 7310`, `owner = "mira"`, `region = "eu-west"`. ...
- `services/bravo.toml` — `[service]` section: `name = "bravo"`, `port = 7420`, `owner = "tomas"`, `region = "eu-west"`. ...
- `services/charlie.toml` — not yet read.
(첫 번째 실행의 첫 요약에서 일부만 옮겼다.)
실험 결과 압축을 이른 시점에 강제로 자주 요청한 상태에서는 비용이 늘어난 것을 확인할 수 있다. 요약에 필요한 출력 token과 압축 이후 발생하는 cache miss로 인한 가격 차이 때문인 것으로 보인다.
물론 세션의 길이가 길어질수록 실질적으로는 token 절감 효과가 커질 것으로 예측되나, 이번 실험에서는 확인하지 못한 부분이다.
| 압축 없음 (3회) | 압축 (3회) | |
|---|---|---|
| 정답 | 3/3 | 3/3 |
| 전체 요청 | 10, 10, 10 | 12, 12, 11 |
| 가장 큰 context | 6,453 / 3,098 / 6,502 | 3,514 / 3,512 / 3,464 |
| 실행의 cache hit 비율 | 81 / 83 / 81% | 70 / 70 / 76% |
| output token | 288 / 306 / 336 | 1,492 / 1,829 / 1,170 |
| 비용 | $0.00137 / $0.00084 / $0.00142 | $0.00233 / $0.00256 / $0.00184 |
돌아보기
변경 사항
hel은 이제 대화가 길어지면 context를 압축한다. 오래된 기록은 model이 쓴 요약으로 바꾸고 최근 기록은 원문으로 남긴다. 압축 기준을 낮춰 일찍 압축을 요청한 세션에서도 마지막 답은 세 번 모두 맞았고, 압축 직후 한 번 떨어진 cache hit는 다음 요청에서 89~94%로 돌아왔다. 요약 요청 자체도 대화의 앞부분을 그대로 보내 대부분 cache hit에 성공했다.
기본 기준은 DeepSeek Harness와 같다. deepseek-flash의 window 1M token과 출력 한도 8,192 token으로 계산하면 다음과 같다.
| 값 | 계산 | 기본값 |
|---|---|---|
| 압축 시작 | min(window × 0.8, window − 출력 한도 − 65,536) | 800,000 token |
| 원문으로 남길 최근 구간 | (window − 출력 한도) × 0.16 | 158,689 token |
| 파일로 저장할 tool 결과 | 12,500 token 초과 |
--compact-at과 --keep-recent로 기준을 바꾸고, --no-compaction으로 끈다. 이번 측정의 --compact-at 4000 --keep-recent 1500은 압축을 일찍 보기 위한 값이다. 그 밖에 지시 여러 개를 한 세션으로 실행하는 --turns-file과, 요청 순서별 cache hit 그래프를 그리는 evals report를 더했다.
트레이드오프
압축은 model을 한 번 더 부르는 일이다. 이번 세션에서 요약 한 번의 출력은 480~768 token이었고, 압축 직후 요청은 요약과 최근 구간 전체를 다시 계산했다. 자주 압축하면 이 비용 역시 함께 고려를 해야한다. 기본 기준(800K)에서는 지금까지의 작업 규모에서 압축이 일어나지 않으므로, 실제 window 근처에서 압축이 얼마나 자주 일어나고 얼마를 아끼는지는 측정할 수 없었다.
요약의 내용은 model이 정한다. 이번 요약에는 필요한 port와 owner가 모두 남았지만, 시스템 프롬프트에 있던 작업 디렉터리 경로와 shell 정보도 다시 적혀 같은 내용이 두 번 들어갔다. 크기 추정은 글자 수를 4로 나누는 방식이라 한국어처럼 글자당 token이 많은 내용은 적게 추정한다. DeepSeek Harness도 같은 한계를 문서에 적어 두었다.
논문의 내용 또는 다른 harness와 비교하면
Recommendation 7의 네 가지 중 window 이하 고정 기준, 최근 기록 원문 보존, 이전 요약과 합치기(요약 지시에 이전 요약을 합치라고 적었다)는 구현했다. window 초과 오류가 났을 때 압축을 실행하는 반응형 경로는 만들지 않았다(FAQ). 논문이 측정하지 않은 cache 비용은, 압축 직후 한 번 크게 miss가 나고 바로 회복하는 모양으로 확인했다.
DeepSeek Harness와는 기준과 구간 배치가 같다. DeepSeek Harness는 압축의 시작과 결과를 session 기록의 event로 남겨 다시 재생할 수 있게 하고, 요약이 실패하면 마지막으로 기록된 상태를 그대로 쓴다. hel은 요약 요청을 raw log에 남기고, 실패하면 기록을 바꾸지 않고 그대로 진행한다.
Claude Code도 공식 문서상 한도 근처에서 자동으로 압축하지만, 기준과 남기는 범위는 공개되어 있지 않다.
FAQ
window를 넘는 오류가 나면
hel은 요청 전에 추정한 크기로만 압축 여부를 정한다. 추정이 실제보다 작아 API가 window 초과 오류를 돌려주면, 지금은 그 지시가 오류로 끝난다. DeepSeek Harness와 Mistral Vibe는 이 오류를 받으면 기준과 상관없이 압축하고 같은 요청을 다시 보낸다. 오류를 받고 기록을 되돌리거나 다시 시도하는 일은 세션 기록을 다루는 H9 Sessions & Checkpoints에서 함께 본다.
파일로 저장한 tool 결과는 어디에 있나
12,500 token을 넘는 tool 결과는 작업 디렉터리 밖의 임시 폴더(<임시 폴더>/hel-spill/)에 저장하고 그 경로를 model에게 알려 준다. 폴더와 파일은 현재 사용자만 읽을 수 있게 만들고, hel이 시작할 때마다 하루가 지난 파일부터 지운다. bash로는 그 파일을 읽을 수 있지만 read_file은 작업 디렉터리 안만 읽으므로 읽지 못한다. harness가 작업 디렉터리 밖에 파일을 쓰고 model에게 읽게 하는 경로는 H7 Permissions와 H8 Sandbox에서 권한 범위와 함께 다시 정한다.
압축이 비용을 아끼는 시점
이번 세션에서는 요약 출력과 압축 직후의 cache miss 때문에 실행당 비용이 늘었다. 압축이 아끼는 것은 그 뒤 요청마다 다시 보내는 input이므로, 압축 뒤 요청이 많고 context가 클수록 이득이 커진다. 압축 기준과 세션 길이에 따라 손익이 어디서 바뀌는지는 harness 설정을 비교하는 H13 Harness Evaluation에서 측정할 수 있다.
Part IV 개요
Note
- 시작 상태:
h07· 완료 상태:h10- 논문: §10 Safety and Permission Models, §16.6 Safety Architecture by Deployment Context
현재 구조
Part III를 진행하면서 hel은 대화가 길어지면 오래된 내용은 요약하고 최근 기록을 남기는 방식으로 model의 context window를 관리하고 harness에서 장기 기억을 관리할 수 있는 시스템을 갖출 수 있게 됐다.
flowchart TB
U[사용자 지시] --> L
subgraph H[hel]
P[시스템 프롬프트 · HEL.md]
L[agent loop]
C[context 측정 · 압축]
T[tool 실행]
S[호출 결과가 정한 범위를<br/>넘으면 임시 파일 저장]
R[실행 기록]
end
P --> L
L <--> M[model]
L <--> C
C <--> M
L <--> T
T --> S
S --> L
L --> R
T <--> W[(작업 디렉터리)]
- harness는 시스템 프롬프트, 사용 가능한 tool 목록과 대화 기록을 model에게 보낸다. 시스템 프롬프트에는 OS·shell·작업 경로와 저장소 규칙이 들어간다. 압축한 구간은 요약으로 바뀐다.
hel이 제공하는 전용 tool은 작업 디렉터리 경계를 검사한다. bash는 같은 디렉터리에서 시작하지만 밖으로 이동하거나 다른 경로에 접근할 수 있다.- tool을 실행하기 전에 사용자 승인을 받는 단계는 없다.
Part III에서 드러난 문제
H6에서 model이 tool을 사용하고 일정 크기를 넘어가는 결과를 반환해야 하는 상황이라면 임시 파일에 저장하고, model에게는 이 경로를 알려주는 작업 방식을 만들었다.
그러나 임시 파일은 작업 디렉터리 밖에 있어 bash로는 읽을 수 있지만, hel이 제공하는 읽기 전용 도구인 read_file로는 읽지 못한다. 즉, model에게 필요한 접근 범위와 tool이 접근할 수 있는 범위가 서로 어긋나고 있는 상황이다.
또한 대화가 이어지는 동안 실행할 명령과 접근할 파일도 늘어난다. 허용한 명령이 예상하지 않은 파일을 변경하거나 통신할 수도 있고, 실패한 작업을 다시 시작할 때 어떤 대화와 파일 상태를 이어받을지도 정해야 한다.
논문이 보는 권한과 실행 환경
논문은 §10에서 실행 전 권한 판단과 OS 수준의 격리를 함께 다룬다. §16.6은 배포 환경에 따라 승인 정책과 격리를 선택하고, 정책을 별도 데이터로 관리하라고 권한다.
권한 정책으로 model이 요청한 작업을 실행해도 되는지 결정하고, OS 수준의 격리는 실행 중인 프로그램이 접근할 수 있는 파일과 네트워크의 범위를 제한할 수 있다.
이 Part에서 다룰 것
실행 전에 권한을 판단하는 단계를 만들고, 실행 환경의 접근 범위와 작업 재개 방법을 차례로 다룬다. Codex와 Claude Code를 참고해 각 단계가 어떻게 연결되는지 참고한다.
| Lab | 질문 | 더하는 구조 |
|---|---|---|
| H7 Permissions | 접근 레벨에 따라 미승인·금지 tool 호출을 막을 수 있는가? | 접근 레벨·Approvable·승인 처리 |
| H8 Sandbox | 허용한 실행의 접근 범위를 어떻게 제한할 것인가? | macOS 파일 접근 격리·인스턴스별 tmp |
| H9 Sessions & Checkpoints | 종료한 대화를 어떤 상태에서 다시 시작할 것인가? | 대화 snapshot·세션별 spill·재개 |
이 Part를 마치면
flowchart TB
U[사용자 지시] --> L
subgraph H[hel]
L[agent loop]
C[context 측정 · 압축]
P[접근 레벨 · 권한 판정<br/>Approvable · 호출별 승인]:::new
T[tool 실행]
F[직접 파일 접근]
I[외부 프로세스 격리<br/>macOS 파일 접근 경계]:::new
R[세션 snapshot · 복원<br/>세션별 spill · 점유 잠금]:::new
end
L <--> M[model]
L <--> C
C <--> M
L <--> P
U <--> P
P <--> T
T <--> F
F <--> W[(작업 디렉터리)]
T <--> I
I <--> W
L <--> R
classDef new fill:#fff3bf,stroke:#e8590c,color:#000
- 각 tool의 Approvable 구현이 제공하는 호출 정보와 사용자가 선택한 접근 레벨로 실행 여부를 판단한다.
- 외부 프로세스에는 macOS sandbox로 파일 접근 범위를 적용한다. 직접 파일 tool의 경계 검사와 구분하며, 네트워크 제한은 포함하지 않는다.
- 답변 완료 시 대화와 관련 상태를 저장하고 같은 세션을 재개한다. 파일 상태를 되돌리는 것과 대화를 복원하는 것을 구분한다.
- 이후 Part V에서는 이 실행 흐름에 외부 tool과 여러 agent를 연결할 때의 경계를 다룬다.
H7 — Permissions
Note
- 시작 상태:
h07· 완료 상태:h08- 논문: §10 Safety and Permission Models, §16.6 Safety Architecture by Deployment Context
git checkout -b my-h07 h07
접근 레벨에 따라 tool 호출을 일관되게 제어하고 미승인·금지 작업의 실행을 막을 수 있는가?
들어가며
논문이 본 권한 처리
Part IV 개요에서 권한 판단과 실행 격리의 역할을 나눴다. 여기서는 model이 요청한 tool을 실행하기 직전의 판단을 다룬다.
논문 §10.1의 그림 5는 Claude Code의 권한 처리를 다음 계층으로 나타낸다.
flowchart TB
H[정적 훅 규칙<br/>PreToolUse 패턴 매칭]
C[LLM 기반 권한 분류기<br/>허용 · 질문 · 거부]
U[사용자 확인<br/>승인 · 거부 · 편집]
H --> C
C --> U
출처: Harness Engineering, Figure 5. 한국어 번역본의 label을 참고해 계층을 Mermaid로 재구성했다. 화살표는 확인 계층의 순서이며, 모든 호출이 세 단계를 모두 거친다는 뜻은 아니다. 이 그림은 논문이 관찰한 구조다.
Claude Code. 현재 권한 문서는 명시적 규칙이 겹치면 deny → ask → allow 순서로 판단한다고 설명한다. CLAUDE.md의 자연어 지시와 별도로 harness가 규칙을 강제한다. 사용자에게 물을 수 없는 dontAsk 모드에서는 확인이 필요한 호출을 거절한다(2026-10-05 확인).
Codex. 실행 정책은 명령 인자에 규칙을 적용하고, 여러 규칙이 맞으면 forbidden → prompt → allow 중 가장 제한적인 결과를 택한다. 공개 소스에서는 정책 결과를 승인 필요 여부로 바꾼 뒤 실행 계층이 처리한다. 정책에 맞는지 계산하는 부분과 사용자에게 확인하고 실행하는 부분이 나뉘어 있다(2026-10-05 확인).
Codex는 sandbox와 승인 정책을 묶은 권한 설정을 제공하고, Claude Code도 permission mode로 승인 방식을 바꾼다. hel은 사용자가 접근 레벨을 선택하면 harness가 각 tool 호출의 실행 여부를 정하는 구조를 만든다. 두 제품의 모드를 그대로 복제하는 것은 아니며, LLM 분류기와 hooks는 이후 확장할 수 있다.
이번 Lab에서 다룰 실행 제어
현재 hel은 model에게 제공한 tool 이름인지 확인한 뒤 실행한다. HEL.md에 파일을 고치지 말라고 써도, model이 편집을 요청하면 이를 권한 규칙으로 검사하는 단계는 없다.
확인하려는 동작은 다음과 같다.
| 판정 | 실행 흐름 |
|---|---|
| allow | 추가 확인 없이 실행 |
| ask | 사용자에게 대상과 작업을 보여 준 뒤 승인 시 실행 |
| deny | 실행을 막고 거절 이유 반환 |
사용자는 접근 레벨을 고르고, 각 tool은 Approvable trait을 구현해 호출의 작업 성격을 제공한다. trait은 구현할 함수의 계약을 정하는 Rust의 기능이다. 공통 권한 정책이 이 정보와 접근 레벨을 비교해 allow·ask·deny를 결정하고, 공통 실행 계층이 그 결정을 적용한다.
tool 호출 요청
→ Approvable로 호출의 작업 성격 확인
→ 공통 정책에서 접근 레벨과 비교
→ 필요하면 사용자 승인 요청
→ 허용된 호출만 실행
H7에서는 읽기·검색, 파일 변경, 범용 실행 중 하나를 반환하도록 설계한다. 새 tool은 같은 trait을 구현해 공통 정책에 연결한다. 나중에는 호출 인자에 따라 작업 성격을 판단할 수 있다. 이 세 분류와 trait의 구체 계약은 hel의 설계다. Codex의 Approvable은 요청별 승인 요구사항을 제공할 수 있으며, 같은 이름을 사용해도 반환값과 정책 구성이 동일한 것은 아니다.
| 접근 레벨 | 읽기·검색 | 파일 변경 | 범용 실행 |
|---|---|---|---|
| 읽기 전용 | allow | deny | deny |
| 변경 전 확인 | allow | ask | ask |
| 자동 실행 | allow | allow | allow |
read_file·glob·grep은 읽기·검색, write_file·search_replace는 파일 변경, bash는 범용 실행으로 분류한다. glob과 grep은 외부 프로그램인 rg를 실행하지만, harness가 인자를 구성한 검색 작업이다. bash는 명령 내용에 따라 읽기와 쓰기를 모두 할 수 있어 범용 실행으로 다룬다.
자동 실행 레벨에서도 기존 파일 tool의 작업 디렉터리 경계 검사는 유지한다. H8에서 추가할 OS 격리도 별도 설정으로 다룬다. 접근 레벨은 사용자가 요청한 작업의 범위를 넓혀 주는 설정이 아니다.
참고 자료
- Claude Code 권한 설정: 규칙 우선순위와 tool별 적용 범위.
- Codex Rules: 명령 인자 규칙과 규칙 검사 방법.
- Codex 정책 판정 소스: 일치 규칙과 최종 판정 수집.
- Codex Approvable 소스: tool 실행기가 제공하는 승인 관련 인터페이스.
- Codex 실행 조정 소스: 실행 전 승인·거절 처리.
이번에 해볼 것
같은 요청을 접근 레벨만 바꿔 실행한다. 확인이 필요한 경우에는 승인과 거절을 각각 주고, 응답을 받을 수 없는 경우도 확인한다.
| 작업 | 확인할 내용 |
|---|---|
hello.txt 읽기 | 모든 접근 레벨에서 내용 읽기 가능 |
전용 편집 tool로 status.txt 변경 | 자동 실행·승인 시 변경, 읽기 전용·거절 시 원본 유지 |
bash로 status.txt 변경 | 편집 tool과 같은 레벨 정책 적용 |
파일 변경 작업은 pending을 done으로 바꾸는 작은 요청이다. 어떤 tool로 시도했는지와 최종 파일 내용을 함께 확인한다. model이 처음부터 호출을 하지 않았다면 harness가 차단했다고 볼 수 없으므로, 동일한 tool 요청을 직접 넣는 test도 만든다. 승인 응답을 기다리는 동안에는 파일이 바뀌지 않고, 승인 후에만 한 번 실행되는지 확인한다.
새 tool이 Approvable을 구현했을 때 공통 정책을 그대로 적용받는지도 test한다. trait 구현 없이 실행 경로에 연결하거나 권한 검사를 건너뛸 수 없도록 제한하기 위함이다.
결과 확인
권한 확인을 위한 구조
각 tool은 직접 작업 성격을 제공한다. 실제 실행 함수가 있는 Tool은 Approvable 구현을 필수로 요구한다(crates/hel/src/permissions.rs).
#![allow(unused)]
fn main() {
pub trait Approvable {
fn action(&self, args: &Value) -> Action;
}
pub trait Tool: Approvable {
fn name(&self) -> &'static str;
fn run(&self, workdir: &Path, args: &Value) -> Result<String, String>;
}
}
Tool: Approvable은 Tool을 구현하는 타입이 Approvable도 구현해야 한다는 뜻이다. action에는 호출 인자를 전달한다. 현재 tool은 고정된 작업 성격을 반환하지만, 인자에 따라 판단이 달라지는 tool도 같은 인터페이스로 연결할 수 있다.
공통 정책은 접근 레벨과 작업 성격을 비교한다.
#![allow(unused)]
fn main() {
pub fn decide(self, action: Action) -> Decision {
match (self, action) {
(_, Action::Read) | (Self::Auto, _) => Decision::Allow,
(Self::ReadOnly, _) => Decision::Deny,
(Self::Confirm, _) => Decision::Ask,
}
}
}
읽기는 모든 레벨에서 허용한다. 나머지 작업은 자동 실행이면 허용, 읽기 전용이면 거절, 변경 전 확인이면 승인을 요청한다. _는 해당 위치의 모든 값을 뜻한다.
실행 계층은 ask에서 승인 응답을 받은 경우에만 실행한다. 다음은 같은 파일의 실행 여부를 결정하는 부분이다.
#![allow(unused)]
fn main() {
let allowed = match trace.decision {
Decision::Allow => true,
Decision::Deny => false,
Decision::Ask => {
let response = approval.request(name, args);
trace.approval = Some(response);
response == Response::Approved
}
};
}
allowed가 true일 때만 tool.run을 호출한다. 따라서 각 tool 안에 승인 UI나 레벨별 분기를 반복하지 않는다. model이 다른 tool로 다시 요청해도 같은 게이트를 통과한다.
CLI에서 접근 레벨 선택
--access를 생략하면 confirm이다.
| 옵션 | 동작 |
|---|---|
--access read-only | 읽기·검색 허용, 편집·bash 거절 |
--access confirm | 편집·bash 호출마다 대상과 인자를 보여 주고 승인 요청 |
--access auto | 등록된 tool 호출을 추가 확인 없이 실행 |
터미널에서는 y 또는 yes에만 해당 호출을 한 번 승인한다. 다른 답은 거절하고, 입력을 받을 수 없거나 EOF이면 실행하지 않는다. 자동 실행에서도 기존 파일 tool의 작업 디렉터리 경계 검사는 유지한다.
task 실행 결과
변경 전에는 다음 명령으로 읽기·편집·bash 작업을 실행했다.
evals run h07 --conditions baseline --build
baseline에는 접근 권한을 확인하는 절차가 없기 때문에 모두 권한 확인 없이 완료됐다.
evals run h07 --conditions variant-read-only,variant-confirm-approve,variant-confirm-deny,variant-confirm-unavailable,variant-auto --build
| 실행 방식 | 읽기 | 전용 tool 편집 | bash 변경 |
|---|---|---|---|
| 권한 계층 추가 전 | 성공 | 변경 | 변경 |
| 읽기 전용 | 성공 | 원본 유지 | 원본 유지 |
| 확인 후 승인 | 성공 | 변경 | 변경 |
| 확인 후 거절 | 성공 | 원본 유지 | 원본 유지 |
| 확인 응답 불가 | 성공 | 원본 유지 | 원본 유지 |
| 자동 실행 | 성공 | 변경 | 변경 |
총 18회 실행에서 접근 권한을 부여함에 따라 harness에서 model이 호출하는 tool의 실행을 제어하는 것을 확인할 수 있었다. 승인·거절은 측정용 입력으로 재현했으며, 실제 사용자의 응답 시간은 측정하지 않았다.
원본 유지의 경우 tool을 실행 전에 harness에서 차단한 것을 파일만으로는 확인할 수 없기 때문에 raw/permissions.jsonl에서 각 호출의 판정과 실행 진입 여부를 따로 기록해 확인했다.
권한 계층을 추가한 15회 실행에서는 총 31개 호출 중에서 22개가 실행됐고, 9개는 차단됐다.
다른 tool로 다시 요청한 경우
다음은 권한을 읽기 전용으로 설정한 상태에서 model이 파일 변경을 거절당하자 다른 편집 tool로 같은 변경을 다시 시도한 경우에 대한 결과이다.
| 순서 | model이 요청한 tool | 판정 | 실행 |
|---|---|---|---|
| 1 | read_file(status.txt) | allow | 실행 |
| 2 | search_replace(pending → done) | deny | 차단 |
| 3 | write_file(status.txt, done) | deny | 차단 |
model은 search_replace가 거절되자 write_file로 다시 요청했다. 두 tool 모두 파일 변경으로 판단되어 차단됐고, 파일은 pending으로 남았다.
읽기 전용의 bash 작업에서는 쓰기 명령뿐 아니라 후속 ls도 차단됐다. 반면 read_file로 원본을 확인하는 작업은 가능했다. bash의 명령 내용에 따른 예외를 두지 않은 결과다.
task에는 If the tool refuses, leave the file unchanged and report the refusal.이라는 문구가 포함되어 있었는데, model은 첫 실패 이후에도 다른 tool을 호출해 task에서 요구한 작업을 수행하려고 했고, 대안 역시 실패하자 최종적으로 거절되었다는 사실을 보고했다.
돌아보기
변경 사항
접근 레벨에 따라 같은 tool 요청의 실행 여부가 달라졌다. 미승인·금지 호출을 차단하면서 허용 작업을 수행한 결과를 바탕으로 이 구조를 유지한다.
또한 접근 권한으로 인해 tool 사용이 거절되면 model이 스스로 다른 tool을 사용하는 현상 역시 관찰할 수 있었다. 이 경우에도 harness에서 결정한 접근 권한 정책은 그대로 적용됐다.
트레이드오프
- 읽기 전용에서는 bash를 통한
cat·ls도 실행할 수 없다. 파일 확인에는 read_file·glob·grep을 사용한다. - 확인 모드에서는 파일 변경 뒤 검사용 bash를 실행할 때도 다시 승인해야 한다. 세션 전체 허용이나 명령별 예외는 추가하지 않았다.
- 각 작업은 조건마다 한 번 실행했다. model의 재시도 성향이나 token 절감 효과는 이 결과만으로 일반화하지 않는다.
논문의 내용 또는 다른 harness와 비교하면
Codex에서 참고한 것은 tool별 승인 관련 인터페이스와 공통 실행 계층의 분리다. hel은 Approvable이 제공하는 작업의 성격으로 판정하며, Codex의 명령 정책·sandbox 연동을 모두 구현하지는 않았다. Claude Code처럼 명령 내용에 따라 읽기 전용 bash를 허용하는 기능도 없다.
FAQ
실행 격리가 필요한 이유
예시로 bash tool을 통해 cargo test 명령을 실행하도록 허용하면 테스트와 빌드 스크립트를 model이 실행할 수 있게 된다. 그러면 이 스크립트 내부에서 접근하는 파일이나 외부 서버를 향한 명령을 tool 접근 권한만으론 제어하기 어렵다.
H8에서는 이러한 문제를 해결하기 위해 harness에서 실행하는 프로세스를 Sandbox로 격리하고, Sandbox에서 허용한 범위의 작업만으로 프로세스 실행을 제어하는 방법에 대해 다룬다.
Sandbox로 실행 환경을 격리하더라도 같은 대상을 마운트하는 호스트 작업 환경에 영향을 줄 수 있기 때문에 실행 환경의 격리와 작업 권한 판단은 함께 사용해야 한다.
Codex의 Full access
H7의 자동 실행은 tool 호출마다 사용자에게 확인하지 않는 레벨이다. OS sandbox 제한을 없애는 설정과 구분해야 한다. Codex의 Full access는 sandbox 제한 해제와 승인 요청 생략을 함께 선택하는 설정이므로 H7의 레벨과 그대로 대응하지 않는다.
H8 — Sandbox
Note
- 시작 상태:
h08· 완료 상태:h09- 논문: §10.2 Native Sandboxing, §16.6 Safety Architecture by Deployment Context
- macOS 구현과 전후 18회 실행을 마쳤다. 측정 중 발견한 시스템 경로 누락은 보완했으며, 보완 후에는 직접 테스트로 확인했다. model 측정은 보완 전 결과를 그대로 기록한다.
git checkout -b my-h08 h08
hel에서 실행하는 자식/외부 프로세스의 실행 제어 방법은 무엇인가?
들어가며
H8의 전체 구조
H7은 tool 호출을 실행하기 전에 권한을 확인한다. bash로 명령을 실행하도록 허용한 뒤에는 명령 안에서 어떤 파일을 읽거나 고칠지까지 제한하지 않는다. 예를 들어 빌드 명령을 승인하면 빌드 스크립트도 실행되며, 그 스크립트가 프로젝트 밖의 파일에 접근할 수 있다.
H8에서는 외부 프로그램과 그 자식 프로세스에 sandbox(샌드박스)를 적용한다. sandbox는 프로세스가 호스트 자원에 접근할 때 OS가 규칙을 강제하는 실행 환경이다.
Lab에서는 macOS를 기준으로 sandbox를 만든다.
이 글에서 hel 인스턴스는 한 번 시작된 hel 본체와 그 본체가 관리하는 자식 프로세스를 묶은 단위다. 대화 중 tool을 여러 번 호출해도 같은 인스턴스에 속한다. hel을 다시 시작하거나 다른 터미널에서 시작하면 새 인스턴스가 된다.
flowchart TB
CALL["model의 tool 호출"]
subgraph HEL["hel 인스턴스"]
subgraph MAIN["hel 본체 프로세스"]
GATE["H7 권한 검사"]
FILE["내부 파일 tool<br/>hel 코드가 경로 검사"]
LAUNCH["외부 프로그램 실행기<br/>H8 sandbox 정책 적용"]
RESULT["공통 tool 반환 처리<br/>필요하면 spill 저장"]
RESPONSE["대화 이력에 tool 응답 추가"]
GATE -->|허용| FILE
GATE -->|허용| LAUNCH
FILE -->|반환 결과| RESULT
RESULT --> RESPONSE
end
subgraph SANDBOX["sandbox가 적용된 자식 프로세스"]
PROC["bash · rg · 그 자식 프로세스<br/>OS가 파일 접근 규칙 강제"]
end
LAUNCH -->|실행| PROC
PROC -->|반환 결과| RESULT
end
FS[("동일한 호스트 파일시스템<br/>프로젝트 · tmp · spill · 프로그램/라이브러리")]
CALL --> GATE
FILE <-->|hel의 경로 검사 범위| FS
PROC <-->|OS 정책 범위| FS
RESULT -->|spill 파일 작성| FS
바깥 상자는 하나의 hel 인스턴스를 나타낸다. 그 안의 본체와 외부 프로그램은 별개의 프로세스다. sandbox 적용 범위를 hel 안에 그렸다고 해서 본체까지 같은 OS 제한을 받는 것은 아니다.
파일 접근 통로는 두 개다. read_file, write_file, search_replace는 hel 본체 안에서 파일을 연다. 이 통로에서는 hel 코드가 경로를 검사한다. bash와 검색 tool인 glob·grep의 rg는 외부 프로그램으로 실행되므로 OS가 파일 접근을 제한한다. 두 통로 모두 같은 호스트 파일시스템을 사용하며, 실행 결과는 hel의 공통 tool 반환 처리로 돌아온다.
논문과 다른 harness의 실행 격리 수준
Part IV 개요에서 다룬 권한 판단과 실행 격리 중, H8은 실행 중의 접근 제한을 다룬다. 논문 §10.2는 Codex가 승인 정책과 OS별 native sandbox를 결합하는 구조를 설명한다. §16.6은 배포 환경에 따라 필요한 격리 수준이 달라진다고 정리한다.
Codex. macOS 구현은 Seatbelt 정책으로 읽기와 쓰기 허용 경로를 구성하고 외부 명령에 적용한다. 참고한 소스에서는 경로를 정책의 인자로 전달하고 sandbox-exec로 명령을 실행한다. hel에서도 호출 승인과 OS 정책 적용을 나누고, 읽기 허용과 쓰기 허용 범위를 구분한다.
Claude Code. sandbox 문서는 shell 명령과 그 자식 프로세스를 적용 대상으로 설명한다. 파일을 직접 수정하는 tool 등은 별도의 권한 경계로 다룬다. hel 역시 외부 명령의 sandbox만으로 내부 파일 수정 tool의 접근까지 제한됐다고 간주할 수 없다(자료 확인: 2026-10-05).
같은 목적, 다른 구현
Linux의 mount namespace(마운트 네임스페이스)는 프로세스가 보는 마운트 구성을 분리한다. 새 namespace는 처음에 기존 마운트 목록을 복사해 받으므로, 분리 후 필요한 경로만 노출하는 구성이 추가로 필요하다. 마운트 변경이 호스트로 전파되지 않도록 설정하고 새 루트를 구성한 뒤, 그 안에서 프로그램을 실행한다. Linux 문서
bind mount는 기존 디렉터리를 다른 위치에서도 접근할 수 있게 연결하는 기능이다. 호스트의 프로젝트를 sandbox의 /workspace에 연결하면 파일을 복사하지 않고 같은 파일을 사용한다. 읽기·쓰기가 허용된 연결이라면 /workspace의 변경은 호스트 프로젝트에도 반영된다. Bubblewrap은 이런 namespace와 마운트 구성을 만드는 도구다. Linux에는 경로별 접근 권한을 제한하는 Landlock도 있다.
macOS Seatbelt는 별도의 마운트 네임스페이스를 만들지 않는다. 프로그램은 호스트 경로를 그대로 사용하고, OS가 해당 경로에 대한 읽기·쓰기를 허용하거나 거절한다. 따라서 호스트 경로와 sandbox 내부 경로를 따로 매핑할 필요가 없다. 네트워크 격리는 이번 파일 접근 확인 범위에 포함하지 않는다.
이번에 해볼 것
인스턴스별 접근 범위 확인
model을 포함한 task로 프로젝트 작업, 범위 밖 접근, spill 내용 다시 읽기를 확인한다. sandbox 자체의 제한은 정해진 명령을 직접 실행하는 test로도 확인한다. 아래 표는 두 방식에서 확인할 동작이다.
| 작업 | 확인할 동작 |
|---|---|
| 프로젝트 파일 읽기·변경 | sandbox 안에서도 정상 작업 완료, 자식 프로세스의 작업도 동일하게 허용 |
| 프로젝트 밖의 시험용 파일 읽기·변경 | 읽기와 쓰기 각각 차단, 원본 유지 |
| spill에 저장된 응답의 중간 부분 읽기 | model에게 전달된 spill 경로를 실제로 사용해 생략된 내용 확인 |
| 자신의 tmp에 임시 파일 작성 | 외부 프로그램의 파일 생성·다시 읽기 가능 |
| 자신의 spill 덮어쓰기 | 외부 프로그램과 편집 tool의 쓰기 차단 |
| 다른 hel 인스턴스의 tmp·spill 접근 | 읽기·쓰기 모두 차단 |
다음 그림은 두 hel 인스턴스에 적용하는 접근 규칙이다. 실선은 허용, 점선은 차단하려는 접근이다.
flowchart TB
subgraph A["hel 인스턴스 A"]
HA["hel 본체 A"]
SA["sandbox 프로세스 A"]
HA -->|실행| SA
end
subgraph B["hel 인스턴스 B"]
HB["hel 본체 B"]
SB["sandbox 프로세스 B"]
HB -->|실행| SB
end
DA[("A 전용 경로<br/>tmp: 프로그램 읽기·쓰기<br/>spill: 프로그램 읽기만")]
DB[("B 전용 경로<br/>tmp: 프로그램 읽기·쓰기<br/>spill: 프로그램 읽기만")]
HA -->|spill 작성| DA
HB -->|spill 작성| DB
SA -->|허용 규칙으로 접근| DA
SB -->|허용 규칙으로 접근| DB
SA -.->|읽기·쓰기 차단| DB
SB -.->|읽기·쓰기 차단| DA
접근을 거절한 것만으로는 정상 동작을 판단할 수 없다. 프로그램 자체가 실행되지 않아도 모든 파일 변경이 막히기 때문이다. 그래서 프로젝트 파일과 자신의 tmp를 정상적으로 사용하는 작업을 함께 확인한다. 두 hel이 같은 프로젝트를 작업 경로로 지정했다면 그 프로젝트 파일은 공유된다. 위 그림에서 격리하는 대상은 인스턴스별 tmp와 spill이다.
범위 밖 접근에는 시험용 디렉터리와 가짜 보호 파일을 사용한다. 읽기 실패로 쓰기 시도까지 생략되지 않도록 두 동작을 각각 실행하고, 오류와 최종 파일 내용을 함께 확인한다. model이 접근을 시도하지 않았다면 OS가 차단한 증거가 되지 않으므로, 정해진 명령을 직접 실행하는 test도 함께 둔다.
spill 확인에서는 model에게 처음 전달한 앞·뒤 부분에 없는 값을 다시 읽게 한다. 원본 파일에서 답을 찾아낸 경우와 spill 파일을 실제로 읽은 경우를 구분한다. 내부 read_file과 외부 명령이 각각 자기 spill만 읽는지도 확인할 대상이다.
경로마다 역할과 권한 나누기
호스트 파일시스템은 hel을 실행한 컴퓨터의 파일과 디렉터리 전체다. 그중 프로젝트는 사용자가 작업 대상으로 지정한 디렉터리다. 외부 프로그램을 실행하려면 프로젝트뿐 아니라 실행 파일과 라이브러리도 필요하고, 프로그램에 따라 임시 파일을 쓸 공간도 필요하다.
아래는 인스턴스별 저장 공간과 sandbox에서 허용할 접근을 함께 나타낸 구조다.
flowchart TB
subgraph HEL["hel 인스턴스 A"]
MAIN["hel 본체<br/>spill 파일 작성"]
subgraph SB["sandbox 적용 범위"]
PROC["외부 프로그램 · 자식 프로세스"]
POLICY["OS 파일 접근 정책"]
PROC -->|파일 접근| POLICY
end
MAIN -->|정책을 적용해 실행| PROC
end
subgraph HOST["호스트의 실제 경로"]
WORK[("프로젝트")]
SYSTEM[("필요한 프로그램·라이브러리")]
subgraph RUN["인스턴스 A 전용 디렉터리"]
TMP[("tmp/")]
SPILL[("spill/")]
end
OTHER[("허용하지 않은 경로<br/>다른 hel 인스턴스의 tmp/spill 포함")]
end
POLICY -->|읽기·쓰기| WORK
POLICY -->|필요한 읽기·실행| SYSTEM
POLICY -->|읽기·쓰기| TMP
POLICY -->|읽기만| SPILL
POLICY -.->|접근 차단| OTHER
MAIN -->|결과 저장| SPILL
| 경로 | 역할 | sandbox 프로그램의 접근 |
|---|---|---|
| 프로젝트 | 소스 코드와 요청받은 작업의 산출물 | 읽기·쓰기 |
| 인스턴스별 tmp | 프로그램이 스스로 만드는 임시 파일 | 읽기·쓰기 |
| 인스턴스별 spill | hel이 따로 보관한 tool 반환 결과 | 읽기 전용 |
| 프로그램·라이브러리 | 명령 실행과 구동에 필요한 파일 | 필요한 읽기·실행 |
| 허용하지 않은 경로 | 프로젝트 밖의 비허용 파일, 다른 hel 인스턴스의 저장 공간 | 읽기·쓰기 차단 |
프로젝트. write_file로 작성한 소스 파일이나 빌드가 만든 작업 산출물은 프로젝트에 남는다. sandbox에서 작업하더라도 실제 프로젝트 파일이 바뀐다. H7에서 해당 tool 호출이 거절되면 경로가 프로젝트 안이어도 실행하지 않는다.
spill. H6에서는 tool을 호출한 결과가 hel에서 정한 크기 범위인 추정 12,500 token을 넘으면 원문을 임시 파일에 저장한다. model에게는 앞·뒤 일부와 원문 경로를 전달한다. write_file이 만든 소스 파일과 그 tool이 반환한 응답 텍스트는 서로 다른 데이터이며, spill 대상은 응답 텍스트다. H8에서는 hel 본체가 작성한 이 파일을 외부 프로그램이 읽을 수 있게 하되, 덮어쓰거나 지우지는 못하도록 제한한다.
tmp. shell에서 실행한 프로그램은 중간 계산이나 정렬 등에 임시 파일을 사용할 수 있다. 이 공간에는 프로그램의 쓰기 권한이 필요하다. spill과 분리하면 임시 파일 작성은 허용하면서 보관된 tool 응답의 변경은 막을 수 있다. 같은 hel 인스턴스에서 시작한 프로그램들은 인스턴스별 tmp를 공유하는 구조를 사용한다. TMPDIR 같은 환경 변수는 프로그램에 임시 경로를 알려 주는 수단이고, 접근 제한 자체는 OS 정책이 담당한다. 변수를 무시하고 다른 경로를 사용하는 프로그램의 동작도 확인해야 한다.
프로그램·라이브러리. bash나 rg의 실행 파일만 허용해도 충분하다고 단정할 수 없다. 프로그램을 메모리에 올리는 로더와 실행 중 사용하는 라이브러리 등도 읽을 수 있어야 한다. 필요한 시스템 경로를 허용하되, 사용자 홈이나 공통 임시 디렉터리 전체를 넓게 열지 않도록 구체 경로를 확인한다.
허용하지 않은 경로. 경로 문자열을 안다는 사실만으로 접근이 허용되지는 않는다. 다른 hel 인스턴스의 tmp나 spill 경로를 인자로 전달해도 OS가 거절해야 한다. 내부 파일 tool 역시 심볼릭 링크가 가리키는 실제 대상과 ..를 포함한 경로를 검사해야 한다. 부모 디렉터리 전체에 권한을 주면 다른 hel 인스턴스의 디렉터리도 포함될 수 있으므로, 인스턴스별 경계를 기준으로 허용한다.
내부 파일 접근과 반환 처리
외부 프로그램의 읽기 권한을 추가해도 hel 내부의 read_file 규칙은 자동으로 바뀌지 않는다. H7까지의 read_file은 작업 디렉터리 안의 파일만 읽고, spill은 그 밖에 저장했다. H8에서는 현재 hel 인스턴스가 만든 spill도 읽을 수 있도록 이 통로를 보완했다. 파일 전체를 다시 읽어 같은 응답이 spill되는 반복을 피하도록 read_file에 특정 범위를 읽는 기능도 추가했다.
경로 설정은 내부 파일 검사와 sandbox 정책 구성이 함께 참조할 정보다. 다만 실제 강제 지점은 다르다. 내부 tool은 hel 코드의 검사를 통과한 뒤 파일을 열고, 외부 프로그램은 파일 접근을 시도할 때 OS의 검사를 받는다. 이 계층은 저장 위치와 접근 범위를 관리하며, 세션의 대화 저장·복원은 H9에서 다룬다.
결과 확인
인스턴스별 경로와 파일 접근 연결
hel 인스턴스마다 다음 디렉터리를 만든다. 인스턴스 ID는 새로 생성한 UUID다.
<임시 디렉터리>/hel-runs/<인스턴스 ID>/
├── .active hel 본체와 자식 프로세스가 보유하는 파일 잠금
├── tmp/ 외부 프로그램의 읽기·쓰기 공간
└── spill/ hel이 원문을 저장하고 tool은 읽기만 하는 공간
runtime.rs의 Runtime이 프로젝트 경로와 이 저장 공간을 관리한다. tool 실행 계층과 H6 결과 처리에는 같은 Runtime을 전달한다. 내부 파일 읽기는 다음 분기로 허용 루트를 선택한다.
#![allow(unused)]
fn main() {
let (root, dir) = if target.starts_with(&self.spill) {
(&self.spill, &self.spill_dir)
} else if target.starts_with(&self.project) && !target.starts_with(&self.store) {
(&self.project, &self.project_dir)
} else {
return Err(io::Error::new(io::ErrorKind::PermissionDenied,
"outside the working directory and current run spill"));
};
}
target은 심볼릭 링크와 ..를 해석한 경로다. 프로젝트가 임시 저장소의 상위 경로여도 다른 hel 인스턴스의 저장 공간은 제외한다. 이후 파일은 허용 루트의 열린 디렉터리를 기준으로 openat과 O_NOFOLLOW를 사용해 연다. 검사한 뒤 경로의 일부가 심볼릭 링크로 바뀌어도 그 링크를 따라 외부 파일을 열지 않도록 하기 위함이다. 편집 tool은 spill을 허용하지 않는다.
외부 프로그램에는 sandbox.rs가 Seatbelt 정책을 적용한다. 프로젝트 쓰기 허용에서도 전체 인스턴스 저장소를 제외하고, 자기 tmp를 별도로 허용한다.
(allow file-write*
(require-all (subpath (param "PROJECT"))
(require-not (subpath (param "STORE")))))
(allow file-write* (subpath (param "TMP")))
경로는 정책 문자열에 직접 끼워 넣지 않고 sandbox-exec의 -D 인자로 전달한다. 읽기 정책에는 자기 spill과 구동에 필요한 시스템 경로를 추가한다. sandbox 초기화가 실패하면 명령을 비격리 상태로 다시 실행하지 않고 오류를 반환한다.
프로그램·라이브러리 외에도 macOS 구동에 필요한 작은 예외가 있다. 현재 디렉터리 확인에 필요한 / 자체의 읽기, 경로 탐색용 상위 디렉터리 정보, /dev/null 같은 장치, shell 선택 경로 등이 포함된다. / 자체 허용은 그 아래 모든 파일을 재귀적으로 허용하는 것과 다르다. 시스템 경로를 넓게 열면 사용자 파일까지 포함할 수 있어, 필요한 런타임 경로와 파일을 구분했다.
인스턴스의 tmp는 사용을 마치면 정리하고, spill은 파일 생성 후 24시간 동안 보관한다. hel 본체와 잠금을 이어받은 자식 프로세스가 모두 사용을 마친 경우에만 정리를 진행한다. .active 잠금은 외부 프로그램에도 이어 주므로, hel 본체가 종료돼도 자식이 잠금을 보유하면 파일을 남겨 둔다. 이전 H6·H7의 공통 hel-spill/은 새 인스턴스별 정리 대상에 포함하지 않는다.
.active에는 active=true 같은 상태값을 적지 않는다. hel이 파일을 열고 OS의 파일 잠금을 획득하며, 정리 코드도 같은 잠금을 획득하려고 시도한다. 다른 프로세스가 잠금을 보유하고 있어 획득할 수 없으면 그 저장 공간은 건드리지 않는다. 잠금을 획득할 수 있을 때 tmp를 지우고, 보관 기간이 지난 spill을 정리한다.
.active 파일 자체는 종료 후에도 남아 있을 수 있다. 사용 중인지 판단하는 기준은 파일의 존재가 아닌 잠금 획득 가능 여부다. 예를 들어 인스턴스 A가 아직 작업 중이면, 나중에 시작한 인스턴스 B가 정리 작업을 해도 A의 tmp와 spill은 유지된다.
read_file의 범위와 cursor
read_file은 프로젝트와 현재 hel 인스턴스의 spill에서 UTF-8 텍스트를 읽는다. start_line은 1부터 시작하고, max_lines는 읽을 줄 수다. 다음 호출은 101~200줄을 요청한다.
{"path":"log.txt","start_line":101,"max_lines":100}
범위를 생략하면 파일 처음부터 읽는다. 반환은 잘림 안내를 포함해 최대 10,000 UTF-8 바이트다. 선택한 범위가 한도를 넘으면 응답에 cursor를 붙인다. cursor는 다음 바이트 위치와 선택한 범위의 남은 줄 수를 기억하는 값이며, 같은 줄 중간에서 잘려도 나머지를 이어 읽을 수 있다.
{"cursor":"직전 응답에서 받은 값"}
cursor를 만들 때의 파일과 현재 파일이 달라지면 오류를 반환한다. 다른 hel 인스턴스의 cursor도 사용할 수 없다. 오래된 cursor가 만료된 경우에는 path와 줄 범위로 다시 읽는다. UTF-8 문자의 바이트 중간에서는 자르지 않으며, 선택한 범위를 다 읽으면 파일의 뒤에 내용이 더 있어도 해당 범위의 읽기는 끝난다.
sandbox를 추가하기 전
시작 상태의 hel로 작업 세 가지를 각각 3회 실행했다. baseline은 sandbox와 인스턴스별 spill 접근을 추가하기 전의 hel을 뜻한다.
evals run h08 --conditions baseline --build
| 작업 | 관찰한 동작 | 기대 동작 충족 |
|---|---|---|
| 프로젝트 파일 작업 | 자식 shell이 파일을 읽고 pending을 done으로 변경 | 3/3 |
| 프로젝트 밖의 시험 파일 접근 | 보호 파일 읽기 성공, 쓰기 대상이 changed로 변경 | 0/3 |
| spill 내용 다시 읽기 | 원문 저장 성공, read_file이 작업 디렉터리 밖 경로 거절 | 0/3 |
baseline을 기준으로 다음을 확인할 수 있었다.
프로젝트 안의 작업. 파일 읽기와 수정이 3회 모두 성공했다. sandbox를 추가해도 동일하게 작동해야 한다.
프로젝트 밖의 접근. 시험용 보호 파일을 3회 모두 읽고 변경했다. baseline에서는 bash 실행을 허용하면, bash를 통해 접근하는 파일을 보호하지 못한다는 것을 확인했다.
실제 시험용 script에서 bash를 통한 스크립트 접근과 실행을 확인할 수 있었다. 보호 파일을 대상으로 실행한 script에서 모두 다음 결과를 반환했다.
read=allowed // 프로젝트 밖의 시험용 파일을 cat으로 읽는 데 성공함
write=allowed // 프로젝트 밖의 시험용 파일에 'changed'를 기록하는 데 성공함
hel이 저장한 spill 읽기. hel에서 spill은 생성됐지만, 작업 디렉터리 밖이기 때문에 read_file로 읽지 못했다. sandbox 추가와 함께 read_file도 허용한 경로에 대해선 접근이 가능하게 변경해야 한다.
spill 작업에서는 매번 248,942바이트의 원문 파일이 생성됐다. 처음 model에게 전달한 앞·뒤 부분에는 정답이 없었고, model은 전달받은 원문 경로를 read_file로 읽으려 했다.
error: /path/to/hel-spill/<file>.txt: outside the working directory
read_file은 세 번 모두 접근을 거절했다. 그중 두 번은 model이 상대 경로로 바꿔 추가 시도했지만 읽지 못했다. bash로 대신 읽거나 생성 script를 다시 실행한 경우는 없었다. 현재 작업 경로 제한 때문에 hel이 직접 저장한 spill도 읽지 못하므로, 현재 hel 인스턴스의 spill을 허용하는 규칙과 특정 범위 읽기 기능을 함께 추가해야 한다.
sandbox 자체의 접근 제한
아래 상황은 model을 호출하지 않고 정해진 명령이나 파일 tool을 직접 실행해 확인했다. macOS 15.1 arm64에서 실제 Seatbelt 정책을 적용했으며, 종료 상태와 실행 전후의 파일 내용을 함께 검사했다.
| 시험 상황 | 기대 동작 | 실제 결과 |
|---|---|---|
| 외부 프로그램이 프로젝트 파일을 읽고 변경 | 정상 읽기·변경 | 자식 shell의 읽기·변경 성공 |
| 외부 프로그램이 자신의 tmp에 파일 생성 후 다시 읽기 | 생성·읽기 성공 | 저장한 문자열과 읽은 문자열 일치 |
| 외부 프로그램이 자신의 spill 읽기 | 원문 읽기 성공 | 저장한 원문과 일치 |
| 외부 프로그램과 편집 통로가 자신의 spill 덮어쓰기 시도 | 쓰기 거절, 원문 유지 | 덮어쓰기·외부 삭제 거절, 원문 유지 |
| 별도 프로세스 두 개에서 hel Runtime을 동시에 실행하고 상대 tmp·spill 접근 | 자기 경로의 허용 작업 성공, 상대 경로 접근 차단 | A·B 모두 자기 파일 읽기 성공, 상대 읽기·쓰기 차단 |
| bash가 실행한 자식 프로그램이 비허용 파일 읽기·쓰기 시도 | 자식에서도 접근 차단, 원문 유지 | 자식 shell에서도 거절, 원문 유지 |
심볼릭 링크 또는 ..로 허용 범위 밖 파일 접근 | 실제 대상 기준 차단 | 내부 파일 검사·OS 정책에서 차단 |
| 내부 파일 읽기 통로로 자기 spill과 다른 hel 인스턴스의 spill 접근 | 자기 spill만 읽기 허용 | 자기 spill 열기 성공, 다른 hel 인스턴스 경로 거절 |
OS sandbox의 거절은 Operation not permitted, hel 내부 파일 통로의 거절은 허용 경로 밖이라는 오류로 확인했다. 병렬 테스트는 model 없이 실제 Runtime을 소유한 프로세스를 두 개 띄우고, 양쪽 접근 시도가 끝날 때까지 둘 다 살아 있도록 했다.
cargo test -p hel sandbox::tests
cargo test -p hel runtime::tests
긴 UTF-8 한 줄을 여러 번 이어 읽은 결과가 원문과 같은지도 확인했다. 파일 변경 뒤 cursor 재사용은 거절됐고, 범위를 생략해도 응답 한도를 넘지 않았다. 사용 중인 hel 인스턴스의 오래된 spill은 보존됐으며, 정상 종료 후 tmp 제거와 사용이 끝난 인스턴스의 spill을 24시간 뒤 정리하는 동작도 확인했다. 최종 전체 테스트는 96개 통과했다.
model을 포함한 작업 결과
변경 전과 같은 작업을 각각 3회 실행했다.
evals run h08 --conditions variant --build
| 확인 항목 | 변경 전 | sandbox 추가 후 |
|---|---|---|
| 프로젝트 파일 작업 | 3/3 | 3/3 |
| 보호 파일 읽기·쓰기 차단과 원본 유지 | 0/3 | 3/3 |
| 실제 spill 경로를 read_file로 읽고 정답 복원 | 0/3 | 3/3 |
| 최종 출력 형식을 포함한 작업 전체 판정 | 3/9 | 7/9 |
18회 모두 유효하게 종료됐다. spill 작업은 세 번 모두 전달받은 실제 경로를 한 번 읽어 cedar-4827을 반환했다. 원문 생성 script를 읽거나 bash로 우회한 호출은 없었다. 이 세 작업에서는 요청한 범위로 정답을 찾았으므로 cursor 사용은 별도 직접 테스트에서 확인했다.
보호 파일 접근 - 두 답변에 섞인 시스템의 경고
보호 파일 접근은 세 번 모두 거절됐다. 그러나 그중 최종 답변 두 개에는 다음 경고가 함께 들어가 출력 일치 판정에서 실패했다.
read=denied
write=denied
Error opening /private/var/select/sh: Operation not permitted
macOS의 /bin/sh가 사용하는 shell 선택 경로를 읽기 허용 목록에서 빠뜨린 것이 원인이었다. 세 호출 모두 tool 응답에 이 경고가 있었고, model이 그중 두 번을 최종 답변에 포함했다. 접근 차단 자체와 최종 답변 실패를 구분해 기록하되, 7/9 판정은 유지한다.
측정 후 이 시스템 파일을 읽기 허용 목록에 추가했다. 같은 보호 파일 script를 model 없이 다시 실행해 stdout은 read=denied·write=denied만 나오고, stderr는 비어 있는 것을 확인했다. 보호 파일 접근 거절과 원본 유지도 그대로 확인했다.
돌아보기
변경 사항
프로젝트 작업을 유지하면서 외부 프로그램의 보호 경로 접근을 막았다. 내부 파일 통로는 현재 hel 인스턴스의 spill을 읽을 수 있게 했고, 줄 범위와 cursor로 응답 크기를 제한했다.
트레이드오프
허용 경로를 좁히면 실제 구동에 필요한 시스템 파일을 빠뜨릴 수 있다. 이번에도 shell 선택 경로의 누락이 경고와 답변 실패로 이어졌다. 현재 확인한 명령은 bash·sh·기본 파일 명령·rg 중심이며, 다른 개발 도구는 필요한 라이브러리나 cache 경로를 별도로 확인해야 한다. 네트워크 제한은 추가하지 않았다.
인스턴스별 저장 공간을 보호하려면 누가 사용 중인지와 파일 보관 기간도 관리해야 한다. cursor 역시 파일 변경과 UTF-8 경계를 처리해야 하므로 단순한 전체 파일 읽기보다 코드가 늘었다. 실제 파일을 공유하는 프로젝트의 동시 편집 충돌은 FAQ에서 H12로 연결한다.
논문의 내용 또는 다른 harness와 비교하면
Codex에서 참고한 호출 승인과 OS 정책 적용의 분리를 hel의 두 파일 접근 통로에 연결했다. macOS 고정 경로 정책만 구현했으며, 동적 권한 상향은 FAQ의 후속 과제로 남겼다. Claude Code의 shell 격리처럼 자식 프로세스에도 규칙을 적용하지만, 내부 파일 tool은 hel 자체의 경로 검사로 제한한다.
FAQ
인스턴스별 디렉터리와 세션 저장의 차이
H8의 인스턴스별 디렉터리는 동시에 동작하는 hel 인스턴스의 임시 데이터 접근을 나누는 단위다. hel을 종료한 뒤 대화를 이어 가는 기능까지 포함하지 않는다. 대화 기록과 실행 상태를 저장하고 복원하는 방법은 H9 Sessions & Checkpoints에서 다룬다. 임시 데이터의 보관·정리 규칙과 재개에 필요한 데이터의 수명은 그때 연결해서 살펴볼 수 있다.
같은 프로젝트에서 두 hel을 실행하면 파일도 분리되는가
각 hel 인스턴스의 tmp와 spill을 분리해도 같은 프로젝트 경로를 허용하면 프로젝트 파일은 공유된다. 동시에 같은 소스를 고치는 충돌을 피하려면 작업 디렉터리 자체를 나눠야 한다. 병렬 작업의 배정과 결과 통합은 H12 Parallelism에서 다룬다.
실행 중에 sandbox 권한을 넓힐 수 있는가
hel이 실행 중일 때 추가 경로와 읽기·쓰기 권한을 사용자에게 승인받는 기능은 만들 수 있다. H8에서 사용하는 sandbox-exec 방식으로 이를 확장한다면, hel 본체가 승인을 받고 확장된 정책을 적용한 새 프로세스에서 명령을 실행하면 된다.
OS는 파일 접근을 자동으로 멈춰 승인창을 띄워 주지 않는다. 승인 요청과 재실행은 harness가 처리해야 한다. 필요한 경로를 미리 알면 실행 전에 승인받을 수 있고, 접근 실패 후에 요청한다면 명령이 이미 일부 작업을 수행했을 가능성을 고려해야 한다. 재실행의 경우 파일 변경 등이 중복될 수 있으므로 실패한 위치부터 그대로 이어진다고 가정해서는 안 된다.
H7의 tool 실행 승인과도 구분한다. 명령 실행에 동의해도 sandbox가 허용하는 파일 범위가 자동으로 넓어지지는 않는다. 권한 상향을 추가할 때는 승인한 명령 한 번에만 적용할지, 이후 호출에도 유지할지 정해야 한다.
H8에서는 hel 인스턴스를 시작할 때 정한 경로 정책을 고정한다. 실행 중 권한 상향은 후속 확장 과제로 남기고, 허용 범위 밖 접근은 오류로 반환한다.
H9 — Sessions & Checkpoints
Note
- 시작 상태:
h09· 완료 상태:h10- 논문: §6.2 Iterative Action-Observation Loop, §9.5 Threshold Compaction, §13.1 Architectural Pattern Catalog
git checkout -b my-h09 h09
hel을 종료하고 다시 시작해도 이전 대화를 복원해 작업을 이어갈 수 있는가?
들어가며
논문이 본 세션과 복원
Part IV에서 다루는 실행 제어에는 작업을 끝내고 다시 이어 가는 문제도 있다. H8의 hel 인스턴스는 한 번 시작된 본체와 자식 프로세스를 묶은 단위다. 세션(session)은 그보다 오래 보관할 대화와 관련 상태를 가리킨다. 하나의 세션을 저장해 두면 나중에 시작한 hel 인스턴스에서도 이어갈 수 있다.
Note
model에게 전달되는 정보가 종료된 세션과 동일하면, model 입장에서는 대화가 중간에 종료됐는지 구별할 필요가 없다. model은 매 요청마다 기존 메시지를 누적해 받고, 이에 따라 답변을 할 뿐이다. 새 hel 인스턴스에서도 같은 model·요청 설정과 대화 내용을 전달하면 같은 문맥에서 다음 답변을 생성할 수 있다. 생성 결과가 매번 같은 문장으로 나오는 것까지 보장하는 것은 아니다.
논문 §2.3은 대화 기록·저장·재개·분기를 여러 subsystem이 공유하는 기반으로 설명한다. 저장할 대상에 따라 복원의 의미도 달라진다.
| 구조 | 보관하는 것과 하는 일 |
|---|---|
| 이벤트 기록 | 메시지와 동작 결과를 누적하고 필요한 대화 상태 재구성 |
| checkpoint(체크포인트) | 특정 시점으로 돌아갈 상태 보관. 시스템에 따라 파일 상태 포함 |
| 세션 트리 | 대화의 분기 관계를 보관하고 이어갈 가지 선택 |
논문 표11·12의 관련 패턴을 요약해 재구성했다. 이 표는 참고 시스템의 구조이며 hel에 모두 구현할 항목은 아니다. §9.5의 Pi는 대화를 되감아도 파일을 복원하지 않으며, Mistral Vibe와 OpenCode는 파일 복원을 포함한 checkpoint를 제공한다.
이번 Lab에서 다룰 대화 복원
현재 hel은 대화 기록을 메모리에 보관한다. 같은 인스턴스에 후속 지시를 보내면 앞의 대화를 함께 전달하지만, 종료한 뒤 다시 시작하면 새 대화가 된다. 측정용 실행 기록을 파일에 남기는 기능은 있어도 그 기록을 불러와 대화를 재개하는 기능은 없다.
이번에는 정상적으로 답변을 마치고 종료한 뒤, 새 인스턴스에서 같은 세션의 대화를 이어가는 동작을 다룬다. 첫 대화에서 전달한 정보를 다음 지시에서 다시 물어보고, 종료 없이 이어간 경우와 비교한다. 저장 전후의 메시지와 재개 후 첫 model 요청도 직접 확인한다.
현재 hel이 보관하는 데이터
현재 대화는 messages: Vec<serde_json::Value>에 보관한다. JSON 메시지 객체를 순서대로 담은 배열이다. 일반 대화에서는 이 배열이 메모리에 있으며, hel을 종료하면 사라진다.
| 메시지 | 내용 | 만드는 쪽 |
|---|---|---|
system | OS·shell·작업 경로와 HEL.md 내용 | hel |
user | 사용자의 지시 | 사용자 입력을 받은 hel |
assistant | 답변, 반환된 reasoning_content, tool_calls | model |
tool | 호출 결과 또는 오류, 대응하는 tool_call_id | hel |
| 압축 요약 | 요약 안내와 <compacted-summary> 내용 | 요약 응답을 받은 hel이 user 메시지로 구성 |
assistant 메시지는 API가 반환한 JSON 객체 전체를 보존한다. tool 호출이 포함된 대화는 다음 순서로 이어진다.
1. system 실행 환경과 HEL.md
2. user “설정 파일에서 포트를 확인해줘”
3. assistant read_file 호출 요청 — 호출 ID: call_1
4. tool 파일 내용 반환 — tool_call_id: call_1
5. assistant “포트는 8080입니다”
tool 호출과 결과의 연결이 빠지면 model API에 다시 보낼 대화가 불완전해진다. H6에서 압축하면 오래된 구간이 요약 메시지로 교체된다. 긴 tool 결과를 잘라내는 처리도 이 배열을 수정한다. 따라서 messages에는 지금 model에게 보낼 대화가 들어 있으며, 처음부터의 원본 기록이 모두 남아 있는 것은 아니다.(압축으로 인해)
메시지 배열 밖에도 hel이 관리하는 데이터가 있다.
| 데이터 | 현재 위치·용도 |
|---|---|
| model 이름·출력 한도·model params | API client가 요청을 만들 때 추가 |
| tool 이름·정의 | Toolset에서 관리, API 요청의 tools로 전달 |
| 압축 정책·Meter | 다음 요청 전에 압축할지 판단 |
| 작업 경로·접근 레벨·Runtime | tool 실행과 파일 접근 제어 |
| token 사용량·호출 횟수·권한 판정·종료 이유 | RunLog의 측정·분석용 데이터 |
| API 요청·응답 원본 | 측정 모드 종료 시 raw/requests.jsonl로 기록 |
- hel은 실험 결과를 분석하기 위한 파일은 만든다.
record.json에 최종 답변, token 사용량, tool 호출, 종료 이유 등이 저장된다. record.json은evals를 통한 측정 실행에만 생성되는 데이터이며, 세션 재개를 위해 만들어야 하는 세션 스냅샷과는 별개의 파일이다.
재개를 위해 보관할 상태
대화 재개에는 기존 데이터를 파일에 남기는 일과, 저장된 대화를 식별하고 해석할 정보가 필요하다.
| 보관할 항목 | 재개할 때의 용도 |
|---|---|
| 세션 ID·저장 형식 버전 | 이어갈 대화 선택, 읽을 수 있는 형식인지 확인 |
현재 messages | 압축 요약과 이후 메시지를 포함한 대화 복원 |
| cursor와 읽기 위치·파일 식별 정보 | 같은 세션에서 기존 cursor로 이어 읽기 |
| model·params·출력 한도·tool 구성 | 요청 설정 재구성, 이전 설정과의 차이 확인 |
| 작업 경로 등 metadata | 재개할 프로젝트와 실행 환경 확인 |
| 압축 정책·Meter | 현재 압축 기준 적용, 입력 조건에 따라 관측값 복원 또는 초기화 |
| 대화가 참조하는 spill 정보 | 원문의 보관 위치와 새 인스턴스의 접근 연결 |
Meter는 마지막 API 요청에 보낸 메시지 개수와 그 요청의 실제 input token 수를 기억한다. Meter가 있어야 세션을 재개하는 시점에 추가되는 메시지가 포함된 input token의 근사치를 계산할 수 있고, hel의 압축 정책에 따라 적절한 시점에 압축 요청을 진행할 수 있다.
spill에는 원문이 별도 파일로 남고 대화에는 경로가 들어간다. 새 인스턴스에서 그 경로를 사용할 때는 파일의 존재와 접근 권한을 함께 확인해야 한다.
read_file cursor도 경로와 읽은 위치를 기억하는 메모리 상태에 연결돼 있다. cursor = "a8f3-..."과 같은 데이터를 가지고 있고, 이 값이 가리키는 실제 정보는 hel의 메모리에 따로 있다.
"a8f3-..." → {
파일 경로: "/.../spill/result.txt",
다음에 읽을 바이트 위치: 9840,
요청한 범위에서 남은 줄 수: 40,
파일 식별 정보: inode, 크기, 수정 시각 등
}
그래서 model이 cursor를 이용한 read_file을 호출하려면 hel의 메모리에 저장된 이 정보도 같이 저장해야 한다.
API key는 다시 얻어 사용한다. HTTP client, 열린 파일, 잠금, 자식 프로세스 같은 실행 중 자원은 새 인스턴스에서 준비한다.
Codex와 Claude Code의 저장·재개
Codex. 로컬 저장 구현은 메시지·tool 호출과 결과를 typed JSONL 기록으로 남기며, 세션 metadata와 압축 기록도 함께 보관한다. 재개할 때 thread ID로 기록을 찾고, 저장된 압축 상태와 이후 메시지를 반영해 model에 보낼 대화를 구성한다. SQLite는 조회를 위해 다시 만들 수 있는 데이터이며, 대화 기록인 JSONL을 먼저 기록한다. 저장 구현, 대화 재구성
Codex의 resume 테스트는 고정된 model 응답으로 대화를 만든 뒤 엔진을 종료하고 저장 이력을 불러온다. 이어서 보낸 요청에 이전 사용자 메시지와 답변이 포함되는지 검사한다. 이 테스트에서 참고할 점은 저장 파일의 존재에 더해 실제로 model에 보낼 요청까지 확인한다는 것이다. 테스트 소스
Claude Code. 공식 문서는 메시지·tool 사용·결과를 로컬 JSONL에 보관하고, --resume으로 지정한 세션에 후속 대화를 추가한다고 설명한다. 세션 재개와 파일 복구는 별도의 기능이다. Agent SDK가 읽어 주는 대화도 압축이 있었다면 요약을 포함한 현재 메시지 연결이며, 저장소의 모든 원본 항목과 같지 않을 수 있다. 세션 설명, 압축 뒤 메시지 읽기
두 시스템 모두 과거 대화와 재개 시점의 실행 설정을 따로 다룬다. Codex는 작업 경로가 달라지면 사용할 경로를 선택하고 model·sandbox 설정을 바꿔 재개할 수 있다. Claude Code도 설정 파일을 다시 읽으며, 복원되는 model·권한 상태에는 조건이 있다. 현재 기본 설정에서는 기록된 시스템 프롬프트를 압축 전까지 재사용하지만, 이를 다시 구성하는 옵션도 제공한다. hel에서도 대화 복원과 현재 작업 경로·권한 설정의 관계를 정해야 한다. Codex resume, Claude 재개 범위, 시스템 프롬프트
자료 확인은 2026-10-05 기준이다.
이번에 해볼 것
현재 데이터와 model 요청
현재 hel에서 대화와 실행 상태는 다음과 같이 나뉜다. 메모리의 messages는 JSON 메시지 객체의 배열이고, 요청 설정과 사용 가능한 tool 목록을 합쳐 API 요청을 만든다.
flowchart TB
subgraph H["현재 hel 인스턴스의 메모리"]
MSG["messages: Vec<Value><br/>system → user → assistant → tool → …<br/>압축 뒤: system → 요약 → 최근 메시지"]
CFG["요청 설정<br/>model · params · 출력 한도"]
TOOLS["Toolset<br/>tool 이름과 JSON 정의"]
CTX["압축 정책 · Meter<br/>기준과 input token 추정 상태"]
REQ["API 요청 JSON<br/>messages · model · tools · max_tokens"]
LOG["RunLog<br/>요청·응답 · 사용량 · 실행 결과"]
MSG --> REQ
CFG --> REQ
TOOLS --> REQ
CTX -.->|압축 판단·대화 갱신| MSG
REQ -.->|요청 기록| LOG
end
MODEL["model API"]
SPILL[("디스크의 spill 원문")]
RECORD[("측정용 record.json<br/>raw/requests.jsonl 등")]
REQ -->|요청 전송| MODEL
MODEL -->|assistant 메시지 추가| MSG
MODEL -.->|응답 기록| LOG
MSG -.->|tool 결과에 담긴 경로로 참조| SPILL
LOG -->|측정 모드 종료 시 저장| RECORD
user 메시지는 입력할 때, tool 메시지는 tool 실행 뒤에 추가한다. 그림의 실선은 데이터 구성·전달, 점선은 제어·기록·참조 관계다. 측정 파일에 API 원본이 남더라도 현재 hel이 그 파일에서 세션을 복원하지는 않는다.
snapshot 저장과 새 인스턴스의 재개
일반 hel 실행은 기본으로 새 세션을 시작한다. 별도의 저장 옵션 없이 답변 완료마다 저장하며, 기존 대화를 이어갈 때는 재개할 세션을 지정한다. 저장 위치는 프로젝트 안의 .hel/sessions/로 정한다. 세션별로 snapshot과 spill 원문을 함께 관리하고, 이 저장소는 Git 추적과 일반 프로젝트 검색에서 제외한다.
사용자 지시 하나에 대한 최종 답변이 완료될 때마다 JSON snapshot을 저장하는 구조를 만든다. 중간의 tool 호출과 그 결과도 최종 답변까지의 대화에 포함한다. 다음은 하나의 세션을 인스턴스 A에서 저장하고, A가 종료된 뒤 인스턴스 B에서 재개하는 구성안이다. 메모리의 JSON 객체를 파일의 JSON 텍스트로 쓰는 과정이 직렬화이고, 파일을 읽어 객체로 구성하는 과정이 역직렬화다.
flowchart TB
subgraph A["hel 인스턴스 A · 세션 S"]
AM["messages<br/>현재 대화·압축 요약"]
AC["요청 설정 · 압축 상태<br/>model · tools<br/>Policy · Meter"]
SAVE["세션 snapshot 구성<br/>JSON 텍스트로 직렬화"]
AM --> SAVE
AC --> SAVE
end
SNAP[("디스크 · 세션 S의 JSON snapshot<br/>세션 metadata<br/>messages · request_config<br/>압축 상태 · 참조 정보")]
ART[("세션 S의 spill 원문<br/>세션 삭제 시 함께 정리")]
SNAP -.->|참조| ART
SAVE -->|답변 완료 후 저장| SNAP
subgraph B["새 인스턴스 B · 세션 S"]
LOAD["파일 읽기 · 형식/세션 확인<br/>JSON 객체로 역직렬화"]
BM["복원된 messages<br/>새 메시지 추가"]
BC["압축 상태 복원<br/>현재 설정·system 적용"]
NEW["새 사용자 메시지"]
NEXT["재개 후 첫 model 입력 준비"]
NEW --> BM
LOAD -->|messages| BM
LOAD -->|request_config 등| BC
BM --> NEXT
BC --> NEXT
end
SNAP -->|세션 S 선택·읽기| LOAD
NEXT -->|대화와 요청 설정 전송| MODEL["model API"]
schema_version과 session_id는 파일을 읽는 hel이 사용한다. model에게는 복원한 대화와 요청 설정으로 구성한 API 요청이 전달된다. 인스턴스 B에서 대화를 더 진행한 뒤 저장하면 세션 S의 snapshot도 최신 상태로 갱신된다.
spill 원문은 snapshot과 별도 파일로 두되 세션에 귀속시킨다. 같은 세션을 재개한 인스턴스는 그 원문을 읽을 수 있게 한다. 세션이 남아 있는 동안 보관하고, 세션을 삭제할 때 snapshot과 함께 정리하는 구조를 만든다. 세션에 귀속된 spill에는 H8의 24시간 정리 규칙을 적용하지 않는다.
프로그램의 임시 작업 공간인 tmp는 기존처럼 인스턴스에 귀속시킨다. snapshot·spill의 수명과 tmp의 수명을 구분한다.
| 데이터 | 소유 단위 | 정리 시점 |
|---|---|---|
| snapshot | 세션 | 세션 삭제 시 |
| spill 원문 | 세션 | 세션 삭제 시 |
| tmp | hel 인스턴스 | 인스턴스와 잠금을 이어받은 자식 프로세스의 사용 종료 후 |
cursor도 snapshot에 저장하고 같은 세션을 재개할 때 복원한다. cursor 값과 함께 파일 경로, 다음 바이트 위치, 남은 줄 수, 파일 식별 정보를 보관해 기존 값으로 이어 읽을 수 있게 한다. 읽을 때는 기존 경로 검사와 파일 변경 검사를 유지한다. 대상 파일이 바뀌었으면 그 cursor로 이어 읽기를 거절한다.
재개할 때 system 메시지는 현재 실행 환경과 HEL.md로 다시 만든다. 저장된 system 메시지를 현재 것으로 교체하고, 나머지 대화와 압축 요약은 유지한다. 세션을 종료한 사이 프로젝트 규칙을 바꿨다면 재개 후 첫 요청부터 새 규칙이 전달된다. 따라서 대화 이력은 이어지지만 system 메시지는 저장 당시와 달라질 수 있다.
model·tool 구성·요청 params·출력 한도도 재개 시점의 실행 설정을 적용한다. 저장된 설정은 이전 실행과 비교할 자료로 남긴다. 설정과 작업 환경이 그대로이고 필요한 상태가 복원되면, 종료 전과 같은 문맥과 동작 조건으로 이어갈 수 있다.
Meter는 저장된 대화·system 메시지·model·사용 가능한 tool 목록이 같으면 복원한다. 입력 토큰 수에 영향을 주는 이 조건들이 바뀌면 기존 관측값을 초기화하고 전체 메시지로 임시 추정한다. 다음 API 응답을 받으면 실제 입력 토큰 수로 갱신한다. 출력 한도나 압축 기준만 바뀌었다면 관측값은 유지하고 새 압축 기준을 적용한다.
한 세션은 한 hel 인스턴스만 사용하도록 점유를 확인한다. 다른 인스턴스가 이미 사용 중인 세션의 재개는 거절한다. 두 인스턴스가 같은 snapshot을 서로 덮어쓰는 일을 막기 위해서다. 서로 다른 세션은 동시에 사용할 수 있다.
사용할 명령은 다음과 같이 정한다.
| 명령 | 동작 |
|---|---|
hel | 새 세션 시작 |
hel --resume <id> | 기존 세션 재개 |
hel sessions | 프로젝트의 세션 목록 |
hel sessions delete <id> | 해당 세션의 snapshot·spill 삭제 |
점유 중인 세션은 삭제도 거절한다. 세션 삭제가 프로젝트 작업 파일을 되돌리거나 삭제하는 것은 아니다.
JSON 메시지와 snapshot 파일
현재 메시지 배열을 세션 metadata와 함께 하나의 JSON 문서로 저장하는 방식이 snapshot이다. 다음은 저장 파일에서 메시지와 요청 설정의 주요 필드를 발췌한 예시다. 실제 파일에는 생성·갱신 시각, 사용 가능한 tool 목록, Meter 관측값과 cursor 상태도 포함한다.
{
"schema_version": 1,
"session_id": "9d0c8f63-fd63-47bd-a93d-a087049583c7",
"cwd": "/path/to/project",
"request_config": {
"model": {
"provider": "deepseek",
"requested": "deepseek-flash",
"params": {}
},
"max_output_tokens": 8192,
"compaction": null
},
"messages": [
{"role": "system", "content": "실행 환경과 프로젝트 규칙"},
{"role": "user", "content": "작업 식별자는 cedar-4827이다"},
{"role": "assistant", "content": "확인했습니다"}
]
}
snapshot을 사용하는 경우, 저장 시점의 메시지 배열과 관련 상태를 JSON으로 직렬화한다. 직렬화는 메모리의 데이터를 파일에 쓸 수 있는 형식으로 바꾸는 과정이다. 새 hel 인스턴스는 재개할 세션을 선택하고 파일을 읽은 뒤, JSON을 해석해 메모리의 메시지 배열과 설정을 구성한다.
복원된 배열의 system 메시지를 현재 환경으로 교체하고 새 사용자 지시를 추가하면 기존 loop로 대화를 이어갈 수 있다. snapshot의 세션 ID·형식 버전은 hel이 사용하고, messages와 요청 설정은 model 입력 준비에 사용한다.
저장 파일은 임시 파일에 완성본을 쓴 뒤 교체하는 방식으로 갱신할 수 있다. 사용자 지시에 대한 최종 답변이 끝나면 변경된 대화 상태를 snapshot에 반영하고 다음 입력을 기다린다. 저장에 실패하면 경고를 표시하고 메모리의 대화는 유지한 채 다음 지시를 받는다. 다음 답변이 완료되면 최신 대화 전체의 저장을 다시 시도한다. 실패한 쓰기로 이전 snapshot을 훼손하지 않도록 하며, 저장이 계속 실패한 채 종료하면 마지막으로 저장에 성공한 지점까지만 재개할 수 있다. 한 번도 저장하지 못했다면 재개할 snapshot이 없다.
대화를 이어받았는지 확인하기
첫 지시에서 임의 식별자를 알려 주고, 다음 지시에서 그 값을 묻는다. 다음 지시에는 정답을 넣지 않고 도구를 통한 파일 읽기도 막는다. 답변에 식별자가 정확히 포함되면 성공으로 취급한다. 식별자 주변의 설명이나 문장 형식은 제한하지 않는다.
| 실행 방식 | 확인할 것 | 횟수 |
|---|---|---|
| 종료 없이 계속 | 같은 인스턴스에서 이전 값을 답하는가 | 3회 |
| 종료 후 새 세션 | 이전 대화가 없을 때 그 값을 답할 수 있는가 | 3회 |
| 종료 후 기존 세션 재개 | 저장된 대화를 복원해 이전 값을 답하는가 | 3회 |
같은 반복 회차에서는 세 실행에 같은 식별자를 주고, 다음 반복에서는 다른 식별자로 바꾼다. 표의 식별자는 설명용이며, 실제 측정에는 미리 생성한 더 긴 임의 문자열을 사용한다.
| 반복 | 종료 없이 계속 | 종료 후 새 세션 | 종료 후 기존 세션 재개 |
|---|---|---|---|
| 1회차 | apple-123 | apple-123 | apple-123 |
| 2회차 | river-456 | river-456 | river-456 |
| 3회차 | cedar-789 | cedar-789 | cedar-789 |
총 9회 실행하며 정상 완료 시 API 요청은 18회 발생한다. 요청당 출력 한도는 8192 토큰, 실행 한 번의 시간 제한은 첫 지시부터 두 번째 답변까지 120초로 두고, 실패를 하더라도 추가 API 요청 없이 실패로 종료한다.
검사는 두 가지로 나눈다.
첫째, 실제 model에게 첫 지시에서 식별자를 알려 주고, 다음 지시에서 다시 물어본다. 그리고 답변에 정확한 식별자가 들어 있는지 확인하면 된다.
둘째, 저장과 복원 코드가 제대로 동작하는지 검사한다. 이전 대화와 압축 요약, tool 호출과 결과를 미리 준비해 저장하고, 새로운 hel 프로세스에서 불러온다. 그리고 질문을 보낼 때 API 요청에 전달할 내용이 올바르게 들어 있는지 확인한다.(이때는 실제 model API 호출 없이 정해진 응답을 돌려주는 mock을 사용한다.)
이어 cursor와 spill 복원도 같은 방식으로 검사한다. 토큰 소모에 영향을 주는 설정이 변경됐을 때 Meter를 어떻게 처리하는지, 사용 중인 세션의 재개, 삭제를 막는지, 저장에 실패해도 대화를 계속하고 다음에 다시 저장하는지도 확인한다.
이를 통해 model이 이전 정보를 활용해 답했는지와 hel이 필요한 정보를 제대로 저장하고 복원했는지를 분리해 확인할 수 있다.
결과 확인
저장 기능을 넣기 전
같은 hel 코드로 종료 없이 계속한 경우와, 종료 후 새 인스턴스에서 다시 시작한 경우를 각각 3회 실행했다.
| 실행 방식 | 식별자를 답한 횟수 | 실제 후속 요청 |
|---|---|---|
| 종료 없이 계속 | 3/3 | 첫 지시·첫 답변·후속 질문 포함 |
| 종료 후 새 세션 | 0/3 | 후속 질문만 포함 |
6회 모두 오류 없이 끝났다. 새로 시작한 인스턴스는 세 번 모두 UNKNOWN을 답했다. API 요청을 확인해도 앞에서 알려 준 식별자가 없었다. 연속 실행에서는 앞 지시와 답변이 그대로 전달됐고 식별자를 모두 답했다. 총 API 요청은 12회였으며 도구 호출은 없었다.
대화가 메모리에만 있으면 종료 후 정보를 이어받지 못한다는 출발점을 확인했다. 이후 세션 저장·복원을 추가하고, 같은 식별자와 질문으로 재개 동작을 확인했다.
저장·복원을 연결한 코드
crates/hel/src/sessions.rs의 Store가 세션 디렉터리와 점유 잠금을 관리한다. 프로젝트 경계는 hel을 실행한 작업 디렉터리(cwd)다. 각 세션의 파일은 다음과 같이 배치한다.
.hel/
├── .gitignore
├── .sessions.lock
└── sessions/
└── <session-id>/
├── .active
├── snapshot.json
└── spill/
.active는 해당 세션의 점유 잠금이고, .sessions.lock은 재개와 삭제가 동시에 같은 디렉터리를 바꾸지 못하도록 짧게 잡는 저장소 잠금이다. tmp는 H8처럼 임시 디렉터리의 hel 인스턴스별 공간에 둔다. .hel은 Git과 프로젝트 검색에서 제외하며, 파일 tool과 sandbox는 현재 세션의 spill 읽기만 예외로 허용한다.
재개 시 저장 파일의 형식 버전·세션 ID·작업 경로와 tool 호출/결과 연결을 확인한다. 다른 프로젝트로 파일을 옮겨 곧바로 재개하거나, 연결이 깨진 메시지를 그대로 API에 넘기지는 않는다. Store::restore에서 과거 system 메시지를 현재 것으로 교체한 뒤 Meter를 선택한다.
#![allow(unused)]
fn main() {
let meter = if messages == snapshot.messages
&& snapshot.request_config.input_signature() == current.input_signature()
{
snapshot.meter
} else {
Meter::default()
};
}
여기서 messages는 system 교체가 끝난 배열이다. 입력에 영향을 주는 내용이 그대로면 관측값을 이어 쓰고, 달라졌다면 다시 추정한다. 출력 한도와 압축 기준은 이 비교에서 제외한다. Reader에 저장한 cursor 값·읽기 위치·파일 식별 정보도 함께 복원한다.
Store::save는 전체 snapshot을 새 임시 파일에 쓴 다음 교체한다. 기존 파일을 먼저 비우지 않는다.
#![allow(unused)]
fn main() {
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
fs::rename(&temporary, self.root.join(SNAPSHOT))?;
runtime::open_directory(&self.root)?.sync_all()?;
}
이 저장 함수는 최종 답변이 완료된 뒤 호출한다. 저장에 실패하면 경고하고 메모리의 대화는 유지한다. API 오류나 출력 한도 때문에 답변이 끝나지 않은 경우에는 snapshot을 갱신하지 않는다.
재개와 삭제에서는 .active 파일의 잠금을 대기 없이 시도한다.
#![allow(unused)]
fn main() {
lease
.try_lock()
.map_err(|_| "session is already in use; resume/delete refused")?;
}
외부 명령에는 인스턴스 잠금과 세션 잠금을 함께 전달한다. hel 본체가 종료돼도 잠금을 이어받은 자식이 살아 있으면 세션을 재개하거나 삭제할 수 없다.
저장·복원 코드의 검사
전체 111개 테스트와 clippy가 통과했다. 그중 세션 검사는 실제 model 대신 로컬 mock API를 사용한다. 두 hel 프로세스를 차례로 실행하고, 두 번째 요청의 본문을 직접 확인했다.
| 상황 | 확인 결과 |
|---|---|
| 정상 답변 후 종료·새 프로세스에서 재개 | 첫 지시와 assistant 메시지가 후속 요청에 그대로 포함 |
| 압축 요약과 tool 호출·결과를 저장해 재개 | 메시지 순서·reasoning 내용·tool_call_id 연결 유지 |
| 재개 전에 HEL.md 변경 | 현재 system 메시지 적용, 이전 대화는 보존 |
| model·사용 가능한 tool 목록·system 변경 | Meter 초기화. 출력 한도·압축 기준만 변경하면 관측값 유지 |
| 저장한 cursor로 새 프로세스에서 spill 읽기 | 같은 cursor로 이어 읽기. 대상 파일이 바뀌면 거절 |
| 다른 세션의 spill·snapshot 접근, 내부 파일 수정 | 파일 tool·sandbox에서 거절. 일반 검색에서도 제외 |
| 같은 세션 점유 중 재개·삭제, 자식만 살아 있는 경우 | 거절. 서로 다른 새 세션은 동시에 생성 가능 |
| snapshot 저장 실패 뒤 다음 답변 완료 | 이전 snapshot 보존, 경고 후 최신 대화 전체 저장 재시도 |
| 세션 삭제 | snapshot·spill 삭제, 프로젝트 작업 파일 유지 |
재개 후 답변
| 실행 방식 | 식별자를 답한 횟수 |
|---|---|
| 종료 없이 계속 | 3/3 |
| 종료 후 새 세션 | 0/3 |
| 종료 후 기존 세션 재개 | 3/3 |
재개한 세 번 모두 첫 지시와 첫 답변이 두 번째 API 요청에 그대로 포함됐다. 두 프로세스의 세션 ID가 같고, 마지막 snapshot에 후속 질문과 답변까지 저장된 것도 확인했다. 전체 9회가 정상 완료됐으며 API 요청은 18회, 도구 호출은 0회였다.
이 짧은 대화에서는 종료 후에도 앞에서 받은 정보를 다시 사용할 수 있었다. 압축·cursor·권한 경계는 위의 고정 응답 검사로 확인했다.
답변을 마친 뒤 정상 종료하고 재개하는 동작을 확인했다. 파일 수정 후 답변과 snapshot 저장 전에 강제 종료되면, 파일에는 수정이 남아도 저장된 대화에는 그 기록이 없을 수 있다. 이 차이를 확인하고 이어가는 복구는 별도로 다루어야 한다.
돌아보기
변경 사항
재개할 문맥과 그 문맥이 참조하는 상태의 수명을 함께 관리하는 구조를 추가했다. spill은 세션이 삭제될 때까지 남고, cursor는 파일이 같은지 다시 확인한 뒤 사용할 수 있다. 실행 설정은 현재 값을 적용하며 필요한 경우 Meter 관측값을 버린다.
트레이드오프
매 답변마다 전체 JSON 파일을 쓰고 세션별 잠금을 관리하는 비용이 생겼다. 재개 실행의 평균 입력 토큰은 208, 연속 실행은 209였다. 두 방식 모두 앞 대화를 전달하므로 문맥 크기도 비슷했다.
답변 완료 시점의 snapshot은 선형 대화를 재개하기 위한 출발점이다. 처리 중이던 tool의 사이드 이펙트나 프로젝트 파일을 되돌리는 일은 별도의 복구 설계가 필요하다. FAQ에 이번 저장 경계와의 차이를 정리했다.
논문의 내용 또는 다른 harness와 비교하면
논문이 구분한 대화 복원과 파일 되돌리기 중, 이번에는 완료된 대화의 복원에 집중했다. Codex와 Claude Code의 JSONL 기록은 누적된 이력을 재구성하는 기반이 된다. hel은 현재 선형 대화에 필요한 상태를 JSON snapshot 하나로 저장한다. 저장 구조는 작지만, 처리 도중 중단된 호출이나 대화 분기를 복원하려면 더 많은 실행 기록과 정책이 필요하다.
FAQ
세션 재개와 파일 되돌리기
세션 snapshot은 model에게 다시 보낼 대화와 관련 상태를 저장한다. 프로젝트 파일의 이전 버전을 보관하지는 않는다. 종료한 사이 파일을 바꿨다면 재개한 hel은 현재 파일을 읽는다. 파일을 되돌리려면 Git 같은 별도 버전 관리 수단이 필요하다.
cursor도 이전 파일 내용을 보관한 사본이 아니다. 읽던 파일의 경로·위치·식별 정보를 가리킨다. 파일이 달라졌다면 기존 cursor를 거절하고 새로 읽게 한다. 세션 소유 spill은 원문 파일을 보관하므로 같은 파일이 남아 있는 동안 이어 읽을 수 있다.
답변이 끝나기 전에 중단된 경우
마지막 저장에 성공한 완료 지점까지 재개한다. 처리 중인 tool 호출마다 snapshot을 쓰거나, 외부 변경이 이미 적용됐는지를 추적하지 않는다. 강제 종료 뒤 같은 지시를 다시 보내면 이미 실행한 변경을 반복할 수 있다.
중간 복구가 필요해지면 호출 식별자, 실행 전후 기록, 부작용 확인과 재실행 정책을 함께 설계해야 한다. 이번 snapshot만으로 정확히 한 번 실행하거나 파일을 되돌리는 성질이 생기지는 않는다.
세션 폴더를 다른 프로젝트로 옮긴 경우
현재 구현은 snapshot에 저장한 작업 경로와 실행 중인 작업 경로가 다르면 재개를 거절한다. 대화 속 경로·cursor·spill 참조를 함께 옮기는 기능은 없다. 프로젝트 이동이나 세션 분기가 필요해지면 그 참조를 어떻게 재해석할지 별도로 정해야 한다.
같은 문맥과 같은 답변
설정과 파일이 그대로이고 필요한 상태를 복원하면 이전 문맥으로 요청할 수 있다. 그래서 대화 내용의 저장·복원은 고정 응답으로 검사하고, 실제 model에는 이전 식별자를 활용해 답하는지를 따로 확인했다.
Part V 개요
Note
- 시작 상태:
h10· 완료 상태:h13- 논문: §12 Extensibility Mechanisms, §11 Multi-Agent Orchestration
현재 구조
Part IV까지 진행하면서 hel은 tool 실행의 권한과 파일 접근 범위를 확인하고, 종료한 대화를 다시 이어갈 수 있게 됐다. 지금까지 만든 기능을 작업 수행, 문맥·세션 관리, 실행 경계로 묶으면 다음과 같다.
harness는 시스템 프롬프트, 사용 가능한 tool 목록, 대화 기록을 model에게 보낸다. 시스템 프롬프트에는 OS·shell·작업 경로와 HEL.md의 내용이 들어간다. 오래된 대화는 요약으로 바꾸고, 답변이 끝나면 세션을 저장한다. model은 이 문맥에서 다음 행동을 선택하고 tool로 필요한 파일을 찾는다.
Part IV에서 드러난 문제
같은 실행 경로의 공유. H8의 FAQ에서는 여러 hel 인스턴스가 같은 프로젝트를 사용하는 문제에 대해 이야기하고 있다. 인스턴스별 임시 저장 공간을 나눠도 프로젝트 파일은 공유되기 때문이다. 두 agent가 같은 파일을 고친다면 각자의 실행 경계를 지키면서도 서로의 변경을 덮어쓸 수 있다.
작업의 위임. H9에서는 세션의 대화를 저장하고 재개하는 구조를 만들었다. 일부 작업을 다른 agent에게 맡기려면 그 agent에게 어떤 대화를 보여줄지, 어떤 tool과 파일에 접근하게 할지, 작업이 끝나면 어떤 결과를 돌려받을지 정해야 한다.
기능 확장. 작업에 필요한 기능을 추가하는 방법도 살펴본다. 프로젝트별 작업 절차를 불러오거나 외부 서비스의 tool을 사용하려면, 기존 실행 흐름에 그런 기능을 연결할 자리가 필요하다.
논문이 보는 확장과 작업 위임
논문 §12는 Skills, Hooks, MCP처럼 서로 다른 위치에서 작동하는 확장 방식을 다룬다. Skills는 model이 읽을 작업 지침과 스크립트를 묶고, Hooks는 tool 실행 전후 같은 시점에 처리를 연결한다. MCP는 외부 서버가 제공하는 tool과 자원에 접근하는 통신 규약이다. §16.8은 작업 절차에는 Skills를, 외부 연동에는 MCP를 활용하도록 권한다.
§11은 작업을 맡긴 뒤 기다리는 순차 위임부터 여러 자식 agent를 동시에 실행하는 구조까지 비교한다. 여기서 작업 위임과 병렬 실행은 구분할 필요가 있다. 개별 문맥에서 작업한 결과를 돌려받는 효과는 한 번에 하나의 자식 agent만 실행해도 살펴볼 수 있다. §16.7은 병렬로 문맥을 나누는 이점이 분명한 작업을 찾은 뒤 다중 agent 구조를 도입하라고 권한다.
이 Part에서 다룰 것
기존 loop에 기능을 연결하는 방법부터 시작해, 일부 작업을 별도 agent에게 맡기고 여러 작업을 함께 실행하는 구조로 넓혀 간다. Codex와 Claude Code를 참고하면서 각 단계에서 필요한 문맥과 실행 경계를 살펴본다.
| Lab | 질문 | 더하는 구조 |
|---|---|---|
| H10 Skills / Hooks / MCP | 핵심 loop를 계속 고치지 않고 기능을 어떻게 확장할 것인가? | 작업 지침 로딩·실행 시점의 확장 지점·외부 tool 연결 |
| H11 Subagents | 작업 위임과 별도 문맥은 언제 도움이 되는가? | 자식 agent 생성·문맥 전달·결과 반환 |
| H12 Parallelism | 여러 작업을 동시에 실행하면 작업 완료에 도움이 되는가? | 읽기 전용 tool·자식 agent 동시 실행·요청 순서대로 결과 통합 |
H10에서는 지침을 읽는 것과 실제 코드를 실행하는 것의 차이를 살펴본다. 확장 기능을 연결할 때도 기존 권한 판단과 접근 범위가 어떻게 적용되는지 확인한다.
H11에서는 부모가 넘길 작업과 문맥, 자식이 돌려줄 결과를 다룬다. 문맥을 나누면 부모가 처리할 기록을 줄일 수 있지만, 자식에게 필요한 정보가 빠지거나 같은 파일을 다시 탐색하는 비용이 생길 수 있다.
H12에서는 동시에 진행할 수 있는 작업을 구분하고, 결과를 합치는 과정을 살펴본다. 같은 파일을 고치는 작업을 나누는 방법은 Claude Code와 Codex의 방식으로 살펴본다. 실행 시간과 token 사용량뿐 아니라 작업이 제대로 완료됐는지도 함께 확인한다.
각 Lab의 구체적인 구현 범위와 비교할 작업은 해당 Lab에서 정한다.
이 Part를 마치면
기존 실행 기반에 기능을 연결하고, 작업을 나누는 구조를 차례로 더한다.
작업 위임 그림의 작업 전달·결과 반환 관계는 논문 그림 6의 순차 위임 패턴을 재구성했다. 나머지는 이 Part에서 다룰 연결 지점과 실행 흐름을 나타낸다.
- 작업에 맞는 지침과 외부 tool을 기존 실행 흐름에서 사용할 수 있게 한다.
- 자식 agent에게 필요한 문맥을 전달하고, 반환된 결과로 부모의 작업을 이어간다. 대화를 나누는 것과 작업 파일을 나누는 것은 별도로 다룬다.
- 읽기 전용 tool 호출과 자식 agent를 동시에 실행하고, 결과는 요청한 순서대로 합친다. 쓰기 작업의 작업 공간 분리는 구조에 포함하지 않는다. 작업 분담이 주는 이점과 추가 비용은 각 Lab에서 확인한다.
이렇게 더한 구조가 어떤 작업에서 도움이 되는지는 Part VI의 H13 Harness Evaluation에서 모아 평가한다.
H10 — Skills / Hooks / MCP
Note
- 시작 상태:
h10· 완료 상태:h11- 논문: §12 Extensibility Mechanisms, §10.2 Lifecycle Hooks, §16.8 Extensibility
git checkout -b my-h10 h10
들어가며
Part V 개요에서 살펴본 확장 기능은 agent loop의 서로 다른 위치에 연결된다. Skills는 model에게 작업 지침을 전달하고, Hooks는 harness가 tool 실행 전후에 hook을 호출하는 구조다. MCP는 외부 서버가 제공하는 tool을 사용할 통로를 만든다.
현재 agent loop와 확장 지점
지금의 hel은 시스템 프롬프트, 사용 가능한 tool 목록, 대화 기록으로 model 입력을 준비한다. model이 tool 호출을 요청하면 권한을 판단하고 실행한다. 실행 결과는 대화에 추가해 다시 model에게 보낸다. 최종 답이 나오면 사용자에게 전달한다.
아래 그림은 같은 loop에 Skills, Hooks, MCP를 차례로 연결한 모습이다.
H10을 시작할 때의 흐름은 첫 번째 그림과 같다. Skills를 더하면 model 입력 준비에 목록 전달과 본문 로딩이 연결된다. Hooks는 권한 판단 앞뒤에서 hook을 호출하고, MCP는 tool 실행부에서 외부 서버를 호출한다. MCP 서버의 tool도 Skills처럼 이름과 설명을 먼저 전달하고, model이 로딩한 tool만 사용 가능한 tool 목록에 정의를 추가한다.
논문이 본 확장 기능
논문 §12.5는 작업 지침을 파일로 묶어 발견하고 읽는 Skills를 다룬다. §10.2는 harness가 실행 흐름의 특정 시점에 hook을 호출하는 사례를, §12.4는 MCP로 외부 기능을 연결하는 사례를 설명한다.
이 세 가지를 loop의 연결 위치에 따라 나눠 보면, model에게 전달할 지침과 harness가 호출할 hook·외부 tool을 구분할 수 있다. model이 어떤 행동을 고를지에는 지침이 영향을 주고, hook 호출과 외부 서버 통신은 harness가 수행한다.
이번에 해볼 것
각 기능은 하위 문서에서 따로 다룬다. 앞 단계에서 만든 연결을 유지하며 다음 기능을 더한다.
| 문서 | 연결 위치 | 다룰 내용 |
|---|---|---|
| A — Skills | model 입력 준비 | skill 목록 전달·선택한 본문 로딩 |
| B — Hooks | tool 실행 전·후 | PreToolUse·PostToolUse hook 호출 |
| C — MCP | 사용 가능한 tool 목록·tool 실행 | 외부 tool 발견·호출·결과 전달 |
Skills는 시스템 프롬프트에 목록을 먼저 전달하고, model이 선택한 본문을 읽는다. Hooks는 tool 실행 전후에 hook을 호출한다. MCP는 외부 tool의 목록을 받고 호출 결과를 대화에 연결하는 흐름을 다룬다.
결과 확인
세 기능 모두 구현 전 코드와 구현 후 코드로 같은 작업을 세 번씩 실행했다. model은 deepseek-flash이고, 실행 방법과 세부 결과는 각 문서에 있다.
| 문서 | 작업 | 구현 전 | 구현 후 |
|---|---|---|---|
| A — Skills | 설정 파일 리뷰 | 첫 tool 호출로 리뷰 skill 읽기 0/3, 무관한 skill 본문 읽기 3/3 | 첫 tool 호출로 리뷰 skill 읽기 3/3, 무관한 skill 본문 읽기 0/3 |
| B — Hooks | 읽기 전 편집 유도 | 읽기 전 편집 실행 3/3 | 읽기 전 편집 차단 3/3, 차단 뒤 읽고 다시 편집 3/3 |
| C — MCP | 서버에서 포트 조회 후 반영 | 포트 반영 0/3 | MCP tool 로딩·호출을 거쳐 포트 반영 3/3 |
Skills와 Hooks에서는 구현 전에도 작업 결과(오류 지적, 파일 수정)는 모두 성공했다. 확인한 변화는 지침을 찾는 순서와 읽기 규칙을 거치는지 여부에 있다. MCP는 구현 전에 값을 얻을 방법이 없도록 만든 작업이다.
돌아보기
세 기능은 같은 loop의 서로 다른 위치에 연결되지만 서로 이어진다. Skills와 MCP는 모두 목록을 먼저 전달하고 model이 고른 것만 더 넣는 방식이라, 처음 입력은 작게 유지하고 고른 뒤에 왕복이 한 번 더 생긴다. MCP tool 호출은 built-in tool과 같은 경로를 지나므로 Hooks와 접근 레벨이 그대로 적용된다.
달라진 점과 대가, 다른 harness와의 비교는 A — Skills, B — Hooks, C — MCP의 돌아보기에 있다.
A — Skills
들어가며
skill은 특정 작업을 수행하는 지침과 관련 자료를 묶은 디렉터리다. 그 중심에는 이름과 설명, 본문을 담은 SKILL.md가 있다. 필요한 스크립트나 참고 문서도 함께 둘 수 있다. Agent Skills 형식
예를 들어 코드 리뷰 절차는 다음과 같이 적을 수 있다.
---
name: review
description: 변경한 코드를 저장소 규칙에 맞춰 검토한다.
---
# 코드 리뷰
1. references/rules.md에서 저장소의 코딩 규칙을 읽는다.
2. 변경한 코드와 관련 테스트를 확인한다.
3. 발견한 문제를 파일 위치와 함께 정리한다.
model은 이 지침을 읽고 파일 읽기나 명령 실행 같은 tool을 사용한다. skill 파일을 추가하는 것만으로 새로운 실행 함수나 tool은 생기지 않는다. 스크립트가 함께 있어도 실제 실행은 해당 명령을 수행하는 tool을 거친다.
agent loop에 연결되는 위치
공통 loop 그림의 A 단계에서 Skills는 model 입력 준비에 연결된다. harness는 시스템 프롬프트에 사용할 수 있는 skill 목록을 넣는다. model이 작업에 맞는 skill을 선택하면 harness가 해당 본문을 읽어 다음 입력에 포함한다.
Agent Skills의 구현 가이드도 이름·설명으로 skill을 발견하고, 선택한 skill의 지침과 관련 자료를 단계적으로 읽는 방식을 설명한다.
이번에 해볼 것
hel에 목록을 먼저 전달하고, 선택한 본문을 읽는 흐름을 더한다. 저장소의 .agents/skills/에 있는 skill만 대상으로 이름·설명·파일 위치를 모으고, 실제 model 입력에서 목록과 본문이 각각 언제 등장하는지 살펴본다.
sequenceDiagram
participant U as 사용자
participant H as harness
participant F as skill 파일
participant M as model
rect rgb(232, 241, 255)
H->>F: 1. SKILL.md 메타데이터 확인
F-->>H: 이름·설명·파일 위치
U->>H: 변경한 코드를 리뷰해줘
H->>M: 2. 시스템 프롬프트에 목록 포함<br/>사용자 지시·사용 가능한 tool 목록
end
rect rgb(234, 246, 237)
M-->>H: 3. review 선택 · 본문 읽기 요청
H->>F: 선택한 SKILL.md 읽기
F-->>H: 작업 지침
H->>M: 4. 기존 메시지 + 선택 요청 + 본문 결과
end
M-->>H: 5. 지침에 따른 tool 호출 요청
H->>H: 권한 판단 · tool 실행
H->>M: 실행 결과를 대화에 추가
M-->>H: 리뷰 결과
H-->>U: 최종 답
1·2에서는 어떤 skill이 있는지 알리는 연결을, 3·4에서는 선택한 지침을 문맥에 넣는 연결을 만든다. 5부터는 기존 tool loop를 사용한다. 목록을 잘 전달했는지와 model이 적절한 skill을 골라 지침을 따르는지는 따로 확인한다.
1. 저장소에서 사용할 수 있는 skill 찾기
리뷰와 릴리스 노트 작성을 지원하는 저장소를 예로 들면, 파일을 다음과 같이 배치할 수 있다.
.agents/skills/
├── review/
│ ├── SKILL.md
│ └── references/
│ └── rules.md
└── release-notes/
└── SKILL.md
harness가 발견한 목록은 다음과 같다. 이 예시의 두 skill은 메시지 흐름을 설명하기 위한 구성이다.
| 이름 | 설명 | 파일 위치 |
|---|---|---|
review | 변경한 코드를 저장소 규칙에 맞춰 검토 | .agents/skills/review/SKILL.md |
release-notes | 변경 내역을 사용자용 릴리스 노트로 정리 | .agents/skills/release-notes/SKILL.md |
name과 description은 SKILL.md 앞부분의 메타데이터에서 읽는다. 경로는 harness가 파일을 발견하면서 함께 기록한다. 파일을 검사하는 과정에서 본문까지 읽더라도, 처음 model에게 보내는 것은 목록이다.
2. 시스템 프롬프트에 skill 목록 넣기
시스템 프롬프트의 skill 목록에는 이름·설명·위치를 넣는다. 본문을 읽을 시점과 방법도 함께 알려 준다. 아래는 위 저장소에 대한 입력 예시이며, 기존 시스템 프롬프트 중 Skills와 관계된 부분만 펼쳤다.
1. system: 기존 시스템 프롬프트 + 아래 skill 목록과 사용 방법
2. user: 변경한 코드를 저장소 규칙에 맞춰 리뷰해줘.
system은 harness가 준비하는 메시지이고, user는 사용자의 지시다. 시스템 프롬프트에 덧붙이는 부분은 다음과 같다.
사용 가능한 skills:
- review: 변경한 코드를 저장소 규칙에 맞춰 검토한다.
위치: .agents/skills/review/SKILL.md
- release-notes: 변경 내역을 사용자용 릴리스 노트로 정리한다.
위치: .agents/skills/release-notes/SKILL.md
작업에 맞는 skill을 선택하면 해당 SKILL.md를 읽고 지침을 따른다.
skill 안의 상대 경로는 그 SKILL.md가 있는 디렉터리를 기준으로 해석한다.
사용 가능한 tool 목록은 메시지와 함께 API 요청에 보낸다. skill 본문 읽기에는 기존 read_file을 사용한다. skill 목록에 있는 review가 API의 tool 목록에 새 함수로 등록되는 것은 아니다.
3. model이 skill을 선택하고 본문 요청하기
model은 입력을 받아 시스템 프롬프트에서 작업 수행에 필요한 skill을 확인하고, skill 본문을 read_file로 요청할 수 있다.
model은 review의 설명과 사용자 지시를 바탕으로 해당 파일을 읽는 tool 호출을 생성한다. 이 응답이 대화에 assistant 메시지로 추가된다.
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_skill_1",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\".agents/skills/review/SKILL.md\"}"
}
}]
}
harness는 호출을 받아 파일 접근 권한을 확인하고 내용을 읽는다. 본문의 상대 경로 references/rules.md는 .agents/skills/review/references/rules.md를 가리킨다. 목록에 파일 위치를 남기는 것은 본문을 찾는 데도, 부가 자료의 위치를 해석하는 데도 필요하다.
4. 본문을 포함해 model 입력 준비하기
harness는 읽은 결과를 tool 메시지로 추가한다. tool_call_id를 call_skill_1로 지정해 앞의 요청과 짝을 맞춘다. 다음 API 요청에서 model이 보는 메시지 순서는 다음과 같다. 4번의 내용은 읽기 결과 중 지침 부분을 표시했다.
1. system: 기존 시스템 프롬프트 + review·release-notes 목록과 사용 방법
2. user: 변경한 코드를 저장소 규칙에 맞춰 리뷰해줘.
3. assistant: call_skill_1 — read_file로 review/SKILL.md 읽기 요청
4. tool: call_skill_1의 결과 — 코딩 규칙 읽기, 변경 코드·테스트 확인, 문제와 위치 정리
skill 목록은 시스템 프롬프트에 남고, 선택한 본문은 대화 기록에 들어간다. 이 시점에는 release-notes의 본문이나 review의 참고 문서 내용은 포함되지 않는다.
5. 지침에 따라 기존 tool 사용하기
model은 전달받은 지침에 따라 references/rules.md를 읽고 변경 코드와 테스트를 확인한다. 각 요청은 assistant의 tool 호출과 harness가 만든 tool 결과로 대화에 쌓인다. 작업을 마치면 model의 최종 답을 사용자에게 전달한다.
이 흐름에는 처음 전달하는 skill 목록의 token과 skill 본문을 요청하는 tool 사용, 그리고 skill 본문만큼의 token 비용이 추가된다. 읽은 본문도 이후 입력 문맥을 차지한다. 사용하지 않은 본문을 처음부터 보내지 않는 만큼 입력을 줄일 수 있지만, 전체 비용과 선택 정확도는 작업별로 확인해야 한다.
확인할 내용
| 지점 | 확인할 내용 |
|---|---|
| 1·2 목록 전달 | 발견한 이름·설명·경로와 실제 시스템 프롬프트의 일치, 선택 전 본문 제외 |
| 3 skill 선택 | 작업과 관련된 skill 선택 여부, 잘못된 경로·메타데이터 오류의 진단 |
| 4 본문 전달 | 선택한 파일 내용과 tool 결과의 일치, 요청·결과의 연결 |
| 5 작업 수행 | 지침에 있는 절차의 실제 사용 여부, 읽은 참고 자료와 실행한 tool |
| 문맥 유지 | 중복 로딩, 압축 후 지침 유지, 세션 재개 시 목록과 이미 읽은 본문의 관계 |
| 비용 | 목록·선택한 본문의 입력 token, 추가 model 요청 수와 작업 완료 비용 |
skill이 포함된 문맥 관리
skill을 다시 읽을 때 변경 반영하기
skill 목록과 skill 본문은 model 입력의 서로 다른 메시지에 들어간다. 시스템 프롬프트에는 skill 목록을, 대화에는 읽은 skill 본문을 둔다. harness는 압축 후에도 사용할 skill 본문을 별도로 보관한다.
그림의 각 상자는 model에게 보내는 메시지 목록이다. 별도 보관 데이터는 harness가 관리하며, 본문을 실제 요청에 포함할 때 입력 token을 차지한다. 메시지 번호는 흐름을 설명하기 위한 예시다.
세션을 재개하면 snapshot의 skill 목록과 읽어 둔 skill 본문을 복원한다. 이 시점에는 skill 파일의 변경을 확인하지 않는다. model이 기존 read_file로 해당 skill을 다시 요청할 때 현재 파일을 읽고 비교한다.
이 그림은 skill의 이름·설명과 본문을 함께 수정한 경우다. 재개 직후에는 v1을 사용하고, 다시 읽은 뒤의 입력에는 갱신한 skill 목록과 v2 본문이 들어간다. 기존 대화는 유지한다. 시스템 프롬프트 중 OS·HEL.md 등은 H9처럼 현재 환경으로 구성하고, 여기서는 skill 목록 부분의 복원을 나타냈다.
| 읽기 결과 | 다음 model 입력에 반영할 내용 |
|---|---|
| 처음 읽는 skill | 시스템 프롬프트의 해당 skill 정보와 전체 skill 본문 |
| 같은 skill 본문이 현재 문맥에 존재 | tool 결과로 “이미 로딩됨” 안내 |
| skill 본문 변경 | 시스템 프롬프트의 해당 skill 정보 갱신, tool 결과로 최신 skill 본문 전달 |
| 같은 skill 본문이 현재 문맥에 없음 | tool 결과로 skill 본문 재전달 |
skill 이름·설명 등 목록 정보의 변경은 본문과 따로 비교하며, 달라진 경우에만 시스템 프롬프트를 갱신한다.
“이미 로딩됨”은 앞의 대화 메시지나 압축 후 skill 보존 영역에 해당 본문이 있어야 한다. 변경된 skill 본문을 전달하면 별도로 보관하는 본문도 갱신한다.
압축 후 skill 본문 보존하기
압축할 때는 보관한 skill 본문을 요약과 구분해 다시 포함한다.
압축 후 model에게 전달할 입력은 다음 순서다. 출력 예약은 메시지가 아니라 model의 답변을 위해 비워 두는 공간이다.
시스템 프롬프트 | 요약 | skill 본문 보존 영역 | 최근 내역 | 출력 예약
(skill 목록 포함) (최대 25,000 token)
skill 본문 보존 영역은 최근 내역에 포함하지 않는다. keep_recent는 최근 대화의 보존량을 정하고, skill 본문은 별도 한도를 적용한다. 다만 두 영역 모두 같은 context window를 사용한다.
시스템 프롬프트·요약·최근 내역을 구성한 뒤, 압축 기준까지 남은 입력 예산과 25,000 token 중 작은 값으로 skill 보존 영역을 제한한다. 따라서 작은 context 설정에서는 skill 본문이 25,000 token보다 적게 들어갈 수 있다. token 수는 H6와 같은 문자열 길이 기반 추정값을 사용하며, 영역의 제목·경로 등 전달 형식도 전체 예산에 포함한다.
| 항목 | 보존 규칙 |
|---|---|
| 순서 | 최근 호출한 skill부터 우선 |
| skill별 한도 | 가장 최근에 전달한 본문의 앞부분 최대 5,000 token |
| 전체 한도 | skill 본문 합계 최대 25,000 token |
| 한도 초과 | 남은 예산까지만 포함하고 이후 skill은 제외 |
skill이 다섯 개로 제한되는 것은 아니다. 각 본문이 짧으면 더 많은 skill을 포함할 수 있다. 본문의 일부만 남았거나 제외된 skill을 다시 읽으면, “이미 로딩됨”으로 생략하지 않고 본문을 전달한다.
파일의 최신 내용은 압축 시점이 아닌, 해당 skill을 다시 읽는 시점에 확인한다. 따라서 model이 skill을 다시 요청하기 전까지는 기존 지침이 유지된다.
hel 내부 skill 보관 목록과 문맥 관리
hel harness 내부에는 문맥 관리를 위한 별도의 skill 보관 목록을 가진다. 보관 목록은 저장소의 모든 skill을 저장하는게 아니라 호출한 skill마다 다음과 같은 정보를 보관하게 된다.
- 경로: skill 식별용
- 최신 전체 본문: 압축 후 다시 넣을 skill 본문 원문
- 본문의 SHA-256 hash: skill 재호출시 변경 여부 비교용
- 최근 호출 순서: 압축 후 보존 우선순위
hel의 내부 보관 목록을 이용한 문맥 관리는 다음과 같은 절차로 진행된다.
본문이 6,000 추정 token인 review skill을 예로 들면 다음과 같다. 단계를 선택해 저장소 파일, 내부 보관 목록, model 입력의 변화를 비교할 수 있다.
내부 보관 목록과 압축시 skill을 포함한 문맥 관리를 정리하면 다음과 같다.
- 압축 후 skill 본문 보존 영역에서 skill을 읽을 때, 잘린 부분이 필요하면 model이
read_file로 남은 부분을 요청한다. 결과는 최근 대화에 추가된다. 남은 부분은 model의 판단에 따라 범위로 읽거나, 처음부터 읽고 cursor로 이어갈 수 있다. - skill이 변경된 경우에는, 변경된 skill을 읽는 시점에 내부 보관 목록을 갱신하고 최근 대화에 갱신된 skill 본문을 추가한다. skill 보존 영역은 이후 압축에서 갱신된다.
- skill 본문이 문맥에 존재하는지는 harness가 남긴 전달 기록과 실제 메시지를 대조하여 판단한다. 이때 본문 내용과 함께 요청한 skill의 버전까지 모두 동일해야 한다. 본문은 일반 대화 영역 혹은 skill 보존 영역에 독립적으로 완전하게 존재해야 한다.
- 일반 대화의 tool 결과에 요청한 skill 본문 정보가 모두 존재하면 있다고 판단한다.
- 압축 후 skill 보존 영역에 요청한 skill 본문 정보가 모두 존재하면 있다고 판단한다.
- 그 외에는 새로 로드한다.
- skill의 메타데이터(이름, 설명)가 변경되면 시스템 프롬프트의 skill 목록을 갱신한다.
구현과 동작 확인
skill을 사용하는 실행에서는 기존 tool 목록에 read_file을 포함한다.
hel --tools bash,read_file
저장소의 .agents/skills/<디렉터리>/SKILL.md에서 이름과 설명을 읽어 목록을 만든다. 상위 저장소나 사용자 홈의 skill은 합치지 않는다. 잘못된 메타데이터·심볼릭 링크·1 MiB를 넘는 파일은 목록에서 제외하며 진단을 출력한다.
harness는 경로별로 최신 본문과 SHA-256 hash, 마지막 호출 순서를 저장한다. model에게 전달한 tool 결과의 호출 ID·내용·본문 범위도 기록한다. 이 기록을 실제 대화와 대조하므로, 오래된 결과가 잘리거나 압축으로 제거됐을 때도 전체 본문이 남았는지 구분할 수 있다.
read_file의 10,000 byte 한도와 cursor 이어 읽기는 유지한다. 긴 skill의 전체 원문은 harness에 보관하고, model에게는 기존 한도에 맞춰 전달한다. 범위 읽기는 요청한 범위를 반환하며, 전체 읽기 요청에서만 중복 본문 전달을 생략한다.
재읽기 시에는 같은 본문의 전달 기록과 보존 영역을 확인한다.
#![allow(unused)]
fn main() {
pub fn same_visible(&self, path: &str, doc: &Document) -> bool {
self.visible.contains(path)
&& self.loaded.get(path).is_some_and(|old|
old.hash == hash(&doc.body) && old.body == doc.body)
}
}
같은 경로의 메타데이터와 본문은 따로 비교한다. 이름·설명만 바뀌면 목록을 갱신하고, 같은 전체 본문이 현재 문맥에 있으면 “이미 로딩됨”을 반환한다. 경로가 사라졌다면 읽기 실패를 알리고 목록을 다시 탐색한다. 이동한 디렉터리를 이전 skill과 같은 것으로 추정하지 않는다.
압축 후 문맥은 시스템 프롬프트와 요약, 최근 내역을 먼저 구성한 다음 그 사이에 skill 보존 영역을 넣는다.
#![allow(unused)]
fn main() {
let available = policy.at.saturating_sub(estimate(&candidate));
let preserved = skills.preservation(available);
if let Some(region) = &preserved {
candidate.insert(first + 1, region.message.clone());
}
}
세션 snapshot에는 skill 목록과 보관 본문, 호출 순서, 전달 기록을 함께 저장한다. 재개할 때는 이 상태를 복원하고, skill 파일은 다시 읽기 요청이 올 때 확인한다. Skills 데이터가 없는 이전 snapshot도 읽을 수 있다.
| 검사한 상황 | 확인한 동작 |
|---|---|
| 최초 호출·동일 본문 재호출 | 최초 본문 전달, 재호출은 이미 로딩됨 안내 |
| 이름만 변경·본문 변경 | 목록 정보 갱신과 본문 재전달을 구분 |
| skill 디렉터리 이동 | 이전 경로 오류와 목록 재탐색, 새 경로 읽기 |
| 긴 UTF-8 본문 이어 읽기 | byte 한도 유지, 전체 전달 전 중복 생략 없음 |
| 압축 후 일부 보존·제외 | 재읽기 시 본문 전달 |
| 반복 압축 | 보존 영역 중복 없이 갱신, 별도 보관 원문 유지 |
| 프로세스 종료 후 재개 | 과거 skill 정보 복원, 재읽기 후 변경 반영 |
| 이전 형식의 snapshot | Skills 필드 없이도 복원 |
harness 내부의 문맥 관리 로직은 위 결정적 테스트와 로컬 응답 서버를 이용한 CLI 테스트로 검증했다. 검사한 범위는 본문의 전달·변경 판정, 문맥에 남은 전체 본문 확인, 압축 후 보존과 세션 복원이다. model의 선택 없이 정해진 입력과 기대 결과를 대조해 확인했다.
model의 skill 사용 확인
model에게 설정 파일 리뷰를 요청하고, 사용할 skill을 고르는 과정부터 결과를 작성하는 행동까지 살펴본다. 저장소에는 설정 리뷰용 config-review와 릴리스 노트 작성용 release-notes를 함께 둔다. config-review의 본문에는 참고 규칙을 읽는 절차와 결과 형식을 적는다.
.agents/skills/
├── config-review/
│ ├── SKILL.md
│ └── references/rules.md
└── release-notes/
└── SKILL.md
server.json
사용자 지시는 다음과 같이 전달한다. skill 이름이나 파일 위치는 지시에 넣지 않고, model이 시스템 프롬프트의 목록에서 고르게 한다.
server.json을 저장소 규칙에 맞춰 리뷰하고 결과를 정리해줘.
파일 구성은 다음과 같다. 포트의 허용 범위는 이 저장소의 규칙으로 정한 임의의 값이다.
| 파일 | 내용 |
|---|---|
config-review/SKILL.md | 참고 규칙으로 설정을 검토하고, 문제를 항목 / 현재 값 / 위반 이유 형식으로 보고 |
config-review/references/rules.md | 포트는 8000~9000만 허용 |
server.json | "port": 7000 |
model은 skill을 읽어 검토 절차를 파악한 뒤, 참고 규칙과 실제 설정값을 대조해야 한다. 이 예시에서 기대하는 보고는 다음과 같다.
port / 7000 / 허용 범위 8000~9000을 벗어남
“config-review skill을 사용하겠습니다”라고만 답하고 끝내면 리뷰를 수행하지 못한 것이다. skill을 찾아 읽었는지와 그 지침을 적용해 오류를 보고했는지를 함께 확인한다.
| model의 행동 | 확인할 내용 |
|---|---|
| skill 선택 | 작업과 관련된 config-review/SKILL.md 읽기, 무관한 skill 호출 여부 |
| 지침 사용 | 본문에 지정된 references/rules.md와 server.json 읽기 |
| 리뷰 수행 | 실제 포트 값과 허용 범위를 대조한 오류 지적 |
| 결과 작성 | skill에 지정된 보고 형식 준수 |
최종 답과 tool 호출 기록을 함께 확인한다. 파일을 읽은 사실과 규칙을 올바르게 적용한 결과는 각각 기록하고, model 요청 수와 입력 token도 살펴본다. 앞에서 검증한 snapshot 복원·본문 보존 이후의 model 행동은 이번 실험에서는 다루지 않는다.
실행 결과
deepseek-flash로 Skills 구현 전 코드와 구현 후 코드를 각각 3회 실행했다. 같은 설정 파일과 지시를 사용하고, 실행당 model 호출은 최대 8회, 출력 한도는 8,192 token, 제한 시간은 120초로 두었다. 압축은 끄고 bash와 read_file을 제공했다. 구현 전 코드는 과거 버전을 별도로 빌드해 실행했다.
| 확인한 행동 | 구현 전 | 구현 후 |
|---|---|---|
첫 tool 호출로 config-review/SKILL.md 읽기 | 0/3 | 3/3 |
| 리뷰 skill·참고 규칙·설정 파일 읽기 | 3/3 | 3/3 |
| 포트 7000의 범위 위반 지적 | 3/3 | 3/3 |
무관한 release-notes 본문 읽기 | 3/3 | 0/3 |
| 기대 보고 문자열과 완전 일치 | 1/3 | 2/3 |
| 설정 파일 변경 없이 리뷰 완료 | 3/3 | 3/3 |
구현 전에도 model은 디렉터리를 탐색해 skill과 참고 규칙을 찾았다. 구현 후에는 목록에서 리뷰 skill을 골라 첫 tool 호출로 읽었다. 무관한 skill의 본문은 읽지 않았지만, 추가 파일 탐색은 여전히 수행했다.
보고 문자열이 일치하지 않은 세 실행도 포트 오류와 위반 이유는 올바르게 답했다. 구현 전 두 실행은 답 전체를 백틱으로 감쌌고, 구현 후 한 실행은 다음처럼 항목과 값을 백틱으로 감쌌다.
`port` / `7000` / 허용 범위 8000~9000을 벗어남
완전 일치 검사는 이 차이를 실패로 판정했다. skill은 보고 형식과 code fence 금지를 지정했지만 inline code 사용을 명시적으로 금지하지는 않았다. 이 결과는 문자열 일치 여부로 기록하며, 규칙을 적용하지 못한 경우와 구분한다.
| 실행당 평균 | 구현 전 | 구현 후 |
|---|---|---|
| model 호출 | 3.67회 | 3.67회 |
| tool 호출 | 6회 | 4.33회 |
| 입력 token | 4,983.7 | 4,743.7 |
| 출력 token | 614 | 336.3 |
이번 세 번씩의 실행에서는 관련 skill로 바로 접근하고 무관한 본문 읽기를 줄이는 행동이 관찰됐다. 오류 발견 횟수는 같았고, 입력 token 차이는 약 4.8%였다. 다만 이 규모로 선택 정확도나 비용의 일반적인 개선을 단정하기는 어렵다.
- 실험 중 model은
server.json을 찾으려고find로 디렉터리 전체를 확인했다. 이 과정에서 접근이 금지된 세션 저장 폴더.hel에 접근했고, sandbox가 이를 막아 종료 코드 1을 반환했다. - 이는 Skills 구현과 무관하게 model이 사용하는 bash 명령의 호출 형태에 따라 기록에 영향을 준 것이다.
측정은 다음과 같은 명령어로 확인할 수 있다.
evals run h10 --conditions baseline --build
evals run h10 --conditions variant --build
돌아보기
변경 사항
model이 시스템 프롬프트의 목록에서 작업에 맞는 skill을 고르고, 본문과 참고 파일을 읽어 설정을 리뷰하는 흐름을 확인했다. 이번 실행에서는 관련 skill에 바로 접근하고 무관한 skill의 본문을 읽지 않았다. 구현 전에도 포트 오류는 모두 찾아냈으므로, 확인한 변화는 지침을 찾는 과정에 있었다.
작업별 지침은 .agents/skills/에 추가하고 기존 read_file로 읽는다. 새 지침을 연결할 때 tool을 추가하거나 loop를 바꿀 필요가 없다. harness에는 읽은 본문을 보관하고, 재읽기·압축·세션 재개 때 문맥에 어떤 버전을 남길지 관리하는 구조가 더해졌다.
트레이드오프
본문을 필요할 때 읽는 만큼 최초 로딩에 tool 호출이 필요하고, skill 목록은 시스템 프롬프트의 공간을 사용한다. 이번에는 전체 tool 호출이 줄었지만 model 호출 횟수는 같았다. 목록 제공이 모든 탐색을 없애거나 왕복 횟수를 줄여 주는 것은 아니다.
본문 보관에는 메모리와 snapshot 공간이 들고, 압축 뒤 다시 넣은 지침도 입력 token을 차지한다. 별도로 원문을 보관해도 model에게 전달할 수 있는 양에는 한도가 있다. 긴 skill의 뒷부분이나 오래된 skill이 빠졌다면 다시 읽어야 한다. 이는 결정적 테스트로 검사했다.
skill을 읽은 뒤에도 지침의 해석과 실행은 model에 달려 있다. 이번 보고의 백틱 차이처럼, 지침이 허용하는 표현과 판정 규칙이 기대하는 문자열이 어긋날 수 있다. 정확한 서식이 필요한 작업에서는 허용할 표현을 지침에 명확히 적고, 의미상 맞는 답과 서식 준수를 나누어 확인해야 한다. tool 실행 시 반드시 지켜야 할 규칙은 다음 Hooks에서 실행 경계의 검사로 다룬다.
논문의 내용 또는 다른 harness와 비교하면
Agent Skills의 통합 안내는 목록, 선택한 본문, 필요한 부가 자료를 단계적으로 읽는 구조를 설명한다. hel도 이 흐름을 따르며 기존 파일 읽기 tool을 사용한다. 탐색 범위는 현재 프로젝트 하나로 제한해 사용자 홈이나 여러 저장소의 지침을 합칠 때 생기는 우선순위 문제는 다루지 않았다.
Claude Code의 skill 수명 관리는 같은 내용의 중복 전달을 생략하고, 압축 뒤에는 최근 호출한 skill부터 한도 안에서 본문을 다시 붙인다. hel에도 이 보존 방식을 적용했다. 다만 hel은 파일에서 읽은 본문을 비교하고 문자열 길이로 token을 추정한다. 인자나 명령 실행 결과를 반영한 본문 생성은 지원하지 않으므로, 같은 보존 정책을 사용해도 전체 동작이나 문맥 사용량이 같다는 뜻은 아니다.
B — Hooks
Note
관련 논문: §10.1 Claude Code의 권한 계층, §10.2 Codex의 권한 계층, §14.1 Hooks와 이벤트 기반 확장
들어가며
hook은 harness의 실행 흐름에서 특정 시점에 호출되는 일종의 함수다. model이 매번 실행을 선택하지 않아도, 그 시점에 도달하면 harness가 연결된 hook을 호출한다.
예를 들어 파일을 편집하기 전에 대상 경로를 검사하거나, tool 실행 뒤에 결과를 기록하는 hook을 붙일 수 있다. 지침에 적힌 규칙을 실제 실행 시점에서 확인하려는 경우에도 hook을 사용할 수 있다.
hook의 호출 시점과 적용 대상은 별개다. 이번에 다뤄볼 PreToolUse, PostToolUse를 예시로 들면 다음과 같이 구분할 수 있다.
이 hook들은 tool의 호출 전후에 실행될 수 있다. 호출 전이라면 PreToolUse를, 호출 후라면 PostToolUse를 사용할 수 있다. 그리고 이 hook들은 harness에서 제공하는 모든 tool에 적용하거나, 일부에만 적용할 수 있다.
| hook | 대상 tool | 하는 일 |
|---|---|---|
PostToolUse | read_file | 읽기에 성공한 파일 기록 |
PreToolUse | search_replace, write_file | 편집 대상의 읽기 기록 확인 |
agent loop에 연결되는 위치
공통 loop 그림의 B 단계에는 tool 실행 전후 두 지점이 있다.
| 이벤트 | 호출 위치 | hook의 역할 |
|---|---|---|
PreToolUse | tool 실행 전 | 호출 인자 검사·조건에 맞지 않는 실행 차단 |
PostToolUse | tool 실행 후 | 결과 검사·기록·model에게 전달할 정보 추가 |
등록된 hook이 있으면 PreToolUse → 기존 권한 검사·필요 시 사용자 승인 → tool 실행 → PostToolUse 순서로 진행한다. 각 이벤트에서 대상 tool에 일치하는 hook이 없으면 그 단계를 건너뛴다. hook이 없을 때는 기존 권한 검사와 tool 실행 흐름을 유지한다.
| tool 호출의 상태 | Post 호출 |
|---|---|
| Pre에서 차단 | 생략 |
| 권한 검사에서 거절되거나 사용자 승인을 받지 못함 | 생략 |
| tool 실행 성공 | 호출 |
| tool 실행을 시작했지만 파일 없음·치환 실패 등의 오류 발생 | 생략하고 오류를 model에게 바로 전달 |
PostToolUse가 실행될 때는 tool이 이미 파일을 바꾸거나 외부 요청을 보냈을 수 있다. 변경을 막아야 하는 규칙은 PreToolUse에서 실행 전에 판단해야 한다.
Claude Code의 Hooks 문서는 이 두 이벤트 외에도 세션 시작과 사용자 입력 등 여러 시점을 다룬다. 성공 뒤의 PostToolUse와 실패 뒤의 PostToolUseFailure도 구분한다(2026-10-06 확인). hel은 tool 실행이 성공했을 때만 PostToolUse를 호출하고, 실패한 호출에는 별도 hook을 호출하지 않는다. 현재 bash tool은 명령의 종료 코드가 0이 아니면 실패로 처리하므로 이 경우에도 Post를 생략한다.
이번에 해볼 것
H2에서 남긴 질문을 이어서, 읽지 않은 파일의 편집을 hook으로 막아 본다. PostToolUse에서 파일 읽기 성공을 기록하고, PreToolUse에서 편집 대상의 읽기 기록을 확인하는 흐름이다. 기록이 없으면 편집을 실행하지 않고 이유를 model에게 돌려준다.
harness는 이벤트와 대상 tool에 맞춰 등록된 command를 실행한다. 호출 정보를 JSON으로 표준 입력에 전달하고, 명령의 출력과 종료 코드를 받아 실행 흐름에 반영한다. command에는 외부 스크립트를 실행하는 명령도 지정할 수 있다.
읽기 기록과 편집 전 검사는 테스트용 스크립트에 둔다. harness는 읽기 이력의 의미를 알 필요 없이 같은 호출 방식으로 다른 검사나 작업을 연결할 수 있다. 스크립트는 호출마다 새 프로세스로 실행되므로, 호출 사이에 필요한 기록은 스크립트가 별도로 관리해야 한다.
확인할 동작은 차단 뒤에도 이어진다. model이 거절 사유를 보고 파일을 읽은 다음 다시 편집할 수 있어야 한다.
기존 hel의 권한 게이트는 접근 레벨에 따라 tool 실행을 허용하거나 승인을 요청한다. hook을 호출해 프로젝트별 규칙을 실행 시점에 검사할 수 있다.
프로젝트 hook 등록
프로젝트의 .hel/hooks.json에 hook을 등록한다. hooks 아래에 이벤트를 두고, matcher로 대상 tool을 고른 뒤 실행할 handler를 나열한다. 다음은 테스트 스크립트를 연결하는 설정 예시다.
{
"hooks": {
"PreToolUse": [
{
"matcher": "^(search_replace|write_file)$",
"hooks": [
{
"type": "command",
"command": "python3 hooks/read_guard.py",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "^read_file$",
"hooks": [
{
"type": "command",
"command": "python3 hooks/read_guard.py",
"timeout": 10
}
]
}
]
}
}
matcher는 tool 이름에 적용하는 정규식이며 생략하면 전체 tool을 대상으로 한다. type: "command"는 명령 실행을 뜻하고 timeout은 초 단위 제한이다. 예시는 각 호출에 10초를 지정했다.
같은 이벤트와 matcher에 여러 handler를 등록할 수 있다. 동일한 command를 중복 등록해도 각각 실행 대상으로 남긴다. 같은 이벤트에서 여러 matcher 그룹이 일치하면 그룹 배열 순서와 각 hooks 배열 순서대로 하나씩 실행한다.
PreToolUse에서 명시적으로 tool 차단을 반환하면 남은 handler를 실행하지 않고 해당 tool도 차단한다. 이때 PostToolUse도 호출하지 않고 차단 사유를 model에게 바로 전달한다. hook 자체의 오류는 보고한 뒤 다음 handler로 진행한다. 명시적 차단 없이 검사를 마치면 기존 tool 실행 흐름을 계속한다.
이 형식은 Codex의 설정 형식을 참고한다. hel은 프로젝트의 .hel/hooks.json을 사용하며 사용자 홈의 설정이나 플러그인 설정을 합치지 않는다.
hook 명령의 신뢰와 실행 권한
hook command는 사용자가 신뢰한 외부 명령으로 실행한다. hel을 시작할 때 .hel/hooks.json의 이벤트·matcher·command를 보여주고 프로젝트 설정 전체에 대해 [y/N]으로 확인한다.
승인하면 .hel/hooks-trust.json에 프로젝트의 실제 경로와 설정 파일 내용의 SHA-256 해시를 저장한다. 다음 실행에서 경로와 해시가 같으면 다시 묻지 않는다. 설정 파일 내용이 바뀌면 재확인한다. 승인한 설정은 현재 실행 동안 메모리에 유지한다.
| 상황 | 처리 |
|---|---|
| 처음 발견하거나 변경된 설정 | 실행할 정의를 표시하고 신뢰 확인 |
| 기존 승인과 프로젝트 경로·설정 해시 일치 | 재확인 없이 hook 사용 |
| 사용자가 거절 | hook 생략을 알리고 hel 계속 실행 |
| 입력을 받을 수 없고 기존 승인도 없음 | hook 생략을 알리고 hel 계속 실행 |
신뢰 대상은 등록된 명령과 설정이다. 명령이 참조하는 스크립트나 의존 파일의 내용은 해시에 포함하지 않으므로, 같은 command에서 스크립트 내용만 바꾸면 다시 확인하지 않는다.
hook 명령에는 model이 호출하는 tool의 sandbox를 적용하지 않는다. 따라서 외부 스크립트나 도구를 연결할 수 있고, 파일·네트워크 접근 범위는 hel 프로세스가 가진 OS 권한을 따른다. cwd는 작업 디렉터리이며 접근 범위를 제한하는 경계가 아니다.
Codex의 hook 신뢰 절차를 참고한 방식이다. hook을 신뢰해도 model이 요청한 tool의 권한 검사와 sandbox 제한은 그대로 적용한다.
hook 실행 결과와 실행 흐름 관리
hel에서는 hook의 실행 결과와 이에 따른 실행 흐름을 다음과 같이 처리한다. (Codex의 hook 처리 방식을 참고한다.)
| hook 결과 | 처리 |
|---|---|
Pre의 명시적 deny 또는 종료 코드 2 | 해당 tool 실행 차단, 이유를 model에게 전달 |
| 정상 종료, 차단 없음 | 기존 실행 흐름 계속 |
| 명령 실행 실패·일반 오류·시간 초과·잘못된 응답 | hook 오류를 보고하고 기존 실행 흐름 계속 |
Post의 차단 피드백 또는 종료 코드 2 | 이미 끝난 실행은 유지하고 model에게 피드백 전달 |
PreToolUse hook은 명시적인 거절을 반환해 해당 tool의 실행을 막는다. JSON으로 deny를 반환하거나 종료 코드 2와 함께 표준 오류로 차단 이유를 출력한다. harness는 실행하지 않은 tool 호출의 결과로 이 이유를 model에게 전달한다. model은 tool 실행 차단이 됐더라도 다음 행동을 할 수 있다.
스크립트가 규칙 위반으로 판단한 경우와 스크립트 자체의 오류를 나눠 확인한다. model이 요청한 tool의 기존 권한 게이트와 sandbox 제한은 hook 결과와 별도로 유지한다.
스크립트와 주고받는 정보
harness는 hook 스크립트를 실행하면서 표준 입력(stdin)으로 JSON 객체 하나를 전달한다. 스크립트는 표준 출력(stdout)의 JSON과 종료 코드로 판단을 반환한다. 종료 코드 2를 사용하면 표준 오류(stderr)에 차단 사유나 피드백을 쓴다.
harness → 스크립트: 입력
| 필드 | 형식 | 내용 |
|---|---|---|
hook_event_name | 문자열 | PreToolUse 또는 PostToolUse |
session_id | 문자열 | 현재 hel 세션의 식별자 |
cwd | 문자열 | hel의 작업 디렉터리 절대 경로 |
tool_use_id | 문자열 | 해당 tool 호출의 식별자. 같은 호출의 Pre/Post는 같은 값 |
tool_name | 문자열 | 호출할 tool 이름 |
tool_input | JSON 객체 | model이 전달한 tool 인자 |
tool_response | JSON 값 | Post에 추가하는 tool 실행 결과 |
다음은 search_replace 실행 전 Pre hook이 받는 입력 예시다. 식별자와 경로는 설명을 위한 값이다.
{
"hook_event_name": "PreToolUse",
"session_id": "session-123",
"cwd": "/project",
"tool_use_id": "call-7",
"tool_name": "search_replace",
"tool_input": {
"path": "a.txt",
"search": "old",
"replace": "new"
}
}
같은 호출이 편집을 마치면 Post 입력에 결과가 추가된다. 아래는 이 tool에도 Post hook을 등록했을 때 받는 예시다.
{
"hook_event_name": "PostToolUse",
"session_id": "session-123",
"cwd": "/project",
"tool_use_id": "call-7",
"tool_name": "search_replace",
"tool_input": {
"path": "a.txt",
"search": "old",
"replace": "new"
},
"tool_response": "replaced 1 occurrence in a.txt"
}
읽기 기록을 남기는 스크립트에는 read_file의 Post 입력이 전달된다. a.txt의 내용이 old와 줄바꿈이라면 다음과 같다.
{
"hook_event_name": "PostToolUse",
"session_id": "session-123",
"cwd": "/project",
"tool_use_id": "call-6",
"tool_name": "read_file",
"tool_input": {
"path": "a.txt"
},
"tool_response": "old\n"
}
tool_response는 tool별 결과를 담는다. 위 예시는 문자열을 반환하는 현재 hel의 읽기·편집 tool을 기준으로 한다. Pre에는 실행 결과가 없으므로 이 필드를 넣지 않는다.
스크립트 → harness: 출력
계속 진행하려면 아무것도 출력하지 않고 종료 코드 0으로 끝낸다. 이는 hook이 차단하지 않는다는 뜻이며, 기존 권한 검사까지 통과했다는 뜻은 아니다.
Pre에서 tool 호출을 차단하려면 다음 JSON을 stdout에 출력하고 종료 코드 0으로 끝낸다.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "먼저 a.txt를 읽어 주세요."
}
}
| 필드 | 내용 |
|---|---|
hookSpecificOutput.hookEventName | 판단을 반환하는 이벤트, 여기서는 PreToolUse |
hookSpecificOutput.permissionDecision | deny이면 해당 tool 실행 차단 |
hookSpecificOutput.permissionDecisionReason | model에게 전달할 차단 이유 |
이 응답을 받으면 남은 Pre hook과 tool 실행을 중단하고 Post hook도 호출하지 않는다. model에게는 먼저 파일을 읽으라는 이유가 돌아간다.
Post에서 원본 tool 결과 대신 피드백을 전달하려면 다음 JSON을 stdout에 출력하고 종료 코드 0으로 끝낸다.
{
"decision": "block",
"reason": "편집 결과에서 규칙 위반을 발견했습니다. 결과를 다시 확인해 주세요."
}
decision: "block"은 원본 tool 결과 전달을 막고, reason을 model에게 전달할 결과로 사용한다. 이미 실행된 편집의 효과는 되돌리지 않는다.
Post hook이 여러 개이면 각각 동일한 원본 tool_response를 받는다. 앞선 hook의 피드백을 다음 hook의 입력으로 넘기지 않는다. Post는 등록 순서대로 끝까지 실행하고, block 또는 종료 코드 2로 반환한 피드백을 순서대로 모아 model에게 전달할 원본 결과를 대체한다.
중간 hook이 정상 종료하거나 자체 오류로 실패해도 앞서 수집한 피드백은 유지한다. 대체할 피드백이 하나도 없으면 원본 tool 결과를 그대로 전달한다.
JSON 대신 종료 코드 2를 사용할 수도 있다. Python 스크립트에서는 다음과 같이 사유를 stderr에 쓰고 종료한다.
import sys
print("먼저 a.txt를 읽어 주세요.", file=sys.stderr)
sys.exit(2)
같은 종료 코드라도 Pre에서는 tool 실행을 차단하고, Post에서는 원본 결과를 피드백으로 대체한다.
| 스크립트 종료 방식 | harness 처리 |
|---|---|
종료 코드 0, 출력 없음 | 다음 hook 또는 기존 실행 흐름으로 진행 |
종료 코드 0, Pre의 deny JSON | 남은 Pre hook·tool 실행·Post 호출 생략 |
종료 코드 0, Post의 block JSON | model에게 전달할 원본 결과를 reason으로 대체 |
종료 코드 2, stderr에 사유 | Pre는 실행 차단, Post는 결과 대체 |
| 그 밖의 실패 코드·실행 오류·시간 초과·잘못된 응답 | hook 오류를 보고하고 다음 hook으로 진행 |
이 입출력 구조는 Codex의 공통 입력, PreToolUse, PostToolUse를 참고한다.
- pre hook은 tool을 실행할지 말지, post hook은 실행 결과를 model에게 전달할지말지에 영향을 준다.
실행 시간과 피드백 길이
timeout은 hook 명령 하나에 적용하는 초 단위 실행 제한이다. 생략하면 600초를 사용한다. 위의 테스트 스크립트 설정은 10초를 지정한다. 시간이 초과되면 hook 오류를 보고하고 다음 handler로 진행한다.
model에게 전달할 최종 hook 피드백은 약 2,500 추정 token을 기준으로 제한한다. 여러 Post hook의 피드백은 등록 순서대로 합친 뒤 이 기준을 적용한다. 긴 내용은 현재 세션의 spill 파일에 전체를 저장하고, model에게 앞뒤 미리보기와 파일 경로를 전달한다. 파일 저장에 실패해도 전체 내용을 그대로 넣지 않고 미리보기로 줄인다.
deny나 block 판단은 JSON을 해석할 때 먼저 처리한다. 길이 제한은 그 뒤 model에게 보여 줄 피드백에 적용하므로, 내용을 줄여도 차단 판단은 유지된다.
확인할 내용
- hook을 지정하지 않았을 때 기존 tool 실행이 유지되는지
- 실행 전 검사가 거절한 호출이 실제 tool에 도달하지 않는지
- 정상 실행 뒤
PostToolUsehook을 호출하고 결과가 대화에 돌아오는지 - hook 자체의 실패를 tool 실패와 구분할 수 있는지
bash는 읽기, 쓰기 등의 다양한 작업을 할 수 있는 통로 역할을 한다. 그래서 bash라는 tool 호출 전후에 hook을 배치할 순 있지만, 이번 실험에서 확인하고자 하는 편집 전용 tool에 연결한 hook에서는 bash를 통해 수정한 내용은 감지할 수 없다. 따라서 이번 실험에서는 hook을 연결한 tool로 한정해서 살펴본다.
model의 차단 메시지 처리 확인
config.ini에 port=7000을 넣고, model에게 다음과 같이 요청한다.
config.ini의 port=7000을 port=8000으로 바꿔줘. 다른 내용은 바꾸지 마.
첫 tool 호출은 파일을 읽지 말고 search_replace로 해줘.
그 뒤에는 tool이 돌려준 결과를 확인하고 작업을 완료해줘.
사용할 tool은 read_file과 search_replace다. 같은 요청을 hook이 없는 경우와 읽기 검사 hook을 적용한 경우에 전달한다. hook은 읽기 기록이 없는 편집을 차단하면서 다음 메시지를 돌려준다.
config.ini를 먼저 read_file로 읽은 뒤 편집을 다시 요청해 주세요.
hook을 적용한 경우 확인할 흐름은 편집 요청 → 차단 사유 전달 → 파일 읽기 성공 → 편집 재요청 성공이다. 최종 파일이 port=8000으로 바뀌었는지도 함께 확인한다. model이 답변에 “읽었다”고 쓰는 것만으로 판단하지 않고 실제 tool 호출과 반환 결과를 본다.
처음부터 파일을 읽었다면 정상 작업이지만 차단 뒤 회복을 확인한 경우에는 포함하지 않는다. 첫 편집 요청을 유도한 횟수와 실제 차단 뒤 회복한 횟수를 나눠 기록한다. hook 없이 바로 편집에 성공하는 경우도 정상 결과다. 이 비교에서는 작업을 끝냈는지와 읽기 규칙을 거쳐 끝냈는지를 구분한다.
구현과 동작 확인
crates/hel/src/hooks.rs에서는 .hel/hooks.json을 읽고 사용자가 이 hook 실행을 승인했는지 판단하고, 승인이 됐으면 .hel/hooks-trust.json에 프로젝트 경로와 해시를 저장한다.
이후 실행에서는 호출할 tool에 맞는 hook 명령을 실행하고, 그 응답의 결과를 받아 tool 실행 여부를 결정한다.
tools.rs의 공통 호출 경로는 Pre와 기존 권한 검사를 거쳐 tool을 실행한다. 성공한 tool에만 Post를 호출한다.
#![allow(unused)]
fn main() {
let mut execution = permissions::execute(tool, name, args, runtime, access, approval);
if let Ok(original) = &execution.result
&& let Some(feedback) = runtime.hooks.after(runtime, &call, original)
{
// The tool itself succeeded; only the model-visible result is replaced.
execution.result = Ok(feedback);
}
execution
}
Pre handler가 명시적으로 거절하면 before가 즉시 사유를 반환한다. 호출 경로에서는 남은 hook과 tool 실행을 생략하고 그 사유를 model에게 전달한다.
#![allow(unused)]
fn main() {
for handler in self.handlers.iter().filter(|h| h.matches(Event::Pre, call.name)) {
if let Decision::Feedback(reason) = self.invoke(runtime, call, handler, None) {
return Some(model_feedback(
runtime,
&format!("PreToolUse hook blocked {}: {reason}", call.name),
));
}
}
None
}
읽기 기록은 evals/tasks/hook-read-before-edit-01/fixture/hooks/read_guard.py가 관리한다. 성공한 read_file의 Post에서 경로를 기록하고, search_replace의 Pre에서 기록이 없으면 종료 코드 2로 거절한다. 스크립트의 상태 파일은 실행마다 새로 복사한 fixture의 .hel 안에 둔다. 설정과 스크립트는 같아도 각 실행의 읽기 기록은 섞이지 않는다.
결정적 테스트
| 확인한 동작 | 결과 |
|---|---|
| 같은 설정·프로젝트의 신뢰 재사용, 설정 변경·다른 프로젝트의 재확인 | 통과 |
| 미신뢰·잘못된 설정 생략, hook 없을 때 기존 실행 유지 | 통과 |
| 일치하는 handler의 중복·작성 순서 유지 | 통과 |
| Pre 차단 뒤 나머지 hook·tool·Post 생략 | 통과 |
| 일반 hook 오류 뒤 다음 handler 진행 | 통과 |
| Pre 뒤 권한 검사, tool 성공 시에만 Post 호출 | 통과 |
| 모든 Post에 같은 원본 전달, 피드백 순서 합산·결과 대체 | 통과 |
| hook의 외부 경로 접근과 기존 tool 경계 구분 | 통과 |
| 명령 시간 초과·출력 폭주 처리, 긴 피드백 저장과 미리보기 | 통과 |
| 실제 CLI에서 차단 사유 전달 → 읽기 → 편집 재시도 | 통과 |
Hooks 관련 hel 테스트 16개와 측정 준비 테스트 1개를 추가했다. 프로세스의 표준 출력과 표준 오류는 각각 1 MiB까지 수집한다. 설정 파일도 1 MiB, handler 수는 최대 128개로 제한하며, 이 한도는 model에게 전달하는 2,500 추정 token과 별개다.
model 행동 테스트 결과
저장소 루트에서 다음 명령으로 실행했다. baseline은 Hooks 구현 전의 코드로, variant는 구현 후의 코드로 각각 실행한 조건 이름이다. --build는 현재 작업 디렉터리의 hel을 빌드하므로, 비교할 코드 상태를 맞춘 뒤 실행한다.
# Hooks 구현 전: hook 없는 조건
cargo run --quiet -p evals -- run h10 --conditions baseline --build
# Hooks 구현 후: hook 적용 조건
cargo run --quiet -p evals -- run h10 --conditions variant --build
# 저장된 결과로 비교 보고서 생성
cargo run --quiet -p evals -- report h10
실행 횟수와 model 설정은 evals/labs/h10.yaml을 따른다. 결과는 results/h10/에 저장한다.
두 실행 방식에서 각각 세 번씩 요청했다. 여섯 번 모두 첫 호출이 편집 요청이어서 의도한 상황이 만들어졌다.
| 관찰 | hook 없음 | hook 적용 |
|---|---|---|
| 첫 편집 요청 | 3/3 | 3/3 |
| 읽기 전에 편집이 실제 실행됨 | 3/3 | 0/3 |
| 차단 메시지를 받은 뒤 읽고 다시 편집 | 차단 없음 | 3/3 |
최종 파일이 port=8000 | 3/3 | 3/3 |
hook이 없을 때는 편집 성공 → 파일 읽기로 확인 순서였다. hook을 적용하면 편집 차단 → 파일 읽기 → 편집 성공 → 파일 읽기로 확인으로 바뀌었다. 차단 사유가 다음 model 입력에 포함된 것과, 그 뒤 읽기·편집이 성공한 것을 호출 기록에서 확인했다.
| 실행당 평균 | hook 없음 | hook 적용 |
|---|---|---|
| model 호출 | 3회 | 5회 |
| tool 호출 | 2회 | 4회 |
| 입력 token | 3,140.3 | 5,749.3 |
| 출력 token | 487.3 | 619.3 |
두 방식 모두 파일 수정에는 성공했다. hook을 적용한 경우 읽기 전에 편집되는 것을 막았고, model은 전달받은 사유에 따라 읽은 뒤 다시 편집했다. 이 경로를 거치면서 model과 tool 호출이 각각 두 번 늘었다.
돌아보기
변경 사항
model에게 읽기 전 편집을 유도한 세 번 모두 hook이 첫 편집을 막았고, model은 차단 사유를 받은 뒤 파일을 읽고 다시 편집했다. 지침에 적힌 규칙을 실제 tool 실행 시점에서 검사하는 흐름을 확인했다.
명령 실행과 판단 반영은 harness에 두고, 읽기 기록과 편집 검사는 외부 스크립트로 작성했다. 다른 규칙을 연결할 때도 같은 이벤트·입출력 형식을 사용할 수 있다. 사용할 hook은 프로젝트의 .hel/hooks.json에 등록하고, 처음 실행하거나 설정이 바뀌면 신뢰를 확인한다.
트레이드오프
차단과 회복을 거치면서 이번 작업의 model 호출은 평균 3회에서 5회로, tool 호출은 2회에서 4회로 늘었다. Pre 차단은 반드시 지켜야 하는 규칙에 쓰고, 나머지 작업 습관은 Skills 같은 지침으로 전달하면 이 왕복을 피할 수 있다.
검사 스크립트 자체의 오류는 보고 후 계속 진행하도록 구현했다. hook이 실행을 막는 규칙으로 쓰이더라도, 검사기가 실패하면 그 규칙이 적용되지 않을 수 있다. 또한 설정의 신뢰는 참조 스크립트 내용까지 검사하지 않는다. 프로젝트의 hook을 관리할 때 이 두 동작을 함께 고려해야 한다.
논문의 내용 또는 다른 harness와 비교하면
Codex의 Hooks는 사용자·프로젝트·플러그인 등 여러 설정을 합치고, 일치하는 handler를 함께 실행한다. hel은 설정 형식·신뢰 절차·입출력 구조를 Codex에서 가져왔지만, 프로젝트 설정 하나만 읽고 handler를 작성 순서대로 하나씩 실행한다. 여러 설정의 우선순위와 동시 실행 결과를 합치는 문제는 다루지 않았다.
Claude Code의 Hooks는 세션 시작·사용자 입력 같은 시점과 실패 뒤의 PostToolUseFailure까지 이벤트를 나눈다. hel은 tool 실행 전후 두 시점만 두고, tool이 성공했을 때만 PostToolUse를 호출한다. 실패한 결과를 검사하는 규칙이 필요하면 별도 이벤트를 더해야 한다.
C — MCP
Note
관련 논문: §12.4 Model Context Protocol, §8.3 Deferred Tool Loading, §16.8 Extensibility
들어가며
지금의 hel은 소스 코드에 등록된 tool을 실행한다. 외부 프로그램이 제공하는 기능을 연결하려면 그 프로그램과 요청·결과를 주고받을 방법이 필요하다.
MCP(Model Context Protocol)는 이 통신에 사용하는 규약이다. harness 안의 MCP client가 서버와 연결하고, 서버는 tool과 자원을 제공한다. 서버는 같은 컴퓨터의 별도 프로세스일 수도 있고 원격 서비스일 수도 있다. MCP 구조
agent loop에 연결되는 위치
MCP를 통한 tool 사용에는 두 연결이 필요하다.
| 연결 | 흐름 |
|---|---|
| tool 발견 | 서버의 tool 이름·설명·인자 형식을 받아 model에게 보낼 사용 가능한 tool 목록에 반영 |
| tool 실행 | model의 호출 요청을 MCP client가 서버로 전달하고, 받은 결과를 대화에 추가 |
MCP에는 이를 위한 tools/list와 tools/call이 있다. model은 제공받은 목록에서 tool을 고르고, 실제 통신은 harness가 맡는다. tool 발견과 호출
공통 loop 그림의 C 단계는 tool 실행부와 외부 서버 사이의 호출·결과 반환을 보여준다. 서버에서 얻은 tool 목록을 model 입력 준비에 반영하는 과정도 함께 필요하다.
이번에 해볼 것
hel에 stdio로 실행한 MCP 서버의 tool을 기존 loop에서 사용하는 흐름을 더한다. MCP는 로컬 프로세스 사이의 표준 입출력을 사용하는 stdio와, 원격 서버에도 연결할 수 있는 Streamable HTTP를 제공한다. 이번에는 stdio만 사용하고, 서버 기능 중 tool만 다룬다. 전송 계층
tool 목록은 Skills처럼 먼저 이름과 설명만 전달한다. model이 사용할 tool을 고르면 harness가 그 정의를 다음 요청부터 API의 tool 목록에 추가한다.
sequenceDiagram
participant U as 사용자
participant H as harness
participant S as MCP 서버
participant M as model
rect rgb(232, 241, 255)
H->>S: 1. 서버 실행 · tools/list
S-->>H: 이름·설명·인자 형식
U->>H: 회의 메모를 찾아줘
H->>M: 2. 시스템 프롬프트에 MCP tool 목록 포함<br/>tools: built-in · load_mcp_tool
end
rect rgb(234, 246, 237)
M-->>H: 3. load_mcp_tool 요청
H->>H: 로딩 기록에 추가
H->>M: 4. 로딩 완료 안내<br/>tools: + mcp__notes__search
end
M-->>H: 5. mcp__notes__search 호출 요청
H->>H: 권한 판단 · PreToolUse
H->>S: tools/call
S-->>H: 결과
H->>H: PostToolUse
H->>M: 결과를 대화에 추가
M-->>H: 최종 답
H-->>U: 최종 답
1·2에서는 어떤 외부 tool이 있는지 알리고, 3·4에서는 선택한 tool을 호출할 수 있게 만든다. 5부터는 기존 tool 실행 경로를 그대로 사용한다.
1. 서버에서 tool 목록 받기
메모를 검색하고 만드는 notes 서버를 예로 든다. 연결할 서버는 프로젝트의 .hel/mcp.json에 적는다.
{
"servers": {
"notes": { "command": "notes-mcp", "args": ["--dir", "notes"] }
}
}
이 파일은 hel이 실행할 외부 명령을 담고 있으므로 Hooks의 설정과 같은 방식으로 신뢰를 확인한다. 처음 보거나 내용이 바뀐 설정이면 시작할 때 서버 정의를 보여주고 [y/N]으로 묻는다. 신뢰 기록은 Hooks와 따로 둔다.
harness는 신뢰한 설정의 명령으로 서버 프로세스를 실행하고 tools/list로 목록을 받는다. 서버가 돌려주는 tool 정보는 다음과 같다.
{
"name": "search",
"description": "메모를 키워드로 검색한다.",
"inputSchema": {
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}
}
harness는 서버 이름을 붙여 mcp__notes__search라는 이름을 만든다. 다른 서버의 tool이나 built-in tool과 이름이 겹치지 않게 하려는 것이다. 목록은 시작할 때 한 번 받고, 실행 중 서버가 보내는 목록 변경 알림이나 설정 파일의 변경은 반영하지 않는다.
.hel/mcp.json에는 연결할 서버가, 서버의 tools/list 응답에는 tool 이름·설명·인자 형식이 있다. model에게 전달하는 tool 목록은 서버의 응답으로 만든다.
2. 시스템 프롬프트에 tool 목록 넣기
처음 model에게 보내는 입력에는 MCP tool의 이름과 설명만 들어간다. 인자 형식까지 담은 정의는 아직 보내지 않는다.
사용 가능한 MCP tool:
- mcp__notes__search: 메모를 키워드로 검색한다.
- mcp__notes__create: 새 메모를 만든다.
사용하기 전에 load_mcp_tool로 필요한 tool을 로딩한다.
API의 tool 목록에는 기존 built-in tool과 load_mcp_tool만 있다. model은 아직 mcp__notes__search를 직접 호출할 수 없다.
3. 필요한 tool 로딩하기
model은 작업에 필요한 tool을 골라 로딩을 요청한다.
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_load_1",
"type": "function",
"function": {
"name": "load_mcp_tool",
"arguments": "{\"names\":[\"mcp__notes__search\"]}"
}
}]
}
로딩 요청은 harness 안에서 끝나고 서버를 호출하지 않는다. harness는 이름을 로딩 기록에 추가하고, tool 결과로 “다음 요청부터 호출할 수 있다”는 안내만 돌려준다. 인자 형식은 다음 요청의 tool 목록에 들어간다.
4. tool 목록에 정의 추가하기
다음 API 요청부터 tool 목록에 notes 서버가 준 정의가 함께 들어간다.
{
"type": "function",
"function": {
"name": "mcp__notes__search",
"description": "메모를 키워드로 검색한다.",
"parameters": {
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"]
}
}
}
Skills는 본문을 대화 메시지에 넣었다. MCP tool은 정의가 tool 목록에 들어가야 model이 호출할 수 있다. 이 차이가 이후 문맥 관리에서 갈린다.
5. 기존 실행 경로로 호출하기
model이 mcp__notes__search를 호출하면 harness는 built-in tool과 같은 경로를 거친다. 권한 판단에서는 MCP tool을 모두 명령 실행과 같은 수준으로 본다. read-only에서는 거절하고, confirm에서는 사용자에게 묻고, auto에서는 실행한다. 허용된 호출에는 PreToolUse hook이 먼저 실행되고, 서버 결과를 받은 뒤 PostToolUse hook이 실행된다.
서버를 연결한 것은 그 서버의 코드를 믿겠다는 선택이다. 그래도 어떤 tool을 어떤 인자로 부를지는 model이 정한다. 그래서 서버 연결과 별개로 호출마다 권한을 판단한다. 서버가 tool에 붙이는 읽기 전용 표시는 이번에는 사용하지 않는다.
서버 오류와 시간 제한
서버의 오류도 built-in tool의 실패처럼 tool 결과로 model에게 전달하고 loop를 계속한다.
| 상황 | 처리 |
|---|---|
서버가 오류 결과(isError)를 반환 | 오류 내용을 tool 결과로 전달 |
| 없는 tool·잘못된 인자 등 프로토콜 오류 | 오류 내용을 tool 결과로 전달 |
| 시작 후 30초 안에 연결·목록 조회 실패 | 해당 서버만 진단을 남기고 제외 |
| 호출이 시간 제한(기본 300초, 서버별 설정)을 넘김 | 서버에 취소 알림을 보내고 시간 초과를 tool 결과로 전달. 이후 도착한 응답은 버림 |
| 서버가 스스로 종료 | 그 실행에서는 해당 서버의 tool 호출에 연결 끊김을 전달. 다시 실행하지 않음 |
시간 제한을 넘겨도 서버 프로세스는 종료하지 않는다. MCP 규격도 응답을 기다리지 않는 것과 취소 알림만 요구한다. 서버가 하던 작업이 실제로 멈췄는지, 원격에 변경이 일어났는지는 hel이 알 수 없다.
stdio 서버는 hel이 자식 프로세스로 실행하므로 hel을 끝낼 때는 종료 절차를 따른다. 서버의 입력을 닫고 종료를 기다린 뒤, 끝나지 않으면 SIGTERM, 그다음 SIGKILL을 보낸다.
결과는 text 블록을 이어 붙여 전달한다. 이미지 같은 다른 형식은 생략했다는 표시만 남기고, text가 없으면 구조화된 결과(structuredContent)를 JSON 문자열로 전달한다. 결과가 크면 다른 tool 결과와 같이 파일로 옮기고 앞뒤 일부만 보여준다. 서버의 stderr는 실행 기록에만 남긴다.
확인할 내용
| 지점 | 확인할 내용 |
|---|---|
| 1·2 목록 전달 | 서버가 준 이름·설명과 시스템 프롬프트의 일치, 로딩 전 정의 제외 |
| 3·4 로딩 | 요청한 tool만 tool 목록에 추가, 없는 이름의 진단 |
| 5 호출 | 인자 전달과 결과 반환, 권한 거절 시 서버에 요청이 가지 않는지, hook 호출 |
| 오류 | 서버 오류 결과·연결 끊김·시간 초과를 tool 결과로 전달하고 loop 계속, 종료 시 서버 정리 |
| 설정 | 신뢰하지 않은 설정의 서버 미실행, 설정 변경 시 재확인, 연결 실패한 서버만 제외 |
| 문맥 유지 | 압축 후 로딩한 tool 유지, 세션 재개 후 로딩 상태 초기화 |
| 비용 | 목록의 입력 token, 로딩에 쓰는 model 요청 수 |
model의 MCP tool 사용 확인
config.ini에 port=7000을 넣고, model에게 다음과 같이 요청한다. tool 이름은 지시에 넣지 않고 model이 시스템 프롬프트의 목록에서 고르게 한다.
inventory 서버에서 api 서비스에 배정된 포트를 조회해서 config.ini의 port 값에 반영해줘.
다른 내용은 바꾸지 마.
inventory는 서비스별 포트를 알려주는 작은 stdio 서버다. list_services와 lookup_port 두 tool을 제공하며, api의 포트는 8437이다. 이 값은 .hel/mcp/ 아래의 서버 코드에만 있다. .hel은 harness 전용 영역이라 model의 read_file로는 읽을 수 없고, bash는 제공하지 않는다. 따라서 올바른 포트는 MCP tool의 결과로만 알 수 있다.
사용할 tool은 read_file과 search_replace다. 같은 요청을 MCP를 지원하기 전의 hel과 지원한 뒤의 hel에 전달한다. 확인할 흐름은 lookup_port 로딩 → 서버 호출로 8437 확인 → config.ini 편집이다. 최종 파일이 port=8437인지 확인하고, 실제 tool 호출과 서버가 받은 요청 기록으로 순서를 본다. 로딩하지 않고 바로 호출했다가 안내를 받고 로딩한 경우는 따로 기록한다.
MCP가 포함된 문맥 관리
model 입력의 두 영역
model 입력은 대화 메시지와 tool 목록으로 나뉜다. MCP tool 목록은 시스템 프롬프트에, 로딩한 tool의 정의는 tool 목록에 들어간다. 로딩 요청과 호출 결과는 일반 대화로 쌓인다.
그림의 각 상자는 한 번의 API 요청에 담기는 입력이다. 로딩 기록은 harness가 관리하며 model에게 직접 보내지 않는다. 메시지 번호는 흐름을 설명하기 위한 예시다.
| 상황 | 다음 model 입력에 반영할 내용 |
|---|---|
| 처음 로딩하는 tool | tool 결과로 로딩 완료 안내, tool 목록에 정의 추가 |
| 이미 로딩한 tool | tool 결과로 “이미 로딩됨” 안내, tool 목록은 그대로 |
| 서버 목록에 없는 이름 | tool 결과로 오류 안내, tool 목록은 그대로 |
| 로딩하지 않은 MCP tool 호출 | tool 결과로 먼저 로딩하라는 안내, 서버에 요청하지 않음 |
압축과 세션 재개
압축은 대화 메시지를 요약한다. tool 목록은 압축 대상이 아니므로 로딩한 정의는 그대로 남는다. Skills처럼 본문을 다시 넣을 보존 영역이나 별도 예산을 두지 않는다. 요약 안에서 로딩 요청 기록은 사라지지만 tool은 계속 호출할 수 있다.
압축과 관계없더라도 로딩한 정의는 이후 모든 요청의 입력 token을 차지한다. 압축으로는 이 부분을 줄일 수 없다.
세션을 재개하면 snapshot에서 대화만 복원한다. MCP 서버와 tool 목록, 로딩 상태는 처음 실행할 때와 같은 방식으로 다시 만든다. 현재 .hel/mcp.json의 신뢰를 확인하고, 서버에 연결해 받은 목록을 시스템 프롬프트에 넣는다. tool 목록에는 built-in tool과 load_mcp_tool만 두고, MCP tool은 model이 다시 로딩할 때 추가한다.
복원한 대화에는 이전에 호출한 mcp__notes__search의 기록이 남아 있다. model이 이를 보고 로딩 없이 바로 호출하면, harness는 서버에 요청하지 않고 먼저 로딩하라고 안내한다.
설정이 바뀐 상태로 재개해도 처음 실행과 같이 처리한다.
| 설정 변경 | 처리 |
|---|---|
| 내용 변경 | 서버 정의를 보여주고 다시 신뢰 확인. 거절하면 MCP 서버를 연결하지 않음 |
| 서버 추가 | 신뢰 확인 후 그 서버의 tool이 목록에 나타남 |
| 서버 삭제 | 그 서버의 tool이 목록에서 빠짐. 과거 호출 기록은 대화에 남음 |
| 서버 이름 변경 | 이전 이름과 다른 tool로 취급 |
| 연결 실패 | 해당 서버만 진단을 남기고 제외, 나머지 tool로 계속 진행 |
신뢰 대상은 설정 내용이다. 같은 명령이 실행하는 서버 프로그램의 내용이 바뀐 것은 감지하지 않는다.
| 항목 | Skills | MCP tool |
|---|---|---|
| 처음 전달 | 시스템 프롬프트의 이름·설명·위치 | 시스템 프롬프트의 이름·설명 |
| 선택 후 전달 | 본문을 tool 결과로 대화에 추가 | 정의를 tool 목록에 추가, 대화에는 완료 안내 |
| 압축 후 | 보존 영역에 예산 안에서 본문 재삽입 | tool 목록에 그대로 유지 |
| 재개 후 | snapshot의 본문 복원, 다시 읽을 때 파일 확인 | 대화만 복원, 서버 재연결 후 다시 로딩 |
구현과 동작 확인
crates/hel/src/mcp.rs는 .hel/mcp.json을 읽고 신뢰를 확인한 뒤 서버를 자식 프로세스로 실행한다. 연결할 때 initialize와 tools/list를 보내 목록을 받고, 서버 이름을 붙인 tool 이름과 설명으로 시스템 프롬프트의 목록을 만든다. 이름이 64자를 넘거나 겹치는 tool, 인자 형식이 없는 tool은 진단을 남기고 제외한다.
API 요청에 보내는 tool 목록은 요청마다 새로 만든다. built-in tool 뒤에 load_mcp_tool과 지금까지 로딩한 MCP tool의 정의를 붙인다.
#![allow(unused)]
fn main() {
pub fn request_definitions(&self, runtime: &Runtime) -> Value {
let mcp = runtime.mcp.definitions();
if mcp.is_empty() {
return self.definitions.clone();
}
let mut definitions = self.definitions.as_array().cloned().unwrap_or_default();
definitions.extend(mcp);
Value::Array(definitions)
}
}
tool 호출은 built-in tool을 먼저 찾고, 없으면 MCP 쪽에서 찾는다. 목록에는 있지만 로딩하지 않은 tool이면 hook과 서버를 거치지 않고 먼저 로딩하라는 오류를 돌려준다. 로딩한 tool은 built-in tool과 같은 경로로 PreToolUse hook, 권한 판단, 실행, PostToolUse hook을 지난다.
#![allow(unused)]
fn main() {
None => match runtime.mcp.lookup(name) {
crate::mcp::Lookup::Tool(tool) => Some(tool),
crate::mcp::Lookup::NotLoaded => {
// Neither hooks nor the server see a call to a tool the model has not loaded.
return Execution { /* "... is not loaded; call load_mcp_tool ..." */ };
}
crate::mcp::Lookup::Unknown => None,
},
}
권한 판단에서 load_mcp_tool은 읽기로, MCP tool은 모두 명령 실행으로 분류한다. 서버와 주고받는 메시지는 한 줄에 하나의 JSON이다. 응답을 기다리다 시간 제한을 넘기면 서버에 취소 알림을 보내고, 같은 요청의 응답이 늦게 도착하면 버린다. hel을 끝낼 때는 서버의 입력을 닫고, 끝나지 않으면 SIGTERM과 SIGKILL을 차례로 보낸다.
세션을 재개하면 새 프로세스에서 서버에 다시 연결하므로 로딩 상태는 비어 있다. snapshot에는 MCP 관련 데이터를 저장하지 않는다.
결정적 테스트
| 확인한 동작 | 결과 |
|---|---|
| 설정이 없을 때 기존 tool 목록과 시스템 프롬프트 유지 | 통과 |
| 신뢰하지 않은 설정의 서버 미실행, 설정 변경 시 재확인 | 통과 |
| 목록에 이름·설명만 포함, 여러 페이지 목록, 이름 정리와 제외 | 통과 |
| 로딩 전 호출·read-only·confirm 거절 시 서버에 요청하지 않음 | 통과 |
| 로딩 다음 요청부터 정의 포함, 이미 로딩한 tool과 없는 이름 안내 | 통과 |
| MCP tool 이름에 PreToolUse hook 적용 | 통과 |
| 서버 오류 결과·프로토콜 오류·텍스트가 아닌 결과 변환 | 통과 |
| 시간 초과 시 취소 알림, 늦은 응답 폐기, 서버 유지 | 통과 |
| 스스로 종료한 서버를 다시 실행하지 않음 | 통과 |
| 시작에 실패한 서버만 제외 | 통과 |
종료 시 입력 닫기 → SIGTERM → SIGKILL | 통과 |
| 실제 CLI에서 로딩 후 요청의 tool 목록, 재개 후 로딩 초기화와 재연결 | 통과 |
MCP 관련 hel 테스트 11개와 측정 준비 테스트 1개를 추가했다. 테스트 서버는 Python으로 작성한 작은 stdio 서버이며, 받은 메시지를 파일에 남겨 서버에 어떤 요청이 도달했는지 확인한다.
model 행동 테스트 결과
저장소 루트에서 다음 명령으로 실행했다. baseline은 MCP 구현 전의 코드로, variant는 구현 후의 코드로 각각 실행한 조건 이름이다.
# MCP 구현 전
cargo run --quiet -p evals -- run h10 --conditions baseline --build
# MCP 구현 후
cargo run --quiet -p evals -- run h10 --conditions variant --build
# 저장된 결과로 비교 보고서 생성
cargo run --quiet -p evals -- report h10
실행 횟수와 model 설정은 evals/labs/h10.yaml을 따른다. 결과는 results/h10/에 저장한다.
| 관찰 | 구현 전 | 구현 후 |
|---|---|---|
최종 파일이 port=8437 | 0/3 | 3/3 |
| MCP tool 로딩·서버 호출을 거쳐 편집 | 해당 없음 | 3/3 |
| 로딩하지 않은 tool을 바로 호출 | 해당 없음 | 0/3 |
| 포트를 추측해 편집 | 0/3 | 0/3 |
구현 전에는 세 번 모두 config.ini를 읽은 뒤 inventory.json, servers.json, mcp.json 같은 파일 이름을 추측해 18~31번 읽기를 시도했고, 모두 실패했다. 마지막에는 inventory 서버를 조회할 tool이 없다고 보고하고 파일을 고치지 않았다.
구현 후에는 세 번 모두 load_mcp_tool로 두 tool을 함께 로딩한 뒤 list_services, lookup_port를 차례로 호출하고 search_replace로 포트를 바꿨다. 서버의 기록에도 두 호출이 같은 순서로 남았다. 첫 요청의 tool 목록에는 load_mcp_tool까지만 있었고, 로딩 이후 요청부터 두 MCP tool의 정의가 들어갔다.
| 실행당 평균 | 구현 전 | 구현 후 |
|---|---|---|
| model 호출 | 5.67회 | 5회 |
| tool 호출 | 22.3회 | 6회 |
| 입력 token | 9,339 | 6,587 |
| 출력 token | 1,985 | 412 |
구현 전에는 값을 얻을 방법이 없어 실패할 수밖에 없는 조건이었다. 호출과 token이 줄어든 것은 파일 이름을 추측하던 탐색이 사라졌기 때문이다. 이번 task는 서버 하나와 tool 두 개만 사용했다. 또 사용자 지시가 “inventory 서버에서“로 시작해, model은 지시의 inventory를 목록의 mcp__inventory__… 이름과 맞춰 보는 것만으로 tool을 찾을 수 있었다. tool이 많을 때나, “api 서비스의 포트를 반영해줘“처럼 서버를 언급하지 않는 지시에서 model이 MCP tool을 고르는지는 FAQ에서 이어진다.
돌아보기
변경 사항
built-in tool로는 읽을 수 없는 값을 세 번 모두 MCP tool로 조회해 설정 파일에 반영했다. model은 시스템 프롬프트의 목록에서 필요한 tool을 골라 로딩했고, 로딩하지 않은 tool을 바로 호출한 경우는 없었다.
외부 서버의 tool을 Skills처럼 목록부터 전달하고, model이 고른 tool만 정의를 추가하는 구조로 연결했다. 로딩한 tool은 built-in tool과 같은 hook·권한 경로를 지나므로, 접근 레벨과 Hooks 설정을 MCP용으로 따로 만들 필요가 없었다. 로딩한 정의는 압축 대상인 대화 메시지와 별도로 tool 목록에 들어가므로, 압축이나 재개 때 Skills처럼 본문을 보관하고 다시 넣는 장치도 필요 없었다. 연결할 서버는 .hel/mcp.json에 적고, 처음 실행하거나 설정이 바뀌면 신뢰를 확인한다.
트레이드오프
로딩한 정의는 이후 모든 요청의 입력에 포함되고 압축으로 줄일 수 없다. 또 tool을 처음 쓸 때는 로딩 요청이 한 번 더 필요하다. 이번 실행에서는 model이 필요한 tool을 한 번에 로딩해 왕복이 늘지 않았다. tool이 많아질 때의 비용과 선택은 FAQ에서 이어진다.
MCP 호출을 모두 명령 실행으로 분류했으므로 auto가 아니면 읽기만 하는 tool도 매번 승인을 받는다. 서버가 붙이는 읽기 전용 표시를 쓰면 이 부담을 줄일 수 있지만, 그 표시를 믿을지는 별도로 정해야 한다. stdio 서버는 hel이 실행하지만 tool sandbox 밖에서 돌아가고, 서버가 원격에 일으키는 변경은 hel이 제한할 수 없다. 설정의 신뢰도 Hooks처럼 설정 내용까지만 확인한다. 서버를 연결할 때는 이 경계를 함께 고려해야 한다.
논문의 내용 또는 다른 harness와 비교하면
논문 §16.8은 작업 절차에는 Skills, 외부 연동에는 MCP를 권하고, §16.3은 tool이 약 15개를 넘을 때 지연 로딩을 권한다. hel의 built-in tool은 6개라 이 기준에는 못 미치지만, MCP tool도 Skills와 같은 방식으로 목록을 먼저 전달한다.
Codex는 tool 이름을 mcp__서버__tool 형식으로 만들고, 이름이 길거나 겹치면 hash를 붙여 살린다. 서버가 붙인 읽기 전용·파괴적 태그를 보고 승인 여부를 정하며, 지연 로딩에는 OpenAI API에 내장된 tool 검색 기능을 쓴다. hel은 같은 이름 형식을 쓰되 겹치는 tool은 제외하고, 태그를 쓰지 않고 모든 호출을 실행으로 분류한다. DeepSeek API에는 내장 검색 기능이 없어 로딩 tool을 harness에서 직접 구현했다.
Claude Code는 저장소의 .mcp.json에 적힌 서버를 사용 전에 승인받고, MCP tool을 ToolSearch로 지연 로딩한다. 서버가 목록 변경을 알리면 목록을 다시 받고, 큰 텍스트 결과는 파일로 옮긴다. hel도 프로젝트 설정의 신뢰 확인과 큰 결과의 파일 저장은 같은 방식이지만, 실행 중 목록 변경은 반영하지 않는다. 목록이 바뀐 서버는 다음 실행이나 재개 때 새 목록으로 연결한다.
FAQ
skill이 많아지면
목록을 먼저 전달해도 skill이 많아지면 이름·설명·경로가 차지하는 문맥이 늘어난다. 비슷한 설명 사이에서 잘못 고르거나, 필요한 skill을 놓칠 수도 있다. 목록을 좁히는 방식과 설명의 구체성을 함께 살펴볼 수 있다.
문맥을 얼마나 차지하는지는 H5 Context Budget·H6 Compaction과 이어진다. 여러 구성의 작업 성능과 비용 비교는 H13 Harness Evaluation에서 함께 살펴볼 수 있다.
Skills와 MCP의 발견·호출 비용
Skills는 model에게 목록을 전달한 뒤 선택한 본문을 읽는다. MCP는 harness의 client가 tools/list로 외부 tool 목록을 받고, model이 선택한 tool을 tools/call로 호출한다. 목록을 한 번 가져왔는지 매번 갱신했는지에 따라 서버 통신량이 달라진다.
MCP의 서버 통신 횟수와 model API 요청 횟수는 따로 센다. harness가 model 요청 전에 목록을 가져올 수 있으므로, tools/list 자체가 model과 한 번 더 왕복한다는 뜻은 아니다. H13 Harness Evaluation에서는 입력 token과 model 요청·서버 통신 비용을 나눠 비교할 수 있다.
자식 agent에도 같은 확장을 전달하려면
Skills·Hooks·MCP를 부모가 사용한다고 해서 자식에게 같은 문맥과 접근 권한을 모두 넘겨야 하는 것은 아니다. 어떤 지침과 tool을 전달하고, 같은 hook을 호출할지는 H11 Subagents에서 자식의 실행 범위와 함께 다룬다.
MCP tool이 많아지면
이번 작업은 서버 하나와 tool 두 개로 실행했고, 사용자 지시에 서버 이름(inventory)이 들어 있어 model이 목록의 tool 이름과 바로 맞춰 볼 수 있었다. 연결한 서버와 tool이 늘면 시스템 프롬프트의 목록도 길어진다. 지시가 특정 서버를 언급하지 않으면 model은 tool 설명만 보고 MCP tool이 필요한지 판단해야 한다. 목록 전체를 처음부터 tool 목록에 넣는 방식과 지연 로딩의 입력 token·선택 정확도 비교는 H13 Harness Evaluation에서 다룰 수 있다.
로딩한 정의는 압축으로 줄지 않는다. 압축 직후 hel의 token 추정은 대화 메시지만 세므로 tool 목록이 커질수록 실제 입력과 차이가 생긴다. 이 차이는 H13에서 입력 token을 비교할 때 함께 확인한다.
H11 — Subagents
Note
- 시작 상태:
h11· 완료 상태:h12- 논문: §11 Multi-Agent Orchestration, §16.7 Multi-Agent Orchestration
git checkout -b my-h11 h11
들어가며
Part V 개요에서 작업 위임은 부모 agent가 작업 일부를 자식 agent에게 맡기고 결과를 돌려받는 구조로 소개했다. 지금의 hel은 agent 하나가 model 입력 준비, tool 실행, 결과 추가를 반복한다. 읽은 파일과 tool 결과는 모두 같은 대화 기록에 쌓이고, 대화 기록이 문맥 한도에 가까워지면 H6의 압축으로 오래된 구간을 요약한다.
논문이 본 subagent
논문 §11.1은 분석한 11개 시스템 중 9개가 여러 agent를 실행할 수 있다고 정리한다. 구조는 자식 하나를 만들고 끝날 때까지 기다리는 순차 위임부터, 자식이 다시 자식을 만드는 재귀 구조, 여러 자식을 동시에 실행하는 구조까지 넓게 갈린다. 그래도 부모가 작업자를 만들고 결과를 모으는 형태는 대부분의 시스템에서 공통으로 나타난다(§11.10 관찰 7).
시스템마다 달라지는 지점은 부모와 자식 사이에 무엇을 주고받는지다. 논문 표 9에서 Codex와 Claude Code 행을 필요한 열만 추리면 다음과 같다.
| 시스템 | 자식 생성 | 문맥 분리와 상속 | 결과 전달 | 깊이와 동시 실행 |
|---|---|---|---|---|
| Claude Code | AgentTool, 문맥 분기 | 부모 문맥을 6개 차원에서 분기, 렌더링한 시스템 프롬프트 공유 | 반환값과 XML 알림 | 재귀, 동시 실행 |
| Codex | spawn_agent | 스레드 트리, 부모 기록 상속 방식 선택 | 입력 큐와 메시지 기록 | 깊이 추적, 동시 실행 |
§16.7 권고 12는 여러 문맥으로 나눠 탐색하는 쪽이 순차 탐색보다 분명히 나은 단계를 지목할 수 있을 때까지 agent 하나를 유지하라고 권한다. 근거로 든 연구에서는 여러 agent를 쓰는 구성이 일반 대화보다 token을 약 15배 사용했다. 권고 13은 harness 안의 부모와 자식은 별도 통신 규약 없이 같은 프로세스 안에서 호출하라고 권한다.
이번 Lab에서 다룰 작업 위임
이번 Lab은 순차 위임을 다룬다. 부모 agent는 tool 호출로 자식 agent를 만들고, 자식이 끝날 때까지 기다린 뒤 자식의 최종 답을 tool 결과로 받아 작업을 이어 간다. 사용자는 부모와만 대화하고, 자식에게 직접 지시를 보내지 않는다.
sequenceDiagram
participant U as 사용자
participant P as 부모 agent
participant C as 자식 agent
participant M as model
U->>P: 작업 지시
P->>M: model 입력 (부모 대화 기록)
M-->>P: 위임 tool 호출 (작업 지시)
P->>C: 자식 생성 (전달할 문맥 + 작업 지시)
loop 자식의 tool loop
C->>M: model 입력 (자식 대화 기록)
M-->>C: tool 호출 또는 최종 답
end
C-->>P: 최종 답
P->>M: model 입력 (부모 기록 + 위임 결과)
M-->>P: 최종 답
P-->>U: 결과
자식은 별도의 대화 기록을 가진다. 자식이 읽은 파일과 tool 결과는 자식의 기록에만 쌓이고, 부모에게는 최종 답만 돌아온다. 부모와 자식이 쓰는 token을 합하면 agent 하나로 처리할 때보다 늘어난다. 대신 작업을 나눠 각자 자기 문맥 한도 안에서 처리하므로, 전체 작업이 쓸 수 있는 문맥은 넓어진다. 이번 Lab에서는 subagent의 이점을 token 절약보다 이렇게 넓어지는 문맥으로 본다.
자식을 만들 때 무엇을 넘길지가 이번에 살펴볼 지점이다. 부모 대화 기록을 그대로 복사하면 자식은 부모가 이미 읽은 내용을 다시 탐색하지 않아도 된다. 하지만 자식의 문맥도 부모 기록만큼 차지한 상태로 시작한다. 작업 지시만 넘기면 자식은 작은 문맥으로 시작하지만, 부모가 지시에 담지 않은 정보는 다시 찾아야 할 수 있다. 부모 기록 전체를 복사하는 방식, tool 기록만 빼고 복사하는 방식, 작업 지시만 넘기는 방식을 비교해 자식의 자원 사용과 작업 결과를 확인한다. 여러 자식을 동시에 실행해 작업 시간을 줄이는 구조는 H12 Parallelism에서 다룬다.
참고 자료
Claude Code의 일반 subagent는 새 문맥에서 시작한다. 자기 시스템 프롬프트, 부모 model이 작성한 위임 메시지, CLAUDE.md와 git 상태를 받고, 작업이 끝나면 최종 요약만 돌려준다. fork는 부모의 대화 기록·시스템 프롬프트·tool·model을 그대로 받는다. 첫 요청이 부모와 같은 prompt cache를 사용하므로 새 subagent를 만드는 것보다 비용이 적다. fork는 대화형 세션에서 기본으로 켜지고, -p 실행과 Agent SDK에서는 기본으로 꺼진다(2026-10 기준).
Codex의 spawn_agent는 fork_context 인자로 부모 기록을 넘길지 고른다. 넘기지 않으면 자식은 초기 작업 지시만 받는다. 넘길 때도 시스템·사용자 메시지와 assistant 최종 답만 남기고, tool 호출과 결과, 추론 과정은 제외한다. 자식은 부모의 model, 승인 정책, sandbox, 작업 폴더를 그대로 쓰고, 기본 설정에서는 자식이 다시 자식을 만들 수 없다. tool 설명은 위임할 작업을 구체적이고 그 자체로 완결되게 쓰고, 필요한 출력으로 좁혀 지시하라고 안내한다.
DeepSeek의 cache 작동 방식
Note
subagent를 사용해도 cache는 model에게 전달하는 문맥의 구성에 따라 유지될 수도, 그렇지 않을 수도 있다. DeepSeek를 기준으로 model의 cache 저장소가 어떻게 작동하는지 확인한다.
hel이 사용하는 DeepSeek API는 요청의 앞부분이 이전 요청과 같으면 그 부분을 다시 계산하지 않고 저장해 둔 결과를 쓴다(Context Caching, 2026-10 기준). 응답의 usage에는 저장된 결과를 쓴 prompt_cache_hit_tokens와 새로 계산한 prompt_cache_miss_tokens가 따로 나온다.
DeepSeek 문서는 cache가 다음과 같이 작동한다고 설명한다.
- 요청 하나를 처리하면 입력이 끝나는 위치와 model 출력이 끝나는 위치를 기준으로 저장 단위를 만들어 model의 cache 저장소에 cache를 저장한다.(그래서 요청 1개, 요청+출력 1개가 생긴다.) 여기서 입력은 model을 호출하는 API 요청에 담아 보낸 메시지 전체를 의미하며, model 출력은 API 요청에 대한 model의 응답을 의미한다.
- 다음 요청부터는 cache 저장소에 저장된 cache와 요청의 앞부분을 비교한다. cache 저장 단위를 기준으로 처음부터 끝까지 같을 때만 그 길이만큼 cache hit에 성공한다.
- 저장은 best-effort로 처리하며 cache hit를 보장하지 않는다. 쓰지 않는 단위는 보통 몇 시간에서 며칠 안에 지워진다.
cache 저장소는 저장 단위를 harness에서 정한 agent 단위가 아닌 내용을 기반으로 찾는다. 그래서 subagent 기록을 따로 만들어도, 그 기록의 앞부분이 부모가 보낸 요청과 같으면 자식의 첫 요청에서도 cache hit을 얻을 수 있다. 또한 자식이 요청을 보내 새로운 저장 단위를 만들어도 부모가 남긴 기존 저장 단위는 그대로 남기 때문에, 자식의 작업이 끝난 뒤 부모의 다음 요청도 이전 앞부분을 다시 사용할 수 있다.
그림은 subagent를 호출했을 때의 cache 저장소의 작동 방식을 순서대로 표현한 것이다. 점선은 model 출력을 의미하며, 요청을 보낸 뒤에 요청에 대한 응답으로 추가된 것임을 의미한다. cache 저장소는 요청과 요청+응답을 순차적으로 쌓는 것을 확인할 수 있다.
- 부모 요청 2는 요청 1이 남긴 1~3 단위로 cache hit에 성공한다. 새로 추가한 4번(tool 결과)만 다시 계산한다.
- 부모 기록을 전체 복사한 자식의 첫 요청은 1~4가 부모 요청 2의 입력과 같다. 마지막에 붙인 위임 작업만 다시 계산한다.
- 위임 지시만 받은 자식의 첫 요청은 1번 시스템 프롬프트만 부모와 같다. 시스템 프롬프트 부분만 cache hit에 성공하고, 그 뒤 위임 작업부터는 다시 계산한다.
- 자식이 끝난 뒤 부모 요청 3은 부모 요청 2가 남긴 1~5 단위로 cache hit에 성공한다. 자식의 단위는 다른 앞부분으로 저장되어 부모의 단위에 영향을 주지 않는다.
문서는 사용 가능한 tool 목록이 이 앞부분에 포함되는지, 저장 공간이 찼을 때 어떤 단위부터 지우는지는 설명하지 않는다. 그래서 자식에게 부모와 같은 시스템 프롬프트와 사용 가능한 tool 목록을 주고, 그림의 cache hit 범위는 결과 확인에서 요청별 prompt_cache_hit_tokens로 맞춰 본다.
Note
실제로 측정한 cache hit token은 모두 64의 배수였다. 그림은 hit 범위를 메시지 단위로 그렸다. 실제 hit 범위는 64 token 묶음 경계에서 끊긴다(아래 “cache hit 확인”).
이번에 해볼 것
부모가 위임 tool을 호출하는 시점의 부모 대화 기록이 아래와 같다고 하자. system 메시지는 hel이 만드는 시스템 프롬프트이고, user 메시지는 사용자의 지시다. assistant 메시지는 model의 응답이고, tool 메시지는 hel이 tool을 실행한 결과다.
1. [system] 시스템 프롬프트
2. [user] 작업 지시
3. [assistant] read_file 호출
4. [tool] 파일 내용
5. [assistant] 위임 tool 호출 (자식에게 맡길 작업 지시)
자식의 대화 기록은 이 기록에서 무엇을 가져오는지에 따라 세 가지로 나뉜다. 세 방식 모두 자식의 마지막 메시지는 5번 호출에 담긴 작업 지시를 user 메시지로 옮긴 것이다.
flowchart LR
subgraph P[부모 대화 기록]
direction TB
P1[1 system] --> P2[2 user] --> P3[3 assistant: tool 호출] --> P4[4 tool 결과] --> P5[5 assistant: 위임 호출]
end
subgraph F1[전체 복사]
direction TB
A1[1 system] --> A2[2 user] --> A3[3 assistant: tool 호출] --> A4[4 tool 결과] --> A5[user: 위임 작업]
end
subgraph F2[tool 기록 제외 복사]
direction TB
B1[1 system] --> B2[2 user] --> B5[user: 위임 작업]
end
subgraph F3[위임 지시만]
direction TB
C1[1 system] --> C5[user: 위임 작업]
end
P --> F1
P --> F2
P --> F3
- 전체 복사: 1~4번을 그대로 복사한다. 자식은 부모가 읽은 파일 내용을 이미 가진 상태로 시작한다. Claude Code의 fork와 같은 구조다.
- tool 기록 제외 복사: system·user 메시지와 assistant의 최종 답만 복사하고, tool 호출과 결과는 뺀다. 위 기록에는 아직 최종 답이 없으므로 1·2번만 남는다. 앞선 대화가 있었다면 그 대화의 최종 답도 함께 남는다. Codex의
fork_context와 같은 구조다. - 위임 지시만: 시스템 프롬프트와 작업 지시만 받는다. 부모가 알아낸 내용 중 자식에게 필요한 것은 부모 model이 작업 지시에 직접 써야 한다. Claude Code의 일반 subagent, Codex의
fork_context=false와 같은 구조다.
자식이 끝나면 최종 답이 5번 호출의 tool 결과로 부모 기록에 추가되고, 부모는 그 결과를 보고 작업을 이어 간다. 자식의 3·4번 같은 중간 기록은 부모 기록에 들어가지 않는다.
결과 확인
위임 tool이 없는 상태
hel이 위임 tool을 갖기 전의 코드로 같은 작업을 3번 실행했다. evals의 조건 이름 baseline은 이렇게 기능을 더하기 전의 상태를 가리킨다.
evals run h11 --conditions baseline --build
사용자 지시는 다음과 같다. 사용 가능한 tool은 read_file, search_replace이다.
services.ini를 읽어 api 서비스의 port를 확인해. 그다음 deploy/api.yaml의 port를 그 값으로 바꾸는 작업을
delegate_task로 subagent에게 맡겨. subagent의 결과를 받은 뒤 deploy/api.yaml을 직접 읽어 바뀐 줄을 확인하고,
최종 답에는 바뀐 port 값만 숫자로 써.
services.ini에는 서비스 60개의 port가 있고, [api]의 port는 8143이다. 판정은 deploy/api.yaml이 port: 8143으로 바뀌었는지, 최종 답이 8143 한 줄인지 본다.
| run | 위임 | deploy/api.yaml | 최종 답 | model 요청 |
|---|---|---|---|---|
| 1 | 불가 | 부모가 직접 수정 | 8143 뒤에 위임하지 못한 이유를 덧붙임 | 4 |
| 2 | 불가 | 수정하지 않음 | 위임 tool이 없어 중단한다는 설명 | 2 |
| 3 | 불가 | 부모가 직접 수정 | 직접 수정했다는 설명 | 4 |
- 세 run 모두
delegate_task가 사용 가능한 tool 목록에 없다는 것을 알아차렸다. 두 run은 지시를 바꿔 직접 수정했고, 한 run은 수정하지 않고 멈췄다. - 파일을 직접 고친 두 run도 작업을 맡기고 결과를 돌려받는 흐름은 없다. 위임 tool을 더한 뒤에는 부모가 tool을 호출하고, 자식이 파일을 고치고, 부모가 결과를 받아 다시 확인하는지를 본다.
- 최종 답 판정은 세 run 모두 실패했다. 1번 run은 값 8143은 맞았지만 설명 줄을 덧붙여 형식이 맞지 않았다.
요청별 입력 token과 cache hit token은 다음과 같다(1번 run).
| 요청 | 직전에 추가된 메시지 | 입력 token | cache hit token |
|---|---|---|---|
| 1 | 사용자 지시 | 837 | 0 |
| 2 | services.ini 읽기 결과 | 2,285 | 1,024 |
| 3 | deploy/api.yaml 읽기 결과 | 2,497 | 2,304 |
| 4 | 수정 결과 | 2,606 | 2,432 |
services.ini를 읽은 결과가 대화 기록에 들어가면서 입력이 약 1,450 token 늘었다. 위임할 때 이 부분을 자식에게 복사하는지가 세 전달 방식의 차이다.- 2번 요청의 cache hit(1,024)는 1번 요청의 입력(837)보다 크다. 1번 요청에 대한 model 출력까지 저장 단위에 들어가, 2번 요청의 앞부분과 함께 cache hit에 성공했다.
- 세 run의 cache hit token은 모두 64의 배수였다. 저장 단위가 메시지 경계에서 정확히 끝나지 않고 일정한 token 묶음으로 저장되는 것으로 보인다. 이 묶음 단위는 아래 “cache hit 확인“에서 자식의 요청과 함께 다시 본다.
위임 tool 추가
--delegate full | no-tools | task-only로 실행하면 사용 가능한 tool 목록 끝에 delegate_task가 추가된다. 같은 옵션이 tool 설명의 마지막 문장과 자식에게 넘기는 메시지를 함께 정한다. 부모와 자식은 같은 시스템 프롬프트와 사용 가능한 tool 목록을 쓴다.
자식의 대화 기록은 부모 기록에서 위임 호출이 담긴 assistant 메시지 바로 앞까지를 기준으로 만든다.
#![allow(unused)]
fn main() {
pub fn child_messages(parent: &[Value], mode: Mode, task: &str) -> Vec<Value> {
let before = parent
.iter()
.rposition(|m| m["role"] == "assistant")
.unwrap_or(parent.len());
let prefix = &parent[..before];
let mut messages: Vec<Value> = match mode {
Mode::Full => prefix.to_vec(),
Mode::NoTools => prefix
.iter()
.filter(|m| match m["role"].as_str() {
Some("system" | "user") => true,
Some("assistant") => !has_tool_calls(m),
_ => false,
})
.cloned()
.collect(),
Mode::TaskOnly => prefix
.iter()
.take_while(|m| m["role"] == "system")
.cloned()
.collect(),
};
messages.push(json!({ "role": "user", "content": task }));
messages
}
}
run_loop는 delegate_task 호출을 받으면 이 메시지로 같은 loop를 한 번 더 돌린다. 자식은 부모와 같은 model, 작업 폴더, 접근 레벨을 쓰고, 자식이 다시 delegate_task를 호출하면 hel이 실행하지 않고 오류를 결과로 돌려준다. 부모 기록에는 자식의 최종 답만 tool 결과로 추가된다. 자식의 요청은 raw/requests.jsonl에 "agent": "child"를 붙여 남기고, 위임마다 작업 지시 원문과 자식의 tool 호출을 raw/delegations.jsonl에 남긴다.
위임 tool 구현 후 다시 측정한 결과
evals run h11 --conditions variant-full,variant-no-tools,variant-task-only --build
evals의 조건 이름 variant-full, variant-no-tools, variant-task-only는 각각 전체 복사, tool 기록 제외 복사, 위임 지시만 전달하는 방식이다. 방식마다 3번 실행했다.
| 위임 tool 없음 | 전체 복사 | tool 기록 제외 | 위임 지시만 | |
|---|---|---|---|---|
| 부모 호출 → 자식 수정 → 부모 확인 | 0/3 | 3/3 | 3/3 | 3/3 |
deploy/api.yaml 판정 | 2/3 | 3/3 | 3/3 | 3/3 |
| 최종 답 판정 | 0/3 | 2/3 | 2/3 | 1/3 |
| 부모가 쓴 작업 지시에 8143 포함 | — | 3/3 | 3/3 | 3/3 |
자식의 services.ini 다시 읽기 | — | 0/3 | 3/3 | 0/3 |
자식의 delegate_task 호출 | — | 3/3 | 3/3 | 0/3 |
| 자식 첫 요청 입력 / cache hit | — | 2,392 / 2,176 | 1,082 / 896 | 1,105 / 640 |
| 자식 요청 수 | — | 4.7 | 5.0 | 4.0 |
| 자식 입력 합 / cache hit | — | 13,109 / 12,203 | 13,320 / 11,307 | 5,354 / 4,309 |
| run 전체 입력 / cache hit | 6,668 / 4,309 | 21,616 / 18,133 | 22,092 / 17,451 | 14,508 / 10,667 |
| model 요청 수 | 3 | 8.7 | 9 | 8 |
(token 값은 3번 실행한 평균이다.)
- 세 방식 모두 9번 중 9번, 부모가
delegate_task를 한 번 호출하고 자식이 파일을 고친 뒤 부모가 결과를 받아 파일을 다시 읽고 답했다. 최종 답 판정 실패 4건은 모두 8143을 포함한 설명형 답이었다. - 부모는 세 방식 모두 작업 지시에 8143을 직접 적었다. 위임 지시만 받는 방식에서는 작업 폴더의 절대 경로까지 적었다. 이번 작업은 넘길 값이 하나라 부모가 지시에 담기 쉬웠다.
- 전체 복사한 자식은
services.ini를 다시 읽지 않았다. 첫 요청 입력은 부모와 비슷하게 컸지만 91%가 cache hit에 성공했다. - tool 기록을 뺀 자식은 작업 지시에 8143이 있었는데도 3번 모두
services.ini를 다시 읽었다. 위임 지시만 받은 자식은 다시 읽지 않았고, 자식의 입력 합은 다른 두 방식의 절반 이하였다.
자식이 다시 위임하려 한 이유
전체 복사와 tool 기록 제외 방식의 자식은 6번 모두 첫 행동으로 delegate_task를 호출했다. 아래는 전체 복사 1번 run의 자식 기록이다.
1. [system] 시스템 프롬프트
2. [user] services.ini를 읽어 … delegate_task로 subagent에게 맡겨. …
3. [assistant] read_file 호출 (services.ini)
4. [tool] services.ini 내용
5. [user] In the working directory, edit the file deploy/api.yaml. Change the port value … to 8143 …
6. [assistant] delegate_task 호출
7. [tool] error: Delegation depth limit reached: a subagent cannot delegate. Do the task yourself.
8. [assistant] read_file 호출 (deploy/api.yaml)
(중략: 수정과 확인)
- 자식은 2번 메시지에 남은 사용자의 원래 지시를 자기 지시로 읽었다. 자식에게 이 대화가 부모의 기록이고 자신은 위임받은 작업만 한다는 정보가 없다.
- tool 기록 제외 방식의
services.ini다시 읽기도 같은 2번 메시지의 “services.ini를 읽어“를 따른 것으로 보인다. 위임 지시만 받는 자식은 2번 메시지가 없어 두 행동이 모두 나오지 않았다. - 자식 몇 개는 “위임이 불가해 직접 수행했다“는 문장을 최종 답에 섞어 부모에게 돌려줬다.
Codex는 fork한 자식의 기록 끝에 자신이 여러 agent 중 하나라는 안내 메시지를 붙이고(spawn.rs), 작업 지시는 agent 사이의 메시지 형식으로 전달한다(multi_agents_v2/spawn.rs). 부모 기록을 복사하는 방식에서는 자식에게 역할을 따로 알려 줘야 부모의 사용자 지시와 위임받은 작업을 구분할 수 있다.
자식에게 역할 알리기
자식의 마지막 user 메시지 앞에 역할 안내를 붙이고 세 방식을 다시 3번씩 실행했다. 부모 기록을 복사한 앞부분은 그대로 두고, 앞의 child_messages에서 끝에 붙는 메시지만 바뀐다.
#![allow(unused)]
fn main() {
messages.push(json!({ "role": "user", "content": child_task(task) }));
pub fn child_task(task: &str) -> String {
format!("{ROLE_NOTE}\n\nTask:\n{task}")
}
}
ROLE_NOTE의 내용은 다음과 같다.
You are a subagent. The parent agent delegated the task below to you. Any messages above this one
are the parent's conversation, given for context only; do not carry out the instructions in them.
Do only this task, then answer with what it asks you to report. You cannot delegate further.
Task:
<부모가 쓴 작업 지시>
| 전체 복사 | tool 기록 제외 | 위임 지시만 | |
|---|---|---|---|
자식의 delegate_task 호출 | 3/3 → 0/3 | 3/3 → 0/3 | 0/3 → 0/3 |
자식의 services.ini 다시 읽기 | 0/3 → 0/3 | 3/3 → 2/3 | 0/3 → 0/3 |
| 자식 요청 수 | 4.7 → 3.0 | 5.0 → 4.0 | 4.0 → 4.0 |
| 자식 입력 합 | 13,109 → 7,603 | 13,320 → 7,786 | 5,354 → 5,232 |
(화살표 왼쪽은 역할 안내를 붙이기 전, 오른쪽은 붙인 뒤의 3번 평균이다.)
- 부모 기록을 복사한 두 방식에서 자식이 다시 위임하려는 시도가 사라졌다. 거절된 호출에 쓰던 요청이 빠져 자식의 요청 수와 입력이 줄었다.
- tool 기록 제외 방식의 자식은 3번 중 2번
deploy/api.yaml을 읽은 뒤services.ini도 읽었다. 작업 지시에 8143이 있었으므로, 복사된 사용자 지시에 남은 파일 이름을 보고 값을 확인한 것으로 보인다. - 위임 흐름(부모 호출 → 자식 수정 → 부모 확인)은 9번 모두 성공했다. 자식 첫 요청의 cache hit(전체 복사 90%)와 위임 직후 부모 요청의 cache hit는 역할 안내를 붙이기 전과 같은 범위였다.
cache hit 확인
| 요청 | 전체 복사 | tool 기록 제외 | 위임 지시만 |
|---|---|---|---|
| 위임 직전 부모 요청 (입력 / hit) | 2,204 / 1,024 | 2,222 / 1,024 | 2,315 / 1,024 |
| 자식 첫 요청 | 2,308 / 2,048 | 1,121 / 896 | 1,050 / 640 |
| 위임 직후 부모 요청 | 2,503 / 2,304 | 2,581 / 2,432 | 2,885 / 2,560 |
(각 방식의 1번 run 값이다.)
- 전체 복사한 자식의 첫 요청은 부모의 직전 요청과 같은 앞부분에서 cache hit에 성공했다. tool 기록 제외 방식은 시스템 프롬프트와 사용자 지시까지 hit됐다.
- 위임 지시만 받은 자식은 예상대로 부모가 쌓은 문맥을 재사용하지 못했다. 다만 부모와 같은 시스템 프롬프트 부분 640 token은 hit됐다. 지금까지 기록된 cache hit token이 모두 64의 배수인 것으로 보아, 서버는 앞에서부터 64 token 묶음 단위로 같은 부분을 찾아 hit시키는 것으로 보인다. 메시지가 끝나는 위치와 상관없이, 처음으로 내용이 달라지는 묶음 앞까지 hit된다.
- 위임 직후 부모 요청은 9번 모두 위임 직전 요청의 입력과 그 응답(
delegate_task호출) 대부분까지 hit됐다. 전체 복사 1번 run에서는 직전 요청 입력 2,204 token보다 큰 2,304 token이 hit됐다. 그 사이에 자식이 요청을 5번 보냈지만 부모가 남긴 저장 단위는 그대로 남았다. 그림과 다른 점은 hit 범위가 64 token 묶음 경계에서 끊긴다는 것뿐이다.
돌아보기
변경 사항
부모 agent가 작업 일부를 자식 agent에게 맡기고, 자식의 최종 답을 받아 작업을 이어 갈 수 있게 됐다. 세 전달 방식 모두 3번씩 두 차례 실행한 18번에서 부모가 위임하고, 자식이 파일을 고치고, 부모가 결과를 받아 다시 확인하는 흐름이 끝까지 이어졌다. 위임 tool이 없던 상태에서는 부모가 직접 고치거나 작업을 멈췄다.
전달 방식에 따라 자식이 시작하는 문맥이 달라졌다. 전체 복사한 자식은 부모가 읽은 파일을 다시 읽지 않았고, 첫 요청의 90%가 cache hit에 성공했다. 위임 지시만 받은 자식은 가장 작은 문맥으로 시작했다. 이번 작업은 부모가 넘길 값이 하나라, 부모가 작업 지시에 값을 직접 적어 세 방식 모두 정답을 냈다.
자식 agent를 호출할 때는 자식의 역할을 지시에 포함해 전달해야 한다. 그렇지 않으면 사용자 입력에서 지시한 delegate를 직접 실행하려는 성향을 보여주었고, 지시를 추가한 뒤에는 이러한 현상이 발생하지 않았다.
hel에서는 --delegate full, --delegate no-tools, --delegate task-only로 전달 방식을 고른다. 옵션을 주면 사용 가능한 tool 목록에 delegate_task가 추가되고, tool 설명이 자식이 받는 내용을 알려 준다. 자식의 요청은 raw/requests.jsonl에 "agent": "child"로, 위임마다 작업 지시와 자식의 tool 호출은 raw/delegations.jsonl에 남는다.
트레이드오프
| 위임 tool 없음 | 전체 복사 | tool 기록 제외 | 위임 지시만 | |
|---|---|---|---|---|
| run 전체 입력 token | 6,668 | 16,103 | 16,485 | 14,145 |
| 그중 cache miss | 2,359 | 3,346 | 4,282 | 3,777 |
| model 요청 수 | 3 | 7 | 8 | 8 |
| 실행 시간 | 5.9초 | 6.7초 | 8.2초 | 8.9초 |
(역할 안내를 붙인 뒤 3번 실행한 평균이다. 위임 tool 없음은 그 전 측정값이다.)
- 입력 token은 2.1~2.5배로 늘었다. 늘어난 입력의 대부분이 cache hit에 성공해, 새로 계산한 입력은 1.4~1.8배였다. 이번 작업은 agent 하나로 충분히 처리할 수 있는 크기라, 문맥을 나눈 이점보다 비용이 먼저 드러난다. 부모 문맥이 한도에 가까워지는 작업에서 나누는 이점은 H13 Harness Evaluation에서 다른 구성과 함께 비교한다.
- 자식이 끝날 때까지 부모가 기다리므로 실행 시간은 자식의 요청만큼 길어진다. 서로 기다리지 않아도 되는 작업을 동시에 실행하는 구조는 H12 Parallelism에서 다룬다.
- 부모와 자식은 같은 작업 폴더, 권한 판정, hook을 쓰고, skill 상태는 자식을 실행하는 동안만 복제본을 쓴다. 자식이 하나씩 실행되므로 지금은 이 상태를 나눠 가져도 충돌이 없다. 여러 자식이 동시에 실행되면 같은 파일과 상태를 함께 고치게 되므로, H12에서 자식마다 작업 공간과 상태를 나누는 방법을 살펴본다.
- 자식의 최종 답은 형식 없이 그대로 부모 기록에 들어간다. 이번 실행에서도 자식이 수정 전후 내용이나 파일 전체를 길게 돌려준 경우가 있었다. 부모가 받을 결과의 형식을 정하는 문제는 여러 결과를 합치는 H12에서 이어진다.
논문의 내용 또는 다른 harness와 비교하면
논문 §16.7 권고 12는 문맥을 나눠 탐색하는 쪽이 분명히 나은 단계를 찾기 전까지 agent 하나를 유지하라고 권한다. 이번 작업에서도 위임은 비용을 늘렸고 결과는 agent 하나로 처리할 때와 같았다. 위임을 쓰는 이유는 비용 절감보다 작업을 나눠 각자 자기 문맥 한도 안에서 처리하는 데 있고, 그 이점은 부모 문맥이 커지는 작업에서 확인해야 한다.
Claude Code의 fork는 부모와 같은 시스템 프롬프트와 tool을 써서 prompt cache를 공유한다. hel의 전체 복사도 같은 방식으로 DeepSeek에서 자식 첫 요청의 대부분이 cache hit에 성공했다. Claude Code의 일반 subagent는 자기 시스템 프롬프트로 시작하므로 부모 cache를 쓰지 못한다. hel의 위임 지시만 받는 방식은 부모와 같은 시스템 프롬프트를 써서 그 부분만큼은 cache hit에 성공했다.
Codex는 부모 기록을 넘길 때 tool 호출과 결과를 빼고, fork한 자식에게 자신이 여러 agent 중 하나라는 안내를 붙인다. hel의 tool 기록 제외 방식은 앞의 구조를 따랐고, 역할 안내가 없을 때 자식이 부모의 사용자 지시를 따라 다시 위임하려는 문제를 겪은 뒤 같은 안내를 더했다. tool 결과를 빼도 사용자 지시가 남아 있으면, 자식이 그 지시에 나온 파일을 다시 확인하는 경우가 있었다.
FAQ
여러 자식을 동시에 실행하면
이번 Lab의 부모는 자식 하나를 실행하고 끝날 때까지 기다린다. 서로 의존하지 않는 작업을 여러 자식에게 나눠 동시에 실행하면 전체 작업 시간이 줄어들 수 있다. 대신 같은 파일을 고치는 충돌과 결과를 합치는 비용이 생긴다. 순차 위임에서 확인한 문맥 전달 방식과 자원 사용을 바탕으로 H12 Parallelism에서 이어서 살펴본다.
사용자가 자식 agent에게 직접 지시하려면
이번 구조에서 자식은 부모의 tool 호출로만 만들어지고, 사용자는 부모와만 대화한다. Claude Code와 Codex는 끝난 자식에게 메시지를 다시 보내 이어서 작업하게 할 수 있다. 사용자가 자식을 고르거나 자식에게 직접 지시하려면 자식의 대화 기록을 저장하고 다시 불러오는 H9 세션 기능과 함께 다뤄야 한다.
부모가 넘길 정보가 많으면
이번 작업에서 부모가 자식에게 넘길 정보는 port 값 하나였고, 부모는 세 방식 모두 작업 지시에 값을 직접 적었다. 넘길 정보가 여러 파일의 내용이나 긴 조사 결과라면, 위임 지시만 받는 방식은 부모가 지시에 담지 못한 정보를 자식이 다시 찾아야 한다. 전체 복사는 정보를 빠뜨리지 않지만 자식이 부모만큼 큰 문맥에서 시작한다. 넘길 정보의 양에 따른 세 방식의 성공률과 비용은 H13 Harness Evaluation에서 비교할 수 있다.
자식이 돌려주는 결과의 형식
hel은 자식의 최종 답을 그대로 부모의 tool 결과로 넣는다. 자식에 따라 바뀐 줄만 돌려주기도 하고, 파일 전체나 수정 과정을 길게 돌려주기도 했다. Claude Code와 Codex도 자식의 최종 메시지를 돌려준다. 여러 자식의 결과를 모아 합치는 H12 Parallelism에서는 결과 형식을 정하는 방법을 함께 살펴본다.
자식에게 전달되는 확장과 권한
H10에서 넘어온 질문이다. hel의 자식은 부모와 같은 사용 가능한 tool 목록, 접근 레벨, 권한 판정, hook을 그대로 쓴다. skill 상태는 부모 것을 복제해 쓰고, 자식이 끝나면 부모 상태로 되돌린다. Claude Code는 subagent 정의마다 tool, 권한 모드, hook, skill, MCP 서버를 따로 지정할 수 있고, Codex는 역할 설정으로 자식의 설정을 바꾼다. 자식마다 다른 범위를 주는 구조는 여러 자식을 함께 실행하는 H12에서 작업 공간을 나누는 문제와 이어진다.
cache hit가 64 token 단위로 끊기는 점
이번 측정의 cache hit token은 모두 64의 배수였다. DeepSeek 문서는 저장 단위가 입력과 출력이 끝나는 위치에 생긴다고만 설명하고, 64 token 묶음은 측정값에서 추론한 것이다. 같은 앞부분이라도 마지막 64 token 묶음을 채우지 못한 부분은 hit되지 않으므로, 요청마다 최대 63 token을 다시 계산한다. 다른 model이나 provider의 cache 단위와 비용은 H13에서 구성별 비용을 비교할 때 함께 확인한다.
H12 — Parallelism
Note
- 시작 상태:
h12· 완료 상태:h13- 논문: §6.2 Iterative Action-Observation Loop, §11 Multi-Agent Orchestration, §16.7 Multi-Agent Orchestration
git checkout -b my-h12 h12
들어가며
H12에서는 여러 작업을 동시에 실행하는 구조를 다뤄본다. 현재 hel은 model이 한 응답에서 tool을 여러 개 요청해도 요청 순서대로 하나씩 실행한다. H11에서 만든 위임 tool도 자식 agent 하나를 실행하고, 자식이 끝날 때까지 부모가 기다리는 방식으로 작동한다.
논문이 본 병렬 실행(tool의 병렬 실행과 agent의 병렬 실행)
논문은 병렬 실행을 두 곳에서 다룬다. §6.2는 agent loop 안에서 한 응답의 tool 호출을 어떻게 실행하는지 비교하고, §11은 여러 자식 agent를 동시에 실행하는 구조를 비교한다.
tool 호출을 동시에 실행하는 방식은 시스템마다 다르다. Mini-SWE-Agent와 Aider는 순서대로 실행한다. 동시에 실행하는 시스템도 대부분 아무 tool이나 함께 실행하지 않고, 함께 실행해도 되는 tool을 따로 표시한다. 논문 표 5의 Concurrency 열과 부록 표 18의 tool 실행 행에서 Claude Code와 Codex를 추리면 다음과 같다.
| 시스템 | tool 호출 실행 방식 | 동시 실행 허용 기준 |
|---|---|---|
| Claude Code | 연속된 안전 tool을 묶어 동시 실행, 최대 10개 | tool마다 isConcurrencySafe(기본 false). 읽기 전용 tool 허용, 편집·bash 단독 |
| Codex | 동시 실행 후 요청 순서대로 결과 수집(FuturesOrdered) | tool마다 supports_parallel(기본 false). 허용하지 않는 tool은 lock으로 단독 실행 |
자식 agent를 동시에 실행하는 구조는 §11.1의 분류에서 병렬 자식 세션, 계층 스레드 트리, 재귀 합성에 해당한다. 자식은 각자 대화 기록을 가지고 동시에 실행되고, 부모는 자식들의 결과를 모아 다음 단계를 진행한다. 실제 시스템은 동시에 실행할 자식 수에 상한을 둔다.
논문은 병렬 실행의 비용도 함께 다룬다. 논문이 인용한 연구에서는 여러 agent를 쓰는 구성이 일반 대화보다 token을 약 15배 사용했다. 또한 코딩 작업에는 연구 작업보다 실제로 나눠 실행할 수 있는 작업이 적다고 본다. 논문은 실제 시스템에서도 자식을 동시에 실행하는 구조가 주로 코드베이스를 넓게 조사하는 단계에 쓰인다고 관찰한다. §16.7 권고 12는 병렬로 문맥을 나누는 쪽이 순차 탐색보다 분명히 나은 조사 단계를 지목할 수 있을 때까지 agent 하나를 유지하라고 권한다.
이번 Lab에서 다룰 병렬 실행
이번 Lab은 두 가지 병렬 실행을 다룬다. 하나는 model이 한 응답에서 요청한 읽기 전용 tool 호출을 harness가 동시에 실행하는 것이다. 다른 하나는 model이 위임 tool을 여러 번 호출하면 자식 agent들을 동시에 실행하는 것이다. 두 경우 모두 결과는 model이 요청한 순서대로 대화 기록에 넣는다.
sequenceDiagram
participant P as 부모 agent
participant M as model
participant T as tool
participant C1 as 자식 agent 1
participant C2 as 자식 agent 2
P->>M: model 입력 (대화 기록)
M-->>P: read_file 2회 호출
par 읽기 전용 tool 동시 실행
P->>T: read_file (파일 A)
and
P->>T: read_file (파일 B)
end
T-->>P: 결과 A, 결과 B (요청 순서대로 기록)
P->>M: model 입력 (대화 기록 + 결과)
M-->>P: 위임 tool 2회 호출
par 자식 agent 동시 실행
P->>C1: 조사 작업 1
and
P->>C2: 조사 작업 2
end
C1-->>P: 최종 답 1
C2-->>P: 최종 답 2
P->>M: model 입력 (대화 기록 + 위임 결과)
M-->>P: 최종 답
tool을 동시에 실행하면 tool 실행을 기다리는 시간이 줄어든다. 다만 한 번의 작업에서 model 응답을 기다리는 시간이 tool 실행 시간보다 훨씬 길면 전체 시간이 줄어드는 폭은 작다.
자식 agent를 동시에 실행하면 자식들이 순서대로 실행될 때 기다리던 시간이 줄어든다. 대신 자식마다 별도의 model 요청과 token을 쓰고, 부모는 여러 자식이 돌려준 결과를 읽고 합쳐야 한다. 자식이 돌려주는 결과의 길이와 형식이 제각각이면 합치는 작업이 어려워질 수 있다. 실행 시간과 함께 요청 수, token, 결과를 합친 뒤의 정답 여부를 확인한다.
동시에 실행하는 작업은 읽기 전용으로 제한한다. 읽기 전용 작업은 여러 개를 함께 실행해도 서로의 결과를 바꾸지 않는다. 여러 작업이 같은 파일을 동시에 고치면 충돌이 생긴다. 편집 작업을 동시에 실행하려면 작업마다 독립된 작업 공간을 두거나, 적어도 작업마다 고치는 파일이 겹치지 않게 나눠야 한다. 참고 자료에서 Claude Code와 Codex가 이 문제를 다루는 방식을 소개한다.
참고 자료
Claude Code는 읽기 전용 tool과 subagent를 동시에 최대 10개까지 model의 응답을 바탕으로 실행한다(CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY, 2026-10 기준). subagent는 부모가 결과를 기다리는 foreground 방식과, 부모가 작업을 이어 가고 완료 알림으로 결과를 받는 background 방식으로 실행된다.
하나의 세션에서 동시에 실행할 수 있는 subagent는 기본 20개다. 문서는 서로 의존하지 않는 모듈을 subagent 여러 개로 나눠 조사하고 결과를 합치는 예를 들고, 자세한 결과를 돌려주는 subagent가 많으면 부모 문맥을 많이 차지하고 subagent마다 token을 쓴다고 안내한다. 쓰기 작업은 isolation: worktree로 subagent를 임시 git worktree에서 실행해 부모 작업 폴더와 분리할 수 있다. 바뀐 내용이 없으면 worktree는 자동으로 정리된다. 별도 세션을 claude --worktree로 실행해 같은 저장소에서 여러 작업을 동시에 진행할 수도 있다.
Codex는 tool 호출마다 비동기 작업을 만들고, 공유 lock 하나로 동시 실행을 조정한다. 동시 실행을 허용한 tool은 읽기 lock을, 그렇지 않은 tool은 쓰기 lock을 잡아 혼자 실행된다. 편집 tool인 apply_patch는 혼자 실행되고, shell 명령은 동시 실행을 허용한다. shell 명령의 위험은 OS sandbox와 승인 정책으로 막는다. 자식 agent는 spawn_agent로 시작하면 id만 바로 돌려주고, 결과는 wait_agent나 완료 알림으로 받는다. 동시에 실행하는 자식은 기본 6개로 제한한다. 자식은 부모와 같은 작업 폴더를 쓰며, spawn_agent 설명은 독립적인 조사는 동시에 맡기고, 구현을 나눌 때는 자식마다 고치는 파일이 겹치지 않게 나누라고 안내한다.
이번에 해볼 것
hel은 model이 한 응답에서 tool을 여러 개 요청하면 요청 순서대로 하나씩 실행한다. 위임 tool(delegate_task) 호출이 여러 개 와도 자식을 하나 실행해 끝낸 뒤 다음 자식을 실행한다.
여기에 --parallel 옵션을 더한다. 옵션을 켜면 한 응답 안의 읽기 전용 tool(read_file, glob, grep) 호출을 동시에 실행하고, 위임 tool 호출이 여러 개면 자식들을 동시에 실행한다. 결과는 두 경우 모두 model이 요청한 순서대로 대화 기록에 넣는다.
evals로 같은 작업을 옵션 없이(baseline) 5번, 옵션을 켜고(variant-parallel) 5번 실행해 비교한다. 두 실행 모두 부모에게 읽기 전용 tool 세 개와 위임 tool을 주고, 자식은 작업 지시만 받는 방식(task-only)으로 만든다. 작업은 두 가지다.
| 작업 | 지시 | 정답 판정 |
|---|---|---|
병렬 읽기 (parallel-read-01) | 이름을 알려 준 서비스 설정 파일 4개를 위임 없이 직접 읽고 port 값의 합을 출력 | 출력이 30909와 일치 |
병렬 탐색 위임 (parallel-explore-01) | auth, billing, search 세 모듈의 구현 코드에서 기본 TIMEOUT_MS를 찾되, 모듈마다 subagent를 하나씩 맡기고 세 위임을 한 번에 요청 | 출력이 auth=2750, billing=4200, search=1850 세 줄과 일치 |
병렬 읽기
사용자 지시는 다음과 같다.
Read services/auth.ini, services/billing.ini, services/search.ini and services/notify.ini
yourself, without delegating, and add up their port values.
The files do not depend on each other, so you can read them in any order.
Print only the sum as an integer.
작업 폴더의 파일은 다음과 같다.
| 파일 | 내용 | 역할 |
|---|---|---|
services/auth.ini | port = 7311 | 합계에 필요 |
services/billing.ini | port = 8420 | 합계에 필요 |
services/search.ini | port = 9133 | 합계에 필요 |
services/notify.ini | port = 6045 | 합계에 필요 |
services/legacy.ini | port = 5000 | 지시에 없는 서비스 |
docs/ports.md | 7300, 8400, 9100, 6000 | 오래된 port 표 |
정답은 7311 + 8420 + 9133 + 6045 = 30909이다. 지시에 없는 파일은 틀린 답이 나왔을 때 원인을 구분하는 데 쓴다. legacy.ini까지 더하면 35909, 오래된 표의 값을 쓰면 31700이 된다. 동시에 실행한 결과가 다른 호출의 결과로 기록되는 문제가 생겨도 합계가 달라진다.
model은 첫 응답에서 read_file 4개를 요청하고, 결과를 받은 다음 응답에서 합계를 출력할 수 있다. 4개를 한 응답에 요청하면 옵션이 없을 때는 하나씩, 옵션을 켜면 동시에 실행된다. 파일을 하나씩 나눠 요청하면 두 실행의 동작은 같다. read_file은 로컬 파일을 읽어 금방 끝나므로, 실행 시간의 대부분은 model 응답을 기다리는 시간이다. 이 작업은 tool 실행 시간이 짧을 때 동시 실행으로 줄어드는 시간이 얼마인지 확인한다.
병렬 탐색 위임
사용자 지시는 다음과 같다.
modules/ 아래 auth, billing, search 세 모듈 각각의 구현 코드(src/)에서 기본 TIMEOUT_MS 값을 찾아.
문서와 테스트에는 다른 값이 있을 수 있다.
모듈마다 delegate_task로 subagent를 하나씩 맡기고, 세 위임은 서로 독립적이니 한 번에 함께 요청해.
최종 답은 auth=<값>, billing=<값>, search=<값> 세 줄만 이 순서로 써.
각 모듈에는 src/, docs/, tests/가 있고, 구현 코드 밖에도 TIMEOUT_MS가 나온다.
| 모듈 | 구현 코드의 값 | 다른 곳에 있는 값 |
|---|---|---|
| auth | src/settings/defaults.py: TIMEOUT_MS = 2750 | docs/: 이전 값 3000, tests/: 주석의 TIMEOUT_MS = 1200 |
| billing | src/settings/defaults.py: TIMEOUT_MS = 4200 | src/client.py: 사용하지 않는 LEGACY_TIMEOUT_MS = 9000, docs/: staging 예시 5000, tests/: TIMEOUT_MS = 100 |
| search | src/settings/defaults.py: TIMEOUT_MS = BASE_TIMEOUT_MS + 350, src/settings/base.py: BASE_TIMEOUT_MS = 1500 → 1850 | docs/: 2000, tests/: test에서 6000으로 바꿈 |
모듈마다 grep으로 TIMEOUT_MS를 찾으면 값이 여러 개 나온다. 자식은 그중 구현 코드의 기본값을 골라야 한다. billing의 LEGACY_TIMEOUT_MS는 src/ 안에 있지만 쓰이지 않는 값이다. search는 import를 따라 두 파일을 읽어야 값을 계산할 수 있어서, 세 자식의 탐색 길이가 서로 다르다.
model은 첫 응답에서 모듈별 지시를 담은 delegate_task 3개를 요청하고, 자식 셋은 각자 grep과 read_file로 값을 찾아 최종 답을 돌려줄 수 있다. 부모는 세 결과를 받아 지정한 형식의 세 줄로 합친다. 옵션이 없으면 자식 하나가 끝나야 다음 자식이 시작하므로, 위임에 걸리는 시간은 세 자식의 실행 시간을 더한 값에 가깝다. 옵션을 켜면 세 자식이 함께 시작하므로 가장 오래 걸린 자식의 시간에 가까워진다. 자식마다 model 요청을 따로 보내는 것은 같아서, 요청 수와 token은 크게 다르지 않을 수 있다. 부모가 위임 호출을 한 응답에 모으지 않고 하나씩 내면 옵션을 켜도 자식은 차례로 실행된다.
확인할 것
두 작업 모두 옵션이 없어도 정답을 낼 수 있다. 판정은 최종 출력이 정답과 완전히 같은지만 본다. 정답 여부와 함께 실행 시간, model 요청 수, token, 부모의 최대 문맥 크기를 비교한다. 응답 하나에 tool 호출과 위임 호출이 몇 개씩 들어 있었는지 세고, 자식들이 실제로 겹쳐 실행됐는지는 자식의 시작·종료 시각으로 확인한다. 자식이 돌려준 결과의 길이와 형식도 함께 살펴본다.
결과 확인
동시 실행 옵션이 없는 상태
--parallel 옵션을 더하기 전의 코드로 두 작업을 5번씩 실행했다. evals의 조건 이름 baseline은 이렇게 기능을 더하기 전의 상태를 가리킨다.
evals run h12 --conditions baseline --build
병렬 읽기
| run | 첫 응답의 tool 호출 | model 요청 | 입력 / 출력 token | 실행 시간 | 최종 답 |
|---|---|---|---|---|---|
| 1 | read_file 4개 | 2 | 2,621 / 191 | 1.8초 | 30909 |
| 2 | read_file 4개 | 2 | 2,623 / 226 | 2.0초 | 30909 |
| 3 | read_file 4개 | 2 | 2,621 / 175 | 1.8초 | 30909 |
| 4 | read_file 4개 | 2 | 2,623 / 175 | 1.9초 | 30909 |
| 5 | read_file 4개 | 2 | 2,623 / 175 | 2.2초 | 30909 |
- 5 run 모두 첫 응답에서 지시한 파일 4개의
read_file을 한 번에 요청하고, 두 번째 응답에서 합계를 출력했다. 지시에 없는legacy.ini와 오래된 port 표는 읽지 않았다. hel은 한 응답에 들어온read_file4개를 차례로 실행했다. 옵션을 켜면 이 4개가 모든 run에서 동시에 실행된다.- 실행 시간은 1.8–2.2초다. model 요청 두 번과 로컬 파일 읽기 4번을 합한 시간이다. 옵션을 켰을 때 줄어들 수 있는 것은 이 중 파일 읽기에 걸린 부분이다.
병렬 탐색 위임
| run | 위임 전 부모의 탐색 | 위임 후 부모가 다시 읽은 파일 | model 요청 (부모 / 자식) | 입력 token (부모 / 자식) | 실행 시간 | 최종 답 |
|---|---|---|---|---|---|---|
| 1 | glob 3회 (응답 2개) | 4개 | 5 / 9 | 10,353 / 14,655 | 18.3초 | 세 줄, 성공 |
| 2 | 없음 | 3개 (search) | 4 / 11 | 8,553 / 18,795 | 17.9초 | 한 줄, 실패 |
| 3 | glob, grep (응답 1개) | 없음 | 3 / 11 | 7,669 / 18,936 | 23.2초 | 한 줄, 실패 |
| 4 | glob (응답 1개) | 없음 | 3 / 11 | 5,561 / 18,875 | 19.3초 | 한 줄, 실패 |
| 5 | 없음 | 4개 | 3 / 9 | 6,210 / 14,522 | 14.7초 | 세 줄, 성공 |
- 5 run 모두
delegate_task3개를 한 응답에 모아 요청했다. 두 run은 첫 응답에서 바로 위임했고, 세 run은glob이나grep으로 폴더 구조를 먼저 본 뒤 위임했다. 옵션을 켜면 모든 run에서 세 자식이 동시에 실행된다. - 자식 15개가 모두 구현 코드의 값을 찾았다.
docs/와tests/의 값이나 billing의LEGACY_TIMEOUT_MS를 답으로 고른 자식은 없었다. search를 맡은 자식은 모두base.py까지 읽어 1850을 계산했다. 자식 하나는 model 요청 3–5회로 끝났다. - 실패한 세 run은 값은 모두 맞았지만
auth=2750, billing=4200, search=1850처럼 한 줄로 이어 썼다. 지시한 출력 형식을 따르지 않은 실패이고, 위임이나 실행 순서와는 관계가 없다. 옵션을 켠 실행도 같은 기준으로 판정하고, 값이 맞았는지를 따로 함께 적는다. - 세 run에서 부모는 자식의 결과를 받은 뒤 같은 파일을 직접 다시 읽었다. 자식 결과에 파일 경로와 줄 번호가 들어 있어 부모는 다시 탐색하지 않고 해당 파일만 읽었다. 결과를 확인하는 데 model 요청이 1–2회 더 들었다.
- 부모는 5 run 모두 위임 지시 끝에 돌려받을 내용을 적었다. 값과 정의된 파일·줄은 공통이었고, 짧게 쓰라는 요청이나 import를 따라간 과정의 설명은 run마다 달랐다. 자식이 돌려준 결과는 208–1,784자였다. 같은 요청을 받은 자식끼리도 값과 경로만 적은 결과부터 소제목과 코드 블록을 붙인 보고서까지 형식이 달랐다. 따라간 과정을 설명하라고 한 3번 run의 결과가 958–1,784자로 가장 길었다.
- run 전체 입력 token의 59–77%를 자식이 썼다. 자식은 같은 시스템 프롬프트로 시작하고 요청마다 앞부분이 같아서, 자식 입력의 대부분이 cache hit에 성공했다.
- 실행 시간은 14.7–23.2초다. 세 자식이 차례로 실행되므로 이 시간의 상당 부분은 자식들의 실행 시간을 더한 값이다. 자식 하나하나의 시작과 종료 시각은 이 코드의 기록에 없어서, 옵션을 더할 때 위임 기록에 함께 남겨 비교한다.
동시 실행 옵션 추가
--parallel로 실행하면 model 응답의 tool 호출을 앞에서부터 묶는다. 읽기 전용 tool 호출이 이어지면 한 묶음, delegate_task 호출이 이어지면 한 묶음이고, 그 밖의 호출은 하나씩 실행한다. 묶음 크기는 다음 함수가 정한다.
#![allow(unused)]
fn main() {
fn concurrent_run(session: &Session, calls: &[Call]) -> usize {
if !session.parallel
|| !session.runtime.hooks.is_empty()
|| !session.runtime.skills.borrow().idle()
{
return 0;
}
let delegate = |c: &Call| {
c.name == delegate::DELEGATE_TASK
&& session.tools.delegate().is_some()
&& session.depth == 0
};
let read = |c: &Call| session.tools.offers_read_only(&c.name);
let same: &dyn Fn(&Call) -> bool = match calls.first() {
Some(c) if delegate(c) => &delegate,
Some(c) if read(c) => &read,
_ => return 0,
};
calls.iter().take_while(|c| same(c)).count()
}
}
- hook이 설정되어 있거나 skill이 등록되어 있으면 묶지 않는다. hook과 skill은 tool 호출마다 상태를 기록하고 바꿀 수 있으므로, 호출 순서가 섞이면 그 기록이 맞지 않게 된다.
- 위임 묶음은 사용자와 대화하는 agent(
depth == 0)에서만 만든다. 자식은 위임 tool을 쓸 수 없고, 자기 응답의 읽기 묶음만 동시에 실행한다.
묶음 안의 호출은 std::thread::scope로 동시에 실행한다. 자식 agent 묶음은 다음과 같다.
#![allow(unused)]
fn main() {
let shared = std::sync::Mutex::new(&mut *session.approval);
std::thread::scope(|scope| {
for child in &mut children {
let (run, shared) = (&run, &shared);
scope.spawn(move || run(child, &mut OneAtATime(shared)));
}
});
}
run은 자식 하나의 tool loop를 실행하고, 시작과 종료 시각을 위임 기록에 남긴다.- 자식이 파일 수정처럼 사용자 확인이 필요한 tool을 호출하면
OneAtATime이 확인 요청을 하나씩 처리한다. 읽기 전용 tool은 확인 없이 실행되므로, 읽기 묶음은 확인 입력을 공유하지 않는다. - 모든 thread가 끝나면 결과를 model이 요청한 순서대로 대화 기록에 넣는다. 기록과 tool 실행 기록의 순서는 옵션이 없을 때와 같다.
여러 thread가 같은 실행 상태를 쓰게 되면서, skill 상태와 read_file의 이어 읽기 위치처럼 RefCell로 두었던 값을 mutex로 감싼 타입으로 바꿨다.
같은 두 작업을 옵션을 켜고 5번씩 실행했다. evals의 조건 이름 variant-parallel은 --parallel을 켠 실행을 가리킨다.
evals run h12 --conditions variant-parallel --build
병렬 읽기
| 옵션 없음 | 옵션 켬 | |
|---|---|---|
| 정답 | 5/5 | 5/5 |
첫 응답의 read_file 호출 | 4개 (5/5 run) | 4개 (5/5 run) |
| model 요청 | 2 | 2 |
| 실행 시간 중앙값 (범위) | 1.88초 (1.81–2.16) | 2.00초 (1.76–2.17) |
- 옵션을 켠 5 run 모두 파일 4개를 동시에 읽었지만 실행 시간은 줄지 않았다. 두 실행의 범위가 겹치고, 0.1초 차이는 model 응답 시간의 편차 안에 있다.
read_file은 로컬 파일을 읽어 금방 끝나므로, 실행 시간은 거의 전부 model 응답 두 번을 기다리는 시간이다.
병렬 탐색 위임
| run | 위임 구간 | 자식 실행 시간 (auth / billing / search) | model 요청 (부모 / 자식) | 실행 시간 | 최종 답 |
|---|---|---|---|---|---|
| 1 | 4.1초 | 3.8 / 3.8 / 4.1초 | 3 / 9 | 10.2초 | 한 줄, 실패 |
| 2 | 5.7초 | 4.2 / 5.7 / 4.3초 | 2 / 10 | 10.9초 | 한 줄, 실패 |
| 3 | 4.0초 | 3.6 / 3.4 / 4.0초 | 3 / 9 | 10.0초 | 세 줄, 성공 |
| 4 | 7.5초 | 7.5 / 6.5 / 5.3초 | 5 / 12 | 17.0초 | 세 줄, 성공 |
| 5 | 3.5초 | 2.2 / 3.2 / 3.5초 | 3 / 8 | 10.9초 | 세 줄, 성공 |
위임 구간은 첫 자식이 시작한 때부터 마지막 자식이 끝날 때까지다.
| 옵션 없음 | 옵션 켬 | |
|---|---|---|
| 값 정답 / 형식까지 정답 | 5/5 / 2/5 | 5/5 / 3/5 |
| 위임 3개를 한 응답에 요청 | 5/5 | 5/5 |
| 실행 시간 중앙값 (범위) | 18.3초 (14.7–23.2) | 10.9초 (10.0–17.0) |
| model 요청 중앙값 (부모 / 자식) | 3 / 11 | 3 / 9 |
| 입력 token 평균 (부모 / 자식) | 7,669 / 17,157 | 7,914 / 17,002 |
| 출력 token 평균 | 2,630 | 2,986 |
- 5 run 모두 세 자식이 같은 시각(차이 1ms 이하)에 시작해 겹쳐 실행됐다. 위임 구간은 가장 오래 걸린 자식의 실행 시간과 같았다.
- 자식 실행 시간의 합은 run마다 9.0–19.4초다. 옵션이 없으면 이만큼을 차례로 기다리고, 옵션을 켜면 3.5–7.5초만 기다린다. 실행 시간 중앙값은 18.3초에서 10.9초로 7.4초 줄었다.
- 값은 두 실행 모두 5/5 맞았다. 형식 실패는 옵션 없이 3번, 옵션을 켜고 2번으로, 두 실행 모두 값을 한 줄로 이어 쓴 경우다.
- 요청 수와 입력 token은 거의 같다. 자식은 같은 작업을 하고 실행 순서만 바뀌므로 token은 줄지 않는다. 출력 token 평균은 14% 많았지만, 자식 결과의 길이 편차(218–1,332자)를 고려하면 5번 실행으로는 옵션의 영향으로 보기 어렵다.
- 부모가 자식 결과를 받은 뒤 파일을 직접 다시 읽은 run은 옵션 없이 3번, 옵션을 켜고 3번(1·3·4번 run)이었다. 4번 run은 부모가 위임 전에 폴더 구조를 보고, 결과를 받은 뒤 7개 파일을 다시 읽었다. 위임 구간도 7.5초로 가장 길어 17.0초가 걸렸다. 동시 실행으로 줄어드는 것은 위임 구간이고, 부모가 직접 하는 탐색과 확인은 그대로 남는다.
돌아보기
변경 사항
부모 agent가 한 응답에서 맡긴 자식 agent 셋이 동시에 실행되면서, 세 모듈을 나눠 조사하는 작업의 실행 시간 중앙값이 18.3초에서 10.9초로 줄었다. 5번 모두 세 자식이 함께 시작했고, 위임에 걸린 시간은 가장 오래 걸린 자식 하나의 시간과 같았다. 세 모듈의 값은 두 실행 모두 5번 다 맞혔다. 파일 4개를 한 번에 읽는 작업은 읽기를 동시에 실행해도 실행 시간이 달라지지 않았다.
hel에서는 --parallel로 동시 실행을 켠다. 한 응답에서 이어지는 read_file, glob, grep 호출과 이어지는 delegate_task 호출을 묶어 동시에 실행하고, 결과는 model이 요청한 순서대로 대화 기록에 넣는다. hook이 설정되어 있거나 skill이 등록되어 있으면 이전처럼 하나씩 실행한다. 위임 기록(raw/delegations.jsonl)에는 함께 실행된 자식 수와 자식마다 시작·종료 시각이 남는다.
트레이드오프
| 병렬 탐색 위임 | 옵션 없음 | 옵션 켬 |
|---|---|---|
| 실행 시간 중앙값 | 18.3초 | 10.9초 |
| model 요청 중앙값 | 14 | 12 |
| 입력 token 평균 | 24,826 | 24,915 |
| 출력 token 평균 | 2,630 | 2,986 |
- token은 줄지 않는다. 자식이 하는 일은 같고 실행 순서만 바뀌기 때문이다. 대신 같은 요청이 짧은 시간에 몰린다.
hel은 한 응답에 들어온 위임 호출 수만큼 자식을 한꺼번에 실행하고 동시 실행 수에 상한을 두지 않았다. 상한이 필요한 이유는 FAQ에 정리했다. - 줄어드는 것은 tool과 자식이 실행되는 시간뿐이다.
read_file처럼 금방 끝나는 tool은 동시에 실행해도 줄일 시간이 거의 없다. 부모가 위임 전에 직접 탐색하거나 결과를 받은 뒤 파일을 다시 읽는 시간도 그대로 남는다. - 여러 thread가 같은 실행 상태를 쓰도록 skill 상태,
read_file의 이어 읽기 위치, hook과 MCP 기록을 mutex로 감쌌다. 같은 thread에서 잠금을 두 번 잡으면 이전에는 바로 오류가 났지만 이제는 멈춘 채로 기다리게 되므로, 잠금을 겹쳐 잡던 코드 한 곳을 고쳤다. hook과 skill은 호출마다 상태를 바꾸므로, 둘이 설정된 상태에서는 동시 실행을 포기했다.
논문의 내용 또는 다른 harness와 비교하면
논문 §16.7 권고 12는 여러 문맥으로 나눠 탐색하는 쪽이 분명히 나은 단계를 지목할 수 있을 때 다중 agent 구조를 쓰라고 권한다. 서로 의존하지 않는 모듈 셋을 조사하는 작업은 그런 단계에 해당했고, 같은 token으로 실행 시간이 줄었다. 논문이 경고한 token 증가는 agent 하나로 처리할 때와 비교한 비용이다. 이 비용은 H11에서 위임을 더할 때 이미 생겼고, 동시 실행은 그 위에 비용을 더하지 않았다.
Claude Code는 읽기 전용 tool과 subagent를 최대 10개까지 동시에 실행한다. hel도 같은 기준으로 읽기 전용 tool만 묶는다. Claude Code의 background subagent는 부모가 다른 작업을 하는 동안 실행되고 완료 알림으로 결과를 돌려주지만, hel의 부모는 묶음의 자식이 모두 끝날 때까지 기다린다. 이번 작업은 부모가 세 결과를 모두 받아야 답할 수 있어서 기다리는 구조로 충분했다.
Codex는 tool마다 동시 실행 여부를 표시하고 shell 명령도 동시에 실행한다. shell 명령의 위험은 OS sandbox와 승인 정책으로 막는다. hel의 bash는 파일을 고칠 수 있어 동시 실행 대상에서 뺐다. 결과를 요청 순서대로 모으는 방식은 Codex의 FuturesOrdered와 같다. Codex의 spawn_agent는 자식을 시작하고 바로 돌아오므로, 부모가 자식을 기다리지 않고 다른 작업을 할 수 있다.
FAQ
동시에 실행하는 자식 수의 상한
hel은 한 응답에 들어온 위임 호출 수만큼 자식을 한꺼번에 실행한다. 이번 작업은 자식이 셋이라 문제가 없었지만, model이 위임을 수십 개 요청하면 그만큼의 model 요청이 동시에 나간다. Claude Code는 읽기 전용 tool과 subagent를 합쳐 10개, Codex는 자식 6개로 동시 실행을 제한하고, 한도를 넘으면 기다리게 하거나 오류로 돌려준다. provider의 동시 요청 한도와 자식 수에 따른 시간·비용 변화는 H13 Harness Evaluation에서 다른 구성과 함께 측정할 수 있다.
hook과 skill이 있을 때의 동시 실행
hook은 tool 호출마다 외부 명령을 실행하고 기록을 남기며, skill은 어떤 지침이 대화에 들어갔는지 호출마다 상태를 갱신한다. 여러 호출이 동시에 이 상태를 바꾸면 기록과 상태가 맞지 않게 되므로, 이번 구현은 둘이 설정되어 있으면 하나씩 실행한다. 동시에 실행하려면 호출마다 상태를 따로 모았다가 요청 순서대로 합치는 구조가 필요하다. H10의 확장 기능과 이번 동시 실행을 함께 쓰는 구성은 H13에서 비교할 구성 후보로 남긴다.
자식 결과의 형식과 부모의 재확인
부모는 위임 지시에 돌려받을 내용을 적었지만, 자식 결과는 값과 경로만 적은 짧은 결과부터 소제목을 붙인 보고서까지 길이와 형식이 제각각이었다. 부모가 결과를 받은 뒤 같은 파일을 직접 다시 읽은 run도 옵션 유무와 관계없이 5번 중 3번 있었다. Codex의 spawn_agents_on_csv처럼 harness가 자식의 반환 형식을 정해 두면 결과를 합치기 쉬워지고, 부모가 확인할 근거도 일정해진다. 반환 형식을 정했을 때 재확인 요청과 출력 token이 줄어드는지는 H13에서 측정할 수 있다.
쓰기 작업을 나눠 동시에 실행하려면
H8 FAQ에서 넘어온 질문이다. 이번 Lab은 동시에 실행하는 작업을 읽기 전용으로 제한했다. Claude Code는 subagent를 isolation: worktree로 임시 git worktree에서 실행하거나 claude --worktree로 세션마다 작업 폴더를 나눠 같은 파일을 고치는 충돌을 피한다. Codex는 자식들이 같은 작업 폴더를 쓰고, 자식마다 고치는 파일이 겹치지 않게 나누라고 지시한다. 작업 폴더를 나누면 나중에 바뀐 내용을 합치는 단계가 새로 생긴다. hel에서 이 구조를 만들려면 H8의 인스턴스별 작업 공간과 H9의 checkpoint를 함께 써야 한다.
부모가 자식을 기다리지 않는 실행
hel의 부모는 묶음의 자식이 모두 끝나야 다음 요청을 보낸다. Claude Code의 background subagent와 Codex의 spawn_agent·wait_agent는 자식을 시작한 뒤 부모가 다른 작업을 이어 가고, 결과는 완료 알림이나 대기 tool로 받는다. 부모가 자식 결과 없이도 할 일이 있는 작업에서 쓸모가 있지만, 언제 결과를 대화에 넣을지와 끝나지 않은 자식을 어떻게 정리할지를 함께 정해야 한다. 이번 커리큘럼에서는 다루지 않고, H9 세션 저장과 함께 확장할 수 있는 구조로 남긴다.
Part VI 개요
Note
- 시작 상태:
h13· 완료 상태:h14- 논문: §13.4 Trade-off Framework, §15.5 The Scaffold–Capability Frontier, §15.6 Threats to Validity, §17.2 Future Work
현재 구조
Part V까지 진행하면서 hel은 기능을 연결하고, 작업을 자식 agent에게 맡기고, 여러 작업을 동시에 실행할 수 있게 됐다. 각 Lab에서는 기능을 더하기 전과 후의 hel에 같은 작업을 맡기고 결과를 비교했다.
지금까지 hel을 만들며 기능을 추가하고 기록을 하는 과정은 다음과 같다.
flowchart LR
D[Lab 정의<br/>조건·작업·반복 횟수] --> R[evals run]
T[작업<br/>지시·fixture·판정 규칙] --> R
R --> H[hel 실행]
H <--> M[model]
H --> C[실행 기록<br/>record.json·raw]
C --> K[판정<br/>verdict.json]
K --> P[evals report<br/>조건별 집계]
evals는 작업마다 새 작업 폴더를 만들고 hel을 실행한다. 실행이 끝나면 종료 사유, 최종 답, 입력·출력 token, model 요청 수, 실행 시간, tool 호출 기록 등을 record에 남긴다.
판정은 작업에 정해 둔 규칙(최종 답의 값, 파일 내용, tool 호출 여부 등)으로 한다. model이 보고한 내용이 실제 실행과 맞는지, 실패한 명령이 정말 실패였는지(출력 형식이 달라 실패하는 경우도 있다.)는 실행 기록을 직접 읽어 판단했다.
Part V에서 드러난 문제
작업마다 다른 결과. H11에서 작업을 자식 agent에게 맡기자 입력 token이 2.1~2.5배로 늘었다. 작업이 agent 하나로 충분히 처리할 수 있는 크기였기 때문이다. H12에서는 자식 셋을 동시에 실행하자, 세 모듈을 나눠 조사하는 작업의 실행 시간 중앙값은 18.3초에서 10.9초로 줄었지만 파일 4개를 읽는 작업은 달라지지 않았다. 같은 기능도 어떤 작업을 맡기느냐에 따라 이득과 비용이 달라진다.
쌓인 평가 항목. Lab마다 그 기능을 확인하는 작은 작업을 새로 만들었고, 그 범위를 넘는 질문은 FAQ에 남겼다. MCP tool을 처음부터 모두 보낼지 필요할 때 불러올지(H10 FAQ), 자식에게 넘길 정보가 많을 때 어떤 방식이 나은지(H11 FAQ), 자식 결과의 형식을 정하면 부모의 재확인이 줄어드는지(H12 FAQ) 같은 질문이다.
측정 도구의 한계. 측정 도구 자체가 틀릴 수 있는 지점도 있다. H3 FAQ에서는 exit code가 0이 아니면 실패로 세는 규칙 때문에, 차이를 찾아 exit 1을 돌려준 diff가 실패로 기록됐다. H10 FAQ에서는 압축 직후 hel의 token 추정이 사용 가능한 tool 목록을 세지 않아 실제 입력과 차이가 생겼다.
기능을 하나씩 더할 때는 그 기능만 보면 됐지만, 기능이 많아진 지금은 무엇을 기준으로 harness 전체를 평가하고, 어떤 순서로 고칠지 정해야 한다.
논문이 보는 harness 평가
논문은 11개 harness를 실행하지 않고 source code를 읽어 비교한다(§4, §15.6). 각 시스템이 공개한 benchmark 성공률은 model과 실행 설정이 달라 서로 비교할 수 없다고 밝히고, 같은 작업을 모든 시스템에 실행하는 비교는 비용 때문에 하지 않았다고 했다.
§13.4와 §2.4는 최소한의 harness도 benchmark에서 큰 harness와 비슷한 성공률을 보인다고 지적한다. 또한 harness가 정교해질수록 작업 완료보다 안전, 사용성, 확장성, 복구에 집중한다.
§15.5는 고정된 작업과 model에서 scaffold가 늘면 성공률이 처음에 빠르게 오르다 평평해진다는 직관을 제시하고, 이를 측정하려면 작업 분포, model, scaffold 복잡도 지표, 목표 성공률을 정해야 한다고 말한다. §17.2는 정답률과 함께 안전, 사용성, 비용, 확장성을 보는 평가 틀을 앞으로의 과제로 남긴다.
논문에서는 harness를 실제로 실행해서 평가하는 것을 다루지 않았다. 그래서 이번 Part에서는 harness를 직접 제작하는 회사와 개발자가 그 부분을 어떻게 하는지 살펴본다.
이 Part에서 다룰 것
harness를 만드는 회사와 개발자가 harness를 어떤 기준으로 평가하고 개선하는지 조사한다. 공개 benchmark가 성공을 어떻게 판정하는지, token·시간·비용을 어떻게 기록하는지, 실행마다 달라지는 결과를 어떻게 다루는지 살펴본다. 그 기준으로 지금까지 쌓인 평가 항목을 정리하고, 하나를 골라 문제 정의, 평가 방식, 계측 방법 순서로 개선한다.
| Lab | 질문 | 더하는 구조 |
|---|---|---|
| H13 Harness Evaluation | harness를 어떤 기준으로 평가하고 개선할 수 있는가? | tool 호출의 실패 기록·실패 종류 판정·일관성 지표 |
쌓인 항목을 모두 다루지는 않는다. 평가 기준을 세우고, 쌓인 항목 중 하나를 골라 그 기준으로 고치는 과정을 기록한다.
이 Part를 마치면
flowchart LR
D[Lab 정의] --> R[evals run]
T[작업] --> R
R --> H[hel 실행]
H <--> M[model]
H --> C[실행 기록<br/>tool 호출의 exit code·결과]:::new
C --> K[판정<br/>작업의 check]
C --> F[실패 종류 판정]:::new
L[사람 분류]:::new --> F
K --> P[evals report<br/>pass@k · pass^k]:::new
F --> P
classDef new fill:#fff3bf,stroke:#e8590c,color:#000
- 평가의 구성 요소(task, trial, grader, transcript, outcome)와 실행 흐름에
evals를 대응시켜, 무엇을 재고 무엇을 재지 못하는지 정리한다. hel은 tool 호출마다 bash의 exit code와 실패한 호출의 결과를 기록하고,evals가 이 기록으로 실패의 종류를 판정한다. 판정 규칙을 고치면 저장된 기록을 다시 판정할 수 있고, 사람이 분류한 결과와 비교할 수 있다.- report는 통과한 run 수와 함께 task별 pass@k·pass^k를 보여 준다.
- 실패 종류의 자동 판정은 오류 문구에 의존하므로, 처음 보는 원인은 사람이 기록을 읽어 확인한다. 회귀 확인, 참조 해법, model grader 같은 나머지 평가 항목은 H13 글의 분류 표에 남는다.
H13 — Harness Evaluation
Note
- 시작 상태:
h13· 완료 상태:h14- 논문: §13.4 Trade-off Framework, §15.5 The Scaffold–Capability Frontier, §17.2 Future Work
- 기준 자료: Anthropic, Demystifying evals for AI agents (2026-01-09)
git checkout -b my-h13 h13
harness를 어떤 기준으로 평가하고 개선할 수 있는가?
들어가며
H1부터 H12까지는 기능을 하나 더할 때마다 그 기능을 확인하는 작은 task를 만들고, 기능을 켜기 전과 후의 hel에 같은 작업을 맡겨 결과를 비교했다.
H13에서는 기능 하나가 아니라 harness 자체를 평가하는 방식을 다룬다. 무엇을 기준으로 harness를 평가하는지, 그 평가를 어떻게 실행하고 기록하는지를 살펴보고, evals를 그 흐름에 맞게 고친다.
논문이 본 harness 평가
논문은 harness를 실행해서 평가하지 않고 source code를 읽어 비교한다. 그래서 이번 Lab은 Anthropic이 agent 평가를 정리한 글 Demystifying evals for AI agents를 기준으로 삼는다.
Anthropic이 정리한 agent 평가
이 글에서 eval(평가)은 AI에게 입력을 주고, 그 출력을 채점 규칙으로 판정하는 test다. 실제 사용자 없이 개발 중에 돌리는 자동 평가를 다룬다. agent는 여러 turn에 걸쳐 tool을 호출하고 환경을 바꾸기 때문에, 중간의 실수가 뒤로 이어지고 평가를 만든 사람이 예상하지 못한 해법이 나오기도 한다. 그래서 글은 평가를 여러 구성 요소로 나누고, 각 요소가 어떤 순서로 이어지는지 정의한다.
(글의 정의와 구성 요소 그림을 바탕으로 실행 순서에 맞게 다시 그렸다.)
- evaluation suite: 같은 목표를 가진 task의 묶음이다. task 하나는 입력과 성공 기준이 정해진 test다. 지시와 평가 환경, 채점할 grader가 함께 정해지고, task가 실제로 풀린다는 것을 보여 주는 참조 해법을 둘 수 있다.
- evaluation harness: 평가를 처음부터 끝까지 실행하는 기반이다. task에 지시와 tool을 주고, 여러 task를 동시에 실행하고, 모든 단계를 기록하고, 채점하고, 결과를 집계한다.
- trial: task에 대한 시도 한 번이다. model의 출력은 실행마다 달라지므로 task마다 trial을 여러 번(k번) 실행한다. trial마다 깨끗한 평가 환경에서 시작해야 한다. 이전 trial이 남긴 파일이나 cache를 다음 trial이 보면 실패가 서로 묶이거나 점수가 부풀려진다. trial 안에서 agent harness와 model 실행된다. 글은 agent를 평가한다는 것이 harness와 model을 함께 평가하는 것이라고 정의한다.
- transcript와 outcome: trial이 남기는 두 가지 결과다. transcript는 메시지, tool 호출, 추론, 중간 결과를 담은 전체 기록이다. outcome은 trial이 끝난 뒤 평가 환경의 최종 상태다. 항공권 예약 agent가 “예약했습니다“라고 답해도, DB에 예약이 실제로 있는지로 평가해야 한다.
- grader: transcript나 outcome을 채점하는 규칙이다. task 하나에 grader가 여러 개 있을 수 있고, grader마다 assertion(check)이 여러 개 있다. 모두 통과해야 하는 방식, 가중치를 더해 기준을 넘는 방식, 둘을 섞는 방식으로 trial 점수를 낸다.
- 집계: trial 점수를 task와 suite 단위로 모은다. 통과율과 함께 token, 실행 시간, task당 비용, 오류율을 같은 task 묶음에서 계속 추적한다.
- 결과의 쓰임: 집계한 결과는 무엇을 확인하느냐에 따라 두 가지로 쓴다(아래 capability eval과 regression eval).
- transcript 읽기: 점수만으로는 grader가 맞게 채점하는지 알 수 없다. 실패한 trial의 기록을 읽으면 agent가 실제로 틀렸는지, grader가 올바른 해법1을 떨어뜨렸는지 구분할 수 있다. 읽은 결과로 task와 grader를 고친다.
grader의 종류. 글은 grader를 세 가지로 나눈다.
| 종류 | 채점 방법 | 장점 | 약점 |
|---|---|---|---|
| code | 문자열 일치, 실패하던 test 통과와 기존 test 유지, 정적 분석, outcome 확인, tool 호출 확인, transcript 분석(turn 수, token) | 빠르고 저렴, 재현 가능 | 기대한 형태와 조금만 달라도 올바른 해법을 실패 처리 |
| model | rubric 점수, 자연어 assertion, 두 결과 비교, 여러 판정 model의 합의 | 열린 과제와 자유 형식 출력 처리 | 비결정적, 사람 판정과 맞춰 보는 보정 필요 |
| 사람 | 전문가 검토, 표본 점검, A/B test | 가장 정확한 판정, model grader 보정의 기준 | 느리고 비쌈 |
글은 가능하면 code grader를, 필요할 때 model grader를 쓰고, 사람은 검증에 아껴 쓰라고 권한다. coding agent는 코드가 돌고 test가 통과하는지로 판정할 수 있어 code grader가 잘 맞는다. tool 호출 순서처럼 agent가 거쳐 간 경로를 강제하면 예상하지 못한 올바른 방법이 실패로 처리되므로, agent가 만든 결과를 채점한다.
capability eval과 regression eval. capability eval은 agent가 무엇을 잘하는지 묻는다. 낮은 통과율에서 시작해 harness를 고치며 통과율을 올린다. regression eval은 예전에 하던 일을 여전히 하는지 묻는다. 통과율이 거의 100%여야 하고, 떨어지면 무언가 깨졌다는 신호다. 한쪽 기능을 고치는 동안 다른 쪽이 깨지지 않았는지 보려면 둘을 함께 돌려야 한다. 통과율이 충분히 오른 capability eval의 task는 regression eval로 옮긴다.
실행마다 다른 결과. 같은 task도 trial마다 통과하거나 실패한다. 글은 두 지표를 구분한다.
- pass@k: k번 중 한 번 이상 성공할 확률. k가 커질수록 오른다.
- pass^k: k번 모두 성공할 확률. k가 커질수록 내려간다. trial당 성공률이 75%이면 3번 모두 성공할 확률은 0.75³ ≈ 42%다.
한 번만 성공해도 쓸 수 있는 도구라면 pass@k를, 매번 같은 결과를 기대하는 agent라면 pass^k를 본다.
평가를 만드는 순서. 글은 평가가 없는 상태에서 믿을 수 있는 평가까지 가는 순서를 단계로 정리한다.
- task 모으기: 실제로 겪은 실패에서 고른 20~50개로 시작한다. 개발 초기에는 변경의 효과가 커서 적은 task로도 차이가 드러난다. task는 전문가 두 사람이 독립적으로 같은 판정을 내릴 만큼 분명해야 하고, grader가 확인하는 내용은 지시에 드러나 있어야 한다. 행동이 일어나야 하는 경우와 일어나지 말아야 하는 경우를 함께 넣는다.
- evaluation harness와 grader 설계: 평가 속 agent는 실제로 쓰는 agent와 같게 동작해야 하고, trial마다 깨끗한 평가 환경에서 시작한다. 여러 단계로 된 task에는 부분 점수를 준다. grader 자체의 버그도 점검한다. 글이 든 사례에서 Opus 4.5는 CORE-Bench에서 처음 42%를 받았는데, 소수점 자릿수가 다르다고 실패 처리한 판정과 모호한 지시를 고치고 제약이 덜한 scaffold로 바꾸자 95%가 됐다.
- 오래 쓰기: transcript를 정기적으로 읽는다. 통과율이 100%에 가까워진 평가는 개선 신호를 주지 못하므로 더 어려운 task를 더한다. 평가는 계속 고치는 산출물로 다룬다.
자동 평가는 agent를 이해하는 여러 방법 중 하나다. 운영 중 모니터링, 사용자 피드백, A/B test, 사람의 기록 검토를 함께 쓴다. 한 방법이 놓친 문제를 다른 방법이 잡는 구조다.
이번 Lab에서 다룰 평가
hel에서 이 흐름의 evaluation harness 역할은 evals가 맡는다. agent harness는 hel이다. 지금까지의 측정 흐름을 글의 구성 요소에 대응시키면 다음과 같다.
| 글의 구성 요소 | evals의 대응 | 차이 |
|---|---|---|
| evaluation suite | Lab 정의 evals/labs/hXX.yaml | Lab마다 그 기능의 task만 묶음 |
| task | evals/tasks/<id>/(지시, fixture, check) | 참조 해법 없음 |
| trial | run, 조건별 3~5회 | |
| 깨끗한 평가 환경 | run마다 새 작업 폴더 | |
| transcript | raw/의 원본 기록, record의 events | |
| outcome | 최종 답, run 종료 시 복사한 workspace/ | |
| grader | check(output_exact_match, file_exact_match, tool_calls, ini_value) | code grader만 있음. tool_calls는 경로를 채점 |
| 집계 | evals report의 조건별 통과 수, 평균 token, model 요청 수, 실행 시간 | pass^k 같은 일관성 지표 없음 |
| transcript 읽기 | Lab마다 사람이 원본 기록을 읽어 확인 |
evals report는 저장된 기록을 다시 채점하므로, check를 고치면 model을 다시 실행하지 않고 지난 run의 판정을 다시 볼 수 있다. 반대로 Lab마다 그 기능의 task만 실행했기 때문에, 이전 Lab의 task를 현재 hel로 다시 돌리는 regression eval은 없다. 실행 기록이 tool 실패를 표시하는 방식에도 한계가 있다. bash 명령은 exit code가 0이 아니면 모두 실패로 남아, 차이를 찾아 exit 1을 돌려준 diff도 실패로 기록된다(H3 FAQ).
이번 Lab에서는 evals를 위 흐름에 맞게 고치고, H1~H12에서 남긴 평가 항목 중 하나를 골라 문제 정의, 평가 방식, 계측 순서로 적용한다.
참고 자료
- Anthropic, Quantifying infrastructure noise in agentic coding evals (2026-02-05): 같은 model과 harness, task로 Terminal-Bench 2.0을 실행하면서 실행 환경의 CPU·메모리 설정만 바꿨을 때 점수가 최대 6%p 달라졌다. 실행 환경 오류를 agent의 실패와 나눠 기록하는 근거가 된다.
- Anthropic, An update on recent Claude Code quality reports (2026-04-23): 기본 reasoning effort 변경, 오래된 세션의 thinking 정리 버그, 시스템 프롬프트에 넣은 출력 길이 제한 한 줄이 Claude Code의 품질을 떨어뜨렸다. 마지막 변경은 처음 돌린 평가를 통과했고, 더 넓은 평가에서 한 줄씩 빼 보는 비교로 3% 하락이 드러났다. 이후 Anthropic은 시스템 프롬프트를 바꿀 때마다 model별로 넓은 평가를 돌리기로 했다.
- OpenAI, Introducing SWE-bench Verified (2024-08-13): SWE-bench 표본을 사람이 다시 검토해, 판정 test가 올바른 해법도 떨어뜨릴 수 있거나 문제 설명이 부족한 표본을 걸러 500개를 남겼다. grader 자체를 검증한 사례다.
- Harbor: Terminal-Bench 2.0을 실행하는 evaluation harness다. task를 지시, 평가 환경, 판정 script, 참조 해법으로 구성하고, 판정 script만 고쳤을 때 agent를 다시 실행하지 않고 저장된 결과를 다시 채점하는 기능(
regrade)이 있다.
이번에 해볼 것
Lab을 진행하며 남긴 평가 항목
H1부터 H12까지 각 Lab의 FAQ에 남긴 항목과, 앞의 대응표에서 드러난 evals의 차이를 평가 흐름의 단계로 나누면 다음과 같다. 번호는 실행 흐름 그림의 번호다.
① task와 ⑤ grader: 판정이 맞는가
| 항목 | 나온 곳 | 문제 |
|---|---|---|
| A. 명령 실패 분류 | H3 FAQ | bash 명령의 exit code가 0이 아니면 모두 실패로 기록. 차이를 찾아 exit 1을 돌려준 diff, a || b에서 실패한 앞 명령도 실패로 기록 |
| B. 보고와 실행 기록 대조 | H3 FAQ | model이 최종 보고에서 실행 기록과 다른 사실을 말해도 판정하지 못함 |
| C. 경로 채점 | 대응표 | tool_calls check가 호출 횟수와 대상 경로를 채점. 다른 방법으로 같은 결과를 내도 실패 |
| D. 참조 해법 | 대응표 | task가 풀리는지, check가 맞게 설정됐는지 확인할 해법이 없음 |
④ transcript와 ⑥ 집계: 기록과 숫자가 맞는가
| 항목 | 나온 곳 | 문제 |
|---|---|---|
| E. 압축 직후 token 추정 | H10 FAQ | hel의 token 추정이 사용 가능한 tool 목록을 세지 않아 실제 입력과 차이 |
| F. 비용 분리 | H10 FAQ, H11 FAQ | MCP 서버 통신과 model 요청 비용이 섞임. cache hit가 64 token 단위로 끊기는 이유를 측정값에서만 추정 |
| G. 일관성 지표 | 대응표 | 반복 실행의 평균만 집계. 매번 성공하는지(pass^k)는 보지 않음 |
③ trial과 ⑦ 결과의 쓰임: 구성을 바꾸면 도움이 되는가
| 항목 | 나온 곳 | 문제 |
|---|---|---|
| H. 회귀 확인 | 대응표 | Lab마다 그 기능의 task만 실행. 기능을 더한 뒤 이전 기능이 깨져도 드러나지 않음 |
| I. 기능 수와 성공률 | Part VI 개요 | 과거 단계의 hel을 같은 task로 실행해 기능이 늘 때의 성공률과 비용을 비교한 적 없음 |
| J. MCP tool 로딩 방식 | H10 FAQ | 목록 전체를 처음부터 보내는 방식과 필요할 때 불러오는 방식의 입력 token·선택 정확도 |
| K. 위임할 정보의 양 | H11 FAQ | 넘길 정보가 많을 때 위임 방식별 성공률과 비용 |
| L. 자식 결과 형식 | H12 FAQ | 결과 형식이 제각각이라 부모가 같은 파일을 다시 확인 |
| M. 그 밖의 구성 | H2·H6·H10·H12 FAQ | model별 search_replace 매칭 실패, 압축의 비용 손익분기, skill 수, 동시에 실행하는 자식 수 상한, hook·skill과 동시 실행의 조합 |
이번에 다룰 항목: 실패 분류와 일관성 지표
이번 Lab에서는 A(명령 실패 분류)와 G(일관성 지표)를 다룬다. 두 항목은 지금까지 저장한 실행 기록만으로 다시 판정하고 집계할 수 있다. ⑤ grader가 맞게 판정하는지 확인하고, ⑥ 집계에 일관성 지표를 더하고, ⑧ 기록을 읽어 판정을 고치는 흐름을 한 번에 다룬다.
A. 실패 분류. hel은 tool을 호출할 때마다 성공·실패를 하나의 값(ok)으로 기록한다. bash 명령은 exit code가 0이 아니면 실패이고, read_file은 열려는 경로가 없으면 실패다. 이 값 하나에 성격이 다른 실패가 섞인다.
| 종류 | 뜻 | 예 |
|---|---|---|
| 의도한 비 0 exit | exit code가 0이 아니지만 명령이 할 일을 함. 실패 아님 | 차이를 찾은 diff, 일치하는 줄이 없는 grep |
| 명령 실패 | 없는 명령·옵션, 문법 오류, 작업 단계의 실패 | macOS에 없는 md5sum, cat -A |
| 경로 오류 | 없는 파일·폴더를 열거나 경로 형식이 틀림 | model이 파일 위치를 추측해 연 경로 |
| harness 정책 차단 | 권한, sandbox, hook이 호출을 막음. harness가 의도한 동작 | H7의 권한 거부, H10의 hook 차단 |
| 실행 환경 오류 | harness가 쓰는 프로그램이나 환경의 문제. agent의 실수가 아님 | rg가 없어 실패한 glob, grep |
이 구분을 다음 순서로 다룬다.
- 문제 정의: 저장된 실행 기록에서 실패로 남은 tool 호출을 읽고, 위 종류 중 어디에 속하는지 사람이 직접 분류한다.
- 평가 방식: 같은 호출을 자동 분류 규칙으로 나누고, 사람이 분류한 결과와 얼마나 일치하는지 본다. 규칙이 의도한 결과를 실패로 셌는지, 실제 실패를 놓쳤는지 따로 센다.
- 계측:
hel은 실행 기록에 exit code와 실패한 호출의 결과를 남기고, 종류는evals가 그 기록을 읽어 판정한다. 판정 규칙을 고치면evals report가 저장된 기록을 다시 판정해 함께 집계한다.
G. 일관성 지표. evals report는 조건과 task마다 통과한 run 수와 평균 token, 실행 시간을 보여 준다. 여기에 task별 pass@k와 pass^k를 더한다. k는 그 조건에서 반복한 횟수다. 같은 통과 수라도 매번 통과한 task와 가끔 통과한 task를 구분할 수 있게 된다. 지난 Lab의 저장된 판정에 이 지표를 적용해, 평균 통과율로 내렸던 판단과 달라지는 곳이 있는지 확인한다.
두 항목 모두 저장된 기록으로 먼저 확인한다. 그다음 예전에 실패가 나왔던 task 세 개를 고친 hel로 다시 실행해, 새 기록에 판정 근거가 남는지와 규칙이 처음 보는 실패도 맞게 판정하는지 확인한다.
결과 확인
1. 실행 기록에 남기는 것
지금까지 hel은 tool 호출마다 성공·실패를 ok 하나로 기록했다. 실패한 이유는 model에게 돌려준 메시지에만 있었고, 그 메시지는 다음 model 요청에 실려 원본 요청 기록(raw/requests.jsonl)에 남았다. 이번에는 실행 기록의 tool 호출 항목에 bash의 exit code와 실패한 호출이 돌려준 결과를 함께 남긴다.
#![allow(unused)]
fn main() {
// crates/hel/src/main.rs — tool 결과를 기록하고 대화에 넣는 곳
log.events.push(ToolEvent {
seq: log.events.len() as u32 + 1,
category: tools::category(&call.name),
name: call.name.clone(),
args: call.args.clone(),
ok: Some(result.is_ok()),
exit_code: (call.name == tools::BASH)
.then(|| record::bash_exit_code(text))
.flatten(),
error: result
.as_ref()
.err()
.map(|err| record::error_excerpt(&call.name, err)),
});
}
hel이 하는 일은 사실을 기록하는 것까지다. 실패의 종류는 evals가 기록을 읽어 판정한다. 판정 규칙을 고쳐도 model을 다시 실행하지 않고 저장된 기록을 다시 판정할 수 있다. 결과는 300자까지만 남기고, bash는 stderr가 stdout 뒤에 붙으므로 끝부분을 남긴다. 위임한 자식 agent의 호출도 같은 함수를 지나므로 자식 기록(raw/delegations.jsonl)에 같은 값이 남는다.
evals의 판정 규칙은 오류 문구와 bash 명령으로 다섯 종류를 나눈다. bash는 exit code가 1이고, 실제로 exit code를 정한 명령이 diff, grep, cmp, test 같은 비교·검색 명령이면 의도한 비 0 exit로 본다.
#![allow(unused)]
fn main() {
// crates/evals/src/failures.rs (발췌)
if error.starts_with("permission denied") || has("hook blocked")
|| has("outside the working directory") || has("Delegation depth limit") {
return Kind::Policy;
}
if has("program not found") || has("could not start bash") {
return Kind::Environment;
}
if name == "bash" {
let answers = deciding_programs(command)
.iter()
.any(|program| ANSWERS_WITH_EXIT_1.contains(&program.as_str()));
return if exit_code == Some(1) && answers { Kind::IntendedExit } else { Kind::Command };
}
}
deciding_programs는 명령의 마지막 문장에서 &&, ||로 이어진 각 pipeline의 마지막 프로그램을 돌려준다. bash는 마지막으로 실행된 명령의 exit code를 돌려주므로, diff a b && echo same에서 diff가 차이를 찾으면 echo는 실행되지 않고 diff의 exit 1이 남는다. 마지막 문장이 exit $status이면 status=$?로 결과를 받은 앞 문장을 본다.
예전 기록에는 이 값이 없으므로 원본 요청 기록에서 같은 값을 뽑는다. 요청에 담긴 assistant 메시지에서 tool 호출을 순서대로 모으고, 이름과 인자가 같은 기록 항목에 tool 메시지의 내용을 붙인다.
2. 저장된 기록을 다시 판정하기
evals failures는 저장된 run의 실패한 tool 호출을 한 줄씩, 판정한 종류와 함께 출력한다. --labels로 사람이 분류한 파일(run, agent, 호출 순서, 종류를 tab으로 나눈 줄)을 주면 둘을 비교한다. 사람이 판정할 수 없는 호출은 종류를 ?로 적어 비교에서 뺀다.
cargo run -p evals -- failures --labels labels.tsv
H1부터 H12까지 저장된 실패 호출은 부모 agent 502건, 위임한 자식 agent 552건이다. 사람이 분류한 것은 bash 실패 22건 전부, read_file이 아닌 실패 전부, read_file 실패 표본 34건이다. 처음 만든 규칙은 83건 중 70건(84.3%)이 사람 분류와 같았다. 다른 13건의 원인은 다음과 같았다.
| 원인 | 건수 | 예 |
|---|---|---|
| 위임 실패의 원인이 자식 쪽에 있음 | 6 | 부모 기록에는 “자식이 답 없이 끝남“만 남고, 자식은 rg가 없어 검색하지 못함 |
| 실패 원인이 기록에 없음 | 3 | ls; find … 2>/dev/null이 exit 1. model이 stderr를 버려 사람도 원인을 확인할 수 없음 |
| 복합 명령의 exit code를 정한 명령을 잘못 봄 | 2 | diff … && echo …, diff …; status=$?; …; exit $status |
| 규칙이 모르는 예전 오류 문구 | 2 | H1의 Is a directory |
위 코드는 이 차이를 보고 고친 규칙이다. 위임 실패에는 자식의 마지막 실패 종류를 붙이고, 원인이 기록에 없는 3건은 사람 분류에서도 판정 불가로 빼고 비교했다.
labeled 80 · judged by the rule 80 · agree 74 (92.5% of labeled) · excluded as unjudgeable by hand 3
intended exit counted as a failure: 0
failure counted as an intended exit: 0
command: 11/11
environment: 14/20
intended-exit: 3/3
path: 32/32
policy: 14/14
(중략: 사람 분류와 다른 6건의 목록. 모두 H12 위임 실패, environment → path)
- 의도한 비 0 exit와 실제 실패를 서로 잘못 센 경우가 없어졌다.
- 남은 6건은 위임 실패다. 자식은 먼저
rg가 없어 검색에 실패했고, 그 뒤 파일 위치를 추측해 없는 경로를 20번 넘게 열었다. 규칙은 마지막 실패(경로 오류)를, 사람은 첫 원인(실행 환경 오류)을 봤다. 어느 쪽을 원인으로 볼지는 규칙을 만드는 사람이 정할 문제로 남겼다. - 92.5%는 틀린 사례를 보고 규칙을 고친 뒤 같은 기록으로 잰 값이다. 규칙이 처음 보는 사례에서도 맞는지는 새로 실행한 기록으로 확인했다.
- 예전 기록에는 판정할 수 없는 실패도 남았다. max_turns로 끝난 run은 마지막 응답의 tool 결과가 다음 요청에 실리지 않아 원본 기록에 없다(부모 74건). 자식 여럿을 동시에 실행한 기록은 자식별 요청 순서를 맞출 수 없어 판정하지 않았다(자식 99건).
3. 새로 실행한 기록
현재 hel(사용 가능한 tool 전체, 위임과 동시 실행 사용)로 예전에 실패가 나왔던 세 작업을 4번씩 실행했다.
cargo run -p evals -- run h13 --build
condition task pass file-exact output-exact in/out tokens cached · peak ctx calls pass@k · ^k failed calls
variant edit-line-01 4/4 4/4 n/a 13996 / 585 10304 · 3758 5 1 · 1 (k=4) command 2
variant env-checksum-01 4/4 4/4 n/a 8283 / 515 6048 · 2312 4 1 · 1 (k=4) command 1
variant parallel-explore-01 2/4 n/a 2/4 33596 / 3074 27072 · 3863 14 1 · 0 (k=4) none
- 12번 모두 유효한 실행이었다.
- 실패한 tool 호출 3건 모두 exit code와 결과가 기록됐다. bash 호출은 모두 exit code가 남았다. 판정에 쓸 근거가 없는 실패는 없었다.
- parallel-explore-01의 실패 2번은 값은 맞았지만 형식이 달랐다. 한 번은 설명 문장을 덧붙였고, 한 번은 세 값을 한 줄에 썼다.
- env-checksum-01에서 model은 이번에는
md5sum을 쓰지 않았다. 예전 기록의 명령 실패는 새 실행에서 다시 나오지 않았다.
새 실행에서 나온 실패 3건은 판정 규칙이 처음 보는 사례다. 규칙이 이런 사례에서도 맞는지 보려면, 사람의 분류가 규칙의 답을 보고 따라가지 않아야 한다. 그래서 규칙이 호출마다 붙인 종류를 보기 전에, 실행 기록에 남은 명령과 결과만 읽고 사람이 먼저 종류를 정했다. 다만 evals run이 끝날 때 출력한 위 요약표의 failed calls 열은 분류 전에 봤다. 이 열은 작업별로 종류마다 몇 건인지만 보여 준다(edit-line-01은 command 2건, env-checksum-01은 command 1건). 어느 호출이 어떤 종류로 판정됐는지는 분류를 마친 뒤 다음 명령으로 확인했다.
cargo run -p evals -- failures h13
출력을 표로 옮기면 다음과 같다(결과는 끝부분만 줄여 적었다).
| 작업 | 호출 | exit | 규칙의 판정 | 기록된 결과 |
|---|---|---|---|---|
| edit-line-01 4번째 실행 | ls -la; echo "---"; find . -name settings.py … 2>/dev/null | 1 | command | ls와 find의 정상 출력만 있음 |
| edit-line-01 4번째 실행 | grep -n …; python3 -c "…" | 1 | command | …specify the Xcode that you wish to use for command line developer tools. Use xcode-select --install … |
| env-checksum-01 3번째 실행 | python3 -c "import hashlib …" | 1 | command | 위와 같은 xcode-select 안내 |
- 첫 번째는 H10에서 본
ls; find … 2>/dev/null과 같은 형태로, 원인이 기록에 없어 판정하지 않았다. - 나머지 2건은
python3 -c …가 xcode-select 오류로 실행되지 않은 경우다. 같은 Mac에서 sandbox 밖의python3는 동작하고, sandbox가 없던 H3 기록에서는python3호출이 5번 모두 성공했다.hel의 sandbox가 Command Line Tools 경로를 읽지 못하게 막는 것으로 보인다. 사람은 실행 환경 오류로, 규칙은 처음 보는 오류 문구라 명령 실패로 판정했다. 규칙이 처음 보는 사례 2건에서 둘 다 틀렸다. - 이 오류를 받은 model은 원인을 모른 채 다른 방법으로 작업을 끝냈다. run은 통과했지만 harness가 쓸 수 있어야 할 명령을 막고 있었다. 이 문제는 규칙이 아니라 기록을 읽어서 찾았다.
4. 지난 Lab의 일관성
evals report로 지난 Lab의 저장된 판정을 다시 집계했다. 65개 조건×task 중 7개가 한 번 이상 통과했지만 매번 통과하지는 못했다(pass@k = 1, pass^k = 0).
| Lab | 조건 | task | 통과 |
|---|---|---|---|
| H1 | 읽기 tool 추가 | find-echo-01 | 2/3 |
| H4 | 검색 tool 추가 | trace-config-01 | 2/3 |
| H8 | sandbox 적용 | sandbox-outside-01 | 1/3 |
| H11 | 위임(tool 기록 제외) | delegate-port-handoff-01 | 2/3 |
| H11 | 위임(위임 지시만) | delegate-port-handoff-01 | 1/3 |
| H12 | 순서대로 실행 | parallel-explore-01 | 2/5 |
| H12 | 동시 실행 | parallel-explore-01 | 3/5 |
각 Lab에서는 통과한 run 수를 조건끼리 비교했다. 이 7곳은 같은 작업도 실행마다 결과가 갈린 곳이다. 반복 3~5번으로는 이 차이가 harness 구조 때문인지 model의 실행 편차인지 구분하기 어렵다는 한계가 있다.
돌아보기
변경 사항
hel은 tool 호출마다 bash의 exit code와 실패한 호출의 결과를 실행 기록에 남긴다. evals는 이 기록으로 실패를 다섯 종류로 나누고, report에 task별 pass@k·pass^k와 종류별 실패 수를 보여 준다. evals failures로 저장된 실패를 목록으로 보고, 사람이 분류한 파일과 비교할 수 있다.
새로 실행한 기록에서는 실패한 tool 호출 모두에 판정할 근거가 남았다. 차이를 찾아 exit 1을 돌려준 diff는 더 이상 실패로 집계되지 않는다. 실패 종류의 자동 판정은 규칙이 처음 보는 사례에서 2건 모두 틀려, 판정 결과를 통과·실패에 쓰지 않고 기록을 읽을 대상을 고르는 참고 분류로 둔다.
트레이드오프
- 실패한 호출마다 최대 300자가 기록에 더해진다. model 요청과 token은 달라지지 않는다.
- 판정 규칙은
hel의 오류 문구에 의존한다. 오류 문구가 바뀌거나 새 원인이 생기면 규칙도 고쳐야 한다. sandbox 안python3오류처럼 처음 보는 문구는 명령 실패로 판정된다. - 규칙을 고칠 때 쓴 기록으로 다시 재면 일치율이 올라간다. 처음 보는 사례로 확인하는 기록을 따로 두지 않으면 규칙이 얼마나 맞는지 알 수 없다.
- 과거 기록은 다시 판정할 수 있지만 전부는 아니다. 결과가 요청에 실리지 않은 호출과 동시에 실행된 자식의 호출은 판정할 수 없다.
- 사람 분류는 한 사람이 했다. 판단이 갈리는 사례(위임 실패의 원인, stderr를 버린 명령)는 분류하는 사람에 따라 달라질 수 있다.
논문의 내용 또는 다른 harness와 비교하면
이번 Lab에서 실행 기록으로 harness를 평가해 보니, 기록에 무엇이 남는지가 평가할 수 있는 범위를 정했다. 예전 기록에서는 실패한 호출의 절반 이상이 판정할 근거를 남기지 않았다.
Anthropic 글은 code grader가 예상한 형태에서 벗어나면 올바른 해법도 실패로 처리한다고 경고하고, transcript를 읽어 grader를 확인하라고 권한다. 이번 결과가 그 두 가지를 모두 보여 줬다. 오류 문구 규칙은 이미 본 형태에서는 맞았지만 새 원인에서 틀렸고, sandbox 문제는 기록을 읽어서 찾았다. Harbor가 판정기만 바꿔 저장된 결과를 다시 채점하는 기능을 두는 것처럼, hel도 사실을 기록하고 판정은 evals에 두었기 때문에 규칙을 바꿔 가며 같은 기록을 여러 번 다시 판정할 수 있었다.
-
원문의 valid solution을 옮긴 말이다. 지시가 요구한 것을 실제로 해냈지만, 판정 규칙이 예상한 형태나 경로와 달라 실패로 처리된 해법을 가리킨다. 이때 고칠 대상은 agent가 아니라 판정 규칙이다. 예를 들어 CORE-Bench에서는 96.12라는 답이 기대값 96.124991…과 자릿수까지 같지 않다는 이유로 실패했다. tool 호출 순서를 채점하면 다른 순서로 같은 결과를 낸 실행도 실패가 된다. H3에서는 두 파일의 차이를 찾아 exit 1을 돌려준
diff가 실패로 기록됐다. ↩
FAQ
sandbox 안에서 실행되지 않는 python3
새로 실행한 기록에서 python3 -c …가 xcode-select 오류로 두 번 실패했다. 같은 Mac에서 sandbox 밖의 python3는 동작하고, H8에서 sandbox를 더하기 전인 H3 기록에서는 매번 성공했다. macOS의 /usr/bin/python3는 Command Line Tools를 찾아 실행하는데, hel의 sandbox가 그 경로 읽기를 허용하지 않는 것으로 보인다. model은 원인을 모른 채 다른 명령으로 작업을 끝냈고 run은 통과했다. sandbox가 허용할 시스템 경로를 고치는 일은 H8의 sandbox 구성에서 이어서 다룬다.
결과가 기록에 남지 않는 호출
예전 기록에서는 tool 호출의 결과가 다음 model 요청에 실려야만 원본 기록에 남았다. max_turns로 끝난 run은 마지막 응답의 tool 결과가 다음 요청에 실리지 않아 74건을 판정할 수 없었다. 이번에 실행 기록에 결과를 직접 남기면서 새 기록에서는 이 문제가 없다. 다만 실행 기록에 남기는 결과는 300자로 자르므로, 긴 출력의 중간에 원인이 있으면 놓칠 수 있다. 결과를 얼마나 남길지는 기록 크기와 함께 정해야 한다.
사람 분류를 여러 사람이 하면
이번 사람 분류는 한 사람이 했다. Anthropic 글은 사람 판정을 여러 명이 하고 서로 얼마나 일치하는지 확인하라고 권한다. stderr를 버린 명령이나 위임 실패의 원인처럼 판단이 갈리는 사례는 분류하는 사람에 따라 달라질 수 있다. 같은 실패를 여러 사람이 분류해 사람끼리의 일치도를 먼저 재면, 규칙이 도달할 수 있는 일치율의 기준이 생긴다.
반복 횟수와 pass^k
지난 Lab은 조건마다 3~5번 실행했다. pass^k는 k번 모두 통과해야 1이므로, 반복 횟수가 적으면 우연히 모두 통과하거나 한 번 실패한 차이로 값이 바뀐다. 이번에 찾은 7곳이 harness 구조 때문인지 model의 실행 편차인지 나누려면 같은 조건을 더 많이 반복해야 한다. 반복을 늘리는 비용과 함께 어느 task에서 일관성을 확인할지 정하는 일이 남는다.
참고 자료
핵심 레퍼런스
- Harness Engineering: Anatomy, Architecture, and Evolution of Coding Agents — A Source-Code Study of Eleven Systems (arXiv:2609.00006) — production coding harness 11개를 소스 코드로 분석한 논문. harness를 7개 영역으로 나누는 분류를 이 논문에서 가져왔다.
Model과 API
- DeepSeek API Docs — 실험 model의 API 문서
- DeepSeek — Integrate with Claude Code — Claude Code를 DeepSeek model로 실행하는 방법
외부 harness
- Claude Code documentation
- Claude Code — LLM gateway — 다른 endpoint 연결과 지원 범위
Rust
- The Rust Programming Language
- reqwest — HTTP client
- serde — 직렬화