본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 05. 25. 22:49

Rust로 LLM 코드 리뷰 에이전트를 만들었다

요약

Rust를 사용하여 Git diff를 분석하고 병렬로 리뷰하는 LLM 기반 코드 리뷰 CLI 도구인 `agent-reviewer`를 소개합니다. 리뷰 단위를 분할하고 단계별로 최적화된 에이전트를 배치하여 비용과 효율성을 극대화한 설계가 특징입니다.

핵심 포인트

  • Rust 기반의 고성능 코드 리뷰 CLI 구현
  • Triage, Review, Finalize의 3단계 파이프라인 설계
  • 리뷰 단위별 모델 할당을 통한 비용 및 성능 최적화
  • 보안 리뷰 등 목적별 프롬프트 및 스키마 분리 지원
  • Cargo workspace를 활용한 모듈형 에이전트 구조

LLM에게 코드 리뷰를 부탁할 때, 처음에는 "이 차이점(diff)을 리뷰해줘"라고 큰 diff를 던지는 것만으로도 어느 정도 작동합니다.

하지만 실제로 사용할 도구라고 생각하면, 곧 몇 가지 문제에 부딪히게 됩니다.

  • 차이점이 클수록, 확인해야 할 부분과 그냥 넘어가도 되는 부분이 섞임
  • 모델에게 전부 읽게 하면 비용과 대기 시간도 증가함
  • 출력 형식이 매번 흔들리면 자동화하기 어려움
  • 보안 리뷰 등 관점이 다른 리뷰를 동일한 프롬프트에 몰아넣고 싶지 않음

그래서 Rust로 LLM 구동 코드 리뷰 CLI를 만들었습니다.

이름은 agent-reviewer입니다.

Git의 차이점을 읽고, 리뷰 대상을 작은 단위로 분할하며, 각각을 병렬로 리뷰한 뒤, 마지막에 Markdown 리포트로 통합합니다.

이 기사에서는 구현을 바탕으로 설계의 요점을 소개합니다.

만든 것

agent-reviewer는 현재의 Git 리포지토리를 대상으로 동작하는 CLI입니다.

RUST_LOG=info cargo run --release -- --output review.md

일반적인 코드 리뷰에 더해, --security-review를 붙이면 보안 리뷰용 프롬프트와 결과 스키마(schema)로 전환됩니다.

RUST_LOG=info cargo run --release -- --security-review --output security-review.md

대략적인 처리는 3단계입니다.

각 단계의 역할은 다음과 같습니다.

  • triage: 차이점을 보고 리뷰해야 할 단위로 분할함
  • review: 각 리뷰 단위를 병렬로 검사함
  • finalize: 여러 리뷰 결과들을 모아서 최종 리포트를 작성함

포인트는 처음부터 "하나의 거대한 에이전트"로 만들지 않았다는 점입니다.

차이점 분류, 상세 리뷰, 리포트화를 분리함으로써 컨텍스트(context), 비용, 출력 형식을 각각 제어하기 쉽게 만들었습니다.

워크스페이스 구성

Cargo workspace는 책임에 따라 나누었습니다.

.
├── src/
│ ├── main.rs # CLI entrypoint
...

CLI 본체는 오케스트레이션(orchestration)에 가깝게 구성했습니다.

ReAct 루프, 도구(tool) 구현, 모델 프로바이더(model provider) 설정은 별도의 crate로 분리했습니다.

이렇게 나누어 두면, 예를 들어 "GitHub에 댓글을 게시하는 tool을 추가한다"라거나 "다른 CLI에서 동일한 에이전트 기반을 사용한다"와 같은 변경을 하기 쉬워집니다.

설정은 provider / model / agent / step으로 나눈다

설정 파일은 agent-reviewer.toml입니다.

모델 관련 설정은 다음 4개 계층으로 나누었습니다.

concurrency = 4
[[model_providers]]
id = "github"
...

각각의 의미는 다음과 같습니다.

  • model_providers: OpenAI, Anthropic, GitHub Models, Bedrock 등의 접속 대상
  • models: provider 상의 실제 모델명에 설정 내에서 사용할 ID를 부여
  • agents: 모델, reasoning effort, 최대 토큰 수, 최대 루프 수를 묶음
  • steps: triage/review/finalize의 각 단계에 어떤 agent를 사용할지 지정

"모델"과 "agent"를 나눈 이유는, 같은 모델이라도 용도에 따라 설정을 바꾸고 싶었기 때문입니다.

예를 들어 같은 모델을 사용하면서도, triage는 가볍게, finalize는 길게 생각하도록 하는 등의 운용이 가능합니다.

review 단계에서는 triage가 각 리뷰 단위에 Light / Standard / Power를 할당합니다.

작은 변경은 가벼운 모델로, 큰 변경이나 위험한 변경은 강력한 모델로 넘기는 구성입니다.

model_providers는 OpenAI를 지정할 수 있으므로 Ollama나 LM Studio와 같은 OpenAI API 호환 로컬 LLM도 사용하는 것이 가능합니다.

Azure는 별도로 없지만 OpenAI에 포함되어 있으므로, Azure OpenAI API를 사용해 주세요.

ReAct 루프

에이전트 실행 기반은 agent-reviewer-agent crate에 있습니다.

구현은 소박한 ReAct 루프입니다.

  • 시스템 프롬프트 (system prompt)와 사용자 프롬프트 (user prompt)를 모델에 전달합니다.
  • 모델이 도구 호출 (tool call)을 반환하면 실행합니다.
  • 도구 결과 (tool result)를 대화에 다시 넣습니다.
  • marker tool이 호출되면, 해당 JSON 인수를 반환값으로 하여 종료합니다.

이 루프에는 최대 횟수가 있습니다. 마지막 턴에서는 사용할 수 있는 도구를 제출용 marker tool로만 제한합니다.

fn create_request(
&self,
system: String,
...

이것은 사소해 보이지만 중요합니다.

"마지막에는 반드시 구조화된 결과를 반환한다"는 방향으로 모델을 유도할 수 있으므로, 호출 측의 구현이 단순해집니다.

marker tool로 결과 구조화하기

일반적인 도구는 파일을 읽거나 Git diff를 보는 것과 같이 부작용 (side effect)을 가질 수 있습니다.

반면 marker tool은 실행을 위한 도구가 아닙니다.

모델이 "이 형식으로 결과를 제출했다"라고 나타내기 위한, 구조화된 종료 시그널 (termination signal)입니다.

이미지로 표현하자면 exit(0)와 같은 기능에 가깝습니다. 호출한 LLM으로 다시 돌아가는 것이 아니라, 종료와 결과 수신을 의미합니다.

triage의 반환값은 예를 들어 다음과 같은 Rust 타입으로 표현합니다.

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) struct SubmitTriageArgs {
...

이 타입으로부터 schemars를 사용하여 JSON Schema를 생성하고, 이를 LLM의 도구 스키마 (tool schema)로 전달합니다.

pub fn tool_description<T: JsonSchema>(
name: &'static str,
description: &'static str,
...

실제 코드에서는 OpenAPI 3용 스키마 설정을 사용하여, 인수가 없는 도구라도 propertiesrequired가 포함되도록 보정하고 있습니다.

이는 해당 키가 없으면 에러를 발생시키는 백엔드가 있기 때문입니다.

이렇게 설계하면 출력 형식의 변경이 Rust 타입의 변경이 됩니다.

프롬프트만으로 "이 JSON을 반환해줘"라고 요청하는 것보다, 컴파일 시점에 구조를 추적하기가 더 쉽습니다.

triage로 리뷰 단위 나누기

Orchestrator::run에서는 먼저 triage agent를 실행합니다.

triage agent에는 읽기 전용 (read-only) 파일 시스템/Git 도구와 탐색용 subagent를 전달합니다.

그곳에서 차이점 (diff)을 읽고, 리뷰 단위 (review unit)를 반환받습니다.

let result = agent.run(&system_prompt, &user_prompt).await?;
let result: SubmitTriageArgs = serde_json::from_value(result)?;

triage의 출력은 ReviewUnit의 배열입니다.

{
"review_units": [
{
...

여기서 modellight / standard / power 중 하나가 됩니다.

후속 리뷰에서는 이 티어 (tier)에 따라 사용할 agent를 전환합니다.

리뷰는 병렬로 실행하기

triage에서 얻은 리뷰 단위는 join_all을 통해 병렬로 처리합니다.

let results = join_all(
result
.review_units
...

하지만 단순히 모든 리뷰를 동시에 모델에 던지면, API 제한이나 비용 관리가 어려워집니다.

그래서 모든 agent가 공유하는 ConcurrencyLimiter를 갖게 했습니다.

let response = {
let _permit = self.concurrency_limiter.acquire().await?;
self.client
...

이 세마포어 (semaphore)는 triage, review, finalize, subagent를 가로질러 공유됩니다.

concurrency = 4라면, 파이프라인 전체에서 동시에 발생하는 LLM 요청이 최대 4개가 됩니다.

「review unit는 병렬화하고 싶지만, LLM API의 동시 실행 수는 제한하고 싶다」라는 요구사항에 대해, 이 부분은 상당히 다루기 쉬운 형태가 되었습니다.

subagent를 tool로서 전달하기

review agent에는 일반적인 파일/Git tool 외에도, subagent를 tool로서 전달하고 있습니다.

주로 2가지 종류가 있습니다.

explorer
: 리포지토리 내부를 가로질러 관련 파일이나 관계성을 조사함

advisor
: 구현 수정 방침이나 설계상의 판단에 대해 조언함

subagent도 내부적으로는 ReAct agent입니다.

다만, 외부의 agent 입장에서는 단순한 tool일 뿐입니다.

impl AgentTool for Explorer {
fn tool(&self) -> Tool {
tool_description::<ExplorerArgs>("explorer", EXPLORER_TOOL_DESCRIPTION)
...

이렇게 구성해 두면, 메인 review agent는 필요할 때만 탐색을 위임할 수 있습니다.

모든 리뷰에서 처음부터 광범위한 파일을 읽게 하는 것이 아니라, "필요해지면 탐색한다"는 방식으로 접근할 수 있어 컨텍스트 (Context)를 절약할 수 있습니다.

내장 tool

현재 시점에서 agent에 전달하고 있는 기본 tool은 읽기 전용 (read-only)인 것들로 구성하고 있습니다.

read_file

list_files

search_file

git_diff_single_commit

git_diff_commit_range

git_diff_summary_single_commit

git_diff_summary_commit_range

git_pull_request_base_branch

git_default_branch

git_current_branch

리뷰 용도에서는 우선 "멋대로 편집하지 않는다"를 전제로 하기 때문입니다.

향후에 수정 제안이나 자동 패치 생성을 도입하더라도, 리뷰와 편집은 분리하는 것이 운영하기 쉽다고 생각합니다.

코딩 에이전트가 멋대로 코드를 수정해 버릴 걱정이 없으며,

코드 편집을 수행하도록 하는 공격이 심어져 있더라도 이를 수행할 기능이 없기 때문에 안전하게 실행할 수 있습니다.

프롬프트는 임베디드 기본값 + 교체

기본 프롬프트는 바이너리 (binary)에 include_str!로 임베디드(embedded)되어 있습니다.

const DEFAULT_NORMAL_REVIEW_SYSTEM: &str =
include_str!("default_prompts/normal/review/system.md");

반면, 설정 파일로부터 단계 (phase)별로 덮어쓸 수 있습니다.

[prompt.triage]
system_file = "prompts/triage.system.md"
user_template_file = "prompts/triage.user.md.jinja2"
...

user prompt는 minijinja로 렌더링(rendering)하고 있습니다.

AI 계열에 익숙한 사용자라면 Python이나 Jinja2에는 익숙할 것이라고 생각하여 minijinja를 채택했습니다.

triage에는 임의의 CLI 인자 prompt를, review에는 ReviewUnit을, finalize에는 모든 review 결과를 전달합니다.

또한, 리포지토리 고유의 리뷰 지시 사항도 읽어옵니다.

우선순위는 다음과 같습니다.

AGENT_REVIEWER.md

AGENTS.md

.github/copilot-instructions.md

GEMINI.md

CLAUDE.md

가장 먼저 발견된 것 하나만 사용합니다.

기존의 AI coding agent용 지시 파일을 유용할 수 있도록 하고 싶었기 때문입니다.

일반 리뷰와 보안 리뷰

일반 리뷰와 보안 리뷰는 동일한 파이프라인 (pipeline)을 사용합니다.

다른 점은 프롬프트 프로파일 (prompt profile)과 review 결과의 타입 (type)입니다.

일반 리뷰의 결과는 summary, findings, unanswered_questions, confidence를 가집니다.

pub(crate) struct SubmitReviewArgs {
pub summary: String,
pub findings: Vec<ReviewFinding>,
...

보안 리뷰 (Security Review)에서는 risk, attack_scenario, cwe, owasp, references 등을 가지는 타입으로 전환합니다.

pub(crate) struct SubmitSecurityReviewArgs {
pub summary: String,
pub overall_risk: SecurityRisk,
...

CLI 측에서는 타입 파라미터를 전환하여 동일한 run을 호출하고 있습니다.

if args.security_review {
run::run::<SubmitSecurityReviewArgs>(args, config).await;
} else {
...

이렇게 구성하면 파이프라인의 제어 로직을 늘리지 않고도, 리뷰 관점과 결과 스키마 (schema)만 변경할 수 있습니다.

구현하며 좋았던 설계

1. 출력을 Rust의 타입으로 보유

LLM의 출력은 가변적입니다.

하지만 도구 스키마 (tool schema)에 맞추면 "가변적이어도 되는 부분"과 "가변적이면 안 되는 부분"을 나눌 수 있습니다.

리뷰 코멘트 본문은 자연문이어도 괜찮지만, severity, path, line, confidence와 같은 값은 구조화하고 싶습니다.

이 경계를 Rust의 타입으로 가질 수 있었던 점이 다루기 편리했습니다.

2. review 전에 triage를 배치

처음부터 모든 차이점 (diff)을 강력한 모델에 전달하는 설계는 간단하지만 낭비가 많습니다.

트리아지 (triage)를 배치함으로써 리뷰 대상을 작게 만들고, 리뷰 단위마다 모델 티어 (model tier)를 선택할 수 있습니다.

결과적으로 저렴한 모델로 충분한 부분과 강력한 모델에 맡기고 싶은 부분을 나누기 쉬워졌습니다.

또한, 여러 에이전트 (agent)를 병렬로 동작시킬 수 있기 때문에 전체 실행 시간은 단축될 수 있다고 생각합니다.

3. subagent는 도구 (tool)로 취급

서브 에이전트 (subagent)를 특별 취급하지 않고 AgentTool로 구현한 것은 좋았습니다.

메인 에이전트 (main agent) 입장에서 보면 read_fileexplorer도 "필요한 정보를 반환하는 도구 (tool)"입니다.

이렇게 추상화해 두면 나중에 test_finderapi_route_mapper와 같은 용도별 서브 에이전트를 추가하기 쉬워집니다.

4. 동시 실행 제어는 글로벌하게 설정

리뷰 단위를 병렬화한 상태에서, LLM 요청 수만 세마포어 (semaphore)로 제한하는 구성은 실용적으로 매우 중요합니다.

단계 (phase)별, 에이전트별로 제한을 두면 서브 에이전트가 늘어났을 때 전체 상한선을 놓치기 쉽습니다.

이번에는 모든 에이전트가 동일한 ConcurrencyLimiter를 공유하므로 설정값의 의미가 명확합니다.

향후 하고 싶은 것

아직 개선하고 싶은 점들이 있습니다.

  • PR 상에 인라인 코멘트 게시
  • review unit 단위의 재시도 및 부분 재실행
  • 검증 에이전트 (verifier agent)에 의한 2단계 체크
  • 리뷰 결과의 SARIF 또는 JSON 출력

특히 Markdown 리포트뿐만 아니라, CI나 GitHub review UI에 자연스럽게 연결될 수 있는 형식을 도입하고 싶습니다.

요약

LLM 코드 리뷰를 CLI로 구현할 때는 모델 선택이나 프롬프트 이전에 파이프라인 설계가 효과를 발휘합니다.

이번 구현에서는 다음과 같은 방침을 중시했습니다.

  • triage / review / finalize로 분리
  • Rust의 타입으로부터 도구 스키마 (tool schema) 생성
  • 마커 도구 (marker tool)로 각 단계 (phase)의 종료와 반환값 표현
  • review unit을 병렬로 처리하면서 LLM 요청 수는 글로벌하게 제한
  • 서브 에이전트를 도구 (tool)로 취급하여 필요한 탐색만 위임

"LLM에게 리뷰를 시키는 것"만 목적이라면 프롬프트 하나로 시작할 수 있습니다.

하지만 일상적으로 사용하는 도구로 만들려면, 어디를 구조화하고 어디를 에이전트에게 맡길지를 나누는 것이 중요하다는 것을 느꼈습니다.

본 기사는 AI의 힘을 많이 사용하여 작성되었습니다.

한 차례 확인 후 적절히 수정하였으나, 부정확한 부분이 있을 수 있습니다.

Discussion

AI 자동 생성 콘텐츠

본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0