Claude Code, Cursor, Aider 같은 코딩 에이전트의 작동 원리를 살펴보고 싶다면 파이썬 파일 하나로 핵심 구조를 익힐 수 있다. 이 예제는 requests만 필수 의존성으로 사용해 코드 읽기와 수정, 테스트 실행이 가능한 에이전트를 만든다.
전체 코드는 Fazel94/agent_500_lines 저장소의 agent.py에서 볼 수 있다. 예제 에이전트에는 파일 읽기·쓰기와 검색, 셸 명령 실행, 사용자에게 질문하기, 웹 검색·페이지 읽기 등 아홉 가지 도구가 들어 있다. OpenAI 호환 API를 지원하므로 환경 변수로 API 제공자와 모델을 지정할 수 있다.
작동의 중심은 모델에 도구 목록을 보내고, 모델이 요청한 도구를 실행해 결과를 다시 전달하는 반복문이다. 모델이 도구 호출 없이 답하면 반복을 끝낸다. 파일 경로가 프로젝트 바깥으로 벗어나지 않게 검사하고, 파일을 쓰거나 셸 명령을 실행하기 전에는 사용자 확인을 받는다. --yes를 사용하면 이 확인 절차를 건너뛴다.
파일 수정 도구 edit_file은 바꿀 문자열이 정확히 한 번 나타날 때만 교체한다. 문자열을 찾지 못하거나 여러 번 나타나면 오류를 모델에 돌려보내 다시 시도하게 한다. 도구 실행 중 생긴 오류도 대화에 메시지로 전달되므로 모델이 이를 읽고 다음 방법을 시도할 수 있다.
다만 이 코드는 교육용 예제로, 제작자는 프로덕션에서 사용하지 말라고 명시한다. bash는 모델이 요청하는 명령을 현재 사용자 권한으로 실행하며, 안전장치는 확인 질문뿐이다. --yes는 그 질문도 끈다. 프로젝트 내부 경로 검사는 파일 도구에만 적용되고 bash에는 적용되지 않는다. 웹 도구도 기본적으로 확인 대상이 아니며, URL에 데이터가 포함될 수 있으므로 유출을 우려하는 코드에 사용할 때는 web_search와 fetch_url도 확인 대상에 넣으라고 안내한다. 테스트도 제공되지 않아 컨테이너나 커밋해 둔 프로젝트 복사본에서 시도할 것을 권한다.
이 예제에는 스트리밍, 재시도, 저장된 세션, 계획 모드, pytest를 항상 허용하는 권한 설정이 없다. 후속 글에서는 스트리밍과 재시도, 세션 저장, 긴 대화 요약, Ctrl-C 처리를 121줄의 코드로 추가한다고 소개한다: What makes a coding agent usable.
프로젝트 폴더로 이동한 뒤 agent.py를 실행한다. OpenAI 호환 API 주소, API 키, 모델은 각각 AGENT_BASE_URL, AGENT_API_KEY, AGENT_MODEL 환경 변수로 지정할 수 있다. 기본 API 주소는 https://api.openai.com/v1, 기본 모델은 gpt-4o-mini이며, API 키는 AGENT_API_KEY가 비어 있으면 OPENAI_API_KEY를 사용한다. 로컬 모델 서버에서는 키를 비워 둘 수 있다.
부록: 원문 예시
def chat(messages: list, tools: list) -> dict: try: r = requests.post( f"{BASE_URL}/chat/completions", timeout=120, headers={"Authorization": f"Bearer {API_KEY}"}, json={"model": MODEL, "messages": messages, "tools": tools, "tool_choice": "auto"}) r.raise_for_status() return (r.json()["choices"][0] ["message"]) except requests.HTTPError: raise RuntimeError(...) except requests.RequestException as e: raise RuntimeError(...)
@tool("List files recursively, optionally " "filtered by a glob on the filename.", path="directory to list (default '.')", pattern="glob matched against the file " "name, e.g. '*.py'") def list_files(path: str = ".", pattern: str = "*"): base = safe_path(path) if not base.is_dir(): return "error: not a directory" hits = [str(f.relative_to(ROOT)) for f in walk(base) if fnmatch.fnmatch(f.name, pattern)] out = "\n".join(hits[:500]) if len(hits) > 500: out += (f"\n...and {len(hits) - 500}" f" more") return out or "(no matches)"
@tool("Create or overwrite a file with exact " "content.", path="file path relative to the " "workspace root", content="full file content") def write_file(path: str, content: str): f = safe_path(path) f.parent.mkdir(parents=True, exist_ok=True) f.write_text(content) return (f"wrote {len(content.encode())} " f"bytes to {path}")
@tool("Run a shell command in the workspace " "root. 60s timeout.", command="shell command") def bash(command: str): try: p = subprocess.run( command, shell=True, cwd=ROOT, text=True, capture_output=True, timeout=60) except subprocess.TimeoutExpired: return "error: timed out after 60s" return (f"{p.stdout}{p.stderr}\n" f"[exit {p.returncode}]")
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요