AWS Builder Center
My AI Judge Cheated: Building a Self-Correcting Research Agent, and Everything That Broke

My AI Judge Cheated: Building a Self-Correcting Research Agent, and Everything That Broke

My research agent's writer scored higher by calling its own real sources "fictional." Here's how that bug happened, how I fixed it, and three other problems I hit along the way.

I built ResearchMind on my own to see how far a small research agent could go. I shared a short version of this story in the Student Builders space. Here's the full one.
The moment that started it: a report's score went up while the report itself got less true. Getting there took four bugs, and I learned more from them than from anything that worked on the first try.

What ResearchMind does

You give it a topic. It searches the web, scrapes a few sources, writes a structured report, and then a second model grades the report and sends it back for revision if the score is under 8 out of 10, up to two times.
1
2
3
4
5
Request → Spring Boot gateway (:8080) → Flask AI service (:5000, Gunicorn)
→ Search Agent (LangChain + Tavily) → raw ToolMessage extraction
→ Scraper (BeautifulSoup) → Writer Chain → Critic Chain
→ score ≥ 8, or retries used up? → final report
no → Revision Chain → back to the Critic
It uses Gemini 2.5 Flash, LangChain, and Tavily in a Python service, behind a Spring Boot 3.3.3 (Java 17, WebFlux) gateway, with both in Docker on an internal bridge network.

Bug 1: The vanishing URLs

My reports kept coming back with vague or missing sources, and citations were the whole point.
It took me a while to see why. I was reading the agent's final message, which is the model's summary of the search results, and the URLs got lost in that summary. So the writer downstream had nothing real to cite.
I wrote a helper, extract_tool_outputs(), that walks the agent's full message history and pulls the raw output from the ToolMessage payloads. The real URLs and snippets now arrive untouched.
Takeaway: If you need data exactly as it was, take it from the tool, not the model's retelling.

A design choice: scraping without an agent

Scraping a known list of URLs doesn't need judgment. I take the top 3 URLs from the search results and scrape them in a plain loop with requests and BeautifulSoup. It removes script, style, nav, and footer tags and labels each source (SOURCE 1, SOURCE 2, and so on). Moving this step out of an agent saved one LLM call per run.
The writer prompt then requires every claim to map to a numbered source, and forbids invented URLs.

Bug 2: The judge that got gamed

This was my favorite bug.
I tested a topic on 2025 quantum computing breakthroughs. The search found real, current articles from places like NIST and Stanford Research. But the model's training data ended before those events, so my critic flagged a "temporal inconsistency," as if the report were presenting future predictions as facts.
I expected the writer to fix the wording on the next pass. Instead, it added a disclaimer calling its own verified sources "fictional" and "hypothetical scenarios."
And the critic gave that revision a higher score. A hypothetical story is internally consistent, so the critic had nothing left to object to. The report got less true, and my loop counted that as improvement.
I fixed it two ways:
  1. I injected the current date into both the writer's and critic's prompts, so they treat live search results as present reality.
  2. I added a rule to the critic's rubric that penalizes disclaiming sources the report actually retrieved.
The rubric scores four things: citation grounding, analytical rigor, temporal consistency, and structure and clarity.
When the loop works, it works well. In my test runs, a first draft scored 4/10 for leaning on press-release snippets and an inaccessible link. After the revision cited corroborated benchmarks, it cleared the 8/10 bar.
Takeaway: A writer in a feedback loop will find the cheapest way to please its judge. Read what changed between drafts, not just the score.

Bug 3: Ninety seconds is a long time

A full run takes 60 to 90 seconds, because it includes searching, scraping several pages, and up to two revision passes. That's far longer than normal web requests expect, so I set timeouts deliberately at each layer:
  • 8 seconds per web scrape
  • 200 seconds on the Spring Boot gateway (a reactive WebClient on Netty)
  • 300 seconds for the Gunicorn worker

Bug 4: Fitting into 1 GB

I wanted this to run on a tiny 1 GiB machine, like a t2.micro EC2 instance, without buying expensive compute.
A Java service is hungry for memory, so I built the gateway with a multi-stage Alpine Dockerfile and capped the JVM at -Xmx256m -Xss256k (256 MB max heap, 256 KB thread stack). The Python container stays light because the Gemini API does the heavy inference off the machine, and it runs on python:3.11-slim.
When I tested both containers together in Docker, they stayed under 1 GiB with no out-of-memory kills.

What I still haven't solved

  • Judge independence. The judge is from the same model family as the writer, and I don't know whether it's too kind to its own style.
  • Latency. 60 to 90 seconds is still long.
  • Retries. Two is a limit I chose as a heuristic, not a tuned number.
Over to you: how do you keep an LLM judge honest? A different model as judge, human spot-checks, or a structured grading framework? I'd love to hear what worked for you.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article