Completed AI LangGraph FastAPI Next.js

DebateAI: Two AIs Argue One Topic

Enter a topic and watch an Optimist and a Skeptic AI take turns, search the web on their own, and hand off to a Moderator who sums up every round and writes a final report. I built this final project to test LangGraph multi-agent flows: FastAPI streams over SSE, and the Next.js frontend runs on Cloudflare Pages.

DebateAI in about a minute. The cover and characters are AI-generated (Codex), the narration is an AI voice (Gemini TTS), and the dialogue is illustrative.

Why I built it#

This was my final project for Advanced Web Programming in the first semester of junior year. I wanted to build something where several AIs work together, not a single LLM call.

I was curious about LangGraph at the time. It promises to manage multiple AI roles, tool calls and turn-taking as one graph, but I had never used it. Instead of reading the docs, I decided to build a product that could not work without those features.

A debate turned out to be a good fit:

  • It needs multiple roles: a proposer, an opponent and a judge, each with its own stance and rules.
  • It needs turns: who speaks first, how many rounds, when to stop. That is exactly what a state machine manages.
  • It needs evidence: from memory alone, a debate quickly turns into empty talk. Letting the AIs search the web shows whether tool calls are wired up correctly.

The other half of the motivation was simpler: I wanted to see what two AIs arguing with each other would actually look like.

Three roles#

RoleStanceWhat it does
🟢 Optimist (for)Supports the topicSpeaks first with arguments and data; 2–3 sentences per turn, no conceding
🔴 Skeptic (against)Challenges the topicLooks for holes and asks for evidence; searches the web first when it needs to
🔵 Moderator (summarizer)NeutralWrites an 80–120 character summary after every round, and a 200–300 character final report: both sides’ arguments, disagreements, assessment and recommendations

Both debaters decide for themselves whether to search. Only the titles and snippets of the top 3 results go to the model, and a status bar shows “Optimist is searching: …” as it happens.

Using it#

1. Enter a topic#

DebateAI dark home page: navigation and recent debates on the left, and an input box reading “Will AI replace most human jobs?” in Chinese

Home: type a topic and press Start. The sidebar starts a new debate and lists the five most recent ones.

2. Watch the two AIs argue live#

A debate in progress: the green Optimist and the red Skeptic take turns, while the status bar shows a web search

Text streams in token by token over SSE. When a debater decides to search, the status bar shows the query.

3. The Moderator sums up every round#

A blue Moderator message summarizing the main arguments and open disagreements of the round

After each round the Moderator lists the main arguments and unresolved points before the next round starts.

4. The final report#

After three rounds the Moderator writes a final report: both sides’ arguments, disagreements, assessment and recommendations, rendered with react-markdown.

To be honest, I can’t capture that screen right now: the live site hits the Groq account’s limit of 8,000 tokens per minute (HTTP 429) in round two or three, and the code does not wait and retry on 429, so the debate stops.

5. History, settings and the other pages#

History page: a paginated list of past debate topics and times

The history page lists ten debates per page; open one to replay it. A debate is only saved when it finishes, so after the quota interruptions it is currently empty.

Settings page: light, dark or system theme, and Chinese or English

Settings: theme (light / dark / system) and language (Chinese / English).

About page describing DebateAI and its features

The About page. Its "sources with confidence scores", "issue matrix" and "one-click export" were plans; the code never implemented them.

Pricing page with Free, Pro and Enterprise plans

The pricing page was a productization exercise; its footer says the prices are examples, and nothing is charged.

Architecture#

DebateAI architecture: the browser loads Next.js on Cloudflare Pages, which calls FastAPI on Cloud Run; inside FastAPI, LangGraph manages the Optimist, Skeptic, Moderator and ToolNode, and calls Groq models, Tavily/DuckDuckGo search and Supabase

The system has three layers:

  • Frontend: Next.js with shadcn/ui, statically exported to Cloudflare Pages, with Chinese/English and light/dark themes. It reads the stream with fetch and a ReadableStream, so no WebSocket is needed.
  • Backend: FastAPI on Google Cloud Run, managed with uv. It turns LangGraph events into eight SSE event types (status, speaker, token, tool_start, tool_end, speaker_end, complete, error), and the frontend updates the screen by event type.
  • AI flow: a LangGraph StateGraph manages roles, rounds and tool calls; the models run on Groq, and finished debates are saved to Supabase.

Technical details#

LangGraph: a debate as a graph#

One debate is one state graph:

  1. Start with the Optimist.
  2. If the Optimist needs data, the ToolNode searches and returns to the Optimist; otherwise it is the Skeptic’s turn.
  3. The Skeptic can also search before speaking.
  4. After the Skeptic, the Moderator summarizes and the round count goes up.
  5. Under three rounds, go back to the Optimist; at three, the Moderator writes the final report and the graph ends.

The whole debate shares one state: messages, topic, current speaker, round number, search count, last speaker and the debate’s language. Conditional edges decide every next step, so the code needs no pile of if statements to track whose turn it is.

Three layers of search fallback#

web_search_tool tries Tavily first, then DuckDuckGo. If both come back empty, it returns a note telling the debater to argue from what it already knows, so the debate never stalls.

Three layers of model fallback#

The main model is gpt-oss-120b on Groq, falling back to kimi-k2-instruct and then llama-3.1-8b-instant. At first the fallback did not work at all; the next section explains why.

How it came together#

WhenWhat
Late NovemberPicked the topic, created a monorepo with FastAPI and Next.js, and confirmed the two ends could talk
Early DecemberBuilt SSE streaming with fake data first to make sure the frontend could render token by token, then switched to real Groq streaming; deployed both ends
Mid DecemberRebuilt the debate flow as a LangGraph StateGraph, with the search tool and the Moderator
Late DecemberHistory, bilingual UI, themes, model fallback, automated tests
Early JanuaryWrap-up and final review

Problems and fixes#

  • The frontend received no stream: the backend was emitting a literal \\n\\n instead of real newlines, so the browser never saw event boundaries. I wrote an sse_event() helper to produce the SSE format consistently.
  • Production called localhost: .env.local took priority over the production variables, so the deployed frontend still called the local API. Sorting out the load order fixed it.
  • No tool events, and endless searching: tools were first called by hand inside nodes, so LangGraph could not track them and the frontend never saw “searching”. Switching to ToolNode and astream_events v2 exposed the tool events, and I added a limit of three searches per role.
  • The small model could not use tools: the 8B model often broke the function-calling format; gpt-oss-120b was reliable.
  • The fallback model never kicked in: with max_retries above 0, the Groq SDK kept retrying the same model internally and swallowed rate-limit errors, so LangChain’s with_fallbacks() never got a chance. Setting max_retries to 0 let errors surface immediately, and the fallback finally worked.
  • The fallback model lost its tools: wrapping with_fallbacks() before bind_tools() left the fallback without tools. The fix was to bind_tools() on each model first, then wrap the fallback.
  • Two users interfered with each other: the language setting was a global variable, so two people debating in different languages overwrote each other. It now lives in each debate’s LangGraph state.
  • Static export cannot do dynamic routes: history details could not use /history/[id], so they use a ?id= query parameter with Suspense.
  • The stream callback saw stale state: the SSE callback closed over an old React state; a ref now holds the latest value.

Scale#

  • Time: late November to early January, about six weeks, mostly in December.
  • Code: 77 commits, about 93% of them mine; teammates helped with the intro page and parts of the UI.
  • Tests: 59 pytest tests on the backend and 37 vitest tests on the frontend, about 77% coverage; CI blocks changes below 50%.

If I did it again#

  • Clickable sources: search results only pass titles and snippets to the model, and the URLs never reach the screen, so readers cannot check the original. The next version would carry source links in the message format.
  • Recheck the search limit: rereading the code, I found the counter resets when it hits the limit, so “at most three searches” may not actually stop. A test that keeps asking for searches would confirm it.
  • Back off on rate limits: today a 429 ends the whole debate. It needs exponential backoff, or shorter per-round context and a configurable round count, so a limited quota can still finish a debate.
  • Protect the API: the backend has no authentication, so anyone with the URL can call it and use up the model quota. A multi-user product would need login, rate limiting and a redesign of who can see which history.

Reference projects#

roy4222
/
DebateAI
DebateAI 是一個基於 LangGraph 與 Groq 的多 Agent 即時辯論平台。 透過「樂觀者」與「懷疑者」兩位 AI 針對主題進行自動化攻防與事實查核。專案採前後端分離架構(FastAPI + Next.js),完美展示了 Agent 狀態管理、聯網搜尋 (Tool Use) 及 即時串流 (Streaming) 等核心 AI 工程技術,旨在呈現極致的推理速度與全端開發能力。
1
0
no-license

The full DebateAI source: FastAPI backend, LangGraph flow, Next.js frontend and CI setup.

DebateAI live
debate.roy422.dev

Type a topic and start a debate.

langchain-ai
/
langgraph
Build resilient agents.
42.2K
7.1K
MIT

The framework that manages the AI roles, tool calls and turn state.