LLM 에이전트가 호출하는 CLI 도구 설계
LLM 에이전트에게 도구를 쥐여줄 때, 도구가 사람용으로 설계되어 있으면 에이전트가 헤맵니다. 사람은 표 형태 출력과 색깔을 좋아하지만, 에이전트는 기계가 파싱하기 좋은 일관된 구조를 원합니다. cron이 주기적으로 LLM을 깨우고, LLM이 시장을 보고 판단하는 트레이딩 CLI를 만들며 정리한 설계 원칙입니다.
원칙 1 — 모든 출력은 ok/error JSON
도구가 성공하든 실패하든, 출력 스키마는 하나여야 합니다. 에이전트는 ok 필드만 보고 분기하면 됩니다.
# 성공
{ "ok": true, "data": { ... } }
# 실패
{ "ok": false, "error": "...", "exception_type": "...", "traceback": "..." }
이걸 모든 스크립트에서 반복하지 않도록 공통 래퍼로 뽑습니다.
import json, sys, traceback
def print_json(data):
print(json.dumps({"ok": True, "data": data}, ensure_ascii=False, default=str))
def print_error(message, exc=None):
payload = {"ok": False, "error": message}
if exc is not None:
payload["exception_type"] = type(exc).__name__
payload["traceback"] = traceback.format_exc()
print(json.dumps(payload, ensure_ascii=False, default=str))
sys.exit(1) # 종료 코드로도 실패를 알린다
def run(handler):
try:
print_json(handler())
except SystemExit:
raise
except Exception as e:
print_error(str(e), exc=e)
각 도구는 로직만 짜고 run()으로 감싸면 끝입니다. 성공은 exit 0 + ok:true, 실패는 exit 1 + ok:false. 에이전트는 물론 셸 스크립트에서도 다루기 쉽습니다.
한 가지 실전 함정: Windows 콘솔(cp949)에서 한글·이모지를 출력하면
UnicodeEncodeError로 도구가 죽습니다. 진입점에서 표준 출력을 UTF-8로 재설정해 두세요.sys.stdout.reconfigure(encoding="utf-8")
원칙 2 — 상태 파일로 히스토리를 대체한다
에이전트를 cron으로 매 틱 부르면, 순진하게는 매번 텍스트 로그를 쌓고 싶어집니다. 하지만 그러면 컨텍스트가 폭발하고, LLM이 과거 로그에 휘둘립니다.
대신 직전 상태만 파일 하나에 덮어씁니다. 히스토리가 아니라 스냅샷입니다.
def _load_state():
if not STATE_FILE.exists():
return {"schema_version": 3, "positions": [], "last_trade_ids": {}}
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
def _save_state(state):
STATE_FILE.write_text(
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
정말 남겨야 하는 이력(체결·정산 같은)만 append-only 파일(ledger.jsonl)에 한 줄씩 붙입니다. 스냅샷은 덮어쓰기, 이력은 누적 — 역할을 나누는 게 핵심입니다.
그리고 가장 중요한 규칙. 진실의 원천은 도구가 아니라 외부 시스템입니다. 트레이딩이라면 현재 포지션·미체결 주문은 상태 파일이 아니라 거래소에서 매번 다시 읽습니다. 상태 파일은 거래소가 모르는 것(직전 측정값, 에이전트의 선택 같은)만 담습니다. 이렇게 하면 상태 파일이 꼬여도 시스템이 어긋나지 않습니다.
원칙 3 — cron 틱은 단일 진입점
에이전트가 매번 어떤 도구부터 불러야 할지 고민하게 만들면 안 됩니다. 한 번의 호출로 판단에 필요한 raw 데이터를 전부 모아 주는 진입점(tick) 하나를 둡니다.
# tick.py — 매 cron 틱의 단일 진입점 (순수 조회)
# 룰·판단은 넣지 않는다. 데이터만 제공하고 결정은 LLM이 한다.
def build_tick():
return {
"account": fetch_account(), # 잔고 / 가용 / 미실현손익
"positions": fetch_positions(), # 거래소가 원천
"macro": measure_macro(), # 거시 지표 raw
"signals": measure_symbols(), # 종목별 지표 + 직전 대비 Δ
}
run(build_tick)
여기서 결정적으로 중요한 설계 선택은, 틱 도구에 매매 룰을 넣지 않는 것입니다. "이 조건이면 진입" 같은 판단 로직을 코드에 심고 싶은 유혹이 크지만, 그러면 에이전트는 껍데기가 되고 코드가 곧 전략이 됩니다. 틱은 철저히 데이터 제공기로 두고, 판단은 LLM에게 온전히 맡깁니다. 주문 실행은 또 다른 명시적 도구(place_order, close_position)로 분리해, 조회와 행동의 경계를 분명히 합니다.
정리
- 출력은
ok/error단일 JSON 스키마로 통일하고 exit code도 맞춘다 - 히스토리 대신 상태 스냅샷(덮어쓰기) + 필요한 이력만 append-only
- 현재 상태의 진실은 외부 시스템에서 매번 다시 읽는다
- cron은 단일 진입점만 호출하고, 룰은 코드가 아니라 에이전트가 갖는다
도구를 데이터와 행동으로 얇게 쪼개 두면, 전략을 바꿀 때 코드가 아니라 프롬프트만 고치면 됩니다. 그게 에이전트 친화적 CLI의 목표입니다.
자율 에이전트 매매는 환각(hallucination) 위험이 있습니다. 작은 규모·테스트 환경에서 충분히 검증하세요. 이 글은 기술 설명이며 수익을 보장하지 않습니다.
댓글
댓글 쓰기