nova tech
Other

LLM to Agentic 4) 에이전트에게-파일을-쥐여주기-전에-할일

박재연·2026. 8. 2.·조회 10

배경

에이전트는 결국 세 가지의 반복이다. 모델이 도구를 고르고, 우리가 실행하고, 결과를 다시 먹여 루프를 돈다. 지난 단계까지 우리는 이 루프가 어떤 실패에도 죽지 않게 만들었다. 없는 도구든 깨진 인자든, 실패는 예외가 아니라 모델이 읽는 관측이 된다.

그런데 여기까지 이 루프가 쥔 도구는 add_numbers 하나뿐이었다. 인자만 있으면 완결되는 순수 계산. 이제 진짜 하고 싶은 건 파일을 읽는 것이다.

그리고 read_fileadd_numbers와 다른 종류의 도구다. "2와 3을 더해라"는 세상에 손대지 않는다. "notes.txt를 읽어라"는 모델의 텍스트 상자 밖, 실제 파일시스템으로 손을 뻗는다. 이 순간 순수 함수였던 에이전트가 처음으로 환경에 접촉한다. 접촉에는 대가가 따른다.

문제 — "파일 도구를 추가한다"는 착각

흔히 이걸 기능 추가로 본다. "핸들러 하나 쓰면 되지." 하지만 파일을 여는 한 줄 안에는 세 개의 숨은 질문이 있다.

  • 그 파일은 어느 workspace에 있는가? — 도구는 자기가 어디서 일하는지 모른다.
  • 이 위험한 도구를 어디에 얹을 것인가? — 실행 로직은 이미 충분히 복잡하다.
  • 그 경로가 workspace 밖으로 나가면? — 모델이 ../../etc/passwd를 요청하면?

가장 쉬운 유혹은 이 셋을 read_file 안에서 한 방에 처리하는 것이다. 그러면 나중에 버그가 났을 때 기능이 틀렸는지, 격리가 뚫렸는지, 구조가 꼬였는지를 동시에 디버깅하게 된다.

그래서 질문을 바꾼다. "어떻게 파일 기능을 추가할까"가 아니라 — 에이전트에게 환경을 쥐여주기 전에, 무엇을 먼저 세워야 하는가. 세 단계(18·19·20)는 사용자에게 보이는 기능을 하나도 추가하지 않는다. 파일은 21에서야 열린다. 그런데도 굳이 나눈 이유가 여기 있다.

분석 — 권한을 쥐여주는 일은 세 개의 "어디"다

위험한 능력을 도구에 넘길 때, 저절로 세 질문이 따라온다. 각 질문이 하나의 단계가 되고, 그 순서는 뒤집을 수 없다.

질문단계세우는 것
도구는 어디서 일하는가18통로 — 런타임 의존성 주입
위험을 어디에 얹는가19구조 — 기능 전 복잡도 격리
도구는 어디까지 닿는가20경계 — 경로 봉인

통로 없이 파일을 열면 전역 상태에 의존해 격리가 깨지고, 구조 없이 경계를 얹으면 기능 버그와 구조 버그가 뒤섞이며, 경계 없이 경로를 결합하면 workspace가 뚫린다. 그래서 통로 → 구조 → 경계 순이다.

분석의 하이라이트 — "경계는 문자열이 아니라 구조로 긋는다"

가장 흥미로운 건 20의 경계였다. 순진한 방어는 이렇게 생각한다. "결과 경로가 workspace로 시작하는지 보면 되잖아?"

str(candidate).startswith(str(workspace))

이건 경계가 아니다. workspace가 /tmp/work/tmp/work-secret도 같은 문자열로 시작한다. 문자열 prefix는 우연히 같은 글자일 뿐, 경로의 포함 관계가 아니다.

해결은 "더 똑똑한 문자열 비교"가 아니었다. 판정의 층위를 바꾸는 것이었다 — 문자열이 아니라 path component 관계로(is_relative_to). 지난 글에서 "경계가 관측 가능성을 정한다"고 했듯, 여기서도 같은 형태의 통찰이 나온다. 무엇을 안전하다고 판정하느냐는, 어느 층에서 판정하느냐가 정한다.

해결 — 통로, 구조, 경계

통로(18) — 도구에 실행 맥락을 명시적으로 꽂는다. 핸들러 계약을 handler(arguments)에서 handler(arguments, context)로 넓힌다. 호출자가 만든 RunContext(workspace=...)run_agent()를 거쳐 모든 핸들러까지 흐른다. 딱 그 통로만 뚫는다 — 파일을 열지도, 경로를 붙이지도 않는다. 이 작은 결정에 원칙 셋이 박혀 있다. ① 모델이 보는 것과 호스트가 주입하는 것을 분리한다 — context는 모델 스키마에 없다. workspace 절대경로가 모델 출력이나 trace로 새지 않는 것도 이 분리 덕분이다. ② 미래 필드는 소비자가 생길 때 만든다 — run ID·sandbox lease는 넣지 않고 지금 쓸 workspace 하나만. ③ 기본값으로 결정을 숨기지 않는다Path.cwd()를 몰래 기본값으로 넣지 않아, 호출부만 읽어도 어느 workspace인지 보인다.

구조(19) — 위험을 얹기 전에 방을 치운다. 이 시점 run_agent()는 tool call 하나를 처리하며 여섯 질문(반복인가·JSON 파싱되나·핸들러 있나·인자 유효한가·예외 던졌나·무엇을 기록하나)을 한 자리에서 답했다. 중첩된 if/try/except 탓에 바깥의 ReAct 루프보다 에러 분기가 먼저 눈에 들어왔다. 새 기능 0개로, 실행 로직을 이름 있는 함수(execute_tool_call, is_repeated_tool_call)와 책임별 폴더로 뽑아 run_agent()엔 오케스트레이션만 남겼다. 참은 것이 오히려 인사이트다 — think()/act()는 만들지 않았다. think에 해당하는 코드는 한 줄뿐이라, 감싸면 복잡도가 줄기는커녕 함수만 하나 더 열게 된다. 복잡도가 실제로 모인 곳(도구 실행)만 분리했다. class도 아니다 — 상태도 lifecycle도 교체 구현체도 없으면, 함수가 가장 작은 경계다.

경계(20) — workspace 밖으로 못 나가게. 순진한 결합 workspace / requested_path는 상대·절대를 구분하지 않아 /etc/passwd·../../secret·바깥 symlink에 뚫린다. 그래서 다섯 단계로 봉인한다 — POSIX 경로로 해석 → 절대경로 거부 → .. 거부 → 실제 경로 계산(symlink 추적) → is_relative_to로 포함 관계 검사. ..는 계산상 안으로 돌아오더라도 거부한다. 모델에게 단순하고 일관된 계약을 주기 위해서다. 오류는 WorkspacePathError라는 typed exception이라, 21의 read_file이 이것만 골라 잡고 무관한 오류를 삼키지 않는다.

검증 — 각 경계를 테스트로 못박는다

세 단계 모두 실험은 같은 형태다. 계약을 단언하는 테스트를 먼저 RED로 깔고, 최소 구현으로 GREEN을 만든다.

  • 통로(18) — 핵심은 "정의만 된 게 아니라 실제로 소비되는가"였다. 받은 context를 저장하는 핸들러를 심고, 모델이 그 도구를 부른 뒤 받은 context == 넘긴 context를 확인한다. dispatch 경로에서 통로가 실제로 뚫렸다는 증거다.
  • 구조(19) — 리팩터의 성패는 "동작이 하나도 안 변했다"의 증명이다. 9가지 상황의 결과를 표로 고정하고 회귀 37개 전부 통과로 못박는다.
  • 경계(20)notes.txt·/etc/passwd·../../secret·내부 symlink·외부 symlink 6개 입력의 결과를 표로 고정하고, 42개까지 통과시킨다.

정직한 한계도 산출물이다. 20은 경로를 계산한 뒤 파일을 열기 직전에 symlink가 교체되는 TOCTOU race는 풀지 않는다. 이건 최종 Docker 제품에서 조작 직전 realpath 재검사로 막는다. 막은 것과 못 막은 것을 적어두는 것 자체가 경계다.

결과 — 능력 지도

세 단계를 지나면, 파일 도구가 딛고 설 바닥이 완성된다.

세운 것코드하는 일
통로RunContexthandler(args, context)도구가 자기 workspace를 안다
구조execute_tool_call / agent·llm·tools위험을 얹을 깨끗한 자리
경계resolve_workspace_path / WorkspacePathErrorworkspace 밖 요청을 거부

에이전트는 아직 파일을 못 읽는다. 하지만 이제 파일을 읽어도 어디서 읽는지 알고, 어디에 코드가 놓일지 정해졌고, 어디까지만 닿을지 봉인됐다.

인사이트

  1. 위험한 능력은 "기능"이 아니라 세 개의 "어디"다. 도구가 어디서 일하고(통로), 어디에 얹히고(구조), 어디까지 닿는지(경계)를 먼저 세운 뒤에야 기능을 올린다. 섞으면 버그도 섞인다.
  2. 경계는 문자열이 아니라 구조로 긋는다. 무엇을 안전하다 판정하느냐는 어느 층에서 판정하느냐가 정한다. 같은 원리로, 무엇을 모델에 노출하느냐도 의존성을 어디서 주입하느냐가 정한다 — 위치가 성질을 정한다.
  3. 제품 기능 0개짜리 단계도 독립할 자격이 있다. 기능을 붙이는 일과, 기능이 딛고 설 바닥을 놓는 일은 다른 일이다. 위험한 것일수록, 바닥을 먼저 그리고 따로 놓는다.

댓글 0