Evaluation Using a News Aggregator System
Every engineering team that has shipped a LangGraph agent in the last year has, at some point, stared at a tracing dashboard and asked the same quiet question: is this thing actually worth it?
Agents are seductive. They promise to replace brittle pipelines with "reasoning." They let you draw beautiful state-machine diagrams. They give you something to demo. But they also introduce non-determinism, latency, cost multipliers, failure modes you can't unit-test, and a debugging surface area that grows faster than your feature set.
The question isn't "can we build an agent?" — it's "does the agent produce measurably better business outcomes than the dumbest thing that could possibly work?"
This article gives you a concrete evaluation framework, then walks through it end-to-end on a real-time news aggregator system built in LangGraph. You'll see the naive pipeline, the agent upgrade, the metrics, the places where we killed the agent, and the decision checklist I now use before approving any agentic feature.
Part 1 — The Evaluation Framework
Before writing a single node, run your proposed agent through these five tests. If it fails more than one, you probably don't need an agent.
Test 1: The Dumb Baseline Test
Build the simplest possible non-agentic version first. A sequential pipeline. Rule-based routing. A single LLM call. Measure its business metrics. This is your bar. If the agent can't beat it by a margin that justifies its complexity, ship the pipeline.
Test 2: The Complexity Tax Audit
Quantify what the agent costs beyond tokens:
Latency: p50 and p95 end-to-end
Cost per request: sum of all LLM calls, tool calls, retries
Failure modes: list every way the graph can get stuck (infinite loops, tool errors, malformed state)
Observability cost: can you reproduce a bad run from a trace?
Maintenance cost: how many engineers understand the graph?
Test 3: The Failure Mode Inventory
For every node, ask: what happens when this node is wrong? In a pipeline, a wrong node produces a wrong output. In an agent, a wrong node can send the graph into a loop, burn $0.40 in tokens, and return a confidently wrong answer. Catalog these.
Test 4: The Business Outcome Link
Map every agent capability to a business KPI. "Better summarization" is not a KPI. "10% increase in daily-active reading time" is. If you can't draw the line, you're building for engineers, not users.
Test 5: The Reversibility Test
Can you A/B test this? Can you roll it back in under 5 minutes? Can you disable a single node without taking down the graph? If no, you're building a monolith with extra steps.
Part 2 — The Use Case: Real-Time News Aggregator
The product
We're building Pulse, a personalized news briefing app. Users get:
A rolling feed of top stories, deduplicated across 40+ sources
Per-user personalization (tech, finance, sports weights)
Breaking-news detection with sub-60-second alerts
A daily 5-bullet morning briefing, written in the user's voice preference
Why this is a great test case
A news aggregator looks simple but has four decision points where a pipeline struggles:
| Decision point | Pipeline approach | Where it breaks |
|---|---|---|
| Source routing | Static rules per source | New sources need code deploys |
| Deduplication | URL + title similarity | Same story, different angles → duplicates |
| Breaking news | Keyword triggers | Misses nuanced breaking stories |
| Personalization | User-tag matching | Doesn't understand why a story matters to a user |
These are exactly the places where an LLM with routing and state pays off — if we can prove it.
Part 3 — The Dumb Baseline (v0)
Before any agent, we shipped this:

v0 metrics (10k DAU, 4 weeks):
Feed CTR: 14.2%
Duplicate rate (user-reported): 8.3%
Breaking-news alert latency (p50): 4m 20s
Morning briefing open rate: 31%
Cost per active user per day: $0.004
p95 feed latency: 380ms
This is our bar. Everything below must beat it.
Part 4 — The LangGraph Agent (v1)
Architecture

The key agentic move: the is_breaking router decides, per article, whether to take the fast path (one small LLM call) or the full pipeline. That's the only real "reasoning" — and it's the only place where a static pipeline genuinely fails.
The code
from typing import TypedDict, Literal, Annotated
from langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
import operator
# ---------- State ----------
class ArticleState(TypedDict):
article: dict
is_breaking: bool
category: str
cluster_id: str | None
user_relevance: dict # per-user score
summary: str
path_taken: Annotated[list, operator.add]
error: str | None
# ---------- Nodes ----------
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def is_breaking_node(state: ArticleState) -> dict:
"""Cheap classifier: is this a breaking-news event?"""
resp = llm.invoke(
f"Is this article reporting a breaking event in the last 30 min? "
f"Answer YES or NO only.\n\nTitle: {state['article']['title']}\n"
f"Lead: {state['article'].get('lead','')[:300]}"
)
return {"is_breaking": "YES" in resp.content.upper(),
"path_taken": ["is_breaking"]}
def fast_path(state: ArticleState) -> dict:
"""Minimal summary for alerts — keep latency under 30s."""
resp = llm.invoke(
f"One-sentence breaking-news alert, neutral tone:\n{state['article']['title']}"
)
return {"summary": resp.content, "path_taken": ["fast_path"]}
def classify_dedupe(state: ArticleState) -> dict:
"""Semantic category + cluster against recent articles."""
# In prod: vector lookup against last 24h embeddings
resp = llm.invoke(
f"Category (one of: politics, tech, finance, sports, world, other) "
f"and a 6-word cluster label for dedup.\n\n{state['article']['title']}"
)
category, cluster = resp.content.split("\n")[0], resp.content.split("\n")[1]
return {"category": category, "cluster_id": cluster,
"path_taken": ["classify_dedupe"]}
def personalize(state: ArticleState) -> dict:
"""Per-user relevance — cached by (user_id, cluster_id)."""
user = state["article"]["_user"]
# Cache lookup omitted for brevity
resp = llm.invoke(
f"User cares about: {user['interests']}. "
f"Rate relevance 0-10 and give one reason why.\n"
f"Article: {state['article']['title']}"
)
return {"user_relevance": {"score": int(resp.content[:2]),
"reason": resp.content[3:]},
"path_taken": ["personalize"]}
def summarize(state: ArticleState) -> dict:
resp = llm.invoke(
f"3-bullet summary, {state['article'].get('_voice','neutral')} voice:\n"
f"{state['article']['content'][:2000]}"
)
return {"summary": resp.content, "path_taken": ["summarize"]}
# ---------- Router ----------
def route_breaking(state: ArticleState) -> Literal["fast_path", "classify_dedupe"]:
return "fast_path" if state["is_breaking"] else "classify_dedupe"
# ---------- Graph ----------
g = StateGraph(ArticleState)
g.add_node("is_breaking", is_breaking_node)
g.add_node("fast_path", fast_path)
g.add_node("classify_dedupe", classify_dedupe)
g.add_node("personalize", personalize)
g.add_node("summarize", summarize)
g.add_edge(START, "is_breaking")
g.add_conditional_edges("is_breaking", route_breaking)
g.add_edge("fast_path", END)
g.add_edge("classify_dedupe", "personalize")
g.add_edge("personalize", "summarize")
g.add_edge("summarize", END)
graph = g.compile()

Join the conversation! Your thoughts help the community grow.