문서 AI로 Bot 만들기

LLM을 참모로

"무엇을 모을지, 일꾼을 어디에 배치할지, 이번 1분은 싸울지 버틸지"를 LLM에게 맡기고, 규칙 계층은 실행과 거부만 담당합니다. 레퍼런스 브레인이 이미 이렇게 동작하며, 이 페이지에서는 그 패턴과 함정을 설명합니다.

Bot을 어느 정도 작성하다 보면 운영 규칙이 한 겹씩 덧붙여져 있다는 것을 알게 됩니다. 벌목 인원 규칙 하나, 금광당 5명 규칙 하나, 목재가 많으면 절반으로 줄이는 규칙 하나, 금이 모자라고 목재가 남으면 금 쪽에 더 보내는 규칙 하나…… 규칙 하나하나는 옳지만, 합쳐 놓으면 “금광에는 일꾼이 모자란데 농부가 전부 나무를 베고 있는” 상황, 즉 어느 규칙도 책임지지 않는 상황이 생깁니다.

“전체를 보고 우선순위를 정하는” 이런 판단은 애초에 if / else로 쓰기에 맞지 않지만, 바로 LLM이 잘하는 일입니다. 레퍼런스 브레인(brains/xwar3/strategy/brain/coach.py)은 아래와 같은 계층 구조를 씁니다.

계층 구조

LLM(어드바이저)             20 게임 초마다 한 번, 비동기, 어떤 틱도 막지 않음
  입력: 한 페이지짜리 게임 상황 스냅샷(자원, 인구, 농부 배치, 금광, 병종, 기술, 영웅, 적 정보, 최근 사건)
  출력: 엄격한 JSON — 한 줄 진단 + 일꾼 배분 + 우선 생산 대상 + 이번 1분의 태세 + 하지 말 것
        │
        ▼  화이트리스트 + 상하한 클램핑 + 거부권
규칙 계층(Bot, 매 틱)       조언을 기존 기능의 "편향"으로 번역: 일꾼 배분, 건설 / 훈련 우선순위, 공격 태세
        │
        ▼
실행 계층(SDK / 반사 계층)  명령, 회신 읽기, 마이크로 컨트롤

출력 계약

모델이 고정된 필드의 JSON만 출력하게 하고, 필드를 더하거나 빼지 못하게 합니다:

{
  "diagnosis": "한 문장: 게임 상황에서 가장 큰 문제. 반드시 입력 데이터에서 근거를 찾을 수 있어야 함",
  "workers":   { "gold": 10, "lumber": 6 },
  "priority":  ["hpea", "hhou", "hbar"],
  "posture":   "creep",
  "avoid":     ["목재가 부족할 때 Iron Plating 연구를 먼저 하지 말 것"]
}
필드규칙 계층의 사용법레퍼런스 브레인의 클램핑
workers금 채집, 벌목의 목표 인원금 2 ~ 25, 벌목 1 ~ 20. 둘의 합은 전체 농부 수를 넘지 않음
priority훈련 / 건설 / 연구의 우선순위최대 4개. “선택 가능 코드” 표에 있는 4자 코드만 받음
posture이번 1분의 태세attack defend creep expand recover hold 중 하나만 가능
avoid이번 1분 동안 하지 말 것최대 2개
diagnosis로그와 콘솔 표시에만 사용—

종족마다 프롬프트를 따로 두고, 그 종족에만 있는 선택지만 적습니다(휴먼의 협동 건설과 민병대, 오크의 굴, 언데드의 저주받은 금광(Haunted Gold Mine), 나이트 엘프의 휘감은 금광(Entangled Gold Mine)……). 공통 규칙은 공통 부분에 두고, 네 번 복사하지 마세요.

네 가지 강한 제약

네 가지 모두 레퍼런스 브레인이 실제로 대가를 치르고 얻은 교훈입니다:

  1. 어드바이저는 절대 유닛에게 직접 명령하지 않습니다. 어드바이저는 150 ms 단위의 현장을 볼 수 없고, 환각도 일으킵니다. 목표와 우선순위만 바꾸고, 누가 어디로 가서 누구를 공격할지는 여전히 규칙 계층과 반사 계층이 정합니다 — 지휘권의 주인은 하나여야 합니다.
  2. 비동기. 어드바이저 호출 한 번에 약 1초가 걸리며, 백그라운드 스레드에서 돌고 최신 결과가 적용됩니다. 어떤 틱도 절대 막지 않습니다. 모델이 떠 있지 않거나, 시간이 초과되거나, 엉뚱한 답을 하면 이 계층이 없는 것으로 보고 순수 규칙으로 돌아갑니다. 너무 오래된 조언(3 간격 초과)도 쓰지 않습니다.
  3. 화이트리스트 + 클램핑. 모든 필드는 기존 기능에 대응해야 하고, 수치는 합리적인 범위로 클램핑합니다. 알 수 없는 내용은 조용히 무시하지 말고 카운트한 뒤 버립니다.
  4. 전 과정 카운트. 몇 번 물었는지, 몇 번 성공했는지, 몇 번 시간 초과됐는지, 몇 번 클램핑됐는지, 필드별로 몇 번 채택됐는지를 마지막으로 모델에 보낸 입력과 함께 발행합니다. 그렇지 않으면 “이 계층이 정말 쓸모가 있는가”는 답할 수 없는 질문이 됩니다.

안전하게 성능을 낮출 수 있는 것일수록 조용히 낮아지기 쉽다

어드바이저는 “실패 = 이 계층이 없는 것”으로 설계되어 있으므로, 모델 서비스가 떠 있지 않으면 Bot은 순수 규칙과 똑같이 동작하고 밖에서는 전혀 티가 나지 않습니다. 레퍼런스 브레인은 인스턴스 6개의 어드바이저가 하루 종일 모두 연결되지 않았는데도 아무도 눈치채지 못한 적이 있습니다. “마지막 성공 시각”과 “마지막 실패 원인”은 반드시 발행하세요 — Farsight 콘솔의 “운영 어드바이저” 페이지가 바로 그 역할을 합니다.

여러분의 Bot에 구현하기

아래는 최소한의 뼈대입니다. OpenAI 호환 API라면 무엇이든(LM Studio, Ollama, 클라우드 API 모두) 쓸 수 있고, 표준 라이브러리만 사용합니다:

import collections, json, threading, urllib.request
from openwar3 import Bot

BASE = "http://127.0.0.1:1234/v1"            # LM Studio / Ollama / 모든 OpenAI 호환 서비스
MODEL = "your-model"
POSTURES = {"attack", "defend", "creep", "expand", "recover", "hold"}
SYSTEM = """너는 워크래프트 III의 운영 코치다. 운영과 전략만 담당하고, 마이크로 컨트롤은 다루지 않는다.
JSON만 출력하고, 필드는 고정이다: {"diagnosis": 한 문장, "workers": {"gold": 정수, "lumber": 정수},
"priority": [4자 코드, 최대 4개, allowed에 있는 것만], "posture": 여섯 중 하나, "avoid": [최대 2개]}
주어진 게임 데이터만 근거로 말하고, 데이터에 없는 것은 지어내지 마라."""


def ask(state: dict) -> dict:
    body = {"model": MODEL, "temperature": 0.3, "max_tokens": 260,
            "messages": [{"role": "system", "content": SYSTEM},
                         {"role": "user", "content": json.dumps(state, ensure_ascii=False)}]}
    req = urllib.request.Request(f"{BASE}/chat/completions", json.dumps(body).encode(),
                                 {"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=8) as r:
        text = json.load(r)["choices"][0]["message"]["content"]
    return json.loads(text[text.index("{"): text.rindex("}") + 1])


class CoachedBot(Bot):
    EVERY = 20.0                              # 게임 초: 운영 결정의 시간 단위는 분이므로 매 틱 물을 필요 없음
    allowed = {"hpea", "hfoo", "hrif", "hkni", "hhou", "hbar", "hbla", "Rhme", "Rhar"}

    def on_start(self, g):
        self.plan, self.plan_at, self.asked_at, self.busy = {}, -1e9, -1e9, False
        self.stats = collections.Counter()

    def summary(self, g) -> dict:             # 스냅샷은 메인 스레드에서 읽어 두고, 백그라운드 스레드는 g를 건드리지 않음
        res = g.resources() or {}
        return {"clock": round(g.clock() or 0), "gold": res.get("gold"), "lumber": res.get("lumber"),
                "food": [res.get("food_used"), res.get("food_cap")],
                "workers": len(g.my_workers()), "idle_workers": len(g.idle_workers()),
                "army": collections.Counter(u.type for u in g.my_army()),
                "enemy_seen": collections.Counter(u.type for u, _t, _age in g.last_seen(max_age=90)),
                "night": g.is_night(), "allowed": sorted(self.allowed)}

    def consult(self, state, now):
        try:
            p = ask(state)
            self.stats["ok"] += 1
            posture = p.get("posture")
            if posture not in POSTURES:
                self.stats["bad_posture"] += 1                       # 카운트한 뒤 버림, 조용히 무시하지 않음
                posture = "hold"
            self.plan = {"gold": min(25, max(2, int(p["workers"]["gold"]))),      # 클램핑
                         "lumber": min(20, max(1, int(p["workers"]["lumber"]))),
                         "priority": [c for c in p.get("priority", []) if c in self.allowed][:4],
                         "posture": posture}
            self.plan_at = now
        except Exception as e:                                       # 시간 초과 / 엉뚱한 답: 이 계층이 없는 것으로 처리
            self.stats[f"error:{type(e).__name__}"] += 1
        finally:
            self.busy = False

    def on_tick(self, g):
        now = g.clock() or 0.0
        if not self.busy and now - self.asked_at >= self.EVERY:
            self.busy, self.asked_at = True, now
            threading.Thread(target=self.consult, args=(self.summary(g), now), daemon=True).start()
        plan = self.plan if now - self.plan_at <= 3 * self.EVERY else {}   # 너무 오래된 조언은 쓰지 않음
        # ↓ 규칙 계층: plan이 비어 있으면 기본 규칙대로. plan이 있으면 배분, 우선순위, 태세만 조정하고 실제 명령은 여전히 규칙이 결정
        ...

모델 고르기

상황권장 사항
로컬, 빨라야 함MoE 모델(호출마다 일부 파라미터만 활성화)은 같은 크기의 dense 모델보다 훨씬 빠릅니다. 레퍼런스 브레인은 Qwen3.6-35B-A3B(LM Studio, Q4)를 쓰며, 중앙값 1.09초, 최대 1.45초, 출력 5/5가 바로 json.loads됩니다
로컬, “생각하는” 모델생각 구간을 반드시 꺼야 합니다. 그렇지 않으면 토큰을 전부 생각에 써 버려 JSON이 하나도 나오지 않습니다. LM Studio는 /no_think를 무시합니다. 레퍼런스 브레인은 /v1/completions로 바꿔 ChatML을 직접 조립하고, 빈 <think></think>와 { 하나를 미리 채워 넣습니다
클라우드 모델지연은 보통 더 크지만, 이 계층 구조는 원래 비동기입니다. 운영 결정은 분 단위이므로 몇 초의 지연은 괜찮습니다

같은 모델로 유닛에게 목소리를 입힐 수도 있습니다: 말풍선과 로컬 모델을 참고하세요. 모델이 (참모가 아니라) 매 틱 직접 명령하게 하고 싶다면 아레나의 JSON 게이트웨이를 기다리세요.