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.
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.
Speaks first with arguments and data; 2–3 sentences per turn, no conceding
🔴 Skeptic (against)
Challenges the topic
Looks for holes and asks for evidence; searches the web first when it needs to
🔵 Moderator (summarizer)
Neutral
Writes 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.
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.
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: theme (light / dark / system) and language (Chinese / English).
The About page. Its "sources with confidence scores", "issue matrix" and "one-click export" were plans; the code never implemented them.
The pricing page was a productization exercise; its footer says the prices are examples, and nothing is charged.
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.
If the Optimist needs data, the ToolNode searches and returns to the Optimist; otherwise it is the Skeptic’s turn.
The Skeptic can also search before speaking.
After the Skeptic, the Moderator summarizes and the round count goes up.
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.
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.
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.
Picked the topic, created a monorepo with FastAPI and Next.js, and confirmed the two ends could talk
Early December
Built 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 December
Rebuilt the debate flow as a LangGraph StateGraph, with the search tool and the Moderator
Late December
History, bilingual UI, themes, model fallback, automated tests
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.
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.