From chatbot to acting agent — before MCP, before LangGraph
User → LLM → text reply. No side effects. May hallucinate facts not in weights.
User → plan → tool/RAG → observe → plan → … → answer. Grounded + actionable.
Reason + Act — paper: Yao et al., 2022
Loop until the model emits a final answer (no more tool_calls). Typical max iterations: 5–15.
| Chat | ReAct Agent |
|---|---|
| 1 LLM call | 1–N LLM calls |
| Static context | Context grows with observations |
| Cannot verify | Can look up, compute, call APIs |
MAX_STEPS = 10
messages = [{"role": "user", "content": user_query}]
for step in range(MAX_STEPS):
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages, tools=TOOLS)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content # final answer
for call in msg.tool_calls:
result = run_tool(call.function.name, json.loads(call.function.arguments))
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
tools = [{
"type": "function",
"function": {
"name": "search_docs",
"description": "Search internal knowledge base",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"top_k": {"type": "integer", "default": 5}
},
"required": ["query"]
}
}
}]
| Rule | Why |
|---|---|
| One job per tool | Model picks correctly |
| Clear description | When to use vs other tools |
| Return structured JSON | Easy for model to parse |
| Handle errors gracefully | Return error string as Observation, don't crash |
| Idempotent when possible | Safe if model retries |
def search_docs(query: str, top_k: int = 5) -> str:
chunks = retriever.invoke(query)[:top_k]
return json.dumps([{"id": c.metadata["id"], "text": c.page_content} for c in chunks])
Agent decides when to retrieve — useful for multi-step tasks ("compare refund vs exchange policy" → two searches).
First gate: send query to the right subsystem
| Intent | Signals | Route |
|---|---|---|
| Knowledge QA | "what is", "explain", policy questions | RAG |
| Transaction | "book", "cancel", "update order" | Tool agent |
| Chitchat | greetings, off-topic | Direct LLM (no tools) |
| Escalation | angry, "speak to human" | Handoff tool |
ROUTER_PROMPT = '''Classify intent. Reply JSON only: {"intent": "...", "confidence": 0.0-1.0}
Intents: knowledge_qa | transaction | chitchat | escalation'''
def route(user_msg: str) -> str:
r = llm.invoke(ROUTER_PROMPT + "\nUser: " + user_msg)
intent = json.loads(r.content)["intent"]
if intent == "knowledge_qa":
return rag_chain.invoke(user_msg)
if intent == "transaction":
return agent_loop.invoke(user_msg)
return llm.invoke(user_msg) # chitchat
TOKEN_BUDGET = 12000
def build_context(history, retrieved, system):
msgs = [system]
msgs += retrieved # RAG chunks as system or tool messages
recent, old = split_at_token_limit(history, TOKEN_BUDGET * 0.6)
if old:
summary = llm.invoke(f"Summarize this conversation:\n{old}")
msgs.append({"role": "system", "content": f"Earlier summary: {summary}"})
msgs += recent
return trim_to_budget(msgs, TOKEN_BUDGET)
| Strategy | Best for | Tradeoff |
|---|---|---|
| Sliding window | Short support chats | Forgets old facts |
| Summarize | Long sessions | Summary loss |
| Retrieve history | "What did I say Tuesday?" | Needs vector index of turns |
| Structured state | Forms, booking flows | Requires schema design |
~60 min — no LangGraph required
pip install openai python-dotenv
| Metric | Target |
|---|---|
| Tool selection accuracy | ≥ 90% on 10 labeled queries |
| Grounded answers | FAQ answers cite retrieved chunk |
| Loop termination | No infinite tool loops |
Next: tune retrieval in Retrieval Engineering; frameworks in LangChain & LangGraph.
| Metric | How to measure | Target |
|---|---|---|
| Tool selection accuracy | Label 20 queries; check correct tool chosen | ≥ 90% |
| Argument validity | JSON schema parse + business rules | 100% parseable |
| Task success rate | Human or LLM judge: goal met? | Domain-specific |
| Steps to completion | Avg ReAct iterations | Lower = cheaper |
# Simple tool accuracy eval
def eval_tool_choice(test_cases, agent_fn):
correct = 0
for case in test_cases:
trace = agent_fn(case["query"], return_trace=True)
if trace["tools_called"] == case["expected_tools"]:
correct += 1
return correct / len(test_cases)
Benchmarks: SWE-bench (code), WebArena (web), AgentBench. Cross-course matrix: Evaluation Guide.