Ollama 기반 Solana 에이전트를 위한 누락된 매뉴얼
요약
Ollama를 활용하여 로컬에서 실행되는 Solana 기반 자율 에이전트 구축 과정을 상세히 다룹니다. MCP 서버, 정책 엔진, 에이전트 루프를 결합하여 보안을 유지하면서도 자율적인 트랜잭션 수행이 가능한 스택을 설계하는 방법을 설명합니다.
핵심 포인트
- Ollama를 이용한 로컬 에이전트 루프 구현
- MCP 서버를 통한 도구 발견 및 활용
- 개인 키 노출을 방지하는 보안 설계
- 정책 엔진을 통한 지출 한도 및 실행 제어
동료 팀원이 제가 기록한 내용만으로 이 에이전트 스택을 재구축할 수 있을까요? 솔직히 일주일 전만 해도 아니었습니다. 5일 동안 저는 진정으로 정교한 무언가를 만들어냈습니다. 클라우드 API 대신 Ollama에서 실행되는 로컬 에이전트 루프 (agent loop), 하드코딩된 지출 한도가 있는 전송 도구 (transfer tool), 이러한 도구들을 어떤 AI 클라이언트도 발견할 수 있는 무언가로 변환하는 MCP 서버, "모델이 결정했다"와 "코드가 허용했다"를 분리하는 정책 엔진 (policy engine), 그리고 전체 과정이 여러 턴에 걸쳐 스스로 목표를 추구하는 자율 실행 (autonomous run)까지 말이죠. 이것을 작동하게 만드는 모든 설계 결정은 오직 한 곳, 제 머릿속에만 존재했습니다. 이 포스트는 그 결정들을 글로 옮긴 것입니다.
인벤토리 확인 (Take inventory)
| 구성 요소 (Component) | 위치 (Lives in) | 기반 (Built on) | 한 문장 요약 (Job in one sentence) |
|---|---|---|---|
| 에이전트 루프 (Agent loop) | agent.mjs (solana-read-agent) | Day 92 | 클라우드 API 키 대신 로컬 Ollama 모델을 사용하여 일반 영어 요청을 도구 호출 (tool call)로 변환함 |
| ... |
흐름 그리기 (Draw the flow)
일반 영어로 작성된 당신의 목표
|
v
...
개인 키 (private key)는 이 체인의 어느 시점에서도 Ollama에 입력되지 않습니다. 모델은 오직 도구 이름, 설명, 그리고 결과만을 볼 뿐이며, 지갑 키페어 (wallet keypair)는 전체 과정 동안 Node.js 프로세스 내부에 머무릅니다.
도구 참조 (Tool reference)
도구: get_balance / get_wallet_balance
- 입력 (Inputs): 없음 (에이전트 자체의 설정된 지갑을 읽음)
- 반환 (Returns):
{ balance_sol: number }, 또는 MCP를 통해 포맷팅된 "<address>hasXSOL" 문자열 - 부수 효과 (Side effects): 없음
- 정책에 의한 보호 (Guarded by policy): 해당 없음, 읽기 전용
도구: send_sol (직접 도구 호출 버전, Day 93)
- 입력 (Inputs):
recipient(string),amount_sol(number) - 반환 (Returns): 성공 시
{ signature, explorer }, 또는{ error } - 부수 효과 (Side effects): 에이전트 지갑에서 SOL을 소비함
- 정책에 의한 보호 (Guarded by policy): 예, 이 단계에서는 도구 자체 내부에 하드코딩된
MAX_SOL_PER_SEND = 0.1체크가 있음 (이 체크는 Day 95가 되어서야 별도의 모듈로 이동함)
도구: initialize_vault (MCP 버전, Day 94)
- 입력 (Inputs):
amountSol(number) - 반환 (Returns): 트랜잭션 서명(transaction signature)과 함께 금고(vault)가 생성되었음을 확인하는 텍스트 블록, "이미 존재함" 알림, 또는 거절 메시지
- 부수 효과 (Side effects): Anchor의
deposit인스트럭션(instruction)을 통해 프로그램 유도 금고 계정(program-derived vault account)에 SOL을 입금함 - 정책에 의한 보호 (Guarded by policy): 예, MCP 도구 핸들러 내부에
MAX_SOL = 0.1체크가 포함되어 있음. Day 93의 한도 설정과 동일한 형태이며, 프로토콜 경계(protocol boundary) 뒤로 이동됨
도구: transfer_sol (자율 워크플로우 버전, Day 96)
- 입력 (Inputs):
to(주소),lamports(정수) - 반환 (Returns):
{ status: "confirmed", signature, amountSol }또는{ status: "denied", reason } - 부수 효과 (Side effects): 운영 지갑(operating wallet)에서 SOL을 소모함
- 정책에 의한 보호 (Guarded by policy): 예, 어떤 서명이 이루어지기 전에
policy.mjs의checkTransferPolicy를 통해 라우팅됨
정책 계층(policy layer) 문서화
이 부분은 스택에서 실제로 안전성을 보장하는 부분이므로, 규칙별로 상세히 기록합니다. policy.mjs는 모든 전송이 허용되기 전에 고정된 순서의 체크를 실행합니다:
- 수신자가 화이트리스트(allowlist)에 포함되어 있는가? 주소는 먼저
PublicKey로 파싱됩니다. 형식이 잘못된 주소는 다른 어떤 프로세스가 실행되기 전에 거부됩니다. 그 다음, 허용된 수신자들의 하드코딩된Set과 대조하여 확인합니다. 목록에 없는 모든 것은 금액에 상관없이 즉시 거부됩니다. - 금액이 양의 정수 램포트(lamports)인가? 0, 음수 또는 정수가 아닌 값은 거부됩니다.
- 전송 금액이 트랜잭션당 한도(per-transaction cap) 내에 있는가? 원래의
policy.mjs에서 해당 한도는0.1 SOL이었습니다. 96일 차에 자율 실행(autonomous run)이 이루어질 무렵에는 운영 한도가0.05 SOL로 강화되었으며, 아래의 모든 로그는 원래의 0.1이 아닌 이 더 엄격해진 수치를 반영합니다. - 현재 실행 중인 세션 총액이 여전히 세션 한도(원래 정책상
0.25 SOL) 내에 있는가? 개별 전송 금액이 충분히 작더라도, 해당 전송이 세션의 누적 지출을 한도 초과로 몰아넣는다면 거부될 수 있습니다. - 위의 항목 중 거부 사유가 없다면, 승인하고 지출을 기록합니다.
어떤 규칙도 요청을 명시적으로 승인하지 않을 때의 기본값은 **거부(deny)**입니다. 체크 과정이 조용히 건너뛰어져서 전송이 성공하는 경로는 이 코드에 존재하지 않습니다.
핵심 불변량(invariant)을 명확히 말하자면 다음과 같습니다: 프롬프트(prompt)는 바뀔 수 있고, 모델(model)도 바뀔 수 있으며, 도구 호출(tool calls)의 정확한 순서도 바뀔 수 있지만, 어떤 트랜잭션도 checkTransferPolicy를 먼저 통과하지 않고서는 자금을 이동할 수 없습니다. 모델 자체가 너무 많은 금액을 보내달라고 요청했든, 주입된 텍스트(injected text)의 일부가 너무 많은 금액을 보내도록 유도했든, 이 계층에서의 거부는 모델에게 동일하게 보입니다. 정책은 요청이 왜 이루어졌는지 묻지 않습니다. 오직 요청이 허용되는지 여부만을 확인합니다.
실제 실행 기록 주석 달기
다음은 96일 차 자율 워크플로우(agent-workflow-ollama.mjs)의 실제 실행 사례 중 하나로, 두 개의 지갑을 읽고 그 사이에서 스스로 SOL을 이동시킵니다:
turn 1
tool_call { index: 0, name: 'get_balance', arguments: { address: 'EQb98...t67K' } }
tool_result { address: 'EQb98...t67K', lamports: 4309970000, sol: 4.30997 }
에이전트는 어떤 결정을 내리기 전에, 요청받지 않아도 먼저 운영 지갑(operating wallet)의 잔액을 확인합니다.
tool_call { index: 1, name: 'get_balance', arguments: { address: '7aPJz...ooT7' } }
tool_result { address: '7aPJz...ooT7', lamports: 600000000, sol: 0.6 }
그다음에는 저축 지갑(savings wallet)을 확인하여, 송금에 대해 추론(reasoning)하기 전에 두 수치를 모두 확보합니다.
tool_call {
index: 2,
name: 'transfer_sol',
...
이번 특정 실행(run)에 적용된 0.25 SOL 세션 한도(session cap) 미만인 0.2 SOL 송금은 정책 검사(policy check)를 통과하였으며, 실제 서명(signature)과 함께 devnet에 성공적으로 도착했습니다.
그리고 동일한 세션 내의 별도 실행에서 발생한 거절 사례는, 가드레일(guardrail)이 실제로 작동하고 있다는 더 설득력 있는 증거입니다:
tool_call {
index: 2,
name: 'transfer_sol',
...
모델은 자신이 계산한 부족분을 메우기 위해 0.4 SOL을 이동시키기로 결정했습니다. 정책 계층(policy layer)은 모델이 왜 이를 보내려 하는지에 대한 인지 없이, 순수하게 금액의 크기만을 근거로 서명이 생성되기도 전에 이를 거부했습니다.
교훈 (Lessons learned)
-
비결정론(Non-determinism)이 도구 호출(tool calls)뿐만 아니라 추론 과정에서도 나타났습니다. 거의 동일한 실행 환경(동일한 두 지갑 잔액, 동일한 목표)임에도 불구하고, 최종 자연어 보고서가 제각각이었습니다. 한 실행에서는 "필요한 전송액은 4.2 SOL이지만, 정책상 0.05 SOL보다 큰 전송은 금지됩니다"라고 정확히 말한 반면, 다른 실행에서는 명확한 이유 없이 부족한 금액을
0.30997 - 0.2 = 0.10997 SOL로 잘못 계산했습니다. 또한 어떤 실행의 "최종 보고서"는 문장이 아닌, 실행되지 않은 도구 호출 JSON 블롭({"name":"transfer","parameters":{...}}) 그 자체였습니다. 실제로 체인(chain)에 영향을 준 도구 호출은 일관적이었으나, 그 주변의 해설은 일관적이지 않았습니다. -
모델이 무엇을 시도하든 모든 실행에서 정책이 유지되었습니다. 모델이 0.4 SOL을 요청하든 5 SOL을 요청하든, 모든 로그에서 전송은 서명 전에 거부되었으며, 에이전트는 전송이 이루어진 척하는 대신 거부 사실을 정직하게 보고했습니다.
-
93일 차에 이르러 Ollama의 네이티브 도구 호출(tool-calling) 기능이 충분히 좋아졌고, 이에 따라 92일 차 매뉴얼에서 사용했던
TOOL:get_balance:<address>문자열 파싱 우회 방식은 불필요한 것이 되었습니다. 초기 가정은 "Ollama는 Claude처럼 도구 호출을 수행하지 못한다"였으나,ollamanpm 패키지의tools파라미터를 올바르게 연결하자 직접 작동했습니다. -
94일 차에 동일한 가드레일(guardrail)을 MCP 뒤로 옮겼음에도 기능이 약화되지 않았습니다.
initialize_vaultMCP 도구는 직접 호출 방식에서 사용했던 것과 동일한MAX_SOL검사를 강제합니다. 이것이 바로 정책을 프롬프트가 아닌 도구 계층(tool layer)에 두는 실제 목적입니다. 즉, 도구가 호출되는 방식이 바뀌더라도 경계(boundary)는 유지됩니다. -
이것이 devnet 너머의 무엇인가와 접촉하기 전에 제가 강화할 사항들: 허용 목록(allowlist)과 두 가지 한도(caps)는 파일 내에 하드코딩된 상수이며, 에이전트(또는 운영자)가 런타임(runtime) 중에 안전하게 검사하거나 변경할 수 있는 것이 아닙니다. 또한 프로세스 재시작 시 세션 지출에 대한 영구적인 기록이 남지 않으므로, 재시작된 에이전트는 별도의 설정 없이 새로운 세션 한도(session cap)를 부여받게 됩니다. 마지막으로, 추론 로그(reasoning logs)에서 관찰되는 "실제 호출 대신 알 수 없는 도구 호출 형태의 텍스트가 나타나는" 실패 모드는, 이전 단계에서 연결이 끊긴 지갑(disconnected wallet)에 별도의 분기(branch)가 필요했던 것과 마찬가지로 명시적인 처리가 필요합니다.
리소스 (Resources)
- Model Context Protocol docs: 94일 차의 클라이언트/서버 분리에 관한 문서
- Ollama's tool-calling docs: 93일 차부터 사용된
tools파라미터에 관한 문서 - Solana System Program docs: 여기서 모든 도구가 최종적으로 호출하는 전송 명령(transfer instruction)에 관한 문서
- Github: 소스 코드 #100DaysOfSolana의 일환으로 작성되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기