Node.js를 사용하여 안전한 로컬 AI 코딩 에이전트 구축하기
요약
Node.js와 Ollama를 활용하여 로컬에서 실행되는 안전한 AI 코딩 에이전트를 구축하는 방법을 설명합니다. Mistral 모델을 사용하여 파일 읽기, 코드 설명, 버그 탐색 등의 기능을 수행하며, 인간의 검토를 거치는 패치 제안 방식을 통해 안전성을 확보합니다.
핵심 포인트
- Ollama와 Mistral을 이용한 완전한 로컬 AI 환경 구축
- 에이전트 루프, 도구 호출, 시스템 프롬프트 등 핵심 패턴 학습
- 파일 직접 수정 대신 패치 제안을 통한 안전 경계 설정
- Human-in-the-loop 방식을 적용한 안전한 코드 변경 프로세스
MCP 및 JS를 활용한 RAG 시리즈의 네 번째 글에 오신 것을 환영합니다.
이 글에서는 JavaScript로 실용적이고 초보자 친화적인 코딩 에이전트를 직접 만들어 보며 AI 에이전트(AI agents)가 무엇인지 배워보겠습니다. 우리는 Ollama에서 실행되는 로컬 LLM인 Mistral을 사용할 것입니다.
유료 구독이나 API 키가 전혀 필요하지 않습니다. 모든 것이 사용자의 컴퓨터에서 로컬로 실행되므로, 학습, 테스트 및 실험을 위해 쉽게 접근할 수 있습니다.
우리가 만드는 것
우리는 로컬 개인용 코딩 에이전트를 구축하고 있습니다.
이 에이전트는 터미널에서 실행되며 JavaScript 프로젝트를 이해하는 데 도움을 줍니다. 다음과 같은 기능을 수행할 수 있습니다:
- 프로젝트 파일 목록 나열
- 프로젝트 파일 읽기
- 텍스트 검색
- 코드 설명
- 가능한 버그 찾기
- 코드 변경 제안
중요한 안전 규칙은 다음과 같습니다:
에이전트는 파일을 검사할 수 있지만, 파일을 직접 수정하지는 않습니다. 무언가를 변경하고 싶다면, 인간 개발자가 검토할 수 있도록 패치 제안(patch proposal)만을 반환합니다.
따라서 간단히 말하자면, 우리는 작은 로컬 코딩 어시스턴트를 만들고 있는 것입니다.
이것이 학습에 도움이 되는 이유
AI 에이전트는 복잡하게 들릴 수 있지만, 대부분의 에이전트 시스템은 몇 가지 공통된 패턴을 사용합니다.
이 프로젝트에서 우리는 순수 JavaScript를 사용하여 다음과 같은 패턴들을 배울 것입니다:
- 에이전트 루프 (Agent loop): 모델이 최종 답변을 제공할 때까지 반복합니다.
- 도구 호출 (Tool calling): 모델이 특정 JavaScript 함수를 요청할 수 있도록 합니다.
- 도구 허용 목록 (Tool allowlist): 승인된 도구만 실행되도록 허용합니다.
- 시스템 프롬프트 (System prompt): 모델이 어떻게 행동해야 하는지 알려줍니다.
- JSON 액션 프로토콜 (JSON action protocol): 모델이 구조화된 JSON으로 응답하도록 만듭니다.
- 모델 어댑터 (Model adapter): Ollama HTTP 코드를 하나의 작은 파일에 유지합니다.
- 안전 경계 (Safety boundary): 파일 접근을 프로젝트 루트 내부로 제한합니다.
- 인간 참여형 변경 (Human-in-the-loop changes): 변경 사항을 직접 적용하는 대신 패치를 제안합니다.
- 안전을 위한 테스트 (Tests for safety): 경로 탐색(path traversal), 대용량 파일, 파일 누락 동작 등을 검증합니다.
이와 동일한 개념들이 더 큰 AI 에이전트 프레임워크에서도 등장합니다. 이 프로젝트는 이를 이해할 수 있을 만큼 작게 유지합니다.
핵심 아이디어
일반적인 챗봇은 보통 다음과 같이 작동합니다:
사용자가 질문함 -> 모델이 답변함
에이전트는 다음과 같이 작동합니다:
사용자가 질문함
-> 모델이 무엇을 할지 결정함
-> JavaScript가 안전한 도구 (tool)를 실행함
...
모델은 사용자의 파일을 직접 읽거나 명령어를 실행하지 않습니다. 모델은 도구 (tool)를 요청하며, 사용자의 JavaScript 코드가 해당 도구의 허용 여부를 결정합니다.
이것이 이 프로젝트의 핵심 아이디어입니다.
프로젝트 구조 (Project Structure)
coding-agents GitHub repository에서 전체 코드베이스를 탐색하고 클론(clone)할 수 있습니다.
주요 파일은 다음과 같습니다:
.
|-- package.json
|-- src
...
각 파일은 명확한 역할을 수행합니다:
src/cli.js: 터미널 진입점 (entry point)src/agent.js: 에이전트 루프 (agent loop) 및 도구 디스패치 (tool dispatch)src/ollama.js: 로컬 Ollama API 클라이언트 (client)src/tools.js: 안전한 파일 시스템 도구 (filesystem tools)test/tools.test.js: 안전성 및 도구 동작 테스트
1: CLI 진입점 (CLI Entry Point)
앱은 src/cli.js에서 시작됩니다.
에이전트를 임포트(import)합니다:
import { runAgent } from "./agent.js";
그 다음 프로젝트 루트 (root)와 모델 (model)을 선택합니다:
const root = options.root || process.cwd();
const model = options.model || process.env.OLLAMA_MODEL || "mistral";
이는 다음을 의미합니다:
- 사용자가 제공하는 경우
--root사용 - 그렇지 않으면 현재 폴더 사용
- 제공된 경우
--model사용 - 그렇지 않으면
OLLAMA_MODEL사용 - 그렇지 않으면
mistral사용
CLI는 두 가지 모드를 지원합니다.
원샷 (One-shot) 모드:
npm start -- "Explain src/tools.js"
대화형 (Interactive) 모드:
npm start
두 경우 모두 CLI는 최종적으로 다음을 호출합니다:
const answer = await runAgent({ goal, root, model, verbose });
따라서 CLI는 입력과 출력만을 담당합니다. 실제 에이전트의 동작은 runAgent에 구현되어 있습니다.
2: 에이전트 루프 (Agent Loop)
메인 함수는 src/agent.js에 있습니다:
export async function runAgent({ goal, root, model, verbose = false }) {
시작 시, 사용 가능한 도구들을 생성합니다:
const { createTools } = await import("./tools.js");
const tools = createTools({ root });
root를 전달하는 것은 중요합니다. 이는 도구(tools)들이 어떤 폴더를 조사할 수 있는지 알려줍니다.
그 다음 에이전트는 메시지 기록 (message history)을 생성합니다:
const messages = [
{
role: "system",
...
system 메시지는 모델을 위한 규칙을 포함합니다. user 메시지는 개발자의 요청을 포함합니다.
그 다음 에이전트는 루프 (loop)를 실행합니다:
for (let step = 1; step <= MAX_STEPS; step += 1) {
const prompt = renderPrompt(messages);
const raw = await generateWithOllama({ prompt, model });
...
MAX_STEPS는 8로 설정되어 있어, 에이전트가 무한 루프에 빠지지 않도록 합니다.
이 루프는 에이전트의 핵심입니다:
messages -> prompt -> model -> action -> tool or final answer
3: 시스템 프롬프트 (System Prompt)
시스템 프롬프트 (system prompt)는 모델에게 어떻게 행동해야 하는지를 알려줍니다.
이 프로젝트에서 프롬프트는 모델이 다음과 같이 행동해야 한다고 명시합니다:
- 프로젝트 관련 주장을 하기 전에 파일을 조사할 것
- 패치 (patch)가 적용되었다고 절대 주장하지 말 것
- 제안된 변경 사항에 대해서만
propose_patch를 사용할 것 - 정확히 하나의 JSON 객체를 반환할 것
또한 사용 가능한 도구들을 설명합니다:
const toolDescriptions = Object.entries(tools)
.map(([name, tool]) => `- ${name}: ${tool.description} Parameters: ${JSON.stringify(tool.parameters)}`)
.join("\n");
이를 통해 모델은 자신이 무엇을 요청할 수 있는지 알게 됩니다.
모델은 반드시 두 가지 형태 중 하나로 응답해야 합니다.
도구를 호출하려면:
{"type":"tool","name":"read_file","arguments":{"path":"src/example.js"}}
종료하려면:
{"type":"final","answer":"Your answer here."}
이것은 단순한 JSON 액션 프로토콜 (action protocol)입니다. 숨겨진 프레임워크의 마법이 없기 때문에 이해하기 쉽습니다.
4: 로컬 모델 어댑터 (Local Model Adapter)
src/ollama.js 파일은 Ollama API 호출을 앱의 나머지 부분과 분리하여 유지합니다.
기본 로컬 URL은 다음과 같습니다:
const DEFAULT_OLLAMA_URL = "http://127.0.0.1:11434";
함수는 프롬프트를 Ollama로 전송합니다:
const response = await fetch(`${baseUrl}/api/generate`, {
method: "POST",
headers: {
...
그 다음 모델 텍스트를 반환합니다:
const data = await response.json();
return data.response || "";
이 파일의 역할은 단 하나입니다:
프롬프트 입력 (prompt in) -> Ollama HTTP 요청 (Ollama HTTP request) -> 모델 응답 출력 (model response out)
이 기능을 하나의 파일로 유지하면 나중에 모델을 교체하기가 더 쉬워집니다.
5: 도구 호출 (Tool Calling)
Ollama가 응답한 후, 에이전트는 응답을 파싱(parsing)합니다:
const action = parseAction(raw);
만약 모델이 최종 답변을 제공하면, 에이전트는 이를 반환합니다:
if (action.type === "final") {
return action.answer;
}
만약 모델이 도구(tool) 사용을 요청하면, 에이전트는 해당 도구가 존재하는지 확인합니다:
const tool = tools[action.name];
if (!tool) {
messages.push({
...
이 과정은 매우 중요합니다.
모델은 도구를 스스로 만들어낼 수 없습니다. 모델은 로컬 JavaScript 객체에 존재하는 도구만을 사용할 수 있습니다.
그 다음 에이전트는 도구를 실행합니다:
const result = await tool.run(action.arguments || {});
그리고 그 결과를 메시지 기록(message history)에 다시 보냅니다:
messages.push({
role: "tool",
content: JSON.stringify({
...
이제 모델은 추측하는 대신 실제 프로젝트 정보를 사용할 수 있습니다.
예시 흐름:
사용자: src/tools.js 설명해줘
모델: read_file 호출
JavaScript: 파일을 안전하게 읽음
...
6: 안전한 도구 (Safe Tools)
도구들은 src/tools.js에 위치합니다.
이 프로젝트에는 네 가지 도구가 있습니다:
list_files
read_file
search_text
...
각 도구는 다음을 포함합니다:
description: 모델에게 해당 도구가 무엇을 하는지 알려줍니다.parameters: 모델에게 어떤 인자(arguments)를 받는지 알려줍니다.run: 실제 JavaScript 함수입니다.
예시 구조:
read_file: {
description: "프로젝트 루트 내부의 UTF-8 텍스트 파일을 읽습니다.",
parameters: {
...
도구들은 의도적으로 범위를 좁게 설정되었습니다.
list_files는 프로젝트 루트 아래의 파일 목록을 나열합니다.
read_file은 안전하고 크기가 너무 크지 않은 경우 하나의 텍스트 파일을 읽습니다.
search_text는 프로젝트 파일 내에서 문자열 또는 정규 표현식(regex)을 검색합니다.
propose_patch는 패치 제안(patch proposal)을 반환하지만, 이를 실제로 적용하지는 않습니다.
마지막 지점이 중요합니다. 모델이 변경 사항을 제안할 수는 있지만, 여전히 사람이 이를 검토합니다.
** 에이전트를 외부 사용자 입력에 노출할 계획이라면 안전한 정규 표현식 엔진 (regex engine)을 사용하세요. **
7: safeResolve를 통한 경로 안전성 (Path Safety)
가장 중요한 안전 기능은 다음과 같습니다:
export function safeResolve(root, requestedPath) {
const absolute = path.resolve(root, requestedPath);
const relative = path.relative(root, absolute);
...
이것은 경로 탐색 (path traversal)을 차단합니다.
예를 들어, 다음은 허용되어야 합니다:
src/tools.js
하지만 다음은 차단되어야 합니다:
../outside.txt
왜 그럴까요?
에이전트는 선택된 프로젝트 폴더만 조사해야 하기 때문입니다. 모델의 출력값은 신뢰할 수 있는 입력이 아니므로, 모든 요청된 경로는 safeResolve를 거쳐야 합니다.
이것은 에이전트 개발에서 가장 중요한 교훈 중 하나입니다:
모델에게 유용한 도구를 제공하되, 실제 안전 점검은 코드에 구현하십시오.
8: 크기 제한 (Size Limits)
도구 계층 (tool layer)은 거대한 파일을 읽는 것도 방지합니다:
const MAX_READ_BYTES = 80_000;
const MAX_SEARCH_FILE_BYTES = 250_000;
read_file은 파일이 너무 크면 에러를 발생시킵니다.
search_text는 너무 큰 파일은 건너뜁니다.
이는 모델의 컨텍스트 (context)를 보호하고 에이전트의 응답성을 유지합니다.
9: 자동 편집이 아닌 패치 제안 (Patch Proposals, Not Auto-Edits)
propose_patch 도구는 다음을 반환합니다:
{
summary,
patch,
...
이것은 인간 참여형 (human-in-the-loop) 설계입니다.
에이전트는 당신의 생각을 돕고 변경 사항을 제안할 수 있지만, 파일을 조용히 수정하지는 않습니다.
초보자용 에이전트에게 이는 훌륭한 안전성 절충안 (safety tradeoff)입니다.
10: 안전성을 위한 테스트 (Tests for Safety)
이 프로젝트는 Vitest를 사용합니다.
package.json 내용:
{
"scripts": {
"test": "vitest run",
...
테스트는 위험한 부분들을 다룹니다:
safeResolve가 일반적인 경로를 허용하는지safeResolve가../탐색을 차단하는지list_files가 누락된 디렉토리를 처리하는지read_file이 누락된 경로 및 디렉토리를 거부하는지read_file이 큰 파일을 거부하는지search_text가 큰 파일을 건너뛰는지propose_patch가applied: false를 반환하는지
경로 탐색 테스트 예시:
expect(() => safeResolve(fixtureProjectRoot, "../outside.txt")).toThrow(/escapes project root/);
대용량 파일 테스트 예시:
await expect(tools.read_file.run({ path: "large-read.txt" })).rejects.toThrow(/too large to read safely/);
여기서 테스트는 단순히 정확성만을 위한 것이 아닙니다. 테스트는 에이전트의 안전 경계 (safety boundary)를 보호합니다.
전체 요청 흐름 (Complete Request Flow)
다음과 같이 실행하면:
npm start -- "Explain src/tools.js"
흐름은 다음과 같습니다:
src/cli.js가 요청을 받습니다.runAgent를 호출합니다.src/agent.js가 안전한 도구 (safe tools)를 생성합니다.- 시스템 프롬프트 (system prompt)가 규칙과 도구를 설명합니다.
src/ollama.js가 프롬프트를 로컬 Ollama로 전송합니다.- 모델이 JSON 액션 (JSON action)을 반환합니다.
- 모델이 도구 사용을 요청하면, 에이전트가 허용 목록 (allowlist)을 확인합니다.
- 도구가 안전 점검 (safety checks)과 함께 실행됩니다.
- 도구 결과가 모델로 다시 전달됩니다.
- 모델이 최종 답변을 반환합니다.
이것이 실질적인 의미에서의 AI 에이전트입니다.
AI 에이전트는 제어된 루프 (controlled loop), 안전한 도구, 그리고 명확한 규칙에 연결된 LLM (Large Language Model)입니다.
실행 방법 (How to Run It)
요구 사항:
- Node.js 18 이상
- Ollama 설치됨
- Mistral 모델 로컬에 다운로드됨
모델 다운로드:
ollama pull mistral
Ollama 시작:
ollama serve
에이전트 실행:
npm start
질문 하나 하기:
npm start -- "Explain src/agent.js"
다른 프로젝트 조사하기:
npm start -- --root /path/to/project "Find bugs in the main CLI file"
테스트 실행:
npm test
구문 체크 실행:
npm run check
기억해야 할 점 (What to Remember)
AI 에이전트는 단순한 LLM이 아닙니다.
AI 에이전트는 보통 다음과 같습니다:
LLM + loop + tools + context + safety rules
이 프로젝트에서는:
- CLI가 사용자 요청을 받습니다.
- 에이전트 루프 (agent loop)가 단계를 관리합니다.
- Ollama가 로컬 모델을 제공합니다.
- 도구 (tools)가 제어된 능력을 제공합니다.
safeResolve가 파일 접근을 보호합니다.- 테스트가 안전 동작 (safety behavior)을 보호합니다.
모델은 액션을 요청할 수 있지만, 실제로 무엇을 실행할지는 JavaScript가 결정합니다.
그것이 핵심 아이디어입니다.
마치며 (Final Thoughts)
이 프로젝트는 의도적으로 작게 설계되었지만, 더 큰 에이전트 시스템들의 이면에 있는 기초를 가르쳐 줍니다.
이 버전을 이해하고 나면, 다음과 같은 더 발전된 아이디어들을 탐구할 수 있습니다:
- 대화 턴 간의 메모리 (memory across chat turns)
- 모델 출력 스트리밍 (streaming model output)
- 더 풍부한 도구 스키마 (richer tool schemas)
- 패치 검증 (patch validation)
- 확인 기반 패치 적용 (confirmation-based patch applying)
- MCP 도구 (MCP tools)
- 더 큰 코드베이스에 대한 RAG (RAG over larger codebases)
하지만 핵심 아이디어는 동일합니다:
Mistral은 학습을 위한 훌륭하고 간단한 기본 모델이지만, 더 강력한 코딩 에이전트를 구축할 때는 코딩에 특화된 모델들이 대개 더 나은 결과를 제공합니다. 시도해 볼 만한 좋은 옵션으로는 qwen2.5-coder, deepseek-coder, 그리고 Gemma가 있습니다.
유용한 도구를 만들고, 범위를 좁게 유지하며, 애플리케이션 코드가 안전 경계(safety boundaries)를 강제하도록 하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기