Building AI Agents

Using AI SDKs

Using AI SDKs

There are two ways to talk to a model from Python: call the HTTP API yourself, or use a library. This page covers both, and when each is the right choice.

Everything here is Python. Frameworks get named so you recognise them later, but this tutorial does not teach them — that is what the classes and the missions are for.

The layers

LayerWhat it isReach for it when
Raw HTTPhttpx against the provider's endpointlearning, or a dependency-free script
Provider SDKthe provider's own libraryyou use one provider and want simple calls
Agent frameworkLangChain, LangGraph, CrewAI, ADKyou need tools, memory, branching, retries

Each layer sits on the one above. None is more correct; they trade control for convenience.

Raw HTTP

Worth doing once, because it shows there is no magic underneath.

import os
import httpx

response = httpx.post(
    "https://api.deepseek.com/chat/completions",
    headers={"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY')}"},
    json={
        "model": "deepseek-chat",
        "messages": [
            {"role": "system", "content": "You are a triage assistant."},
            {"role": "user", "content": "The database is unreachable."},
        ],
        "temperature": 0.2,
    },
    timeout=30,
)
response.raise_for_status()

data = response.json()
print(data["choices"][0]["message"]["content"])

That is a complete model call. A list of message dictionaries goes in; a nested dictionary comes back.

Nearly every provider follows this same shape, which is why one mental model covers most of them.

The message roles

RolePurpose
systemstanding instructions, sent every time
userwhat the person asked
assistantwhat the model previously replied
toolthe result of a tool call

The whole list is sent on every request. That list is the conversation memory — the model retains nothing between calls.

The settings that matter

  • temperature — randomness. Low, around 0 to 0.3, for classification and extraction. Higher for drafting prose. For agent work you usually want it low.
  • max_tokens — a cap on the reply length, and on what you pay for it.
  • model — which model. A cheap fast one is often enough for routing and classification.

A provider SDK

from openai import OpenAI

client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)

response = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "Hello"}],
)

print(response.choices[0].message.content)

The same request, with retries, timeouts and typed objects handled for you. Note the dot access — response.choices[0].message.content — rather than dictionary keys.

Many providers copy OpenAI's interface, so the same client often works against a different service by changing base_url. That is why you see the OpenAI library pointed at other providers.

Streaming

For anything a person is watching, stream it.

stream = client.chat.completions.create(
    model="deepseek-chat",
    messages=[{"role": "user", "content": "Explain retries"}],
    stream=True,
)

for chunk in stream:
    piece = chunk.choices[0].delta.content
    if piece:
        print(piece, end="", flush=True)

stream=True returns a generator instead of a finished reply. end="" stops a newline per chunk and flush=True shows it immediately.

Use streaming when a human is waiting. Use a normal call when the result feeds the next step of your program, because it is far easier to work with.

Agent frameworks

Once you want tools, memory, branching or retries, writing it yourself stops being worthwhile.

from langchain_core.tools import tool

@tool
def get_ticket(ticket_id: int) -> str:
    """Fetch an incident ticket by its id."""
    return db.fetch(ticket_id)

What a framework gives you:

  • schema generation from your type hints and docstrings
  • the agent loop, written and tested
  • one interface across providers
  • streaming, retries and tracing as options
  • branching, cycles and state, in the graph libraries

The names you will meet:

FrameworkShape
LangChaintools, models and chains behind one interface
LangGraphagents as a graph — nodes, edges, state, cycles
CrewAIseveral role-playing agents working together
Google ADKGoogle's agent toolkit

They differ in style more than in substance, and all of them rest on the Python in this tutorial. Decorators, Pydantic models, TypedDict, functions passed as values, generators for streaming — that is what their source is made of.

Structured output

The feature that most changes how you build. Instead of parsing text, you declare the shape you want.

from pydantic import BaseModel, Field

class Triage(BaseModel):
    severity: str = Field(description="One of P1, P2, P3")
    owner: str = Field(description="Team that should take this")
    summary: str

structured = llm.with_structured_output(Triage)
result = structured.invoke("Database is down, users cannot log in")

print(result.severity)

result is a Triage object. No regex, no json.loads, no handling the day it wraps the JSON in a code fence.

If you take one thing from this page, take this. Most flaky agent code is text parsing that should have been a Pydantic model.

Choosing a layer

  • Raw HTTP — learning, or a single call with no dependencies
  • Provider SDK — one provider, straightforward calls, streaming
  • Framework — tools, multi-step work, branching, or you want to switch providers later

Start at the lowest layer that does the job. A great deal of production work is one well-aimed model call, not an agent.

What to check before choosing a library

  • does it support async? Without it you cannot run tool calls concurrently
  • does it support streaming?
  • does it support tool calling in the shape the provider expects?
  • does it give structured output?
  • is it maintained? This ecosystem moves fast and abandoned wrappers break

Common mistakes

  • Reaching for a framework for a single model call.
  • Parsing text by hand where structured output would have guaranteed the shape.
  • No timeout on a model call.
  • A high temperature on a classification task, then wondering why answers vary.
  • Forgetting the whole message list is re-sent, so cost grows with conversation length.
  • Copying a tutorial's model name without checking it still exists.

Practise this

  • Make one raw HTTP call to a model with httpx and print the reply.
  • Make the same call again with temperature at 0 and at 1, twice each, and compare.
  • Stream a response and print it token by token.
  • Define a Pydantic model for something you would want extracted, and write the field descriptions as if the model will read them — because it will.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1# A real model call, with no framework.
2# Needs: pip install httpx python-dotenv
3# And a .env file containing: DEEPSEEK_API_KEY=sk-...
4
5import os
6import httpx
7from dotenv import load_dotenv
8
9load_dotenv()
10
11API_KEY = os.getenv("DEEPSEEK_API_KEY")
12BASE_URL = "https://api.deepseek.com/chat/completions"
13
14
15def require_key() -> str:
16 """Fail loudly, with a useful message, rather than sending an unauthenticated request."""
17 if not API_KEY:
18 raise RuntimeError("DEEPSEEK_API_KEY is not set. Add it to your .env file.")
19 return API_KEY
20
21
22def ask(question: str, temperature: float = 0.2) -> str:
23 """Send one question to the model and return its reply."""
24 response = httpx.post(
25 BASE_URL,
26 headers={"Authorization": f"Bearer {require_key()}"},
27 json={
28 "model": "deepseek-chat",
29 "messages": [
30 {"role": "system", "content": "You are an incident triage assistant."},
31 {"role": "user", "content": question},
32 ],
33 "temperature": temperature,
34 },
35 timeout=30,
36 )
37 response.raise_for_status()
38
39 data = response.json()
40 # The reply lives at: choices -> first item -> message -> content
41 return data["choices"][0]["message"]["content"]
42
43
44def ask_streaming(question: str) -> None:
45 """Same call, printed piece by piece as it arrives."""
46 with httpx.stream(
47 "POST",
48 BASE_URL,
49 headers={"Authorization": f"Bearer {require_key()}"},
50 json={
51 "model": "deepseek-chat",
52 "messages": [{"role": "user", "content": question}],
53 "stream": True,
54 },
55 timeout=None,
56 ) as response:
57 response.raise_for_status()
58 for line in response.iter_lines():
59 if line.startswith("data: ") and "[DONE]" not in line:
60 print(line[6:])
61
62
63if __name__ == "__main__":
64 if not API_KEY:
65 print("No DEEPSEEK_API_KEY found, so nothing was sent.")
66 print()
67 print("To run this for real:")
68 print(" 1. pip install httpx python-dotenv")
69 print(" 2. create a .env file containing DEEPSEEK_API_KEY=sk-...")
70 print(" 3. add .env to your .gitignore")
71 print(" 4. run this again")
72 print()
73 print("The request it would send:")
74 print(' POST https://api.deepseek.com/chat/completions')
75 print(' {"model": "deepseek-chat", "messages": [...], "temperature": 0.2}')
76 else:
77 print(ask("The database is unreachable. What should I check first?"))
78
79 # Then try the same question at temperature 0 and at 1, twice each,
80 # and see how much the answer varies.