AI Code Review with Two Local LLMs: OpenCode Subagents on Multi-GPU Ollama
Local LLM Coding Agent series ① Local LLM Core Concepts · ② Ollama Local LLM Setup Guide · ③ Ollama Multi-GPU Operations & Troubleshooting · ④ AI Code Review with Two Local LLMs (this post)
A follow-up to Ollama Local LLM Setup Guide: Running OpenCode on Two NVIDIA A30 GPUs. That post spread a single model across both GPUs to reach a 64K context. This one does the opposite: one model per GPU, so the implementing model and the reviewing model are different models. OpenCode’s primary/subagent structure turns that into a loop where A implements, B reviews, and A applies.
The end state:
1
2
3
4
5
6
7
8
9
User
│ task in plain language
▼
OpenCode
├─ primary agent "build" ──▶ Qwen3-Coder 30B-A3B ──▶ A30 #0
│ edits files, runs commands, runs tests
│
└─ subagent "reviewer" ──▶ EXAONE 4.0 32B ──▶ A30 #1
read and git diff only, no edit permission
| Previous post | This post | |
|---|---|---|
| Models | 1 | 2 |
| GPU layout | one model split across both cards | one model per card |
| Context | 65,536 | split per model (32K implement / 16K review to start) |
| Inter-GPU traffic | goes over PCIe | almost none |
OLLAMA_MAX_LOADED_MODELS |
1 | 2 |
[01] Where self-review breaks down
Ask a model to review the code it just wrote and it will usually pass it. The same probability distribution that produced the code is doing the reviewing, so whatever the model got wrong while generating, it gets wrong again while checking. A missing error path stays invisible; a misremembered API signature still looks correct.
Shared context compounds this. When the model’s own code is already sitting in its context, review becomes a search for improvements within that code’s assumptions. The possibility that the assumptions themselves are wrong never enters the search.
A reviewer from a different model family changes both conditions. Different training data and tokenizer mean the failure distributions don’t overlap, and a separate context means the reviewer sees the code only as an artifact.
What this does not fix is equally clear. If both models believe the same wrong fact, the review passes. A 30B-class local model is not a substitute for human review — the correct place for this loop is as a first-pass filter before a human reads git diff.
[02] Splitting the roles
The previous post picked Qwen3.5 35B-A3B on a “one model does everything” criterion. With two models the criterion changes: each role needs different strengths, so neither model has to be average at both.
| Role | Needs | Choice | Why |
|---|---|---|---|
| A. Implement | tool-calling reliability, repo-scale edits, running tests | Qwen3-Coder 30B-A3B Q4_K_M | shipped for coding-agent and repository work |
| B. Review | precision of written critique, requirement cross-checking | EXAONE 4.0 32B Q4_K_M | strong on Korean domain knowledge and phrasing, official GGUF |
The drawbacks listed in the previous post’s comparison table stop mattering here. Qwen3-Coder’s weaker general prose doesn’t matter because EXAONE writes the critique, and EXAONE’s tight context budget doesn’t matter because it only reads a diff.
Picking two models from the same family removes the reason for the setup. Pairing Qwen3-Coder with Qwen3.5 gives you a reviewer that overlooks the implementer’s kind of mistake. Prefer a different lineage.
The VRAM layout:
1
2
3
4
5
A30 #0 24GB ├─ Qwen3-Coder 30B-A3B Q4_K_M ~19GB
└─ KV cache + runtime ~5GB
A30 #1 24GB ├─ EXAONE 4.0 32B Q4_K_M ~19.3GB
└─ KV cache + runtime ~4GB
Ollama’s documented placement rule is to load a model entirely onto one GPU and to spread it only when it doesn’t fit. Both models fit inside 24GB, so one lands on each card without any device pinning. That is the same reason the previous post dropped CUDA_VISIBLE_DEVICES, and it still applies.
[03] Ollama — keeping both models resident
3-1. systemd changes
Two values change from the previous post.
1
sudo systemctl edit ollama
1
2
3
4
5
6
7
8
9
[Service]
Environment="OLLAMA_HOST=127.0.0.1:11434"
Environment="OLLAMA_CONTEXT_LENGTH=16384"
Environment="OLLAMA_NUM_PARALLEL=1"
Environment="OLLAMA_MAX_LOADED_MODELS=2"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="OLLAMA_KEEP_ALIVE=-1"
Environment="OLLAMA_MODELS=/data/ollama/models"
1
2
sudo systemctl daemon-reload
sudo systemctl restart ollama
| Change | Previous | Here | Reason |
|---|---|---|---|
OLLAMA_MAX_LOADED_MODELS |
1 | 2 | both models must stay resident or every round pays a reload |
OLLAMA_CONTEXT_LENGTH |
65536 | 16384 | keep the global default low; raise per model in 3-2 |
Per the Ollama docs the default for OLLAMA_MAX_LOADED_MODELS is 3 × number of GPUs, so two models load without setting anything. The value only needs attention if you pinned it to 1 following the previous post — leave it there and Ollama evicts one model to load the other on every single request.
OLLAMA_MODELS carries over the data-disk path set up in Ops & Troubleshooting 02. This setup pulls two models and then derives more from them in 3-2, so disk pressure is higher here than in the previous post.
| Item | Approximate size |
|---|---|
qwen3-coder:30b-a3b-q4_K_M |
19GB |
| EXAONE 4.0 32B Q4_K_M | 19.3GB |
| Qwen3.5 35B-A3B, if kept from the previous post | +24GB |
The derived models (coder-32k, reviewer-16k) reference the original weights rather than copying them, so nothing doubles. Three models together still exceed 60GB, so apply Ops & Troubleshooting 02 first if models still live on the root filesystem.
OLLAMA_KEEP_ALIVE=-1 is mandatory rather than optional in this setup. Loading nearly 38GB across two models is not a cost you want to pay per round. The flip side is that all 48GB of GPU memory stays committed to Ollama, so if the same server also runs training or other inference, this layout is the wrong one.
3-2. Per-model context
OLLAMA_CONTEXT_LENGTH is a server-wide setting, and the OpenAI-compatible endpoint has no num_ctx parameter. To give each role a different context, bake the parameter into a derived model with a Modelfile.
The implementer holds several source files plus test output, so it needs the room:
1
2
3
4
5
6
7
8
ollama pull qwen3-coder:30b-a3b-q4_K_M
cat > /tmp/coder.Modelfile <<'EOF'
FROM qwen3-coder:30b-a3b-q4_K_M
PARAMETER num_ctx 32768
EOF
ollama create coder-32k -f /tmp/coder.Modelfile
The name after FROM has to be a real registry tag. Give it one that doesn’t exist and ollama create fails while fetching the manifest.
1
Error: pull model manifest: file does not exist
The 30B-family tags for qwen3-coder are 30b, 30b-a3b-q4_K_M, and 30b-a3b-q8_0. There is no 30b-a3b. Using 30b-a3b-q4_K_M, with the quantization in the name, keeps you on the same weights even after latest moves. Check tags on the model’s Tags tab, and ollama pull first so ollama create works from the local copy.
The reviewer reads a diff and a requirement, so it doesn’t:
1
2
3
4
5
6
cat > /tmp/reviewer.Modelfile <<'EOF'
FROM hf.co/LGAI-EXAONE/EXAONE-4.0-32B-GGUF:Q4_K_M
PARAMETER num_ctx 16384
EOF
ollama create reviewer-16k -f /tmp/reviewer.Modelfile
The asymmetry is the practical gain. Give both models 32K and VRAM runs out; give the reviewer 16K and the implementer gets to keep 32K.
Those numbers are starting points. Measure the real ceiling:
1
2
3
4
ollama run coder-32k "test" # just load it, then /bye
ollama run reviewer-16k "test"
ollama ps
nvidia-smi
Success looks like PROCESSOR reading GPU for both models and nvidia-smi showing roughly 20GB on each of GPU 0 and 1. If either falls back to a CPU split, lower num_ctx and ollama create again. If both models pile onto one card, one num_ctx is large enough that Ollama gave up on the clean placement — adjust the same way.
100% CPUis not a context problem, it’s a driver problem. See Ops & Troubleshooting 01.
3-3. When placement has to be pinned
Ollama’s automatic placement depends on load order and free memory. If you need a specific model on a specific card, run two Ollama instances and give each one GPU.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# /etc/systemd/system/ollama-reviewer.service
[Unit]
Description=Ollama Service (reviewer, GPU1)
After=network-online.target
[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
Environment="CUDA_VISIBLE_DEVICES=1"
Environment="OLLAMA_HOST=127.0.0.1:11435"
Environment="OLLAMA_CONTEXT_LENGTH=16384"
Environment="OLLAMA_MAX_LOADED_MODELS=1"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="OLLAMA_KEEP_ALIVE=-1"
[Install]
WantedBy=default.target
Give the original ollama.service CUDA_VISIBLE_DEVICES=0 and let both instances share the same model directory (/usr/share/ollama/.ollama/models). Because OLLAMA_CONTEXT_LENGTH is now per-instance, the Modelfile work in 3-2 becomes unnecessary.
The cost is two of everything: two ports, two units, two log streams, and a second provider in the OpenCode config. Start with 3-1 and move here only if placement actually drifts.
[04] OpenCode — primary and subagent
4-1. Where the config file goes
OpenCode reads config from two places.
| Scope | Path | Applies to |
|---|---|---|
| Global | ~/.config/opencode/opencode.json |
every project |
| Project |
opencode.json in the project root |
that repository; commit it to share with the team |
The .jsonc extension, which allows comments, is also recognized. The two files are merged, not replaced — the project file wins only on conflicting keys.
There is one Ollama server per machine regardless of user account, so provider and model definitions belong in the global file. If you already created ~/.config/opencode/opencode.jsonc in the previous post, replace its contents with what follows.
4-2. The complete global config
This is the finished file for the single-instance layout from 3-1. Provider and agent go in the same file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
mkdir -p ~/.config/opencode
cat > ~/.config/opencode/opencode.jsonc <<'EOF'
{
"$schema": "https://opencode.ai/config.json",
"model": "ollama/coder-32k",
"small_model": "ollama/coder-32k",
"provider": {
"ollama": {
"npm": "@ai-sdk/openai-compatible",
"name": "Ollama Local - A30 x2",
"options": {
"baseURL": "http://127.0.0.1:11434/v1"
},
"models": {
"coder-32k": {
"name": "Qwen3-Coder 30B-A3B - implement",
"limit": { "context": 32768, "output": 8192 }
},
"reviewer-16k": {
"name": "EXAONE 4.0 32B - review",
"limit": { "context": 16384, "output": 4096 }
}
}
}
},
"agent": {
"build": {
"model": "ollama/coder-32k"
}
}
}
EOF
| Key | Meaning |
|---|---|
model |
session default; point it at the implementing model |
small_model |
used for lightweight work like session titles. Name the same local model to avoid pulling in an external provider |
provider.ollama.models |
must match the derived model names created with ollama create in 3-2 exactly |
limit.context |
same value as num_ctx in 3-2. If they disagree, OpenCode builds requests past the server’s limit and Ollama truncates from the front |
agent.build |
pins the model for the default implementing agent. The reviewer is defined separately in 4-3 |
The review model (reviewer-16k) is registered under models but never set as model. The subagent in 4-3 refers to it by name.
Check the result:
1
2
cd /path/to/your/project
opencode
1
/models # both "Qwen3-Coder 30B-A3B - implement" and "EXAONE 4.0 32B - review" should appear
For the two-instance layout from 3-3, add a second provider named ollama-reviewer with baseURL http://127.0.0.1:11435/v1, and write the 4-3 model as ollama-reviewer/reviewer-16k.
4-3. Defining the reviewer agent
OpenCode agents differ by mode. A primary agent is one the user talks to directly; a subagent is invoked by a primary agent or called by hand with @name.
The implementing agent was finished in 4-2 with a model override under agent.build. The reviewer needs its own prompt and permissions, so it goes in a markdown file — not JSON, a separate file, and the filename becomes the agent name.
1
mkdir -p ~/.config/opencode/agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!-- ~/.config/opencode/agents/reviewer.md -->
---
description: Reviews implemented changes and reports problems. Does not modify code.
mode: subagent
model: ollama/reviewer-16k
temperature: 0.1
permission:
edit: deny
write: deny
webfetch: deny
bash:
"git diff*": allow
"git show*": allow
"git status*": allow
"*": deny
---
You are a code reviewer. You never modify code directly.
## Procedure
1. Run `git diff` to see what changed.
2. Read the surrounding code in the changed files for context.
3. Compare against the stated requirement and find omissions and errors.
## Finding format
For each finding, state:
- Severity: critical / major / minor / suggestion
- Location: file path and line
- Problem: one sentence on what is wrong
- Evidence: why it is wrong. If you are guessing, say so
- Fix direction: the concrete change. Do not rewrite the whole file
## Prohibited
- Never pass something with "looks good" and no evidence.
- If there are no problems, answer only "No findings." Do not pad with praise.
- Never classify a style preference as critical or major.
- Report problems in unchanged files as a separate section.
permission is the load-bearing part. Leave edit and write open and the reviewer starts fixing the code itself, which dissolves the author/reviewer separation the whole setup exists for. Restricting bash to the git diff family leaves exactly the access review needs.
That makes two files in total.
1
2
~/.config/opencode/opencode.jsonc provider, both models, agent.build model override
~/.config/opencode/agents/reviewer.md the review subagent's prompt and permissions
Restart OpenCode and check registration.
1
/agents # reviewer should appear as a subagent
[05] Running the loop
5-1. Three steps
One round is implement, review, apply.
1
2
3
4
① implement build(A) make the change, run tests, do NOT commit
② review @reviewer(B) read git diff, report findings by severity
③ apply build(A) apply accepted findings, re-run tests
④ gate human read git diff, then commit
① Implement
1
2
3
4
Add a Ready condition to this repo's status.conditions update logic.
First check how the existing conditions are handled and follow the same pattern.
After the change, run the related unit tests and report the result.
Do not commit.
Blocking the commit matters: the reviewer reads git diff, which needs the changes still sitting in the working tree.
② Review
1
2
@reviewer Review what just changed.
The requirement was: "add a Ready condition to status.conditions, following the existing condition pattern."
Calling @reviewer runs the subagent in its own context and returns the result into the session. You can let the primary agent invoke it automatically through the task tool, but then it’s hard to control when review happens. Manual invocation is the better default.
③ Apply
1
2
3
Apply only the "critical" and "major" findings.
For "minor" and "suggestion", tell me whether you accept or reject each and why — don't touch the code.
Re-run the tests afterward.
Not applying everything is the point. Findings from a 30B local model include false positives, and applying those makes the code worse. Filter by severity and take only a judgment on the rest.
5-2. Cap the rounds
Stop at two rounds. Without a cap the two models trade responses indefinitely: the reviewer is prompted to find something every time, and the implementer tries to apply it every time, so the loop has no natural fixed point.
1
2
3
Round 1: implement → review → apply most real problems surface here
Round 2: re-review → apply only checks whether round 1 introduced new problems
Round 3+ human steps in; the requirement itself is ambiguous
If critical and major findings keep appearing in round 2, splitting the requirement is faster than another round. The task-decomposition principle from Setup Guide 07 applies unchanged.
5-3. The human gate
Read git diff before committing. Two models agreeing means they did not share a misunderstanding — not that the code is correct.
1
2
3
4
git switch -c ai/two-model-loop
# run the loop
git diff
git add <files>
[06] Failure modes
| Symptom | Cause | Response |
|---|---|---|
| Reviewer only says “looks good overall” | no finding format or prohibitions in the prompt | use the severity format and the “no praise” rule from 4-3; keep temperature near 0.1 |
| Reviewer edits code |
permission.edit is open |
confirm edit: deny and write: deny
|
| Reviewer can’t see the diff |
bash fully denied, or the change was already committed |
allow git diff*; don’t commit during the implement step |
| Tens of seconds of lag each round | model swapping | check both models appear in ollama ps; re-check MAX_LOADED_MODELS and KEEP_ALIVE
|
| Findings drift to unrelated files | no review scope in the prompt | pass the requirement with the call; keep the “unchanged files as a separate section” rule |
| Three or more rounds | ambiguous requirement | stop per 5-2 and split the task |
| Tests break after applying | false-positive findings were applied | filter by severity; keep the 5-1 ③ prompt shape |
The first row is the one you will hit most. A short reviewer prompt pushes the model toward generating an inoffensive summary. It is not excessive for format-enforcing instructions to take up half the prompt.
[07] Verification
Four things to check:
1
2
3
4
5
6
7
8
9
10
11
12
# 1. Both models resident, on different GPUs
ollama ps
nvidia-smi
# 2. The reviewer actually uses model B
sudo journalctl -u ollama -f # look for reviewer-16k requests during an @reviewer call
# 3. The reviewer cannot use edit tools
# Tell @reviewer "edit this file directly" — it must refuse
# 4. No swapping
# After two rounds, UNTIL still reads Forever in ollama ps and responses stay fast
Do check number 3 at least once. If permission never took effect, the reviewer quietly edits code, and that edit is the one change nobody reviewed.
[08] What this post did not verify
This setup makes concrete the per-GPU split floated as a next step in Setup Guide 10. The following is written from documentation and was not measured on this server.
- VRAM figures and context ceilings. 19GB and 19.3GB are the distribution sizes listed on Ollama and Hugging Face respectively; actual occupancy with KV cache, and whether 32K/16K fit in 24GB, has to be measured with the procedure in 3-2. These are starting values, not verified limits.
- Where the two models land. One-per-card is the expected outcome of Ollama’s documented placement rule. If load order changes it, pin with 3-3.
- Review quality. How much of Qwen3-Coder’s error surface EXAONE actually catches depends on the repository and the kind of work. The comparison method in Setup Guide 10 — 10 to 20 real tasks — is the only basis for judging it.
-
OpenCode config schema. The
agent,mode, andpermissionkeys follow the OpenCode docs and change between versions. On an error, check thehttps://opencode.ai/config.jsonschema first.
References
- OpenCode Agents · Config · Providers
- Ollama FAQ (concurrent models, multi-GPU placement) · Modelfile · Context Length
- Qwen3-Coder 30B-A3B · EXAONE 4.0 32B GGUF
- Previous post: Ollama Local LLM Setup Guide: Running OpenCode on Two NVIDIA A30 GPUs
Environment: Ubuntu 22.04 / NVIDIA A30 24GB × 2 (no NVLink) / Ollama / OpenCode. Documentation current as of 2026-07-31; the items listed in [08] were not measured on this server.