SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-10-23

VPS에서 AI PR 리뷰 에이전트 직접 호스팅하기

자체 서버에서 AI PR 리뷰 에이전트를 운영하는 방법을 소개합니다. GitHub Actions runner 설정부터 diff 필터링, Anthropic API 연동까지 보안과 비용을 최적화하며 코드 리뷰를 자동화하는 실무 가이드를 확인해 보시기 바랍니다.

자체 호스팅 PR 리뷰 에이전트의 역할

자체 호스팅 PR 리뷰 에이전트는 사용자가 소유한 서버에서 실행되는 작은 프로그램입니다. 이 프로그램은 풀 리퀘스트(PR)의 diff를 읽고 변경된 라인만 모델로 전송합니다. 반환된 결과는 인라인 리뷰 코멘트로 게시됩니다. 이 에이전트는 브랜치를 체크아웃하지 않으며, PR에서 수정되지 않은 파일은 절대 읽지 않습니다. 에이전트가 보유한 자격 증명은 모델 API(애플리케이션 프로그래밍 인터페이스) 키 하나와 코멘트 작성 권한만 가진 토큰 하나뿐입니다. 이는 에이전트에 대한 좁은 정의이며, 자율적인 존재라기보다는 필터가 앞에 붙은 프롬프트에 가깝습니다. 이를 구축하기 전에 전체적인 그림을 파악하고 싶다면 개념부터 직접 작성하는 루프까지의 단계별 경로에서 기반 지식을 확인할 수 있습니다.

모델은 diff를 읽을 수 있습니다. 그 부분은 이미 해결되었습니다. 중요한 것은 diff가 어디로 가는지, 그리고 누가 키를 보유하는지입니다. 호스팅된 리뷰 봇을 사용하면 모든 비공개 저장소의 diff가 네트워크를 벗어나 제3자의 로그에 기록되고, 그들의 보존 정책에 따라 관리됩니다. 사용자가 소유한 VPS(가상 프라이빗 서버)에서는 diff가 GitHub에서 서버로, 다시 모델 API로 전달되며, 무엇이 전송되는지 결정하는 40줄의 코드를 직접 읽을 수 있습니다.

시작하기 전에 필요한 것

  • Ubuntu 24.04가 실행 중인 VPS와 해당 리포지토리에 이미 등록된 자체 호스팅 GitHub Actions runner가 필요합니다. 아래 워크플로우가 해당 레이블을 기준으로 선택하므로, 등록 시 pr-review 레이블을 추가로 지정하십시오.
  • Claude Console에서 발급받은 Anthropic API 키가 필요합니다.
  • 풀 리퀘스트를 생성할 수 있는 사용자를 제어할 수 있는 리포지토리가 필요합니다. 비공개 리포지토리가 가장 간단한 경우입니다. 아래 포크(fork) 섹션에서 공개 리포지토리의 경우를 다루지만, 해당 방식은 다소 번거로울 수 있습니다.

VPS에 리뷰어 설치하기

러너 서비스는 ./svc.sh install 실행 시 생성한 권한이 없는 계정으로 실행됩니다. 작업이 sudo 없이 실행될 수 있도록 동일한 계정으로 리뷰어를 설치하십시오. 아래의 runner을 본인의 계정 이름으로 바꾸십시오.

sudo apt update && sudo apt install -y gh python3-venv
sudo install -d -m 755 -o runner -g runner /opt/pr-review
sudo -u runner python3 -m venv /opt/pr-review/venv
sudo -u runner /opt/pr-review/venv/bin/pip install anthropic
gh --version

gh --version는 2026년 8월 기준 Ubuntu 24.04에서 gh version 2.45.0를 출력합니다. 2.20 이후의 모든 릴리스에는 아래에서 사용되는 --input 플래그가 포함되어 있습니다. Command 'gh' not found 메시지가 나타나면 universe 구성 요소가 활성화되지 않은 것이므로, sudo add-apt-repository universe을 실행한 후 다시 시도하십시오.

키와 토큰의 위치

두 가지 비밀 정보는 수명이 서로 다릅니다. 어느 것도 저장소에 포함해서는 안 됩니다.

ANTHROPIC_API_KEY는 저장소 비밀 정보로, Settings의 Secrets and variables, 그리고 Actions 항목에서 설정합니다. GitHub는 이를 암호화하여 실행 시점에 단계별 환경 변수로 주입합니다. 디스크상의 파일로 존재하지 않으며 git 기록에도 남지 않습니다.

GITHUB_TOKEN은 다르게 작동합니다. Actions는 각 작업마다 새로운 토큰을 생성하고 작업이 종료되면 이를 폐기합니다. 해당 토큰의 권한 범위는 워크플로 내의 permissions: 블록에서 설정하며, 여기서 최소 권한 원칙이 실제로 적용됩니다.

permissions:
  contents: read
  pull-requests: write

이 토큰은 리뷰를 게시할 수 있습니다. 커밋을 푸시하거나, 브랜치를 병합하거나, 워크플로 파일을 수정하거나, 다른 저장소에 접근할 수는 없습니다. 댓글을 달 수 있는 에이전트는 리뷰어일 뿐입니다. 푸시 권한이 있는 에이전트는 커미터가 되는데, 이는 누구도 의도하지 않은 결과입니다. 모델 키는 계정의 비용을 발생시키므로 동일하게 주의해서 다루어야 합니다. 이러한 문제 유형에 대한 자세한 내용은 AI 에이전트가 비밀 정보에 접근하지 못하도록 관리하기에서 확인할 수 있습니다.

Actions는 작업 로그에서 정확히 일치하는 비밀 문자열을 ***로 대체합니다. 정확히 일치하는 문자열만 필터링하므로, base64로 인코딩하거나 두 줄로 나누거나 한 글자씩 출력하는 경우에는 비밀 정보가 그대로 노출됩니다. 환경 변수를 덤프하는 디버그 단계를 추가하지 마십시오.

포크에서 생성된 풀 리퀘스트에 API 키가 전달되지 않는 이유

GitHub의 규칙은 간단합니다. GITHUB_TOKEN을 제외하면, 포크된 저장소에서 워크플로우가 트리거될 때 시크릿은 러너(runner)로 전달되지 않습니다. 따라서 포크에서 실행된 pull_request는 ANTHROPIC_API_KEY 없이 스크립트를 시작하며, 첫 번째 API 호출은 invalid x-api-key 오류와 함께 실패합니다.

이를 해결하기 위해 트리거를 pull_request_target로 변경하고 싶은 유혹이 들 수 있습니다. 이 방식은 베이스 저장소의 컨텍스트에서 실행되므로 시크릿에 접근할 수 있기 때문입니다. 하지만 여기서는 그렇게 하지 마십시오. GitHub의 보안 지침에 따르면 이러한 워크플로우는 "권한이 부여된 상태이며, 이는 다른 권한 있는 워크플로우 트리거와 메인 브랜치의 캐시를 공유하고, 저장소 쓰기 권한 및 참조된 시크릿에 접근할 수 있음을 의미한다"고 명시되어 있으며, 그 결과가 "저장소를 탈취하는 데 악용될 수 있다"고 경고합니다.

같은 지침은 러너에 대해서도 단호하게 말합니다. "GitHub의 공개 저장소에는 자체 호스팅 러너(self-hosted runner)를 거의 사용하지 말아야 합니다. 누구나 저장소에 풀 리퀘스트를 열어 환경을 침해할 수 있기 때문입니다."

이러한 이유로 두 가지 설계 원칙이 도출됩니다. 첫째, 작업(job)에 가드(guard)를 설정하여 자신의 저장소로 푸시된 브랜치에서만 실행되도록 합니다. 둘째, 워크플로우에는 actions/checkout 단계가 전혀 없습니다. 에이전트는 디스크에 해당 브랜치를 저장하지 않으므로, 악의적인 풀 리퀘스트는 모델로 전송되는 텍스트일 뿐입니다. VPS에서 빌드 스크립트를 실행할 수 없는데, VPS의 그 무엇도 해당 스크립트를 실행하지 않기 때문입니다. 하지만 텍스트가 곧 무해함을 의미하지는 않습니다. 낯선 사람이 작성한 diff는 모델에 입력되는 신뢰할 수 없는 데이터이며, 이는 에이전트에게 웹 검색 권한을 부여할 때 마주하는 것과 동일한 신뢰 경계입니다. 여기서 이 데이터를 격리하는 유일한 장치는 이 에이전트가 오직 댓글을 작성하는 것 외에는 아무것도 할 수 없다는 점입니다.

저장소 전체가 아닌 diff만 가져오기

단일 요청으로 전체 diff를 일반 텍스트로 가져올 수 있습니다.

export GH_TOKEN=your_token   # in the workflow this comes from secrets.GITHUB_TOKEN
gh api /repos/OWNER/REPO/pulls/42 -H "Accept: application/vnd.github.diff"

Accept: application/vnd.github.diff 미디어 타입을 사용하면 응답 형식이 풀 리퀘스트를 설명하는 JSON 객체에서 통합 diff 자체로 변경되며, gh api은 해당 본문을 수정 없이 출력합니다. 출력되는 첫 번째 줄은 diff --git a/로 시작해야 합니다. gh: Not Found (HTTP 404)가 표시된다면 토큰으로 저장소에 접근할 수 없다는 의미이며, 세분화된 개인 토큰(fine-grained personal token)을 사용하는 경우 대부분 Pull requests 권한이 누락되었을 때 발생합니다.

토큰을 소비하기 전에 필터링하십시오

이 섹션은 사람들이 읽는 봇과 사람들이 무시하는 봇을 가르는 기준입니다. 아래의 각 필터는 모델이 단 1바이트라도 읽기 전에 실행됩니다.

  • 경로 필터. 잠금 파일, 벤더 디렉터리, 최소화된 번들 및 생성된 코드를 제외하십시오. package-lock.json에 대한 모델의 코멘트는 단순한 소음이며, 이러한 파일들은 종종 diff에서 가장 많은 바이트를 차지합니다.
  • 크기 제한. 제한을 초과하면 리뷰를 건너뛰고 성공으로 종료하십시오. 4,000줄 규모의 리팩토링에 대해 60개의 추측성 코멘트를 남기는 대신, 너무 커서 자동 리뷰를 수행할 수 없다는 정직한 코멘트 한 줄을 남기십시오.
  • 심각도 임계값 및 코멘트 제한. 심각도가 높음(high) 및 중간(medium)인 결과만 보고하며, 심각도가 높은 순서대로 최대 10개까지만 보고하십시오. 11번째 코멘트를 읽는 사람은 아무도 없습니다.

스크립트

이 내용을 /opt/pr-review/review.py로 저장합니다. 스크립트는 환경 변수에서 설정을 읽어오므로, 코드 수정 없이도 워크플로에서 모델을 변경할 수 있습니다.

#!/usr/bin/env python3
"""Review only the changed lines of one pull request."""
import json
import os
import subprocess
import sys

import anthropic

REPO = os.environ["GITHUB_REPOSITORY"]
PR = os.environ["PR_NUMBER"]
MODEL = os.environ.get("REVIEW_MODEL", "claude-haiku-4-5-20251001")
MAX_DIFF_BYTES = int(os.environ.get("MAX_DIFF_BYTES", "120000"))
MIN_SEVERITY = os.environ.get("MIN_SEVERITY", "medium")
MAX_COMMENTS = 10
RANK = {"low": 0, "medium": 1, "high": 2}
SKIP = ("package-lock.json", "poetry.lock", "/vendor/", "/node_modules/", ".min.js")

raw_diff = subprocess.run(
    ["gh", "api", f"/repos/{REPO}/pulls/{PR}",
     "-H", "Accept: application/vnd.github.diff"],
    check=True, capture_output=True, text=True,
).stdout

파일별로 diff를 분할해야 경로 필터링이 가능합니다. 각 줄에 번호를 매겨야 리뷰 코멘트를 정확한 위치에 남길 수 있습니다. GitHub는 diff에 포함된 줄에만 인라인 코멘트를 허용하므로, 모델은 반드시 실제 줄 번호를 인용해야 합니다. 번호를 직접 전달하면 모델이 번호를 지어내지 않고 그대로 복사할 수 있습니다.

def per_file(diff_text):
    """Split a unified diff into one string per file."""
    sections, current = [], []
    for line in diff_text.splitlines():
        if line.startswith("diff --git ") and current:
            sections.append("\n".join(current))
            current = []
        current.append(line)
    if current:
        sections.append("\n".join(current))
    return sections


def annotate(section):
    """Prefix every line that exists in the new file with its line number."""
    out, n, in_hunk = [], 0, False
    for line in section.splitlines():
        if line.startswith("@@"):
            n = int(line.split("+")[1].split(",")[0].split(" ")[0])
            in_hunk = True
            out.append(line)
        elif not in_hunk or line.startswith(("-", "\\")):
            out.append(line)
        else:
            out.append(f"{n}\t{line}")
            n += 1
    return "\n".join(out)


kept = [s for s in per_file(raw_diff)
        if not any(p in s.split("\n", 1)[0] for p in SKIP)]
payload = "\n".join(annotate(s) for s in kept)

if not payload.strip():
    print("every changed file was filtered out")
    raise SystemExit(0)
if len(payload) > MAX_DIFF_BYTES:
    print(f"diff is {len(payload)} bytes, over the {MAX_DIFF_BYTES} cap")
    raise SystemExit(0)

hunk 헤더에 번호 정보가 담겨 있습니다. @@ -12,7 +12,9 @@는 새 파일의 hunk가 12번 줄에서 시작함을 나타내며, 카운터는 여기서 시작하여 추가되거나 변경되지 않은 줄에서만 증가합니다. 삭제된 줄은 새 파일에 존재하지 않으므로 번호를 매기지 않고 건너뜁니다. 백슬래시로 시작하는 줄에 대한 가드는 git이 파일 끝에 작성하는 no-newline 마커를 건너뛰게 합니다. 이 마커를 처리하지 않으면 이후의 모든 번호가 1씩 밀리게 됩니다.

두 종료 지점 모두 1이 아닌 상태 코드 0을 사용합니다. 필터링되었거나 크기가 초과된 pull request라도 녹색 체크 표시가 나타나야 합니다. 사람이 조치할 수 없는 빨간색 체크는 무시되기 마련이며, 한 번 무시된 체크는 이후 모든 체크가 무시되는 결과를 낳습니다.

SYSTEM = (
    "You review one pull request diff. Every line that exists in the new file is "
    "prefixed with its line number and a tab character. "
    "Report only defects you can see in the lines shown: a crash, a resource leak, "
    "a security mistake, a wrong boundary condition, a broken contract with code "
    "that is visible in this diff. Do not comment on style, naming or formatting. "
    "Do not guess about code you cannot see. Leave out anything you are not "
    "certain about. An empty findings list is a normal and common answer. "
    'Reply with JSON only, in this shape: {"findings": [{"path": "src/app.py", '
    '"line": 42, "severity": "high", "comment": "what is wrong, then why"}]} '
    "Every line number must be one you can see in the left column of that file."
)

client = anthropic.Anthropic()
message = client.messages.create(
    model=MODEL,
    max_tokens=2000,
    system=SYSTEM,
    messages=[{"role": "user", "content": payload}],
)
print(f"stop={message.stop_reason} in={message.usage.input_tokens} "
      f"out={message.usage.output_tokens}", file=sys.stderr)

text = message.content[0].text
findings = json.loads(text[text.find("{"):text.rfind("}") + 1])["findings"]
findings = [f for f in findings if RANK.get(f["severity"], 0) >= RANK[MIN_SEVERITY]]
findings.sort(key=lambda f: -RANK.get(f["severity"], 0))
del findings[MAX_COMMENTS:]

if not findings:
    print("nothing above the severity threshold; posting no comment")
    raise SystemExit(0)

review = {
    "event": "COMMENT",
    "body": f"Automated review of the changed lines. {len(findings)} finding(s).",
    "comments": [
        {"path": f["path"].removeprefix("b/"), "line": f["line"], "side": "RIGHT",
         "body": f"**{f['severity']}** {f['comment']}"}
        for f in findings
    ],
}
subprocess.run(
    ["gh", "api", "-X", "POST", f"/repos/{REPO}/pulls/{PR}/reviews", "--input", "-"],
    input=json.dumps(review), text=True, check=True,
)

해당 블록에서 세 가지 세부 사항이 핵심적인 역할을 합니다. 모델이 답변을 코드 블록으로 감싸는 경우가 있는데, json.loads은 코드 블록을 처리하지 못하므로 JSON은 첫 번째 {과 마지막 } 사이에서 잘라냅니다. path는 앞부분의 b/을 제거합니다. 해당 접두사는 diff 헤더에서 오지만, GitHub는 저장소 상대 경로를 요구하기 때문입니다. 그리고 --input -은 전체 리뷰를 하나의 API 호출로 전송하므로, 10개의 발견 사항이 10개의 알림이 아닌 하나의 알림으로 도착합니다.

보고할 내용이 없으면 스크립트는 아무것도 게시하지 않습니다. 모든 pull request에 "발견된 문제 없음"이라고 작성하는 봇은 사용자가 내용을 대충 훑어보게 만들며, 결국 중요한 알림마저 무시하게 됩니다.

워크플로우에 연결하기

이 내용을 .github/workflows/pr-review.yml로 저장합니다:

name: pr-review

on:
  pull_request:
    types: [opened, synchronize, reopened]
    paths-ignore:
      - '**.md'
      - 'docs/**'

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: pr-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  review:
    if: github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.pull_request.labels.*.name, 'no-ai-review')
    runs-on: [self-hosted, linux, pr-review]
    steps:
      - name: Review the changed lines
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REVIEW_MODEL: claude-haiku-4-5-20251001
          MIN_SEVERITY: medium
        run: /opt/pr-review/venv/bin/python /opt/pr-review/review.py

GITHUB_REPOSITORY은 Actions가 모든 작업에 대해 이미 설정하므로 해당 env: 블록에 포함되지 않습니다. concurrency 그룹은 비용 청구와 관련이 있습니다. 이 설정이 없으면 브랜치에 3번의 빠른 수정 사항을 푸시할 때 3번의 전체 검토가 실행되어 모두 비용이 발생하지만, 이 설정을 사용하면 마지막 작업만 남게 됩니다.

if: 라인은 두 가지 역할을 합니다. 앞부분은 포크된 저장소에서 오는 풀 리퀘스트를 건너뛰는데, 어차피 키가 없으면 실패하게 됩니다. 뒷부분은 팀을 위한 중단 스위치를 제공합니다. 풀 리퀘스트에 no-ai-review 라벨을 추가하면 해당 작업은 실행되지 않습니다.

풀 리퀘스트를 열고 어떤 일이 일어나는지 확인합니다:

gh run list --workflow=pr-review.yml --limit 3
gh run view --log
gh pr view 42 --comments

로그에 nothing above the severity threshold; posting no comment이 표시되며 몇 초 안에 완료되는 실행은 정상적으로 작동하는 것입니다. 작고 깔끔한 풀 리퀘스트에서는 이것이 예상되는 결과입니다.

자동화된 풀 리퀘스트 리뷰의 비용

diff가 입력의 거의 전부를 차지하므로 diff의 크기가 가격을 결정합니다. 아래는 500줄의 diff와 시스템 프롬프트를 측정한 결과이며, 추정치가 아닌 토큰 카운팅 엔드포인트를 사용하여 계산했습니다.

차트Tokens for one 500 line pull request diff, measured August 2026
이 차트의 데이터
[
  {
    "label": "Haiku 4.5",
    "input_tokens": "8,000",
    "output_tokens": "1,200"
  },
  {
    "label": "Sonnet 5",
    "input_tokens": "10,400",
    "output_tokens": "1,560"
  },
  {
    "label": "Opus 5",
    "input_tokens": "10,400",
    "output_tokens": "1,560"
  }
]

해당 diff는 Haiku 4.5에서 8,000개의 입력 토큰, Sonnet 5에서 10,400개의 입력 토큰을 기록했습니다. 같은 텍스트라도 모델에 따라 카운트가 다릅니다. 4.7 버전 이후의 Claude 모델은 동일한 입력에 대해 약 30% 더 많은 토큰을 생성하는 최신 토크나이저를 사용하며, 이는 Anthropic의 가격 페이지에 명시되어 있습니다. 백만 토큰당 가격만으로 신형 모델과 구형 모델을 비교할 때는 이 점을 고려해야 합니다.

2026년 10월 기준 정가는 다음과 같습니다. Haiku 4.5는 입력 토큰 백만 개당 $1, 출력 토큰 백만 개당 $5입니다. Sonnet 5는 각각 $2와 $10입니다. Anthropic은 이를 처음에는 도입가로 판매했으나 이후 표준 가격으로 확정하고 $3 및 $15로의 인상 계획을 취소했습니다. Opus 5는 각각 $5와 $25입니다.

차트Cost of that one review at list prices, August 2026
이 차트의 데이터
[
  {
    "label": "Haiku 4.5",
    "cost_per_pr_cents": 1.4,
    "cost_200_prs_usd": "2.80"
  },
  {
    "label": "Sonnet 5",
    "cost_per_pr_cents": 3.64,
    "cost_200_prs_usd": "7.28"
  },
  {
    "label": "Opus 5",
    "cost_per_pr_cents": 9.1,
    "cost_200_prs_usd": "18.20"
  }
]

이는 Haiku 4.5에서 풀 리퀘스트당 1.4센트, Opus 5에서 9.1센트입니다. 한 달에 200개의 풀 리퀘스트를 병합하는 팀은 Haiku 4.5 사용 시 약 $2.80, Sonnet 5 사용 시 $7.28$, Opus 5 사용 시 $18.20$를 지불하게 됩니다.

실제 청구 금액이 이 추정치를 넘어서게 만드는 두 가지 요인이 있습니다. synchronize는 모든 푸시마다 리뷰를 트리거하므로, 푸시가 8번 발생한 활성 브랜치는 8번의 리뷰 비용이 발생하며, 동시성 규칙은 푸시가 시간상 가깝게 발생할 때만 도움이 됩니다. 또한 위 수치는 경로 필터가 정상 작동함을 가정합니다. 필터링되지 않은 lock 파일 하나만으로도 입력량이 두 배가 될 수 있습니다.

프롬프트 캐싱은 여기에서 도움이 되지 않습니다. 캐시된 접두사는 호출 간에 바이트 단위로 동일해야 하지만, diff는 매번 다르기 때문입니다. 시스템 프롬프트만이 유일하게 안정적인 부분인데, 이는 최소 캐시 가능 길이보다 훨씬 작습니다. 일반적인 규칙은 프롬프트 캐싱이 비용 효율적인 경우를 참조하고, 위 세 모델 중 선택하는 방법은 작업별 Claude 모델 선택 가이드를 확인하십시오.

기능을 켜기 전에 직접 diff를 측정하십시오

payload가 빌드된 후 아래 라인을 추가하고, 지난달의 풀 리퀘스트 몇 개를 대상으로 스크립트를 수동 실행해 보십시오:

print(client.messages.count_tokens(
    model=MODEL, system=SYSTEM, messages=[{"role": "user", "content": payload}]
).input_tokens)

카운트 엔드포인트는 모델을 실행하지 않으므로 입력 또는 출력 토큰을 소비하지 않으며, 지정한 모델의 토크나이저를 사용합니다. 자신의 저장소에서 10개의 실제 풀 리퀘스트를 대상으로 실행하고 평균이 아닌 중앙값을 취하십시오. 그래야 대규모 마이그레이션 하나가 추정치를 왜곡하지 않습니다.

리뷰 봇이 차단되는 이유와 방지 방법

두 가지 행동이 봇에 대한 신뢰를 무너뜨리며, 둘 다 위 코드에서 수정할 수 있습니다.

모든 것을 한꺼번에 리뷰하기. 40개의 코멘트를 남기는 봇의 코멘트는 아무도 읽지 않습니다. 심각도 임계값과 10개 코멘트 제한은 예의 차원이 아니라 실제 중요한 발견 사항을 눈에 띄게 유지하기 위한 조치입니다. 절삭 전에 심각도순으로 정렬하면, 무작위로 10개를 남기는 대신 가장 중요하지 않은 발견 사항부터 제외하게 됩니다.

확인할 수 없는 내용에 대해 확신을 가지고 코멘트하기. 이것이 엔지니어들이 봇을 영구적으로 끄게 만드는 주된 이유입니다. 40,000줄의 코드베이스 중 200줄만 보여준 모델은 본 적도 없는 파일에 대해 "이것이 redis_client.py의 캐시 무효화를 깨뜨린다"는 식의 코멘트를 작성할 것입니다. 시스템 프롬프트는 평이한 언어로 이를 제어해야 합니다. 즉, 표시된 라인에서 확인 가능한 결함만 보고하고, 확신할 수 없는 내용은 제외하도록 지시하십시오. 일반적인 정확도를 요구하는 것보다 실패 지점을 직접 명시하는 것이 더 효과적이며, 결과가 없어도 정상임을 모델에 알리는 것이 두 줄짜리 변경 사항에 대해 억지로 코멘트를 생성하는 것을 막는 방법입니다. 저장소 전체에 걸쳐 나타나는 결함은 별도의 작업이며, 보안 스캔을 위한 open-kritt 자체 호스팅은 이 리뷰어가 볼 수 있는 범위를 넓히지 않고도 해당 영역을 커버하는 방법입니다.

리뷰는 항상 COMMENT로 게시하고, REQUEST_CHANGES로 게시하지 마십시오. 모델의 의견이 병합을 차단해서는 안 됩니다. 차단이 가능해지는 순간, 마감 기한에 쫓기는 누군가는 봇과 논쟁하는 대신 워크플로우 전체를 제거해 버릴 것입니다. 에이전트의 판단에 실질적인 무게를 실어야 한다면, 믿어야만 하는 결과물을 내놓게 하는 대신 직접 확인할 수 있는 결과물을 생성하도록 만드는 것이 좋습니다. 이것이 직접 재실행 가능한 증거 보고서를 제공하는 에이전트 활용의 핵심 아이디어입니다.

실패 유형 및 확인 가능한 메시지

리뷰 게시 시 HTTP 422 오류 발생. gh는 gh: Unprocessable Entity (HTTP 422)를 출력하며 응답 본문은 필드명을 명시합니다: Pull request review thread line must be part of the diff. GitHub는 해당 코멘트를 고정할 수 없습니다. 일반적인 원인은 모델이 임의로 생성한 줄 번호, 여전히 b/ 접두사가 붙어 있는 path, 또는 삭제된 줄에 대한 코멘트입니다. 삭제된 줄의 경우 side를 RIGHT 대신 LEFT으로 설정해야 합니다. 게시 전 리뷰 JSON을 출력하여 코멘트 하나를 diff와 직접 대조해 보십시오.

모델 API에서 invalid x-api-key 발생. 첫 번째 messages.create 호출 단계에서 실패합니다. 저장소에 ANTHROPIC_API_KEY 시크릿이 설정되지 않았거나, 포크(fork)에서 발생한 풀 리퀘스트여서 Actions에 시크릿이 전혀 전달되지 않은 경우입니다. if: 라인의 포크 가드(fork guard)가 이를 건너뛰었어야 하므로 해당 라인을 먼저 확인하십시오.

gh: Resource not accessible by integration (HTTP 403). 작업 토큰이 풀 리퀘스트에 쓰기 권한을 갖지 못했습니다. permissions: 블록에 pull-requests: write을 추가하십시오. 이미 추가되어 있다면 Settings, Actions, General 순으로 이동하여 조직 정책이 워크플로우 토큰의 요청 권한을 제한하고 있는지 확인하십시오.

json.decoder.JSONDecodeError. 모델이 파싱 가능한 JSON을 반환하지 않았습니다. 흔한 원인은 응답이 토큰 제한에 도달하여 객체 중간에 잘린 경우입니다. 로그 라인에 stop_reason이 출력된다면 정확히 이 문제입니다. 값이 max_tokens이라면 max_tokens를 높이거나 MAX_COMMENTS을 낮추어야 합니다.

워크플로우가 전혀 실행되지 않음. gh run list에 풀 리퀘스트 관련 내용이 표시되지 않습니다. paths-ignore가 변경된 모든 파일을 필터링하지 않았는지 확인하고, 포크 가드와 라벨 가드, 그리고 VPS에서 sudo systemctl status 'actions.runner.*'을 통해 러너(runner)가 활성화되어 있는지 확인하십시오. 오프라인 상태인 러너는 풀 리퀘스트에 아무런 오류 메시지 없이 작업을 대기열에만 남겨둡니다.

모든 리뷰가 빈 상태로 반환됨. 1회 실행 동안 MIN_SEVERITY을 low로 설정하십시오. 결과가 나타난다면 임계값(threshold)이 정상 작동하는 것입니다. 아무것도 나타나지 않는다면 payload를 출력하여 필터가 전체 diff를 제거하지 않았는지 확인하십시오.

다른 에이전트와 함께 실행하기

리뷰어는 크기가 작기 때문에 다른 모든 것을 실행 중인 서버에 함께 올리고 싶은 유혹이 생길 수 있습니다. 저장소가 중요하다면 별도로 분리하십시오. 이 프로세스는 코드에 댓글을 달 수 있는 토큰과 비용을 지불할 수 있는 키를 보유하며, 자체 호스팅 러너는 설계상 워크플로우 코드가 실행되는 공간입니다. sudo 권한이 없는 전용 비권한 계정을 사용하고 다른 아무것도 실행하지 않는 호스트를 운영하는 것이 기본 원칙입니다. 코드를 체크아웃하는 대화형 에이전트를 함께 실행한다면 에이전트당 일회용 VM을 사용하는 것이 권장되는 패턴이며, VPS에서 코딩 에이전트 실행하기가 일반적인 설정 방법을 다룹니다. Anthropic API가 처음이라면 VPS에서 첫 Claude API 앱 실행하기가 이 작업보다 시작하기에 더 작은 규모의 프로젝트입니다.

FAQ

AI PR 리뷰 에이전트가 내 저장소에 대한 쓰기 권한이 필요한가요?

아니요. 리뷰를 게시하기 위한 pull-requests: write과 diff를 가져오기 위한 contents: read만 있으면 됩니다. 이것이 전체 목록이며, 워크플로의 permissions: 블록에서 설정하여 작업별 GITHUB_TOKEN이 수행할 수 있는 범위를 제한합니다. 이 두 줄만 있으면 에이전트는 풀 리퀘스트에 댓글을 달 수 있지만, 커밋을 푸시하거나 브랜치를 병합할 수는 없습니다. REQUEST_CHANGES 대신 event: COMMENT를 사용하여 리뷰를 게시하면 병합을 차단할 수도 없습니다.

왜 리뷰 댓글이 HTTP 422 오류로 실패하나요?

GitHub는 풀 리퀘스트 diff의 일부인 줄에 대해서만 인라인 리뷰 댓글을 허용하며, 그렇지 않은 경우 Pull request review thread line must be part of the diff을 반환합니다. path이 diff 헤더의 b/ 접두사 없이 저장소 상대 경로인지, 그리고 줄 번호가 해당 파일의 hunk 내에 포함되어 있는지 확인하십시오. side는 추가되거나 변경되지 않은 줄의 경우 RIGHT이어야 하며, 삭제된 줄의 경우 LEFT이어야 합니다. 모델에 diff를 보내기 전에 모든 줄 앞에 새 파일의 줄 번호를 붙이는 것이 모델이 임의로 번호를 생성하는 것을 방지하는 방법입니다.

포크에서 발생한 풀 리퀘스트가 있는 공개 저장소에서 이 기능을 실행할 수 있나요?

현재 설계로는 불가능합니다. GitHub는 포크에서 트리거된 워크플로에 시크릿을 전달하지 않으므로 모델 키가 누락되어 실행이 실패합니다. 또한 GitHub는 자가 호스팅 러너를 "공개 저장소에 사용해서는 안 된다"고 명시하고 있는데, 이는 누구나 풀 리퀘스트를 열어 사용자의 머신에서 코드를 실행하게 만들 수 있기 때문입니다. 공개 프로젝트의 경우, 리뷰어의 범위를 저장소 자체로 푸시된 브랜치로 제한하거나(if: 가드가 수행하는 작업), 리뷰 단계를 GitHub 호스팅 러너로 옮기고 diff가 자체 인프라를 벗어나는 것을 감수해야 합니다.

풀 리퀘스트 리뷰에는 어떤 모델을 사용해야 하나요?

Haiku 4.5로 시작하십시오. 고정된 결함 유형 목록을 기준으로 제한된 diff를 읽는 것은 어려운 추론 문제가 아니며, 가장 저렴한 모델을 사용하면 월간 비용을 논란의 여지가 없는 수준으로 유지할 수 있습니다. 사용 중인 언어나 프레임워크에서 실제 버그를 놓치는 경우가 발견되면 Sonnet 5로 업그레이드하되, 추측하지 말고 측정하십시오. Opus 5는 세 모델 중 풀 리퀘스트당 비용이 월등히 높으므로, 모든 기능 브랜치의 모든 푸시보다는 릴리스 브랜치에서 사용하는 것이 정당화하기 쉽습니다.

이 글이 도움이 되었나요?