RustでLLMコードレビューエージェントを作った
LLMにコードレビューを頼むとき、最初は「この差分をレビューして」と大きなdiffを投げるだけでもそれなりに動きます。
ただ、実際に使う道具として考えると、すぐにいくつかの問題に当たります。
- 大きな差分ほど、見るべき箇所と流してよい箇所が混ざる
- モデルに全部を読ませるとコストも待ち時間も増える
- 出力形式が毎回揺れると、自動化しづらい
- セキュリティレビューなど、観点の違うレビューを同じプロンプトに押し込みたくない
そこで、RustでLLM駆動のコードレビューCLIを作りました。
https://github.com/SiLeader/agent-reviewer
名前は
agent-reviewerです。Gitの差分を読み、レビュー対象を小さな単位に分割し、それぞれを並列にレビューして、最後にMarkdownのレポートへ統合します。
この記事では、実装をもとに設計の要点を紹介します。
作ったもの
agent-reviewerは、現在のGitリポジトリを対象に動くCLIです。
RUST_LOG=info cargo run --release -- --output review.md
通常のコードレビューに加えて、
--security-reviewを付けるとセキュリティレビュー向けのプロンプトと結果スキーマに切り替わります。
RUST_LOG=info cargo run --release -- --security-review --output security-review.md
大まかな処理は3段階です。
各段階の役割は次の通りです。
-
triage
: 差分を見て、レビューすべき単位に分割する -
review
: 各レビュー単位を並列に検査する -
finalize
: 複数のレビュー結果をまとめて、最終レポートを書く
ポイントは、最初から「1つの巨大なエージェント」にしていないことです。
差分の仕分け、詳細レビュー、レポート化を分けることで、コンテキスト、コスト、出力形式をそれぞれ制御しやすくしています。
ワークスペース構成
Cargo workspaceは責務ごとに分けています。
. ├── src/ │ ├── main.rs # CLI entrypoint │ ├── run.rs # 設定読み込み後の実行処理 │ ├── config.rs # TOML設定 │ ├── prompt.rs # プロンプト管理 │ └── orchestrator/ # triage/review/finalize ├── agent-reviewer-agent/ # ReActエージェント実行基盤 ├── agent-reviewer-tools/ # filesystem/git tools └── agent-reviewer-model-provider/ # genaiのprovider設定
CLI本体はオーケストレーションに寄せています。
ReActループ、ツール実装、モデルプロバイダ設定は別crateに分離しました。
この分け方にしておくと、たとえば「GitHubにコメントを投稿するtoolを追加する」「別のCLIから同じエージェント基盤を使う」といった変更をしやすくなります。
設定は provider / model / agent / step に分ける
設定ファイルは
agent-reviewer.tomlです。
モデルまわりの設定は、次の4層に分けています。
concurrency = 4 [[model_providers]] id = "github" type = "GitHub" [[models]] id = "gpt-5-mini" model = "openai/gpt-5-mini" provider = "github" [[agents]] id = "standard" model = "gpt-5-mini" effort = "medium" max_tokens = 8192 max_loops = 10 [steps.triage] agent = "standard"
それぞれの意味は次の通りです。
-
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-agentcrateにあります。
実装は素朴なReActループです。
- system promptとuser promptをモデルに送る
- モデルがtool callを返したら実行する
- tool resultを会話に戻す
- marker toolが呼ばれたら、そのJSON引数を戻り値として終了する
このループには最大回数があります。最終ターンでは、使えるtoolをsubmit用のmarker toolだけに絞ります。
fn create_request(
&self,
system: String,
messages: Vec<ChatMessage>,
is_last_turn: bool,
) -> ChatRequest {
ChatRequest {
system: Some(system),
messages,
tools: if is_last_turn {
Some(
self.tools
.get_tool_description_by_name(&self.submit_tool_name)
.into_iter()
.collect(),
)
} else {
Some(self.tools.description())
},
previous_response_id: None,
store: Some(false),
}
}
これは地味ですが重要です。
「最後は必ず構造化された結果を返す」方向にモデルを寄せられるので、呼び出し側の実装が単純になります。
marker toolで結果を構造化する
通常のtoolは、ファイルを読む、Git diffを見る、といった副作用を持つ可能性があります。
一方でmarker toolは、実行するためのtoolではありません。
モデルが「この形式で結果を提出した」と示すための、構造化された終了シグナルです。
イメージ的には
exit(0)のような機能に近いです。呼び出したLLMに返ることはなく、終了と結果受け取りを表します。
triageの戻り値は、たとえば次のRust型で表しています。
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) struct SubmitTriageArgs {
#[schemars(required, description = "The review units to submit.")]
pub review_units: Vec<ReviewUnit>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ReviewUnit {
pub task: String,
pub focus_files: Vec<String>,
pub model: ReviewModel,
}
この型から
schemarsでJSON Schemaを生成し、LLMのtool schemaとして渡します。
pub fn tool_description<T: JsonSchema>(
name: &'static str,
description: &'static str,
) -> Tool {
Tool {
name: ToolName::Custom(name.to_string()),
description: Some(description.to_string()),
schema: Some({
let mut settings = schemars::generate::SchemaSettings::openapi3();
settings.inline_subschemas = true;
let generator = schemars::generate::SchemaGenerator::new(settings);
normalize_tool_schema(generator.into_root_schema_for::<T>().to_value())
}),
strict: Some(true),
config: None,
}
}
実際のコードではOpenAPI 3向けのschema設定を使い、引数なしtoolでも
propertiesと
requiredが入るように補正しています。
これらのキーがないとエラーを吐いてしまうバックエンドがあったための措置です。
この設計にすると、出力形式の変更がRustの型変更になります。
プロンプトだけで「このJSONを返して」と頼むより、コンパイル時に構造を追いやすいです。
triageでレビュー単位に分ける
Orchestrator::runでは、まずtriage agentを起動します。
triage agentには、read-onlyなfilesystem/Git toolと、探索用subagentを渡します。
そこで差分を読み、レビュー単位を返してもらいます。
let result = agent.run(&system_prompt, &user_prompt).await?; let result: SubmitTriageArgs = serde_json::from_value(result)?;
triageの出力は
ReviewUnitの配列です。
{
"review_units": [
{
"task": "Review error handling in the CLI entrypoint",
"focus_files": ["src/main.rs"],
"model": "standard"
}
]
}
ここで
modelが
light/
standard/
powerのいずれかになります。
後続のreviewでは、このtierに応じて使うagentを切り替えます。
reviewは並列に流す
triageで得たレビュー単位は、
join_allで並列に処理します。
let results = join_all(
result
.review_units
.into_iter()
.map(|unit| self.run_review(unit, explorer.clone())),
)
.await
.into_iter()
.collect::<anyhow::Result<Vec<_>>>()?;
ただし、単純に全レビューを同時にモデルへ投げると、API制限やコストの管理が難しくなります。
そこで、全agentで共有する
ConcurrencyLimiterを持たせています。
let response = {
let _permit = self.concurrency_limiter.acquire().await?;
self.client
.exec_chat(&self.model_name, request, self.options.as_ref())
.await?
};
この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)
}
async fn run(&self, args: &Value) -> anyhow::Result<String> {
run_subagent(&self.agent, EXPLORER_SYSTEM_PROMPT, args).await
}
}
この形にしておくと、メインのreview agentは必要なときだけ探索を委譲できます。
すべてのレビューで最初から広範囲のファイルを読ませるのではなく、「必要になったら探索する」に寄せられるので、コンテキストを節約できます。
組み込み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!で埋め込んでいます。
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" [prompt.review] system_file = "prompts/review.system.md" user_template_file = "prompts/review.user.md.jinja2"
user promptは
minijinjaでレンダリングしています。
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向け指示ファイルを流用できるようにしたかったためです。
通常レビューとセキュリティレビュー
通常レビューとセキュリティレビューは、同じパイプラインを使います。
違うのは、プロンプトプロファイルとreview結果の型です。
通常レビューの結果は、
summary、
findings、
unanswered_questions、
confidenceを持ちます。
pub(crate) struct SubmitReviewArgs {
pub summary: String,
pub findings: Vec<ReviewFinding>,
pub unanswered_questions: Vec<String>,
pub confidence: f32,
}
セキュリティレビューでは、
risk、
attack_scenario、
cwe、
owasp、
referencesなどを持つ型に切り替えます。
pub(crate) struct SubmitSecurityReviewArgs {
pub summary: String,
pub overall_risk: SecurityRisk,
pub findings: Vec<SecurityFinding>,
pub assumptions: Vec<String>,
pub unanswered_questions: Vec<String>,
pub confidence: f32,
}
CLI側では、型パラメータを切り替えて同じ
runを呼んでいます。
if args.security_review {
run::run::<SubmitSecurityReviewArgs>(args, config).await;
} else {
run::run::<SubmitReviewArgs>(args, config).await;
}
この形にすると、パイプラインの制御ロジックを増やさずに、レビュー観点と結果schemaだけを変えられます。
実装してみてよかった設計
1. 出力をRustの型で持つ
LLMの出力は揺れます。
ただ、tool schemaに寄せると「揺れてよい場所」と「揺れてほしくない場所」を分けられます。
レビューコメント本文は自然文でよい一方、severity、path、line、confidenceのような値は構造化したい。
この境界をRustの型として持てたのは扱いやすかったです。
2. reviewの前にtriageを置く
最初から全差分を強いモデルに渡す設計は簡単ですが、無駄が多くなります。
triageを置くことで、レビュー対象を小さくし、レビュー単位ごとにモデルtierを選べます。
結果として、安いモデルで十分な部分と、強いモデルに任せたい部分を分けやすくなりました。
また、複数のエージェントを並行して動作させられるため、全体の実行時間は短くなるとも考えられます。
3. subagentはtoolとして扱う
subagentを特別扱いせず、
AgentToolとして実装したのはよかったです。
メインagentから見れば、
read_fileも
explorerも「必要な情報を返すtool」です。
この抽象化にしておくと、後から
test_finderや
api_route_mapperのような用途別subagentを足しやすくなります。
4. 同時実行制御はグローバルにする
review単位を並列にしたうえで、LLMリクエスト数だけsemaphoreで絞る構成は実用上かなり重要です。
phaseごと、agentごとに制限を持つと、subagentが増えたときに全体の上限を見失いやすくなります。
今回は全agentが同じ
ConcurrencyLimiterを共有するので、設定値の意味が明確です。
今後やりたいこと
まだ改善したい点はあります。
- PR上へのインラインコメント投稿
- review unitごとのリトライと部分再実行
- verifier agentによる二段階チェック
- レビュー結果のSARIFやJSON出力
特に、Markdownレポートだけでなく、CIやGitHub review UIに自然に接続できる形式は入れたいです。
まとめ
LLMコードレビューをCLIとして実装するときは、モデル選びやプロンプト以前に、パイプライン設計が効きます。
今回の実装では、次の方針を重視しました。
- triage / review / finalize に分ける
- Rustの型からtool schemaを作る
- marker toolで各phaseの終了と戻り値を表す
- review unitを並列に処理しつつ、LLMリクエスト数はグローバルに制限する
- subagentをtoolとして扱い、必要な探索だけを委譲する
「LLMにレビューさせる」だけならプロンプト1つで始められます。
ただ、日常的に使う道具にするなら、どこを構造化し、どこをエージェントに任せるかを分けるのが大事だと感じました。
本記事はAIの力を多分に使用し作成しました。
一通り確認の上、適宜修正を行いましたが不正確な部分がある場合があります。