게임 번역 수만 건을 에이전트 CLI 배치 호출로 처리하기

항목당 1회 추론으로는 끝나지 않는 번역 작업을 Antigravity CLI(agy)에 100건씩 묶어 JSON 으로 주고받는 방식으로 옮겼습니다. 배치 크기 기준과 응답 누락·오류 오판·이스케이프 소실을 실측값과 함께 정리했습니다.

배포된 게임 폴더를 받아 한국어 번역 파일까지 만드는 CLI 를 운영하고 있습니다. 게임 하나에서 추출되는 번역 항목은 많으면 2만 건이 넘습니다. 처음에는 로컬 GGUF 모델로 항목마다 한 번씩 추론했는데, 번역 대기 중인 게임이 예닐곱 개가 되자 이 방식으로는 끝나지 않는다는 것이 분명해졌습니다. 그래서 번역을 Antigravity CLI 에 맡겼습니다. 명령 이름이 agy 라 아래에서는 그렇게 적습니다. 이 글은 처리를 그 배치 호출로 옮기면서 정한 기준과, 그 과정에서 실제로 무엇이 어긋났는지에 대한 기록입니다.

호출 방식별 항목당 지연 시간

agy 는 명령 한 번이 프로세스 기동과 시스템 프롬프트 전송을 포함합니다. 번역 지침과 용어집이 들어간 시스템 프롬프트가 24K 토큰이고 프로세스 기동에 약 5초가 걸리므로, 항목마다 호출하면 번역 자체보다 이 부가 비용이 더 큽니다. 세 가지 방식을 비교 측정했습니다.

단발 호출9.3
상주 세션1.3
100건 배치0.3
항목 하나를 번역하는 데 걸리는 시간입니다. 배치는 호출당 30초 전후를 100건으로 나눈 실효값입니다.

항목당 1회 호출은 어느 방식으로도 수만 항목에서 성립하지 않았습니다. 기동 비용을 없앤 상주 세션도 2만 건이면 7시간이 넘게 걸립니다. 배치로 방향을 정하고 나니 기존 추론 백엔드 계층에 agy 어댑터를 새로 만들 이유가 사라졌습니다. 그 계층은 항목 하나를 받아 하나를 돌려주는 구조여서 배치와 맞지 않았습니다. 구현 중이던 어댑터는 폐기하고 별도 스크립트로 분리했습니다.

요청과 응답의 JSON 형식

1회 호출에 들어가는 것은 시스템 프롬프트와 JSON 배열 하나입니다. 요청 배열의 각 항목은 id·화자·원문으로 구성됩니다. 응답도 id 와 번역문만 담은 JSON 배열을 요구합니다. 순서가 아니라 id 로 대조하므로 모델이 순서를 바꿔 반환하더라도 복구할 수 있습니다.

return (
    f"{system}\n\n"
    "아래 JSON 배열의 각 항목을 위 규칙대로 한국어로 번역하라.\n"
    '- 출력은 `[{"id": 0, "ko": "번역문"}, ...]` 형태의 JSON 배열 **하나만**. '
    "설명·주석·코드펜스 금지.\n"
    "- 입력 id 를 하나도 빠짐없이, 순서대로 그대로 돌려줄 것.\n"
    "- `{...}` 태그와 `[...]` 보간자는 개수·내용·순서를 원문 그대로 보존한다. "
    "번역하거나 지우지 말 것.\n"
    "- `speaker` 는 화자 참고용이며 번역 대상이 아니다.\n\n"
    f"{json.dumps(payload, ensure_ascii=False)}"
)

화자 이름을 함께 전달하는 이유는 어조를 맞추기 위해서입니다. 같은 문장이라도 말하는 사람에 따라 존댓말과 반말이 달라집니다. 다만 화자 자체는 번역 대상이 아니므로 이 점을 프롬프트에 명시합니다.

배치 분할 기준을 항목 수에서 문자 수로 바꾼 이유

처음에는 배치를 100건씩 나누었습니다. UI 문자열 구간에서는 문제없이 동작했으나 장문 대사 구간에 들어가자 배치 뒷부분의 id 가 통째로 빈 상태로 반환되었습니다. 같은 100건이라도 원문 분량이 달랐기 때문입니다. UI 문자열 100건은 원문 합이 3천 자였지만 장문 대사 100건은 2만 7천 자에 달했습니다. 이로 인해 후반부 항목이 모델의 출력 한계를 초과했습니다.

그래서 문자 수를 주 기준으로 두고 항목 수는 상한으로 남겼습니다. 기본값은 4,000자, 100건입니다. 한 항목이 예산보다 길면 나눌 수 없으므로 그 항목만으로 배치를 만듭니다.

cur: list[parser.TranslationItem] = []
cur_chars = 0
start = 0
for idx, it in enumerate(queue):
    size = len(it.source_text)
    over_items = len(cur) >= max_items
    over_chars = max_chars > 0 and cur and cur_chars + size > max_chars
    if over_items or over_chars:
        yield start, cur
        cur, cur_chars, start = [], 0, idx
    cur.append(it)
    cur_chars += size
if cur:
    yield start, cur

응답에서 누락된 id 를 같은 실행에서 회수하기

문자 수 기준을 도입하기 전에는 1회 실행의 실패 197건 중 189건이 응답 누락이었습니다. 이는 검증에서 반려된 것이 아니라 모델이 해당 id 를 전혀 반환하지 않은 결과였습니다. 이런 항목은 체크포인트에 완료 상태로 남지 않으므로 다음 실행 시 재시도되기는 합니다. 그러나 게임 하나를 처리하는 데 수 시간이 걸리므로 다음 패스까지 처리를 미루면 시간 손실이 큽니다.

현재는 배치 응답을 수신한 즉시 누락된 id 만 모아 더 작은 단위로 묶어 한 번 더 요청합니다. 회수된 건수는 실행 로그에 그대로 기록됩니다.

  + 누락 32건 재요청 → 32건 회수
  [82/16168  0.5%] extras_gallery_script.rpy · 번역 82 실패 0 · 1.4분

잘린 JSON 배열에서 완전한 객체만 회수하기

출력 한계에 걸린 응답은 배열이 중간에서 끊어집니다. 이 경우 표준 파서는 파싱에 실패합니다. 응답을 그대로 버리면 앞쪽 수십 건의 정상 번역까지 함께 사라집니다. 그래서 객체를 하나씩 읽어 파싱에 성공한 부분까지만 취하고, 나머지는 빈 항목으로 남겨 다음 실행이 재시도하도록 했습니다.

def _salvage_objects(body: str, start: int) -> list[dict]:
    """잘린 JSON 배열에서 완전한 객체만 순서대로 회수한다."""
    dec = json.JSONDecoder()
    objs: list[dict] = []
    i = start + 1
    while i < len(body):
        while i < len(body) and body[i] in ", \n\r\t":
            i += 1
        if i >= len(body) or body[i] != "{":
            break
        try:
            obj, end = dec.raw_decode(body, i)
        except json.JSONDecodeError:
            break
        if isinstance(obj, dict):
            objs.append(obj)
        i = end
    return objs

오류 판정 패턴이 번역문 본문을 오탐하는 문제

agy 는 사용량 소진·서버 혼잡·콘텐츠 차단을 자연어 문장으로 반환합니다. 그래서 응답 전체를 정규식으로 검사해 429 · 503 · try again · safety 같은 오류 패턴을 찾고, 패턴이 감지되면 30초·60초·120초 백오프 뒤 재시도하도록 했습니다.

그런데 이 검사가 번역문 본문까지 오탐합니다. 숫자 500 이나 503 이 나오는 대사, safety 라는 단어가 들어간 대사를 포함한 배치는 정상적으로 번역되었는데도 매번 폐기되었습니다. 백오프 대기에 3분을 소모한 뒤 버려졌습니다. 이로 인해 처리량이 3분의 1로 떨어졌습니다.

해결 방법은 검사 순서를 바꾸는 것이었습니다. 프로세스 종료 코드가 0 이고 응답이 요청한 형태를 포함하고 있으면 오류 패턴 검사를 건너뜁니다. 오류 판정 패턴을 응답 본문 전체에 무조건 적용하지 않는 것이 핵심입니다.

def _looks_like_payload(body: str) -> bool:
    """응답이 우리가 요청한 번역 JSON 으로 보이는지."""
    if not body:
        return False
    return bool(re.search(r'\{\s*"id"\s*:\s*\d+\s*,\s*"ko"\s*:', body))


# 호출부: 형태가 맞으면 오류 패턴 검사를 건너뛴다
if _looks_like_payload(body) and proc.returncode == 0:
    _empty_streak = 0
    return body

for pat in _QUOTA_PATTERNS:
    if pat.search(out):
        raise QuotaExhausted(pat.pattern)

JSON 왕복에서 줄바꿈 이스케이프 표기가 유실되는 문제

번역 결과는 파일에 반영되기 전에 검증기를 통과해야 합니다. 검증기는 태그 개수, 변수 보간자의 내용, 줄바꿈 개수를 원문과 대조합니다. 그런데 배치 경로로 옮긴 뒤 이 검증 단계가 정상 번역을 반려하기 시작했습니다. 로그에 남은 사유는 "줄바꿈 개수 불일치 (원문 1 vs 번역 0)" 였습니다.

원인은 데이터 표현 계층의 차이였습니다. 번역 원문은 게임 스크립트 소스이므로 줄바꿈이 백슬래시와 n 두 글자로 표현됩니다. 이를 JSON 으로 직렬화해 전달하면 모델에게는 백슬래시가 이스케이프된 형태로 보입니다. 모델 역시 JSON 관례에 따라 응답에 개행 이스케이프를 사용합니다. 그러나 응답을 역직렬화하면 실제 개행 문자 한 글자가 되므로, 검증기가 원문에서 세던 두 글자짜리 표기는 0개가 됩니다.

단계백슬래시+n 개수
게임 스크립트 원문progress.\nAre you...1
프롬프트(직렬화 후)"text": "progress.\\nAre you..."-
모델 응답"ko": "...사라집니다.\n정말..."-
역직렬화 후실제 개행 1문자0

모델은 정상이었습니다. 번역문도 줄바꿈 위치도 맞았고 표현만 JSON 관례를 따랐을 뿐입니다. 검증기는 의미가 아니라 표현을 비교하므로 이 형식 차이만으로 전량 반려가 됩니다. 재현율은 100% 였습니다. 실제로 한 게임의 공통 스크립트에 남은 미번역 14건 중 해당 표기를 포함한 7건이 실행할 때마다 전부 반려되었습니다. 작업 중이던 게임 여덟 개의 미번역 86,712건 가운데 273건이 같은 원인에 해당했습니다.

수정한 지점은 한 곳입니다. 응답을 역직렬화한 직후 실제 개행 문자를 원문 표기로 되돌립니다. 검증기와 로컬 추론 경로는 수정하지 않았습니다. 파일에 쓰는 함수가 동일한 변환을 수행하므로 기록 단계는 원래부터 안전했고, 문제는 그보다 먼저 실행되는 검증 단계뿐이었습니다.

def _literalize_newlines(text: str) -> str:
    r"""응답의 실제 개행을 .rpy 표기인 리터럴 ``\n`` 으로 되돌린다."""
    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
    return normalized.replace("\n", "\\n")

파이프라인에 직렬화 경로를 새로 추가할 때는 이스케이프 단계부터 확인하는 것이 좋습니다. 줄바꿈뿐 아니라 따옴표와 백슬래시 처리에도 같은 문제가 생길 수 있습니다. 처리 순서도 중요합니다. 개행 문자로 시작하는 원문이 있으므로, 표기를 되돌리는 처리가 양 끝 공백 제거보다 먼저여야 문두의 줄바꿈이 유지됩니다.

콘텐츠 필터에 차단된 배치를 이분 분할로 격리하기

일부 항목은 모델의 콘텐츠 안전 필터에 걸려 차단됩니다. 배치 100건 중 한두 줄 때문에 응답 전체가 빈 상태로 반환됩니다. 처음에는 이를 사용량 소진과 동일하게 취급해 실행을 멈추도록 했습니다. 그러나 이 때문에 한 게임의 번역이 3.3% 진행된 시점에 전체 작업이 중단되는 일이 생겼습니다.

현재는 차단된 배치만 절반으로 나누어 다시 전송하며, 분할 횟수는 최대 네 번으로 제한합니다. 100건 기준 배치는 6~7건 단위까지 좁혀지고, 그보다 더 나누면 호출 수가 배치당 최대 2N-1 회로 늘어나 분할의 실익보다 시간 손실이 더 큽니다. 끝까지 차단된 항목 묶음은 로컬 모델로 번역합니다.

빈 응답은 콘텐츠 차단뿐 아니라 인증 만료로도 발생합니다. 두 경우는 응답 내용만으로 구분되지 않으므로 연속 발생 횟수로 판별합니다. 12회 연속으로 발생하면 개별 콘텐츠 문제가 아니라 환경 문제로 판단해 실행을 중단합니다.

체크포인트와 게임 단위 큐

배치마다 파일에 기록하고 체크포인트를 저장합니다. 작업이 중단되어도 그 시점까지의 결과가 보존되므로, 같은 명령을 다시 실행하면 남은 항목만 이어서 처리합니다. 체크포인트 파일은 로컬 추론 경로와 동일한 것을 쓰므로 웹 UI 진행률 표시와 사용자 교정 수집이 그대로 연동됩니다.

게임 큐체크포인트 기준 남은 항목 순워커 2개한 게임은 한 워커만문자 예산으로 배치 분할4,000자 · 100건 상한agy 1회 호출시스템 프롬프트 + JSON 배열JSON 배열 파싱잘리면 완전한 객체만 회수누락된 id 만 재요청항목별 검증태그 · 보간자 · 줄바꿈배치마다 파일 기록 + 체크포인트반려분 · 차단분은 다음 실행 몫
게임 큐에서 파일 기록까지의 경로. 반려분과 차단분은 완료로 기록되지 않아 다음 실행이 다시 처리합니다.

큐의 우선순위를 번역 파일에 남은 빈 항목 수로 산정하면 실제 작업량과 크게 어긋납니다. 번역이 불가능해 원문으로 채운 항목과 검증에 반려된 항목이 그대로 섞이기 때문입니다. 실제 작업량 기준은 전체 항목 수에서 체크포인트의 완료 건수를 뺀 수치입니다. 실측한 차이는 다음과 같았습니다.

게임파일 기준 잔여체크포인트 기준 남은 항목
게임 15070
게임 21,8851
  • 워커는 두 개이고 한 게임은 한 워커만 잡습니다. 같은 번역 파일을 둘이 쓰면 서로 덮어씁니다.
  • 모델은 우선순위 목록으로 둡니다. 앞 모델의 사용량이 소진되면 다음으로 내려가고 전환은 한 방향입니다. 소진된 모델로 돌아가 봐야 같은 오류를 다시 받습니다.
  • 진행 로그는 줄 단위로 내보냅니다. 파일로 리다이렉트하면 파이썬이 블록 버퍼링을 해서, 몇 시간짜리 실행의 로그가 종료 시점까지 보이지 않습니다.