Agent System Design¶
Agent system design questions test whether you can reason about reliability and observability in non-deterministic systems — the hardest part of production agent engineering.
Q1: Design a LangGraph research agent for a news summarization product.¶
Show answer
Clarifying questions: - Real-time or batch? (determines latency vs throughput tradeoff) - What sources? (web search, internal RSS feeds, specific APIs) - What's the output format? (short bullets, full article, structured JSON) - How do you handle breaking news where sources conflict?
Graph design:
from langgraph.graph import StateGraph, START, END
from typing import Annotated, TypedDict
import operator
class NewsState(TypedDict):
topic: str
queries: list[str]
articles: Annotated[list[str], operator.add] # accumulates across researcher nodes
summary: str
quality_score: float
attempts: int
# Nodes:
# planner: decomposes topic into 3-5 search queries
# researcher: searches one query, extracts relevant snippets (run in parallel)
# writer: synthesizes articles into a structured summary
# editor: scores quality (0-1), returns to writer if < 0.80
def route_after_edit(state: NewsState) -> str:
if state["quality_score"] >= 0.80 or state["attempts"] >= 3:
return END
return "writer"
graph = StateGraph(NewsState)
graph.add_node("planner", plan_queries)
graph.add_node("researcher", research_parallel)
graph.add_node("writer", write_summary)
graph.add_node("editor", edit_and_score)
graph.add_edge(START, "planner")
graph.add_edge("planner", "researcher")
graph.add_edge("researcher", "writer")
graph.add_edge("writer", "editor")
graph.add_conditional_edges("editor", route_after_edit, {END: END, "writer": "writer"})
Parallel researcher nodes using asyncio.gather inside a single node:
import asyncio, os
from openai import AsyncOpenAI
aclient = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
async def research_parallel(state: NewsState) -> dict:
results = await asyncio.gather(*[search_and_extract(q) for q in state["queries"]])
return {"articles": list(results)} # operator.add appends to existing
Q2: How do you make agents reliable enough for production?¶
Show answer
Agents are non-deterministic — the same input can produce different paths. Reliability requires:
1. Narrow the tool set — each additional tool is another surface for the model to misuse. Remove tools that overlap in purpose or are rarely called.
2. Validate tool calls before executing — check that arguments are valid before running any side effects.
def safe_execute(tool_name: str, args: dict) -> str:
if tool_name not in REGISTERED_TOOLS:
return f"ERROR: Unknown tool '{tool_name}'"
tool = REGISTERED_TOOLS[tool_name]
try:
validated_args = tool.input_schema(**args) # Pydantic validation
return tool.execute(**validated_args.dict())
except Exception as e:
return f"ERROR: {e}"
3. Step limits and timeouts — hard caps prevent infinite loops.
4. Structured output for key decisions — force routing nodes to output a typed decision (not free text).
5. Regression tests with fixed seeds — run your agent on 50 representative inputs before every deployment, compare outputs to expected outcomes.
6. Human-in-the-loop for irreversible actions — use interrupt_before for any node that sends emails, modifies databases, or calls external services.
Q3: How would you implement human-in-the-loop for a high-stakes agent?¶
Show answer
LangGraph's interrupt_before pauses execution before a specified node. A human reviews the state, optionally modifies it, and resumes.
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph
memory = MemorySaver()
# Pause before any node that takes irreversible action
app = graph.compile(
checkpointer=memory,
interrupt_before=["send_email", "create_ticket", "update_database"],
)
config = {"configurable": {"thread_id": "approval-123"}}
# Agent runs until interrupt
result = app.invoke({"task": "Send onboarding email to 500 new users"}, config=config)
pending_state = result # human reviews this
# API endpoint for human approval
@app.post("/approve/{thread_id}")
async def approve(thread_id: str, modifications: dict = None):
config = {"configurable": {"thread_id": thread_id}}
if modifications:
# Optionally update state before resuming
app.update_state(config, modifications)
# Resume from interrupt
return app.invoke(None, config=config)
@app.post("/reject/{thread_id}")
async def reject(thread_id: str, reason: str):
# Log and discard — don't resume
log_rejection(thread_id, reason)
return {"status": "rejected"}
For asynchronous approval workflows (reviewer not online), store the thread_id in a database and notify via email/Slack. The agent state persists in the checkpointer until resumed.
Q4: How do you scale an agent that runs many tool calls per request?¶
Show answer
Each tool call adds latency. With 5 tool calls per request at 500ms each = 2.5s of tool latency alone. Strategies:
Parallel tool execution — if tool calls are independent, run them concurrently:
import asyncio, json, os
from openai import AsyncOpenAI
aclient = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
async def execute_parallel_tools(tool_calls: list) -> list[str]:
return await asyncio.gather(*[
execute_tool(tc.function.name, json.loads(tc.function.arguments))
for tc in tool_calls
])
Async task queue for long-running agents — if an agent run takes > 30 seconds, don't block the HTTP request. Use a job queue:
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
jobs: dict[str, dict] = {} # in-memory; use Redis in production
@app.post("/agent/start")
async def start_agent(query: str, background_tasks: BackgroundTasks):
job_id = str(uuid.uuid4())
jobs[job_id] = {"status": "running", "result": None}
background_tasks.add_task(run_agent, job_id, query)
return {"job_id": job_id}
@app.get("/agent/status/{job_id}")
async def get_status(job_id: str):
return jobs.get(job_id, {"status": "not_found"})
Tool result caching — cache deterministic tool calls (web search for the same query, database lookups by ID) with a short TTL.
Q5: How do you monitor and debug agents in production?¶
Show answer
Agents are harder to debug than chains because the execution path is non-deterministic. Structured observability is essential.
What to capture per agent run:
from langsmith import traceable
@traceable(name="agent_run", tags=["production"])
async def run_agent(query: str, user_id: str) -> str:
# LangSmith automatically captures:
# - All nested LLM calls with inputs/outputs
# - Tool calls and their results
# - Total tokens and cost
# - Latency per step
result = await agent_graph.ainvoke({"query": query})
return result["answer"]
Metrics to alert on: - Average steps per run (spike = agent looping or task complexity increase) - Tool call error rate (spike = external service degradation) - Runs that hit step limit (increase = agent failing to complete tasks) - Cost per run (spike = prompt injection or runaway loops)
Debugging a specific failure: 1. Retrieve the LangSmith trace for the run ID 2. Inspect the state at each step — what did the model see, what did it decide? 3. Look for: hallucinated tool calls, correct tool call with wrong arguments, loop patterns, premature final answers
LangSmith's trace viewer makes this dramatically faster than reading raw logs — every node's input/output is visible with latency and token counts at each step.
Previous: RAG Architecture | Next: Mock Interview Simulator