Posts LLM에게 실행 권한을 줄 때 필요한 안전장치
Post
Cancel

LLM에게 실행 권한을 줄 때 필요한 안전장치

판단은 모델이, 한도는 코드가

LLM에게 도구를 쥐여주고 자율적으로 판단하게 만드는 구조를 개인 프로젝트로 몇 달 굴려봤다. 대상은 자동매매였지만, 배운 것 대부분은 도메인과 무관했다.

가장 중요한 결론부터 쓰면 이렇다.

모델의 판단을 프롬프트로 제약하려 하지 말고, 실행 계층에서 구조적으로 막아야 한다.

프롬프트에 “하루 8회 이상 거래하지 마”라고 쓰면 대체로 지킨다. 대체로는. 그리고 안 지킬 때는 이유를 아주 그럴듯하게 설명한다. “오늘은 변동성이 특별히 커서 예외적으로…”

계층을 나눈다

1
2
3
4
5
6
7
8
9
자연어 전략 문서          사람이 편집. 모델이 매 사이클 읽는다
        │
    LLM 세션              후보 평가 → 판단 → 도구 호출
        │
    도구 계층 (MCP)       모델이 호출할 수 있는 함수들
        │
    실행 계층 (Python)    ★ 여기서 한도를 강제한다
        │
    거래소 API

핵심은 한도가 실행 계층에 있다는 것이다. 도구 계층에 있으면 안 된다. 도구 설명에 “하루 8회까지”라고 써두면 모델이 그걸 읽고 지키려 하지만, 그건 여전히 모델의 협조에 의존하는 구조다.

1
2
3
4
5
class RiskManager:
    def check(self, state: AccountState) -> Decision:
        if not within_daily_count(state.daily_trade_count, self._cfg.daily_trade_limit):
            return Decision.reject("daily trade limit reached")
        ...

모델이 무슨 이유를 대든 이 함수를 통과하지 못하면 주문은 안 나간다.

킬 스위치는 파일 하나로

1
2
3
4
KILL_SWITCH_FILE = os.environ.get("TB_KILL_SWITCH_FILE", "/tmp/trade_bot.kill")

def orders_blocked() -> bool:
    return os.path.exists(KILL_SWITCH_FILE)

파일 존재 여부로 하는 이유가 있다.

  • 의존성이 없다. DB도, 네트워크도, 설정 재로드도 필요 없다. 시스템의 다른 부분이 전부 망가져도 이건 동작한다.
  • 누구나 켤 수 있다. touch /tmp/trade_bot.kill 한 줄이면 된다. 급할 때 API 문서를 찾아보게 만들면 안 된다.
  • 모델이 끌 수 없다. 파일 시스템 쓰기 도구를 노출하지 않았으므로 구조적으로 불가능하다.

이 검사는 주문 경로의 가장 마지막에 둔다. 앞쪽에 두면 검사와 실행 사이에 시간이 생긴다.

읽기 전용 모드를 도구 구성으로 만든다

같은 시스템을 “분석만 하는 세션”으로도 돌리고 싶었다. 프롬프트에 “이번엔 매매하지 마”라고 쓰는 대신, 도구 구성 파일을 분리했다.

1
2
config/mcp/agents.mcp.json      market, sentiment, risk, execution, common
config/mcp/education.mcp.json   market, sentiment, risk, common      ← execution 없음

실행 그룹이 없는 구성으로 띄우면 모델에게 주문 도구 자체가 보이지 않는다. 매매가 구조적으로 불가능하다. “안 하기로 했는데 했다”가 성립할 수 없다.

이 패턴은 일반화된다. 위험한 작업 묶음을 별도 도구 그룹으로 떼어내고, 필요할 때만 붙인다. 프롬프트에서 금지하는 것보다 훨씬 확실하고, 무엇보다 로그를 안 봐도 안전이 보장된다.

멱등성으로 중복 실행을 막는다

자동 안전장치(손절·트레일링)와 모델의 판단이 같은 대상에 동시에 작동할 수 있다. 둘 다 매도를 걸면 보유량보다 많이 팔린다.

해결은 실행 계층의 오버셀 가드다.

1
2
3
4
5
6
def place_sell(symbol: str, qty: Decimal) -> Order | None:
    held = self._positions.get(symbol)
    if held is None or held.qty <= 0:
        return None                      # 이미 청산됨 — 조용히 무시
    qty = min(qty, held.qty)             # 보유량을 넘지 않는다
    ...

여기서 예외를 던지지 않고 조용히 무시하는 것이 의도적이다. 예외를 던지면 모델이 그걸 “실패”로 읽고 재시도하거나, 더 나쁘게는 다른 방법을 찾으려 한다. 이미 원하는 상태(청산됨)에 도달했으면 그건 성공이다.

멱등성은 LLM 자동화에서 특히 중요하다. 사람은 같은 명령을 두 번 안 내리지만, 모델은 이전 사이클의 결과를 못 봤거나 다르게 해석하면 태연히 반복한다.

세션 경계와 인계

매 사이클을 새 세션으로 시작하면 맥락이 없고, 한 세션을 무한히 이어가면 컨텍스트가 넘친다.

날짜 단위로 회전시키는 방식이 실용적이었다.

1
~/.trade-bot/manager_session.id     형식: "YYYY-MM-DD <session_id>"

같은 날이면 이어서(resume), 자정 이후 첫 사이클에서 인계 문서를 남기고 새 세션으로 넘어간다.

인계 문서에 무엇을 넣느냐가 실제로 품질을 갈랐다. 현재 상태(보유 종목, 진입가, 근거)만 넣으면 부족하다. 왜 그렇게 판단했는가무엇이 깨지면 그 판단을 폐기하는가를 같이 넣어야 다음 세션이 이어서 판단할 수 있다.

자연어 전략 문서를 코드 밖에 둔다

판단 기준을 코드에 넣으면 바꿀 때마다 배포해야 한다. 자연어 문서 하나를 두고 모델이 매 사이클 읽게 했다.

1
2
3
4
5
6
7
8
## 진입
- 상위 시간프레임 추세가 상방 **그리고** 단기 타이밍 확인. 둘 다 충족해야 한다.
- 기대 상승폭이 손절폭의 최소 2배(R:R ≥ 2)인 경우만.
- 하락 추세 종목은 과매도 지표와 무관하게 진입 금지.

## 청산
- 매도 트리거는 둘뿐이다. ① 진입 근거의 무효화 조건이 실제로 충족됨
  ② 목표에 도달해 상방 여력 소진. 그 외에는 보유.

여기서 실제로 효과가 컸던 것은 무효화 조건을 명시하게 만든 것이다. 모델에게 “방향 + 확신도 + 이 조건이 깨지면 판단을 폐기한다”를 한 세트로 내놓게 하면, 그 무효화 조건이 곧 청산 트리거가 된다. 판단과 판단 철회 기준을 동시에 만들게 하는 셈이다.

이걸 안 하면 모델은 상황이 나빠졌을 때 매번 새 이유를 찾아 보유를 정당화한다.

다만 문서로 못 막는 것이 있다

“자주 사지 마라”를 문서에 아무리 강하게 써도 한계가 있었다. 실제로 효과가 있었던 것은 서버가 강제하는 일일 거래 한도와 재진입 쿨다운이었다. 문서는 방향을 정하고, 한도는 코드가 정한다.

인증과 비용 사고 방지

구독 인증으로 돌리는 구성에서 환경변수에 API 키가 있으면 그쪽으로 붙어서 예상치 못한 과금이 발생한다. 프리플라이트에서 아예 막았다.

1
2
3
4
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
  echo "ANTHROPIC_API_KEY가 설정되어 있다. 과금 방지를 위해 중단한다." >&2
  exit 1
fi

그리고 매 사이클 시작에 가벼운 스모크 호출로 인증을 확인하고, 실패하면 사이클을 건너뛴다. 인증이 만료된 상태로 크론이 계속 돌면 로그만 쌓이고 아무 일도 안 일어나는데, 그 사실을 아무도 모른다.

1
0 * * * * cd /path/to/bot && timeout 240 scripts/cycle.sh >> ~/.bot/cycle.log 2>&1

timeout이 중요하다. LLM 호출은 가끔 안 끝난다. 타임아웃 없이 크론에 걸면 안 끝난 프로세스가 쌓이고, 다음 사이클과 겹쳐 같은 판단이 두 번 실행된다.

실측이 문서를 고친다

한동안 손실이 누적됐는데, 거래 기록을 뒤져보니 패턴이 명확했다.

1
2
보유 6분 미만 청산  → 전부 손실
길게 보유한 거래    → 수익

수수료(왕복 약 0.1%)와 슬리피지를 이기지 못하는 단타를 반복하고 있었다. 그래서 전략 문서에 이 실측을 그대로 적고, 자동 익절이 보유 30분 경과 이후에만 발동하도록 코드에 넣었다.

여기서 배운 것은, 자연어 전략 문서에 근거 없는 원칙을 쓰면 모델이 그걸 상황에 맞게 유연하게 해석한다는 점이다. “길게 보유하라”보다 “6분 미만 청산은 실측상 전부 손실이었다”가 훨씬 잘 지켜진다. 숫자가 붙은 관찰은 해석의 여지가 적다.

정리

막고 싶은 것프롬프트로코드로
과도한 실행 빈도잘 안 됨일일 한도 + 쿨다운
위험한 작업 자체안 됨도구 그룹 분리
중복 실행안 됨멱등성 가드
긴급 정지안 됨파일 킬 스위치
판단 기준이쪽이 맞음코드에 넣으면 경직됨
판단 철회 기준이쪽이 맞음 (무효화 조건 명시)

경계가 꽤 선명하다. “무엇을 할 것인가”는 자연어로 주고, “얼마나·어디까지 할 수 있는가”는 코드로 막는다. 이 둘을 섞으면 양쪽 다 나빠진다. 프롬프트에 한도를 넣으면 안 지켜지고, 코드에 판단을 넣으면 못 바꾼다.

This post is licensed under CC BY 4.0 by the author.

git 밖에 있는 것들 — 추론 엔진 자산 관리

-