Why LangGraph Multi-Agents Blow Up in Production and How to Fix Them at the Code Level
26 de julio de 2026
0
Computing/SoftwareComments (0)
Log in to leave a comment
No posts yet
Log in to leave a comment
No posts yet
There is a common misconception among backend developers transitioning from single prompt chains to multi-agent systems: the idea that writing better prompts will make the system stable. Most problems that occur in production have nothing to do with prompts. The real culprits are system architectural issues such as state corruption, infinite loops, API rate limits, and undebuggable asynchronous traces.
To deploy a LangGraph-based multi-agent system to a production environment, you must handle state isolation, concurrency control, and tracing at the code level like building a backend system, rather than treating it as a collection of prompt resources.
In a state-based graph, race conditions occur when multiple nodes directly modify a single shared object. In a concurrent fan-out pattern, if you overwrite regular fields without an explicit reducer, only the result of the slowest-finishing node remains, and the rest are lost.
To prevent this, you must attach explicit reducers to the parent graph state and completely encapsulate child agents into subgraphs with independent schemas.
`python
import operator
from typing import Annotated, List, TypedDict
from langgraph.graph import END, START, StateGraph
class ParentState(TypedDict):
task_id: str
input_query: str
audit_logs: Annotated[List[str], operator.add]
final_response: str
class InternalAgentState(TypedDict):
sub_task: str
scratchpad_messages: List[str]
sub_result: str
def internal_processing_node(state: InternalAgentState) -> dict:
updated_messages = state["scratchpad_messages"] + ["내부 격리 추론 진행 중"]
return {
"scratchpad_messages": updated_messages,
"sub_result": f"하위 작업 완료: {state['sub_task']}"
}
subgraph_builder = StateGraph(InternalAgentState)
subgraph_builder.add_node("internal_processing", internal_processing_node)
subgraph_builder.add_edge(START, "internal_processing")
subgraph_builder.add_edge("internal_processing", END)
compiled_subgraph = subgraph_builder.compile()
def call_isolated_subgraph_wrapper(state: ParentState) -> dict:
subgraph_input: InternalAgentState = {
"sub_task": state["input_query"],
"scratchpad_messages": []
}
subgraph_output = compiled_subgraph.invoke(subgraph_input)
return {
"audit_logs": [f"[서브그래프 결과]: {subgraph_output['sub_result']}"]
}
parent_builder = StateGraph(ParentState)
parent_builder.add_node("isolated_agent", call_isolated_subgraph_wrapper)
parent_builder.add_edge(START, "isolated_agent")
parent_builder.add_edge("isolated_agent", END)
main_graph = parent_builder.compile()
`
In the parent ParentState, we specified the operator.add reducer for the audit_logs field where parallel writes occur. Child tasks are isolated into subgraphs using their own state, InternalAgentState, and pass results back and forth strictly through wrapper functions. By blocking data corruption, you can cut infinite-loop debugging time by more than 5 hours per week.
It is also common for ReAct feedback loops to get stuck in circles because they fail to meet termination conditions. You need a guardrail router that tracks a counter in the state schema and filters it at conditional edges.
`python
from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
class GuardedState(TypedDict):
query: str
draft: str
feedback: str
is_approved: bool
iterations: int
max_iterations: int
def drafting_node(state: GuardedState) -> dict:
return {
"draft": f"작성된 초안 (반복 회차: {state['iterations'] + 1})",
"iterations": state["iterations"] + 1
}
def review_node(state: GuardedState) -> dict:
approved = state["iterations"] >= 3
return {
"is_approved": approved,
"feedback": "승인 완료" if approved else "반려: 내용 수정 필요"
}
def loop_guardrail_router(state: GuardedState) -> Literal["drafting", "fallback_escalation", "end"]:
if state["is_approved"]:
return END
if state["iterations"] >= state["max_iterations"]:
return "fallback_escalation"
return "drafting"
def fallback_escalation_node(state: GuardedState) -> dict:
return {
"draft": "에이전트 검수 피드백 루프 최대 횟수 초과. 담당자 수동 검토 건으로 이관 처리되었습니다."
}
builder = StateGraph(GuardedState)
builder.add_node("drafting", drafting_node)
builder.add_node("review", review_node)
builder.add_node("fallback_escalation", fallback_escalation_node)
builder.add_edge(START, "drafting")
builder.add_edge("drafting", "review")
builder.add_conditional_edges(
"review",
loop_guardrail_router,
{
"drafting": "drafting",
"fallback_escalation": "fallback_escalation",
END: END
}
)
builder.add_edge("fallback_escalation", END)
guarded_graph = builder.compile()
`
When the counter (iterations) reaches the threshold (max_iterations), it is configured to branch immediately to a manual escalation node (fallback_escalation). This definitively cuts off token waste caused by infinite cycling.
Firing off child nodes in parallel all at once hits OpenAI or Anthropic API Tokens Per Minute (TPM) limits, triggering HTTP 429 errors. The moment the backend stumbles, the entire transaction becomes paralyzed.
You must throttle concurrent requests with asyncio.Semaphore and apply exponential backoff using tenacity so that the API doesn't crash.
`python
import asyncio
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
API_SEMAPHORE = asyncio.Semaphore(5)
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(Exception),
reraise=True
)
async def safe_llm_call_with_backoff(llm: ChatOpenAI, prompt: str) -> str:
async with API_SEMAPHORE:
response = await llm.ainvoke([HumanMessage(content=prompt)])
return response.content
async def parallel_worker_node(state: dict) -> dict:
llm = ChatOpenAI(model="gpt-4o", temperature=0)
task_input = state["task_data"]
result_text = await safe_llm_call_with_backoff(llm, f"하위 작업 처리: {task_input}")
return {"results": [result_text]}
`
We limit concurrent calls to a maximum of 5, and upon failure, double the wait time with each retry from 2 seconds up to 10 seconds. This completely prevents system outages caused by external API call failures.
While a single-loop pattern forces you to spend over 14 seconds re-evaluating everything when just one node fails, isolating nodes and setting up backoffs like this drops failure recovery time down to the 10ms level. Since successful node results are preserved, there is no unnecessary token re-consumption.
Asynchronously coupled agents cannot have their execution flow traced through simple console outputs alone. You need to attach an OpenTelemetry-based observability platform like Langfuse and include non-LLM logic—such as DB operations or backend processing—into tracing spans to actually spot bottlenecks.
`python
import os
from langfuse.decorators import observe, langfuse_context
from langgraph.graph import StateGraph, START, END
@observe(name="vector_store_retrieval")
def query_vector_store(query: str) -> list:
langfuse_context.update_current_observation(
input={"query": query},
metadata={"top_k": 3, "database": "pgvector"}
)
return ["문서 1: 보안 규정 예시", "문서 2: 서비스 약관"]
def retrieval_node(state: dict) -> dict:
docs = query_vector_store(state["query"])
return {"context": docs}
def execute_graph_with_tracing(app, user_query: str, session_id: str, user_id: str):
from langfuse.callback import CallbackHandler
langfuse_handler = CallbackHandler()
config = {
"configurable": {"thread_id": session_id},
"callbacks": [langfuse_handler]
}
return app.invoke({"query": user_query}, config=config)
`
With the @observe decorator, even standard functions like vector searches can be included in traces. Passing the CallbackHandler during graph invocation lets you monitor overall agent behavior and token consumption per session at a glance.
If an error occurs at the 10th node mid-execution, restarting from the beginning wastes both time and money. Using the PostgresSaver checkpointer saves node execution snapshots directly to the DB.
`python
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import StateGraph
DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/agent_checkpoints"
def build_app_graph():
builder = StateGraph(dict)
return builder
def resume_execution_from_failure(app, thread_id: str, fixed_payload: dict, last_valid_node: str):
config = {"configurable": {"thread_id": thread_id}}
app.update_state(
config,
values=fixed_payload,
as_node=last_valid_node
)
resumed_output = app.invoke(None, config)
return resumed_output
`
After inspecting the last healthy state using get_state_history, you can fix the problematic data with update_state and invoke app.invoke(None, config) to resume precisely from where it stopped.
Throwing GPT-4o at every agent node is a waste of budget. Tiering techniques—using high-performance models for main planning and attaching lightweight models like Claude 3.5 Haiku for simple classification or verification nodes—are essential.
On top of that, pairing this with a RedisVL-based semantic cache allows identical or similar verification requests to return in just 50ms without even calling an LLM.
`python
from redisvl.extensions.llmcache import SemanticCache
from langchain_community.chat_models import ChatAnthropic
audit_semantic_cache = SemanticCache(
name="audit_nodes_cache",
redis_url="redis://localhost:6379",
distance_threshold=0.1,
ttl=86400
)
def audit_verification_node(state: dict) -> dict:
prompt_query = f"다음 최종 결과물의 정책 준수 여부를 검수하세요: {state['final_response']}"
cached_response = audit_semantic_cache.check(prompt=prompt_query)
if cached_response:
return {
"audit_passed": cached_response[0]["response"] == "PASSED",
"audit_logs": ["[Audit Node]: 시맨틱 캐시 데이터 활용 (LLM 호출 스킵)"]
}
audit_llm = ChatAnthropic(model="claude-3-5-haiku-20241022", temperature=0)
eval_result = audit_llm.invoke(prompt_query).content
audit_semantic_cache.store(
prompt=prompt_query,
response=eval_result,
metadata={"node": "audit_verification"}
)
return {
"audit_passed": eval_result == "PASSED",
"audit_logs": [f"[Audit Node]: 신규 모델 검수 완료 ({eval_result})"]
}
`
Setting a strict distance_threshold of 0.1 prevents false positives, calling the lightweight Haiku model only when a cache miss occurs. Just establishing this setup can slash API token costs by up to 40% while preserving overall inspection quality.
Deploying an agent system to production doesn't require fancy prompt tricks. It requires solid backend foundation work—state isolation, concurrency control, checkpoint recovery, and model tiering—to keep the system from crashing.