Agentic AI is everywhere right now. Tutorials make it look deceptively simple: wrap an LLM with a few tools, add a ReAct loop, and watch it solve problems autonomously. What they don’t show you is what happens in month two of production.
This post is a collection of hard-won patterns from building agentic systems that handle real workloads — not demos. I’ll cover tool design, memory management, observability, and the failure modes that will eventually bite you if you ignore them.
1. The ReAct Loop Is Just the Starting Point
The Reason-Act (ReAct) pattern — where the LLM reasons about what to do, calls a tool, observes the result, and repeats — is a solid foundation. But it has two properties that break down in production:
- It’s synchronous by default. Every tool call blocks the loop. When tools have latency, this compounds badly.
- Context accumulates silently. Each observation goes into the context window. Long tasks exhaust the window without warning.
The fix for the first problem is structured parallelism — allowing the agent to dispatch independent tool calls concurrently. LangChain’s tool-calling agents support this, but you have to design your tools to be safe to call in parallel (stateless, idempotent where possible).
# Parallel tool dispatch with asyncio
import asyncio
from langchain.tools import BaseTool
async def dispatch_parallel(tools: list[BaseTool], inputs: list[dict]):
tasks = [tool.arun(**inp) for tool, inp in zip(tools, inputs)]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Handle exceptions per-tool rather than failing the whole batch
return [
{"result": r, "ok": True} if not isinstance(r, Exception)
else {"error": str(r), "ok": False}
for r in results
]
2. Tool Design Is Your Biggest Lever
The LLM is only as good as the tools you give it. I’ve seen agents fail not because the model was wrong, but because the tool interface was ambiguous or the output was too verbose to fit in context.
Three rules I follow for every tool:
- One responsibility. A tool that does too many things confuses the LLM about when to call it. Keep tools narrow and compose them at the agent level.
- Structured output. Return structured data (JSON/dict), not prose. The agent needs to parse the result — make that trivial.
- Explicit error semantics. Don’t raise exceptions. Return
{"ok": false, "error": "..."}. The agent should be able to reason about failures and retry or escalate.
Treat your tool schemas as a contract. The description you write in the schema docstring is literally the instruction the LLM receives about when and how to call the tool. Spend more time on the schema than on the implementation.
3. Memory: What to Store and What to Discard
Agents need memory at multiple time horizons. Conflating them is a common design mistake:
- Working memory — the current conversation / tool call history. Lives in the context window. Aggressively summarize or prune when it grows.
- Episodic memory — past interactions the user or agent has had. Store externally (e.g., a vector DB with metadata filters), retrieve on demand.
- Semantic memory — facts and knowledge the agent should always have access to. Bake into the system prompt or retrieve via RAG at session start.
from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI
# Auto-summarize when token count exceeds threshold
memory = ConversationSummaryBufferMemory(
llm=ChatOpenAI(model="gpt-4o-mini"), # cheap model for summarization
max_token_limit=2000,
return_messages=True,
)
# Inject episodic context from vector store
def build_system_context(user_id: str, query: str) -> str:
past_episodes = vector_store.similarity_search(
query, k=3,
filter={"user_id": user_id}
)
return "\n".join(ep.page_content for ep in past_episodes)
4. Observability Is Not Optional
An agent that fails silently is worse than no agent at all. In production, you need to answer: why did it take that action? and where did it go wrong?
At a minimum, trace every agent run with:
- Full input to each LLM call (messages, system prompt, tools available)
- Full output (reasoning, tool call decision)
- Tool call inputs and raw outputs
- Latency per step
- Token counts (input + output per call)
I use LangSmith for LangChain-based agents and a custom structured logging layer for others. The investment pays for itself the first time you need to debug a multi-step failure that reproduces only in production.
5. Failure Modes That Will Happen
These are the failures I’ve encountered in production that surprised me — even after reading the papers:
Tool call loops
The agent calls the same tool repeatedly with slightly different inputs, never converging. Solution: track tool call history in the agent executor and surface it in the system prompt. Add a max-iterations guard with a graceful fallback.
Context poisoning
A malformed tool response (a stack trace, HTML error page, or truncated JSON) confuses the agent into producing garbage on subsequent steps. Solution: always sanitize and validate tool outputs before injecting them into context. Return a structured error instead.
Prompt injection via tool results
If your tools fetch external content (web pages, documents, user input), that content can contain instructions that redirect the agent’s behavior. This is especially dangerous in autonomous agents. Sanitize external content and consider a separate “untrusted content” context window.
Before shipping an agentic system: set a maximum iteration limit, add structured logging, sanitize all tool outputs, test with adversarial inputs, and define a graceful degradation path for when the agent can’t complete the task.
Agentic AI is genuinely powerful — but it rewards careful engineering, not just clever prompting. The LLM is the reasoning engine; everything around it (tools, memory, observability, safety guards) is what makes it reliable.
If you’re building agentic systems and want to swap notes, reach out on LinkedIn or GitHub. Always happy to compare war stories.