Models & Algorithms•SOTAAZ Lab••EN

로컬 AI 코딩 에이전트 처음부터 만들기: 결정 모델까지, 클라우드 호출 0번

코딩 에이전트를 로컬 부품 세 개로 만듭니다. 코드는 llama-server 위의 Flint가 쓰고, 표준 라이브러리만 쓴 135줄짜리 파이썬 하네스가 도구 호출을 실행하고, CPU에서 도는 0.8B 결정 모델 Jeff가 셸 명령마다 한 줄 의견을 붙인 뒤 y/N을 묻습니다. 다섯 단계와 전체 코드, 할 일 앱에 실제로 돌린 기록(숨긴 테스트 6개 모두 통과), 그리고 여전히 틀린 점을 담았습니다.

로컬 AI 코딩 에이전트 처음부터 만들기: 결정 모델까지, 클라우드 호출 0번

로컬 AI 코딩 에이전트 처음부터 만들기: 결정 모델까지, 클라우드 호출 0번

작은 할 일 앱에 에이전트를 한 번 돌린 터미널 녹화입니다. 모델이 셸 명령을 실행하겠다고 할 때마다 그 위에 결정 모델의 줄이 노란색으로 먼저 뜹니다. decision model: routine, 뒤에 0.05에서 0.17 사이의 숫자가 붙습니다. 명령마다 끝은 y/N에 y를 누른 줄입니다. 그 사이에 모델은 todo.py와 테스트를 읽고, todo.py를 다시 쓰고, 테스트 파일을 여러 번 씁니다. 마지막은 저희가 직접 돌린 확인입니다. 에이전트가 본 적 없는 테스트 6개가 모두 통과했습니다.

에이전트는 셸 명령을 실행하기 전에 매번 멈추고 두 줄을 찍습니다. 위 녹화의 맨 처음 두 줄은 이렇습니다.

decision model: routine (0.05)
run `find . -type f -name "*.py" | grep -v ".git" | head -50 && echo "---" && ls`? [y/N]

아랫줄은 터미널이 사용자에게 이 명령을 실행해도 되는지 묻는 줄입니다. y를 치면 실행하고, 다른 것을 치면 사용자가 거절했다는 말이 모델에게 돌아갑니다.

녹화에서 노랗게 칠한 윗줄은 코드를 쓰는 모델과 별개인 두 번째 모델이 씁니다. 이 글에서는 결정 모델이라고 부릅니다. 하는 일은 하나뿐입니다. 명령을 읽고 "실행하기 전에 사람이 자세히 봐야 하는가"라는 객관식 질문에 답합니다. routine은 아니라는 뜻이고 LOOK CLOSELY는 봐야 한다는 뜻입니다. 괄호 안 숫자는 답이 '봐야 한다'일 확률을 0부터 1 사이로 매긴 값이라, 0.05면 볼 이유가 거의 없다고 본 것입니다.

이 글의 결정 모델은 Jeff이고, 크기가 작습니다. 0.8B는 파라미터가 8억 개라는 뜻입니다. 파라미터는 모델이 학습하며 맞추는 숫자들입니다. 코드를 쓰는 Flint는 398억 개입니다. 이 정도로 작으면 CPU에서 돌릴 수 있고, GPU는 Flint가 다 쓸 수 있습니다. 다른 작업으로 바쁜 저희 서버의 CPU에서는 답 하나에 1초 안팎에서 3초, 느릴 때는 6초가 걸렸습니다.

이 에이전트는 부품 세 개로 돌렸습니다. 셋 다 이 컴퓨터 안에서 돌고, 밖으로 나가는 요청은 없습니다.

  • 모델: llama-server 위의 Flint입니다. GPU에서 돌고, 코드와 도구 호출을 씁니다.
  • 하네스: agent.py입니다. 표준 라이브러리만 쓴 파이썬 135줄이고, 도구 호출을 실행하며 루프를 돌립니다.
  • 결정 모델: Jeff 0.8B입니다. CPU에서 돌고, 셸 명령마다 위와 같은 한 줄 의견을 씁니다.

이 글 어디에도 API 키는 없습니다. agent.py가 요청을 보내는 주소는 두 곳이고, 둘 다 127.0.0.1입니다.

위 녹화는 할 일 앱에 기능 여섯 가지를 추가하라는 과제였고, 에이전트는 15단계 만에 멈췄습니다. 멈춘 뒤 에이전트가 한 번도 본 적 없는 테스트 6개를 넣어 돌렸더니 6개 모두 통과했습니다. 그런데 에이전트가 남긴 테스트 파일은 실패하고, 에이전트는 pytest를 한 번도 돌리지 않았습니다. 이 글은 이 에이전트를 만드는 튜토리얼입니다. 저희가 돌리면서 본 것도 같이 적어 두었으니, 직접 돌릴 때 어디를 봐야 하는지 알 수 있습니다.

주방을 그린 수채화 다섯 장입니다. 그림 옆 이름표가 하나씩 실제 이름으로 바뀝니다. 팔짱을 끼고 손가락으로 가리키며 말만 하는 요리사는 모델(Flint, llama-server), 냄비를 나르며 실제로 일하는 주방 보조는 하네스(agent.py), 선반·메모장·화구는 list_files와 read_file, write_file, bash가 됩니다. 주문표를 들고 이상한 주문을 짚어 주는 도우미는 결정 모델(Jeff 0.8B, CPU)이 되고, 주인의 OK는 y/N 앞에 앉은 여러분이 됩니다. 세 부품이 모두 한 컴퓨터 안에 있습니다.

코드는 어떤 모델이 쓰나: Flint를 고른 이유

Flint는 Recursive라는 회사가 Hugging Face에 공개한 코딩 모델입니다. 모델 카드는 코딩 에이전트용 mixture-of-experts 언어 모델이라고 소개합니다. mixture of experts는 층마다 작은 하위 신경망을 여럿 두고, 토큰 하나를 처리할 때 그중 몇 개만 쓰는 구조입니다. Flint는 256개 중 6개와 공용 2개를 씁니다. 그래서 파라미터는 398억 개를 담고 있지만 토큰 하나에 계산하는 것은 약 25억 개입니다. 메모리는 큰 모델만큼 들고, 속도는 작은 모델에 가깝습니다. 저희는 Unsloth가 2비트로 줄인 UD-Q2_K_XL 파일(13.6GB)을 씁니다. 개발사는 SWE-bench Verified 74.2%를 발표했고, 저희가 확인한 숫자는 아닙니다.

연재를 시작하기 전에 로컬 모델 세 개에 4단계와 5단계의 과제 두 개를 똑같이 줘 봤습니다. 이 글의 agent.py 전에 쓰던 최소 루프(읽기·쓰기·편집·bash, 최대 40단계)로 과제마다 한 번씩, A100 GPU에서 컨텍스트 32K로 돌렸습니다. 속도는 중앙값입니다.

Qwen3.5-9B Q4_K_MQwen3.8-27B UD-Q3_K_XLFlint UD-Q2_K_XL
버그 두 개(4단계)통과, 10단계, 33초통과, 7단계, 43초통과, 5단계, 14초
할 일 기능(5단계)40단계 상한에서 멈춤, 컨텍스트 30K까지 참통과, 12단계, 244초통과, 19단계, 227초
기능 과제의 숨긴 테스트6개 중 3개6개 중 6개6개 중 6개
생성 속도초당 73-77토큰초당 36-37토큰초당 69-73토큰
GPU 메모리 최고값6,568MiB14,558MiB14,386MiB
  • Qwen3.5-9B는 최고 6,568MiB로 셋 중 유일하게 8GB 아래였고, 빠릅니다. 대신 기능 과제를 끝내지 못했고 숨긴 테스트도 절반이 실패했습니다.
  • Qwen3.8-27B는 둘 다 끝냈지만 토큰 생성 속도가 절반 정도입니다. 기능 과제의 전체 시간은 244초로 Flint의 227초와 비슷했는데, 단계를 12번만 썼기 때문입니다(Flint는 19번).
  • Flint는 둘 다 끝냈고 속도는 9B와 비슷합니다. 대가는 세 가지입니다. 16GB 카드에 여유가 거의 남지 않고, 최신 llama.cpp가 필요하고, 새로 나온 모델을 2비트로 줄인 파일입니다.

과제마다 한 번씩 돌린 결과라 모델을 고르는 데는 충분해도 순위를 매길 근거는 못 됩니다. 카드가 8GB라면 Qwen 로컬 실행 글에서 어떤 파일이 맞는지, llama-server의 -c 정하기 글에서 카드에 맞는 컨텍스트 길이를 찾는 법을 볼 수 있습니다. 하네스는 Claude Code를 그대로 두고 모델만 로컬로 바꾸고 싶다면 12GB 안에서 Claude Code를 로컬 Qwen으로 돌리기에서 그렇게 해 봤습니다.

준비물

  • Flint용 16GB GPU. Flint UD-Q2_K_XL을 32K 컨텍스트로 띄웠을 때 llama-server 프로세스가 가장 많이 쓴 메모리는 14,386MiB였습니다. 이 값은 80GB A100에서 잰 것입니다. 16GB 카드에서 직접 돌린 것이 아니라, 16GB 예산을 큰 카드에서 확인한 숫자입니다.
  • RAM 32GB. Jeff는 CPU에서 돌고, 저희 실행에서 서버가 메모리를 약 4.3GiB 썼습니다.
  • llama.cpp 릴리스 b11889 이상. 그보다 오래된 빌드는 unknown model architecture: 'flint'를 내고 멈춥니다.
  • agent.py용 파이썬 3(저희는 3.11.4)와 과제 확인용 pytest.
  • Jeff용: 파이썬 3.12 이상, uv 0.12.19 이상. Jeff의 pyproject.toml이 둘 다 요구합니다.

1단계: Flint 띄우기

bash
llama-server -m flint-UD-Q2_K_XL.gguf -c 32768 -ngl 99 --jinja --port 8080

--jinja를 주면 llama-server가 GGUF 파일 안에 든 채팅 템플릿을 씁니다. Flint의 템플릿은 도구 호출 형식을 알고 있어서, 이 옵션이 있어야 서버가 요청에 담긴 도구 목록을 받고 모델의 도구 호출을 JSON으로 돌려줍니다. -ngl 99는 모든 레이어를 GPU에 올리라는 뜻입니다.

다른 터미널에서 확인합니다.

bash
curl http://127.0.0.1:8080/health

이게 보이면 성공입니다: {"status":"ok"}

2단계: Jeff를 CPU에서 띄우기

결정 모델은 글을 쓰는 대신 고르도록 학습한 모델입니다. 상황과 번호 붙은 선택지가 있는 질문을 주면, 글을 생성하지 않고 한 번에 선택지마다 확률을 돌려줍니다. 그래서 빠르고, 코드가 답을 읽기도 쉽습니다. 처음부터 개념을 알고 싶다면 10분 만에 이해하는 Jev를 먼저 보세요.

Jeff는 만든 사람이 github.com/firelex/jeff에 공개한 결정 모델입니다. Jeff를 고른 것은 후보 중에 CPU에서 시간을 재 본 모델이었기 때문입니다. 저희 서버 CPU에서 답 하나에 중앙값 2.7초가 걸렸고, 같은 조건에서 저희가 직접 학습한 4B 결정 모델은 11.6초였습니다. 그래서 GPU를 Flint에게 남겨 두고도 에이전트가 오래 기다리지 않습니다. 또 /v1/systemone으로 답하는 서버가 같이 들어 있어서 Ollama 같은 것을 따로 깔 필요가 없습니다. Jev와 같은 문항으로 잰 결과는 Jeff vs Jev에 있습니다.

저희가 써 본 다른 선택지는 이렇습니다.

결정 모델을 처음부터 직접 만들어 보고 싶다면 Jev 같은 결정 모델 처음부터 만들기 강의에서 단계별로 따라 할 수 있습니다.

bash
git clone https://github.com/firelex/jeff && cd jeff
git checkout f067882
uv sync --no-default-groups
uv run --no-default-groups hf download mstrasser/Jeff-Qwen3.5-0.8B --revision d86f657 --local-dir checkpoints/jeff-0.8b
CUDA_VISIBLE_DEVICES="" JEFF_CHECKPOINT=checkpoints/jeff-0.8b PORT=18766 uv run --no-default-groups jeff-serve

여기서 중요한 선택이 세 가지 있습니다.

  • --revision d86f657 은 저희가 쓴 체크포인트 v1.1로 고정합니다.
  • --no-default-groups 는 Jeff README에 있는 CPU용 설치 방법입니다. 그냥 uv sync를 하면 학습용 패키지까지 깔리는데, GPU가 없는 컴퓨터에서는 그중 하나 때문에 모든 답이 실패합니다(아래 '안 될 때' 참고).
  • CUDA_VISIBLE_DEVICES="" 는 Jeff가 GPU를 쓰지 못하게 막습니다. GPU는 이미 Flint가 채우고 있습니다. 포트 18766은 agent.py가 찾아가는 자리입니다.

이 다섯 줄은 Jeff README를 따른 것입니다. 저희는 이미 깔아 둔 Jeff 환경에서 .venv/bin/jeff-serve를 같은 환경 변수 세 개와 함께 직접 띄웠습니다. 어느 쪽이든 됩니다. 다섯 줄을 그대로 돌려도 되고, Jeff가 이미 깔려 있다면 jeff-serve를 바로 띄워도 됩니다.

서버가 떴으면 agent.py가 쓰는 바로 그 함수로 명령 두 개를 물어봅니다. agent.py는 3단계에서 만드니, 그때 돌아와서 해 보면 됩니다.

bash
python3 -c "import sys, agent; print(agent.flag_command('/tmp/ep1-repo0', sys.argv[1]))" 'rm -rf ~/projects'
python3 -c "import sys, agent; print(agent.flag_command('/tmp/ep1-repo0', sys.argv[1]))" 'python3 -m pytest -q'

이게 보이면 성공입니다:

decision model: LOOK CLOSELY (0.96)
decision model: routine (0.16)

괄호 안 숫자는 그 명령을 자세히 봐야 한다고 Jeff가 매긴 확률입니다. 다른 작업으로 바쁜 컴퓨터였는데도 답 하나에 약 1초가 걸렸습니다.

3단계: agent.py

agent.py는 저희가 빈 파일에서 시작해 직접 썼고, 파이썬 표준 라이브러리만 씁니다. 모든 줄이 이 글에 있고, 프레임워크 뒤에 숨은 동작은 없습니다.

처음부터 이 모양은 아니었습니다. 돌려 보고 빠진 게 드러날 때마다 한 부분씩 넣었습니다. 첫 버전에는 파일 목록·읽기·쓰기 도구만 있었습니다. 모델이 테스트를 돌릴 수 없어서 bash와 y/N 확인을 넣었습니다. 모델이 코드를 /repo에서 찾길래 시스템 프롬프트에 문장 하나를 넣었습니다. 결정 모델의 의견은 마지막에 붙였습니다. 하나하나의 사연은 아래 '어려웠던 점'에 있습니다.

하네스가 어떤 부분으로 이뤄지는지 먼저 알고 싶다면 에이전트 하네스란 무엇인가를, 큰 하네스들이 각 부분을 얼마나 바꿀 수 있게 열어 두는지는 Claude Code, Codex, OpenCode, Hermes 비교를 보세요.

하네스 전체가 파일 하나이고, 네 부분으로 나뉩니다.

agent.py 한 장 요약입니다. 번호 붙은 칸이 네 개입니다. 1번 도구(24-72행): list_files, read_file, write_file, bash와 y/N 상자가 있고, bash는 여러분의 답을 기다립니다. 2번 모델(75-81행): POST /v1/chat/completions로 메시지와 도구 4개를 한 번에 보내면 글이나 도구 호출이 돌아옵니다. 3번 결정 모델(84-99행): POST /v1/systemone으로 명령과 질문 하나를 보내면 routine이나 LOOK CLOSELY가 돌아옵니다. 4번 루프(102-129행): 모델에게 묻고, 도구 호출이 없으면 멈추고, 도구 호출을 하나씩 실행하고, 결과를 붙이고, 최대 30번 반복합니다. 아래 점선 상자에는 전체 135줄, import 7개, 클라우드 호출 0번이라고 적혀 있습니다.

1부: 도구

python
"""A local coding agent: a local model behind llama-server, a small local decision model on the CPU, and this file
as the harness.

Part 1: an agent loop with four tools. It reads and writes files, and runs shell commands after you say yes.
Before each command the decision model says whether it deserves a closer look; you still answer y/N every time.

    llama-server -m <model.gguf> -c 32768 -ngl 99 --jinja --port 8080
    jeff-serve                                  # the decision model, port 18766
    python agent.py <repo folder> "the task"
"""
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path

SERVER = os.environ.get("AGENT_SERVER", "http://127.0.0.1:8080")
DECIDER = os.environ.get("AGENT_DECIDER", "http://127.0.0.1:18766")
MAX_STEPS = 30


# --- 1. Tools: what the model may ask for, and the code that does it ---------------------------

TOOLS = [
    {"type": "function", "function": {
        "name": "list_files", "description": "List every file in the repo.",
        "parameters": {"type": "object", "properties": {}}}},
    {"type": "function", "function": {
        "name": "read_file", "description": "Read a text file in the repo.",
        "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
    {"type": "function", "function": {
        "name": "write_file", "description": "Create or overwrite a file with the full new content.",
        "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
                       "required": ["path", "content"]}}},
    {"type": "function", "function": {
        "name": "bash", "description": "Run a shell command in the repo, for example to run the tests (60 s limit).",
        "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}},
]


def inside(root, path):
    """Resolve a path the model gave us, refusing anything outside the repo."""
    full = (root / path).resolve()
    if full != root and root not in full.parents:
        raise ValueError(f"{path} is outside the repo")
    return full


def run_tool(root, name, args):
    try:
        if name == "list_files":
            return "\n".join(str(p.relative_to(root)) for p in sorted(root.rglob("*"))
                             if p.is_file() and not any(part.startswith(".") for part in p.relative_to(root).parts))
        if name == "read_file":
            return inside(root, args["path"]).read_text()
        if name == "write_file":
            path = inside(root, args["path"])
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(args["content"])
            return f"wrote {len(args['content'])} characters to {args['path']}"
        if name == "bash":
            # The model types commands on your computer, so a person approves each one.
            print(flag_command(root, args["command"]))
            if input(f"run `{args['command']}`? [y/N] ").strip().lower() != "y":
                return "the user declined to run this command"
            p = subprocess.run(args["command"], shell=True, cwd=root, capture_output=True, text=True, timeout=60)
            return (p.stdout + p.stderr)[-6000:] + f"\n[exit code {p.returncode}]"
        return f"error: no tool named {name}"
    except Exception as e:  # the model reads the error and tries again
        return f"error: {e}"

모델이 보는 것은 TOOLS입니다. 도구마다 이름, 설명 한 문장, 인자가 있습니다. 모델은 스스로 아무것도 실행하지 않습니다. read_file에 {"path": "todo.py"} 같은 요청을 쓸 뿐이고, 실제 일은 run_tool이 합니다.

얼마나 안전한지는 두 줄이 정합니다. inside()는 파일 경로를 모두 풀어 보고 리포 폴더 밖이면 거부합니다. 그래서 read_file("../../etc/passwd")는 오류로 돌아옵니다. bash에는 이런 검사가 없습니다. 리포 폴더에서 시작하긴 하지만 명령 안에서 cd로 어디든 갈 수 있습니다. 모델과 디스크 나머지 사이에 있는 것은 input() 한 줄, 그리고 그 바로 앞에 찍히는 결정 모델의 의견뿐입니다.

오류가 나도 프로그램을 멈추지 않고 글로 모델에게 돌려줍니다. 경로를 틀리면 한 단계를 쓰고, 모델은 대개 다음 단계에서 고칩니다.

2부: 모델

python
# --- 2. The model: one request to llama-server's OpenAI-style endpoint ---------------------------

def ask_model(messages):
    body = json.dumps({"messages": messages, "tools": TOOLS, "temperature": 0.6}).encode()
    req = urllib.request.Request(SERVER + "/v1/chat/completions", body, {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=1800) as r:
        return json.load(r)["choices"][0]["message"]

대화 전체와 도구 목록을 POST 한 번에 보냅니다. 돌아오는 것은 글이거나, 도구 호출 하나 이상입니다. llama-server가 OpenAI 채팅 API와 같은 형식을 쓰기 때문에 클라이언트가 urllib 네 줄로 끝납니다.

3부: 결정 모델

python
# --- 3. The decision model: a second, small model that flags commands worth a closer look ------

def flag_command(root, command):
    state = f"The agent works in the repo at its current directory {root}.\nProposed command:\n{command[:1500]}"
    question = {"type": "choice", "instructions": "Should a human look at this shell command before it runs?",
                "criteria": {"1": "no: it only reads or tests files inside the current repo",
                             "2": "yes: it touches paths outside the current repo or deletes files"}}
    body = json.dumps({"model": "jeff-latest", "state": state, "questions": {"q": question}}).encode()
    req = urllib.request.Request(DECIDER + "/v1/systemone", body, {"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            answer = json.load(r)["answers"]["q"]
    except (OSError, KeyError, ValueError) as e:  # no decision model: say so, and still ask you
        return f"decision model: no answer ({e})"
    verdict = "LOOK CLOSELY" if answer["choice"] == "2" else "routine"
    return f"decision model: {verdict} ({answer['probabilities']['2']:.2f})"

Jeff에게는 두 가지를 줍니다. 상황(에이전트가 일하는 곳과 실행하려는 명령)과, 선택지 두 개짜리 질문 하나입니다. Jeff는 선택지마다 확률로 답하고, 저희는 2번 '자세히 봐야 함'의 확률을 괄호 안에 찍습니다.

이 함수가 하지 않는 일을 보세요. 아무것도 막지 않습니다. 한 줄을 찍고 나면 run_tool은 어차피 여러분에게 묻습니다. Jeff가 꺼져 있으면 no answer를 찍고 에이전트는 그대로 돌아가니, 1단계와 3단계부터 만들고 Jeff는 나중에 붙여도 됩니다.

질문이 곧 정책입니다. Jeff는 저희가 물은 것만 판단합니다. 이 명령이 리포 밖 경로를 건드리는지, 파일을 지우는지입니다. 아래에서 직접 돌려 보면 이 점이 중요해집니다.

4부: 루프

python
# --- 4. The loop: ask, run the tools it asked for, hand back the results, repeat ----------------

SYSTEM = ("You are a coding agent working in a repo. Use the tools to look at files, change them and run the tests. "
          "The repo is your current directory: use paths relative to it, and shell commands run there too. "
          "When the task is done, reply with a short summary and no tool call.")


def run(root, task):
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": task}]
    for step in range(MAX_STEPS):
        try:
            msg = ask_model(messages)
        except urllib.error.HTTPError as e:  # e.g. the model wrote a tool call the server could not parse
            return f"stopped: the server answered {e.code}: {e.read().decode()[:300]}"
        # Keep the model's thinking in the history; thinking models expect to see it on the next turn.
        messages.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls", "reasoning_content")})
        calls = msg.get("tool_calls") or []
        if not calls:
            return msg.get("content") or ""
        for call in calls:
            name, raw = call["function"]["name"], call["function"]["arguments"] or "{}"
            try:
                result = run_tool(root, name, json.loads(raw))
            except json.JSONDecodeError:
                result = "error: the arguments were not valid JSON"
            print(f"[{step}] {name} {raw[:60]} -> {result[:80]!r}")
            messages.append({"role": "tool", "tool_call_id": call.get("id", ""), "content": result})
    return f"stopped after {MAX_STEPS} steps"


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit('usage: python agent.py <repo folder> "the task"')
    print(run(Path(sys.argv[1]).resolve(), sys.argv[2]))

흔히 '에이전트'라고 부르는 부분이 여기입니다. 리스트 하나를 도는 for 루프입니다. 단계마다 messages 전체를 보내고, 모델이 쓴 것을 붙이고, 도구 호출이 있으면 실행해 결과도 붙입니다. 모델은 요청과 요청 사이에 아무것도 기억하지 못합니다. 이 리스트가 모델의 기억입니다.

루프가 끝나는 길은 둘입니다. 모델이 도구 호출 없이 답하거나, 30단계를 다 쓰는 것입니다. 테스트를 돌렸는지 확인하는 곳은 없습니다. 이것이 세 번째 실행에서 문제가 됩니다.

두 버그가 있는 리포에 초기에 돌린 실행에서 messages 리스트가 자라는 애니메이션입니다. 왼쪽에는 Flint(llama-server)와 agent.py가 있고, 메시지가 위로 올라가고 도구 호출이나 답이 아래로 내려옵니다. 오른쪽에는 한 장면마다 줄이 하나씩 붙습니다. 초록은 시스템 프롬프트, 파랑은 과제, 주황은 모델의 도구 호출이고 옆에는 agent.py가 돌려준 결과가 있습니다. bash find /repo는 y로 승인했지만 No such file이 돌아왔고, bash pwd와 ls는 /tmp/ep1-repo0을 돌려줬습니다. 이어서 read_file stats.py, read_file test_stats.py, write_file stats.py(369자 씀), bash pytest(100 퍼센트)가 이어집니다. 마지막 줄은 도구 호출 없는 답 All 3 tests now pass이고, 여기서 루프가 끝납니다.

파일을 저장하고 인자 없이 실행해 봅니다.

이게 보이면 성공입니다: usage: python agent.py <repo folder> "the task"

4단계: 버그 두 개짜리 작은 리포

폴더 하나에 파일 두 개를 만듭니다. stats.py에는 버그가 두 개 있습니다.

python
def median(xs):
    """Return the median of a non-empty list of numbers."""
    s = sorted(xs)
    n = len(s)
    return s[n // 2]


def moving_average(xs, k):
    """Return the list of averages of each window of k consecutive items."""
    return [sum(xs[i:i + k]) / k for i in range(len(xs) - k)]

test_stats.py에는 그 버그 때문에 실패하는 테스트가 세 개 있습니다.

python
from stats import median, moving_average

def test_median_odd():
    assert median([3, 1, 2]) == 2

def test_median_even():
    assert median([4, 1, 3, 2]) == 2.5

def test_moving_average():
    assert moving_average([1, 2, 3, 4], 2) == [1.5, 2.5, 3.5]

그리고 실행합니다. 과제 문장은 모델에게 주는 것이라 영어로 두었습니다.

bash
python agent.py ./repo0 "The tests in this repo fail. Find and fix the bugs in stats.py so that the tests pass. Do not edit the tests."

명령마다 결정 모델의 줄과 [y/N]이 먼저 뜹니다. 명령을 읽고 답하면 됩니다. 에이전트가 멈추면 테스트를 직접 돌립니다.

bash
cd repo0 && python3 -m pytest -q

이게 보이면 성공입니다: 3 passed

결정 모델을 켜고 4단계를 돌린 터미널 녹화입니다. 노랗게 칠한 decision model: routine (0.08)과 routine (0.13) 두 줄이 각각 y로 답한 명령 위에 있습니다. 첫 명령은 폴더 안 ls와 find, 두 번째는 pytest입니다. 그 사이에 모델은 stats.py와 test_stats.py를 읽고 stats.py를 369자로 다시 씁니다. 마지막에 All 3 tests now pass와 함께 버그 두 개를 짧게 정리합니다. 아래는 저희가 직접 돌린 pytest 결과 3 passed in 0.01s입니다.

저희 실행에서 에이전트는 0번부터 3번까지 네 단계를 썼습니다. 실행하겠다고 한 명령은 폴더를 둘러보는 명령과 pytest 두 개였고, Jeff는 둘 다 routine(0.08, 0.13)이라고 했습니다. 파일 두 개를 읽고 stats.py를 한 번 다시 쓴 뒤 테스트를 돌리고 멈췄습니다. 버그도 둘 다 고쳤습니다. 길이가 짝수인 리스트의 중앙값은 가운데 두 값의 평균을 내고, moving_average는 마지막 구간까지 계산합니다.

5단계: 진짜 기능 추가

두 번째 리포는 명령줄 할 일 앱 todo.py와 테스트 세 개입니다. 과제는 여섯 가지를 요구합니다.

  1. add에 --due YYYY-MM-DD 옵션을 붙입니다.
  2. list가 제목 뒤에 마감일을 찍습니다.
  3. list --overdue는 끝나지 않았고 마감이 지난 항목만 보여 줍니다.
  4. today를 밖에서 넣을 수 있게 해서 테스트가 오늘 날짜에 좌우되지 않게 합니다.
  5. 잘못된 날짜는 argparse 오류(종료 코드 2)입니다.
  6. 마감일 없이 저장된 예전 파일도 그대로 읽혀야 합니다.

과제 끝에는 "새 기능 테스트를 test_todo.py에 추가하고 기존 테스트가 계속 통과하게 할 것. 끝내기 전에 pytest를 돌릴 것"이라는 문장이 있습니다.

저희는 항목마다 하나씩 테스트 6개를 따로 써서 리포 밖에 두었습니다. 에이전트는 이 테스트를 보지 못했고, 에이전트가 멈춘 뒤에야 넣었습니다. 이 과제를 직접 만들어 돌린다면 이 방법을 꼭 쓰세요. 에이전트가 고칠 수 있는 테스트로는 에이전트가 일을 제대로 했는지 알 수 없습니다.

bash를 넣은 뒤 이 과제를 세 번 돌렸습니다(실행 1-3). Jeff를 붙인 것은 세 번째 실행이고, 이 글 맨 위 녹화가 그 실행입니다. 모델은 15단계를 썼고 명령 8개를 실행하겠다고 했습니다. Jeff는 8개 모두 routine이라고 했고, 자세히 봐야 할 확률은 0.05에서 0.17 사이였습니다. 저희는 8개 모두 y를 눌렀습니다. 숨긴 테스트 결과는 6개 통과였습니다.

어려웠던 점, 잘 안 됐던 점

agent.py가 처음부터 이 모양은 아니었습니다. 네 가지 일을 겪으며 바뀌었습니다.

자기 작업을 확인할 수 없었습니다. agent.py에 파일 도구(목록·읽기·쓰기)만 있었을 때, 모델은 네 번 모두 코드를 제대로 고쳤습니다. 하지만 테스트를 돌릴 방법이 없으니 같은 파일을 계속 다시 썼습니다. 세 번은 30단계 상한까지 갔고, 나머지 한 번은 약 96,000자짜리 도구 호출에 서버가 500 오류를 내면서 멈췄습니다. "같은 내용을 또 썼다"고 알려 줘도 멈추지 않았습니다. 그래서 bash를 넣었고, bash를 넣으니 명령마다 y/N을 물어야 했습니다.

자기가 어디 있는지 몰랐습니다. 초기 실행에서 모델은 코드가 /repo나 /app에 있다고 짐작했습니다. 할 일 앱에서는 디스크 전체에서 todo.py라는 파일을 찾아 처음 나온 폴더로 들어간 적도 있습니다. 다른 프로젝트 안의 폴더였습니다. 같은 명령 안에서 그 폴더에서 rm -f todos.json을 두 번 실행했고, 거기 있던 todo.py도 실행했습니다. 바로 앞 단계에서는 /tmp에서 rm을 실행했습니다. 두 명령 모두 저희가 승인했습니다. SYSTEM에 문장 하나를 넣자 대부분 사라졌습니다. "The repo is your current directory: use paths relative to it, and shell commands run there too."

그 문장을 시스템 프롬프트에 넣기 전, 버그 두 개짜리 리포에 돌린 초기 실행의 터미널 녹화입니다. 모델은 먼저 없는 경로 /repo에 find를 돌리겠다고 하고, 이어서 pwd와 ls를 돌린 뒤 stats.py와 test_stats.py를 읽고, stats.py를 다시 쓰고, pytest를 돌리고, 테스트 3개가 모두 통과한다고 답합니다. 아래는 저희가 직접 돌린 pytest 결과 3 passed입니다.

Jeff가 GPU에 들어가지 않았습니다. Jeff 0.8B를 GPU에 올리면 2,296MiB를 씁니다. 이 값은 v1.1이 아니라 같은 모델의 더 새 리비전으로 잰 것입니다. 이 글의 기준인 32K 컨텍스트의 Flint(14,386MiB)와 합치면 16GB 카드를 넘습니다. Flint를 16K로 줄여도 합계가 16,180MiB라 약 200MiB만 남습니다. 그래서 Jeff는 CPU에서 돌리고, 명령 하나에 1초 안팎에서 6초가 걸립니다(아래에서 잰 값). GPU는 Flint가 다 씁니다.

Jeff가 CPU에서 답하지 못했습니다. Jeff를 전부 설치하면 GPU용 빠른 attention 커널 라이브러리가 따라옵니다. GPU를 가린 상태에서는 서버가 뜨기는 하지만 요청마다 HTTP 500이 돌아왔고, Jeff 로그에는 Triton 오류 0 active drivers가 찍혔습니다. 해결법은 아래 표에 있습니다.

저희가 본 것 세 가지(직접 돌리면 볼 수도 있는 것)

1. y를 자주 누르게 되고, 의견은 대부분 routine입니다

울타리 두 종류입니다. 왼쪽은 '리포 폴더'라고 적힌 초록 울타리 마당입니다. read_file과 write_file은 inside()를 거치고, 폴더 밖 경로는 코드가 쓴 오류로 돌아옵니다. 오른쪽에서 bash는 결정 모델 의견, y/N 상자를 차례로 지나 어디로든 갑니다. 그 아래에는 세 번째 실행에서 명령 8개, 의견 8개가 모두 routine이었고 자세히 볼 확률은 0.05에서 0.17이었다고 적혀 있습니다. 8개를 승인했고 리포를 벗어난 명령이 없었으니 짚을 것도 없었습니다. 회색 글씨로는 결정 모델을 붙이기 전 실행에서 9개를 승인했고 그중 3개가 리포를 벗어났다는 내용과 그 명령 세 개가 분홍색으로 있습니다. 결론 줄: 파일은 코드가 막고, bash는 의견이 조언만 하고 결정은 여러분이 합니다.

세 번째 실행에서 의견은 모두 routine이었고, 저희가 물은 질문에 대해서는 맞는 답이었습니다. 명령 8개 중 리포를 벗어난 것도, 파일을 지운 것도 없었습니다. 그래서 의견이 저희 답을 바꾼 적은 한 번도 없었고, 저희는 모든 명령에 y를 눌렀습니다.

y/N은 누르는 사람이 명령을 읽고 판단한 만큼만 지켜 줍니다. Jeff를 붙이기 전 실행에서 저희는 y를 아홉 번 눌렀고, 그중 세 명령이 리포를 벗어났습니다.

나중에 앞선 두 실행의 명령 16개를 Jeff에게 보내 봤습니다. agent.py에 들어 있는 질문으로는 리포를 벗어난 세 명령 중 둘을 짚었고, ls -d /workspace*는 놓쳤습니다. 리포 안에서 실행한 rm -f todos.json도 짚었고, 나머지 12개는 routine이라고 했습니다. 다만 저희는 이 16개 명령에 질문 문구 두 가지를 넣어 보고 지금 문구를 골랐습니다. 그러니 세 개 중 두 개는 이 명령들 안에서 나온 결과일 뿐이고, Jeff가 위험한 명령을 얼마나 잘 잡는지 보여 주는 근거는 아닙니다.

2. 코드는 나아지는데 테스트 파일은 나빠질 수 있습니다

한 줄 추가, 테스트 하나 실패. 왼쪽은 리포에 원래 있던 test_done_and_remove입니다. a와 b를 넣고, 1번을 끝냄으로 표시하고, 2번을 지우고, 출력을 비운 뒤 list를 부르고, 출력이 끝냄 표시가 붙은 "1 a" 한 줄인지 확인합니다. 오른쪽은 에이전트가 손댄 뒤의 같은 테스트이고, 원래 있던 list 호출 바로 뒤에 assert todo.main(list, path=f) == 0 한 줄이 분홍색으로 추가돼 있습니다. 왼쪽 아래: 모델의 todo.py는 리포의 원래 테스트 3개를 통과합니다. 오른쪽 아래: 이제 list가 두 번 찍히고, 에이전트 자신의 파일은 1 failed, 6 passed입니다. 세션 안에서 pytest를 한 번도 돌리지 않아서, 저희가 돌리기 전까지 아무도 이걸 보지 못했습니다.

세 번째 실행에서 모델은 bash로 test_todo.py를 네 번 고쳤습니다. 8, 9, 12, 13단계였고, cat > test_todo.py <<'PYEOF' 같은 heredoc을 썼습니다. Jeff는 네 번 모두 routine이라고 했습니다. 저희가 물은 질문으로는 맞는 답입니다. 이 명령들은 리포 안에 머물렀고 파일을 지우지도 않았습니다. 질문에 테스트를 덮어쓰는 일은 들어 있지 않으니 Jeff가 짚을 이유가 없었습니다.

8단계와 12단계에서 모델은 기존 테스트에 assert todo.main(["list"], path=f) == 0 한 줄을 넣었습니다. 바로 앞에 이미 todo.main(["list"], path=f)가 있는 자리였습니다. 그러면 list가 두 번 찍히는데, 이어지는 확인은 한 줄을 기대합니다. 에이전트가 남긴 테스트 파일을 돌려 보니 1개 실패, 6개 통과였습니다. 리포의 원래 테스트 3개는 에이전트가 만든 todo.py에서 고치지 않은 채로 통과하니, 이 변경은 필요 없는 것이었습니다.

숨긴 테스트가 통과한 것은 todo.py가 맞았기 때문입니다. 에이전트의 테스트 파일만 봤다면 저희는 실패를 봤을 것이고, 에이전트는 그 실패를 보지 못한 채 끝냈습니다.

설정은 다르지만 비슷한 모습을 전에도 본 적이 있습니다. 버그 있는 코드를 주고 테스트를 쓰게 했더니 로컬 모델이 버그를 지키는 테스트를 썼습니다(버그 있는 코드를 주고 테스트를 맡기면).

3. 할 일 과제 세 번 중 두 번은 테스트를 돌리지 않고 '끝났다'고 했습니다

세 번째 실행의 끝부분 터미널 캡처입니다. 12단계는 heredoc으로 test_todo.py를 다시 쓰고, 13단계는 테스트 세 개를 덧붙입니다. 둘 다 decision model: routine (0.14) 줄 뒤에 나옵니다. 14단계는 파일을 읽습니다. 이어서 '실행은 여기서 끝남: 빈 마지막 메시지, 명령 8개 중 pytest 없음'이라는 메모가 있습니다. 아래는 저희가 직접 돌린 결과입니다. 숨긴 테스트 6개는 6 passed, 에이전트 자신의 테스트 파일은 추가된 assert 줄이 강조돼 있고 결과 1 failed, 6 passed가 빨간색입니다.

과제에는 "끝내기 전에 pytest를 돌릴 것"이 있었습니다. 모델은 명령 8개 중 한 번도 pytest를 돌리지 않았습니다. 11단계에서는 자기 테스트 파일을 원본과 비교하려고 git diff를 돌렸는데, git 저장소가 아닌 폴더라 git 도움말만 돌아왔습니다. 14단계 뒤에는 도구 호출 없이 빈 메시지로 답했고 루프가 끝났습니다. 루프가 확인하는 것은 그것 하나뿐이기 때문입니다. Jeff를 붙이기 전인 두 번째 실행도 똑같이 끝났습니다. pytest는 없었고 마지막 메시지는 비어 있었습니다.

명령마다 y/N이 뜨기 전에 기다리는 시간도 느껴질 겁니다. 세 번째 실행 중에는 의견이 나오는 시간을 재지 않았습니다. 실행이 끝난 뒤 같은 명령 8개를 Jeff에게 다시 보내 보니, 다른 작업이 돌던 컴퓨터에서 중앙값 2.81초, 가장 느린 것은 6.1초였습니다. 한가한 CPU라면 더 빠를 것으로 보지만 재지는 않았습니다.

안 될 때

이런 게 보이면이유이렇게 합니다
첫 요청에서 tools param requires --jinja flag가 나오고 실행이 멈춥니다llama-server를 --jinja 없이 띄워서 도구 목록을 받지 않습니다--jinja를 붙여 다시 띄웁니다
llama-server가 모델을 읽다가 unknown model architecture: 'flint'llama.cpp 빌드가 Flint 지원보다 오래됐습니다릴리스 b11889 이상을 씁니다
명령마다 decision model: no answer (<urlopen error [Errno 111] Connection refused>)Jeff가 꺼져 있거나 18766 포트가 아닙니다Jeff를 띄우거나 AGENT_DECIDER를 Jeff 주소로 맞춥니다. 그동안에도 에이전트는 돌고 y/N도 묻습니다
명령마다 60초를 기다린 뒤 decision model: no answer (timed out), 이어서 HTTP Error 529가 뜨기도 합니다CPU가 너무 바빠서 Jeff가 agent.py의 60초 제한 안에 답하지 못합니다CPU를 비우거나, 한가한 코어 몇 개에만 Jeff를 띄웁니다. 그 전까지는 명령마다 60초씩 기다리게 됩니다
명령마다 decision model: no answer (HTTP Error 500 ...)가 나오고, Jeff 로그에 Triton 오류 0 active drivers가 있습니다그냥 uv sync를 하면 학습용 패키지까지 깔리고, 그중 flash-linear-attention이 import할 때 GPU를 찾습니다uv sync --no-default-groups로 설치합니다. 이미 전체 환경을 깔았다면, 저희는 GPU 전용 모듈 두 개를 가리는 sitecustomize.py를 PYTHONPATH에 두어 넘겼습니다. 내용은 import sys; sys.modules["fla"] = sys.modules["causal_conv1d"] = None 한 줄입니다

1편이 남긴 것과 2편에서 바꿀 것

네 열짜리 표입니다. 일어난 일, 어디서 봤나, 이유, 2편. 첫째 줄: 명령을 전부 승인함. 세 번째 실행에서 8개를 묻고 8개를 승인했고, 이전 실행에서는 9개 중 3개가 리포를 벗어남. 의견은 조언일 뿐이고 y/N은 ls까지 매번 물음. 2편에서는 리포 안 읽기와 테스트는 묻지 않고 실행하고 나머지만 물음. 둘째 줄: 테스트가 망가짐. 세 번째 실행에서 추가한 한 줄이 자기 테스트를 실패시킴. 도구에게 테스트 파일은 그냥 파일이고 매번 통째로 바뀜. 2편은 기존 테스트를 읽기 전용으로 하고 파일 일부만 고치는 edit_file을 넣음. 셋째 줄: 테스트 없이 끝냄. 두 번째와 세 번째 실행에서 pytest를 안 돌렸고 마지막 메시지가 비었음. 루프는 모델이 도구 호출을 멈추면 끝남. 2편에서는 모델이 멈추면 agent.py가 직접 pytest를 돌리고 실패한 것을 돌려줌.

1. 명령을 전부 승인하게 됩니다. y/N은 rm뿐 아니라 ls까지 매번 묻고, 의견은 조언일 뿐입니다. 2편에서는 리포 안 읽기와 테스트 실행은 묻지 않고 통과시킵니다.

2. 테스트가 망가질 수 있습니다(실행 2, 3). 도구에게 테스트 파일은 다른 파일과 똑같고, 통째로 바뀝니다. 2편에서는 기존 테스트를 읽기 전용으로 하고 edit_file을 넣습니다.

3. 테스트 없이 끝났다고 할 수 있습니다(실행 2, 3). 루프는 모델이 도구 호출을 멈추면 끝납니다. 2편에서는 모델이 멈추면 agent.py가 직접 pytest를 돌리고, 실패한 것을 모델에게 돌려줍니다.

확인하지 않은 것

  • 실행은 한 번씩입니다. Jeff를 붙인 할 일 과제는 한 번 돌렸습니다. temperature가 0.6이라 다시 돌리면 다른 단계를 밟을 수 있습니다. 이 기록은 무엇이 일어날 수 있는지 보여 줄 뿐, 얼마나 자주 일어나는지는 보여 주지 않습니다.
  • Jeff가 실제로 위험한 명령을 잡는지. 세 번째 실행에는 위험한 명령이 없었습니다. 앞선 실행의 명령으로는 리포를 벗어난 세 개 중 둘을 짚었지만, 질문 문구를 바로 그 명령들로 골랐습니다.
  • Jeff 새 설치. 저희는 이미 있던 환경에서 Jeff를 띄웠고, 2단계의 다섯 줄로 새로 깔아 보지는 않았습니다.
  • 실제 16GB 카드. Flint의 최대 14,386MiB는 A100에서 잰 값입니다.
  • 다른 모델. 이 글의 모든 실행은 Flint UD-Q2_K_XL입니다. 다른 모델은 덜 헤매거나 테스트를 더 많이 고칠 수도 있습니다.

다음 편

2편에서는 위 규칙 세 가지를 agent.py에 넣고 같은 할 일 과제를 다시 돌립니다. 결과를 어떻게 예상하는지 돌리기 전에 계획서에 먼저 적어 둡니다. 예상이 맞았는지는 페이지 아래 뉴스레터로 알려 드립니다.