
코드 에이전트에서의 파일 멘션 신화
요약
코드 에이전트에서 파일 멘션(@) 기능이 토큰을 낭비한다는 통념을 비판적으로 검토합니다. 직접 에이전트를 개발하며 파일의 메타데이터와 해시값을 활용해 토큰 효율성을 높이는 설계 방안을 제안합니다.
핵심 포인트
- 파일 멘션은 단순히 파일 내용을 프롬프트에 통째로 넣는 방식만 있는 것이 아님
- 모델의 성능 향상과 컨텍스트 윈도우 확대로 인해 툴 호출 방식의 효율성 증대
- 파일의 SHA256 해시와 메타데이터를 활용한 토큰 절약 설계 가능성 제시
이 기사는 「The Mention File Myth in Code Agents」의 일본어판입니다. 원문은 저 자신이 작성한 것이지만, 문장의 재구성 및 퇴고에는 GLM 5.2를 사용했습니다. 바탕이 된 조사의 일부도 GLM 5.2와 함께 진행했습니다.
계기
Claude Code가 막 나왔을 무렵, 공식 블로그[1]로부터 두 가지 습관을 배웠습니다. !로 쉘 커맨드 (shell command)를 그 자리에서 실행하는 것과, @로 파일을 멘션 (mention)하는 것입니다. 둘 다 사용한 순간 「이것은 편리하다」라고 느꼈습니다.
! 덕분에 익숙한 커맨드를 실행하여 출력을 Claude의 컨텍스트 (context)에 흘려 넣거나, 반대로 경위를 모두 Claude가 파악하게 둔 채 다음 작업을 직접 이어받을 수 있었습니다. @도 그만큼 쾌적했습니다. 파일의 위치를 Claude에게 정확하게 전달할 수 있기 때문입니다. 파일 이름만 전달하면, 모델은 툴 호출 (tool call)을 위한 텍스트를 생성하여 파일을 찾아 나서야 하며, 위치를 틀리거나 올바른 파일에 도달할 때까지 여러 번 왕복해야 하는 경우도 있습니다.
한동안 이것이 저의 당연한 업무 방식이었습니다.
「토큰 낭비」설
몇 달 후, X에서 @에 반대하는 의견을 보기 시작했습니다. 논리는 단순했고, 당시에는 분명히 옳게 느껴졌습니다. @는 마법이 아닙니다. 클라이언트 측에서 동작하는 결정적인 코드가 디스크에서 파일을 읽고, 그 내용을 사용자의 프롬프트 (prompt)에 전개하여 통째로 LLM에 보내는 것뿐입니다[2]. 따라서 거대한 파일을 @하면 방대한 토큰을 낭비하게 된다는 것입니다—.
과연, 하고 납득했습니다. 파일 멘션은 그렇게 구현할 수밖에 없습니다. !가 쉘을 호출하는 것과 마찬가지로, 본질적으로는 결정적인 프로그램이기에 이러한 문제는 피할 수 없다고 생각했습니다. 그렇게 납득했습니다.
바이브 코딩 (Vibe Coding)의 시대
이윽고 바이브 코딩이 유행하고 모델은 눈에 띄게 똑똑해졌으며, 어느샌가 저는 @를 거의 사용하지 않게 되었습니다. 모델의 작업을 지켜보고 있으면, 필요할 때마다 디렉토리 구조를 확인하고, 어떤 파일을 읽어달라고 부탁하면 대부분 이미 위치를 알고 있습니다. 컨텍스트 윈도우 (context window)도 앞다투어 커졌기 때문에, 툴 호출과 왕복을 몇 번 더 시키는 정도는 큰 비용이 아니게 되었습니다. 그 대신 정확한 경로를 입력하지 않고 모호한 표현으로 파일을 지칭할 수 있게 된 것입니다 (그렇습니다, 병목 현상은 이제 저 자신—저의 타이핑 속도—이 되어 있었습니다).
그 후 오랫동안 @의 구현에 대해서는 생각조차 하지 않았습니다.
직접 에이전트를 만들어 보니
상황이 바뀐 것은 자작 코드 에이전트 (code agent)인 paimon을 만들기 시작하면서부터입니다. 파일 멘션을 구현할 단계가 되자, 그 오래된 의문이 다시 돌아왔습니다. 다시 한번 차분히 생각해 보니, 과거의 결론이 반드시 옳지는 않을 수도 있다는 생각이 들었습니다.
@는 입력 시의 로컬한 보조(파일 경로 자동 완성)일 뿐이며, 프로그램은 내용을 전혀 프롬프트에 읽어 들이지 않는 설계도 가능하지 않을까?- 한 걸음 더 나아간다면, 파일 멘션은 확실히 결정적인 처리이므로, 에이전트가 한 번 파일을 읽은 시점에서 전문과 함께 내용의 sha256 같은 추가 정보를 보낼 수 있을 것입니다. 다음에 같은 파일을 읽을 때, 이미 전송되었고 sha256도 변하지 않았다는 것을 알 수 있다면, 메타데이터만 보내서 「이 파일은 읽은 적이 있고 내용도 변하지 않았다」라고 모델에게 전달할 수 있지 않을까?
- 그렇다고 가정했을 때, 정말로 내용을 전개할 필요가 생기면 컴팩션 (compaction, 이력 요약)은 어떻게 처리해야 할까? 뭔가 복잡한 관리 기구가 필요하지 않을까?
고민 끝에 메타데이터를 전달하는 것을 축으로 한 설계에 도달했습니다. 멘션된 파일은 XML 엔벨로프 (envelope)로 감싸고, 파일 경로, 내용의 sha256, 동봉한 것이 전문인지 일부인지, 포함된 행 범위, 실제 총 행 수를 모델에게 전달합니다. 대략 그런 형태입니다.
<file
path="src/parser.py"
sha256="3f6a…"
...
동일한 세션 내에서 이미 전문(full text)을 보냈고 해시(hash)도 바뀌지 않았다면, 참조용 메타데이터(metadata)만 보내어 모델에게 "읽었으며 변화 없음"을 알립니다. 일정 크기를 초과하는 파일은 앞부분만 보내고, 실제 길이는 메타데이터에 기록합니다. 이러한 약속을 본 적이 있는 모델은 존재하지 않으므로, 규칙은 시스템 프롬프트(system prompt)에 명시합니다. 백그라운드에서는 지금까지 보낸 모든 파일(해시 및 정확히 무엇을 전송했는지)을 메모리상의 테이블로 관리하며, 컴팩션(compaction) 후에는 "전문 전송 완료" 상태가 성립되지 않으므로 리셋합니다. 이를 쌓아 올리다 보니 꽤 복잡한 상태 전이도(state transition diagram)가 되었습니다.
번거롭게 들릴 수도 있지만, ChatGPT 5.6 sol의 도움을 받아 상태와 흐름을 빠르게 정리할 수 있었습니다. 뿐만 아니라, 모델이 센스를 발휘하여 설계를 하나 더 얹어주었습니다. 사용자가 @filename:10-20과 같은 구문으로 행 번호를 고정했을 경우에도, 전문을 보낸 범위를 기록해 두었다가 이후의 멘션(mention)이 이전에 보낸 범위와 겹쳐서 완전히 덮인다면 범위를 병합(merge)할 수 있다는 것입니다. 이 겹침 계산—어느 범위가 전송되었는지, 어느 부분이 부분적으로 교차하는지, 어느 것을 하나로 합쳐야 하는지—이 설계 전체에서 정말 까다로운 부분이었습니다.
잠깐, 그거 Cursor가 했던 것 아닌가?
문득 정신이 들었습니다. 이 메커니즘, 너무 복잡해지고 있는 것 아닌가?
생각난 것은, Cursor가 막 등장했을 무렵 그 내부 메커니즘을 모두가 분석하고 추측했던 일입니다. 당시 사람들은 LLM의 컨텍스트 윈도우(context window)는 작기 때문에, 코드는 RAG로 검색하여 관련 단편(fragment)만 보내고 있다고 말했습니다[3]. 더 깊이 들어간 분석에서는, 도구 호출(tool call)이 파일이나 명령 조작을 마치면 호출 주변의 컨텍스트는 통째로 버려지고 결과만 다시 끼워 넣어지는 것이라는 추측까지 있었습니다. 후자가 사실이었는지는 차치하더라도, 당시 믿어지던 그 설계는 매우 교묘해 보였습니다. 다만, 제가 직접 사용해 본 범위 내에서는 긴 에이전트적 작업에서의 결과가 그리 좋지 않았습니다. 그 후 Claude Code가 나타나 거의 정반대의 방식을 취했습니다. rg와 같은 단순한 명령어로 힘껏 코드를 찾고, 컨텍스트를 되돌리는 일은 결코 하지 않으며, 윈도우가 가득 차서 컴팩션(compaction)이 실행될 때까지 오로지 모든 것을 앞으로 쌓아 나갑니다. 저에게는 이 방식이 훨씬 극적으로 잘 작동했습니다.[4]
LLM의 작동 방식을 완전히 이해한다고는 할 수 없지만, Claude Code의 방식을 보고 그 소문 난 설계가 본질적으로 무엇을 하고 있었는지 깨달았습니다. 컨텍스트 윈도우를 일종의 데이터베이스(database)로 취급하여, LLM에 보낸 것은 확실히 기억된다고 가정했던 것입니다. 하지만 현재 주류인 어텐션(attention) 기반의 LLM은 그런 보장을 해주지 않습니다. 컨텍스트에 이미 있는 내용이 확실히 사용된다는 보장이 없으며, 긴 이력의 깊은 곳에 가라앉아 있는 경우에는 더욱 그렇습니다[5]. 그렇기 때문에 모델은 같은 것을 다시 읽으려고 시도합니다.
그리고 지금, 저는 바로 그런 종류의 "교묘한 메커니즘"을 설계하고 있었습니다. 범위 병합, 해시를 통한 중복 제거, "해시가 일치하면 메타데이터만 보낸다"는 트릭. 모두 컨텍스트 윈도우를 공식적인 기록 데이터베이스로 취급하는 것들입니다. 제가 똑같은 실수를 반복하고 있는지도 모른다는 생각이 들었습니다.
사실 확인
다행히 이제 오픈 소스 코드 에이전트는 도처에 널려 있으며, 유명한 것들도 적지 않습니다. 이론을 늘어놓는 대신, 작년에 도달했던 결론이 정말 맞는지 실제로 조사해 보기로 했습니다. opencode와 GLM 5.6을 사용하여 잘 알려진 오픈 소스 에이전트 5개——pi, opencode, gemini-cli, grok-build, codex——의 코드를 읽었습니다.[6]
첫 번째 질문—애초에 @는 파일 내용을 보내는가—라는 지점에서부터 벌써 의견이 일치하지 않습니다. 5개 중 4개는 파일을 읽어 사용자 메시지에 인라인(inline)으로 전개합니다. pi는 <file name="...">로 감싸고(단, CLI 실행 인자로 전달한 파일에 한하며, 대화형 TUI에서는 @가 단순한 경로 완성으로 작동하여 경로 문자열이 그대로 전송됨), grok-build는 행 번호가 포함된 <file_contents path="...">를 사용하며, gemini-cli는 --- Content from referenced files ---를 사용합니다.
라는 마커 사이에 끼워 넣으며, opencode는 도구 호출 (tool call)을 위장합니다. 모델 입장에서는 Called the Read tool with the following input: {...}
뒤에 표준적인 Read 출력이 이어져, 마치 스스로 도구를 호출한 것처럼 보이게 됩니다. 그리고 codex만은 파일을 전혀 읽지 않습니다. @
는 파일명의 모호한 검색 (fuzzy search)이며, 후보를 선택하면 경로가 단순한 텍스트로서 삽입될 뿐입니다. 내용이 궁금하다면 모델이 cat
이나 rg
를 사용하면 된다는 설계입니다. 이는 정확히 제 첫 번째 불렛 포인트에 있었던 "@는 단순한 보완 (completion)일지도 모른다"라는 가능성 그 자체였습니다.
| 에이전트 |
@가 보내는 것 |
경로 이외의 메타데이터 | 행 범위 구문 | 전송된 기록 |
|---|---|---|---|
| pi |
전문, 생략 없음 (CLI 인자만 해당) | 없음 | ❌ | ❌ |
| opencode |
자체 Read 도구를 통한 내용 (2000행 / 50 KB 상한), 도구 호출로 위장 | 생략에 대한 주석: "Use offset=N to continue" | ✅ @file#12-18 | ❌ |
| gemini-cli |
전문 (2000행 상한, 20 MB에서 거부) | read_file로 유도하는 생략 경고 | ❌ | ❌ |
| grok-build |
추정 ~5,000 토큰까지 전문, 초과분은 메타데이터만 포함된 스텁 (stub) | 거대 파일에는 skipped="true"와 이유 명시 | ✅ @foo.rs:10-20 | ❌ |
| codex |
아무것도 보내지 않음 — 경로를 단순 텍스트로 삽입 | — | ❌ | ❌ |
이어서, paimon의 설계를 이러한 구현들과 한 항목씩 대조해 보았습니다.
해시 (hash)를 보내는 것은 전무하며, 파일의 실제 크기는 생략에 대한 주의 사항에만 나타납니다. 엔벨로프 (envelope)가 운반하는 것은 경로와 내용, 실질적으로 그것뿐입니다 (opencode의 Read 스타일 출력은 끝에 총 행수를 붙입니다). 제 메타데이터 설계와 가장 유사한 것은 grok-build의 거대 파일 처리 방식입니다. 추정 5,000 토큰을 초과하면 본문을 통째로 버리고 스텁만 보냅니다 — <file_contents path="..." skipped=""true" reason="file too large (~5800 estimated tokens, limit 5000). Use read_file tool to read specific sections."/>.
생략을 수행하는 에이전트는 모두 동일한 패턴을 보입니다. 내용이 잘렸음을 모델에게 알리고, 자체 read 도구로 유도합니다. 메타데이터는 "직접 읽으러 가라"는 힌트일 뿐, 중복 제거 (deduplication)를 위한 키로는 결코 사용되지 않습니다.
전송된 내용을 추적하는 것도 전무합니다. 해시도, mtime도, 메모리 상의 테이블도, "이 파일은 이미 가지고 있다"라는 분기(branch)도 존재하지 않습니다. 멘션 (mention)을 할 때마다 디스크를 다시 읽고, 전문을 다시 보냅니다. 유일한 중복 제거는 하나의 메시지 내에서 중복된 멘션을 묶어주는 Set뿐입니다. 멘션 경로에서 발견한 유일한 sha256 (grok-build의 것)은 디스크 상의 임시 파일 명명에 사용될 뿐, 모델에는 전달되지 않습니다. 유일한 진짜 반례는 gemini-cli의 깊은 곳에 숨겨져 있었습니다. ContextCompressionService라는 클래스가 있는데, 파일 내용을 해싱하여 작은 모델이 각 파일을 FULL / PARTIAL / SUMMARY / EXCLUDED로 분류하게 만듭니다 — 제 설계의 일부와 놀라울 정도로 닮아 있습니다. 다만 기본적으로 비활성화된 실험용 플래그(experimental flag) 뒤에 숨겨져 있으며, 제가 읽은 커밋 시점에서는 런타임 어디에서도 인스턴스화되지 않았고, 설령 작동한다 하더라도 처리하는 것은 read 도구의 응답뿐이며, @ 멘션의 인라인 (inline)은 대상이 아닙니다. 같은 생각을 한 사람은 있었습니다. 하지만 세상에 나오지는 않았습니다.
행 범위 구문은 존재하지만, 중첩 (overlap) 관리는 없습니다. opencode는 @file#12-18 (offset과 limit이 포함된 Read 호출로 변환됨)을, grok-build는 @foo.rs:10-20
를 지원합니다. 하지만 둘 다 보낸 범위를 기록하지 않으며, 중첩된 범위를 병합(merge)하지도 않습니다. 모든 멘션(mention)은 독립적인 읽기(read)입니다. ChatGPT와 제가 스케치했던 그 중첩 병합 메커니즘은 어디에도 존재하지 않았습니다.
시스템 프롬프트(system prompt)에서 이를 설명하는 것도 없습니다. 5개의 시스템 프롬프트 모두 멘션이 어떤 형태를 띠고 있는지에 대해 침묵하고 있습니다. pi와 grok-build는 XML이 자기 기술적(self-descriptive)이라는 점에 의존하고 있으며, grok-build 소스의 주석은 자신들의 포맷을 "우리가 오랫동안 사용해 온 훈련 포맷"이라고 부릅니다. opencode의 가짜 Read 호출은 가장 교묘한 회피책입니다. 모델은 Read의 출력이 어떤 것인지 이미 알고 있기 때문에 아무것도 문서화할 필요가 없습니다. 그리고 codex에는 설명해야 할 것이 아무것도 없습니다. 보내는 것은 경로(path)뿐이니까요.
컴팩션(compaction)은 멘션을 특별 취급하지 않습니다. 5개 모두 히스토리가 요약될 때, 인라인으로 전개된 파일 내용은 단순한 사용자 텍스트로서 요약기(summarizer)에 통째로 전달됩니다. 해시(hash)를 통한 분류도, 플레이스홀더(placeholder)로의 교체도 없습니다. "상태(state)가 컴팩션을 어떻게 살아남을 것인가"라는 나의 걱정은 깔끔하게 사라졌습니다. 살아남아야 할 상태가 애초에 존재하지 않았던 것입니다.
신선도(freshness)를 감시하는 것도 없습니다. 멘션된 파일이 그 후 디스크 상에서 변경되더라도, 컨텍스트(context) 내의 오래된 복사본에 만료 표시를 하는 메커니즘은 없습니다. 모델은 다음에 우연히 그 파일을 읽었을 때 비로소 깨닫게 될 뿐입니다.
그리고 가장 시사적인 발견: codex는 한때 정반대의 방식을 사용했었습니다. TypeScript 버전 CLI 시절, codex의 @는 다른 에이전트와 동일하게 동작했습니다. 도입 당시의 풀 리퀘스트(pull request) 문구를 빌리자면, "파일 내용은 LLM에 보내지기 전에 자동으로 XML 블록으로 전개(expand)"
더욱 까다로운 점은, 그 해답이 모델 간에 호환되지 않을 수도 있다는 것입니다. 모델 개선에는 강화학습 (RL)이 큰 역할을 하고 있으며, 이는 동시에 각 모델에 고유한 작업 스타일을 각인시킵니다. 따라서 각 벤더의 CLI는 자사 모델이 훈련될 때 사용되었고, 자사의 평가에서 최고점을 기록하는 메커니즘을 자연스럽게 탑재하게 됩니다. grok-build의 소스 코드는 이를 당당하게 밝히고 있습니다. 즉, 저 XML 포맷이 존재하는 이유는 그것이 "우리가 계속 사용해 온 훈련 포맷"이기 때문이라는 것입니다. 타인의 모델 위에 범용 에이전트를 구축하려는 사람에게 이것은 조용하지만 영구적인 고민거리입니다. 최선의 메커니즘은 보편적이지 않으며, 그렇다고 모든 것을 측정할 여유도 없기 때문입니다.
"Claude Code: Best practices for agentic coding". 원래는 Anthropic의 엔지니어링 블로그 기사였으며, 현재는 공식 문서의 일부로 유지되고 있습니다. ↩︎
공식 문서는 지금도 @를 다음과 같이 설명합니다: "코드가 어디에 있는지 설명하는 대신 @로 파일을 참조하세요. Claude는 응답하기 전에 파일을 읽습니다." ↩︎ -
이 추측은 "로컬에서"라는 부분을 제외하면 대체로 맞았던 것으로 보입니다. Cursor는 파일 분할은 로컬에서 수행하지만, 임베딩 (embedding) 계산은 서버 측에서 수행하며 원격 벡터 데이터베이스 (vector database)에 저장합니다. 코드 자체는 사용자의 머신에 남습니다. How Cursor Indexes Codebases Fast를 참조하세요. "도구 호출 (tool call)의 컨텍스트를 버린다"는 부분은 제가 아는 한 확인된 바가 없습니다. 애초에 초기 Cursor에는 에이전트 방식의 도구 호출 자체가 없었습니다. ↩︎
이 기사를 팩트 체크하는 과정에서 처음 알게 된 사실인데, 초기 Claude Code도 RAG와 로컬 벡터 데이터베이스를 시도했다가 단순한 에이전트 검색 (agentic search)을 선택하고 버렸다고 합니다. 저자인 Boris Cherny는 "Early versions of Claude Code used RAG + a local vector db, but we found pretty quickly that agentic search generally works better." (초기 Claude Code는 RAG와 로컬 벡터 DB를 사용했지만, 에이전트 검색이 일반적으로 더 잘 작동한다는 것을 상당히 빨리 발견했다)라고 말했습니다. 같은 스레드의 다른 Anthropic 엔지니어도 "In our testing we found that agentic search outperformed [it] by a lot, and this was surprising." (테스트 결과 에이전트 검색이 [RAG를] 크게 앞질렀으며, 이는 놀라운 결과였다)라고 언급했습니다. ↩︎
이것은 막연한 느낌이 아니라 측정된 현상입니다. 모델은 긴 컨텍스트 (context)의 중간에 있는 정보를 처음이나 끝에 있는 정보보다 명확하게 잘 활용하지 못합니다. Lost in the Middle: How Language Models Use Long Contexts를 참조하세요. ↩︎
5가지 모두 2026년 7월 중순, 다음 커밋에서 확인했습니다: pi 87ad8243, opencode efb6cc2d4, gemini-cli 3ff5ba2, grok-build 98c3b24, codex 315195492c. 모두 작동하는 대상이므로, 이 글을 읽을 때쯤에는 세부 사항이 바뀌어 있을 수 있습니다. ↩︎
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기