Packaging & Deployment
Turn the Module 3 agent into something other people can use: an installable command (ask-agent "question") and a FastAPI service with /ask and /health. Settings from environment variables, clear errors, a timeout, a limit on parallel runs - and the bug we found only because we tested the packaged version.
What you will be able to do
- Split an agent into a reusable core, a command-line entry point and an HTTP entry point
- Read settings (model, URL, limits) from environment variables
- Make a command with pyproject.toml, and use exit codes and stderr correctly
- Serve the agent with FastAPI: build once at startup, validate input, health check
- Protect the service: a timeout, a limit on parallel runs, clear error codes
- Test the packaged version - not only the notebook version
The idea, in plain English
So far every agent in this course ran inside one script that only you could start. To be useful, other people - and other programs - must be able to use it. That means packaging (making it installable, with a clear entry point) and deployment (running it where others can reach it).
We take Lesson 3.4’s agent (llama3 with a weather tool and a calculator) and give it two "doors": a command-line tool, and a small web service with FastAPI (see the FastAPI course for the basics). The agent itself is written once and shared by both.
Everything below was run: LangGraph 1.2.14, langchain-ollama 1.1.0, FastAPI 0.143.0, uvicorn 0.54.0, llama3 on Ollama.
Worked example: The weather-and-calculator agent from Lesson 3.4, packaged as helpdesk-agent: installed with pip, run from the terminal, and served over HTTP - with three questions sent at the same time.
1 - The command-line door
pip install makes a real command, ask-agent. The answer goes to stdout, the info line to stderr, and the exit code tells scripts what happened.
The same graph, used from a terminal and over HTTP. Real results from our runs.
Words you will see in this lesson
A few words about making software usable by others.
PackagingMaking your code installable with pip, with a name, a version and its dependencies.DeploymentRunning your code where other people or programs can reach it.Entry pointWhere a program starts: a command, or an HTTP endpoint.pyproject.tomlThe file that describes a Python package.Environment variableA setting given to a program from outside: OLLAMA_URL=http://... Exit codeA number a command returns when it ends. 0 means OK; other numbers mean a problem.stdout / stderrTwo output streams: results, and messages for people.Health checkAn endpoint that says "I am working" - used by servers and monitors.An everyday example: a home cook opens a restaurant
- A home cook makes great food - but only family can eat it, and only when the cook is home. (A script only you can run.)
- Opening a restaurant means a fixed menu, a front door, and opening hours. (A package with clear entry points.)
- The same kitchen serves the dining room and a take-away window. (One agent, a command and an HTTP service.)
- A restaurant also needs rules: how many orders at once, what to say when the oven breaks, how long a customer waits. (Limits, error codes, timeouts.)
Step 1 - one core, two doors
First, separate the agent from the ways people use it:
- agent.py: the graph and the tools. No input(), no print(), no web code. It has a function build_graph() that returns the compiled graph.
- cli.py: reads the question from the command line, runs the graph, prints the answer.
- server.py: reads the question from HTTP, runs the graph, returns JSON.
- Settings come from environment variables with safe defaults: OLLAMA_URL, AGENT_MODEL, AGENT_MAX_STEPS. The same code then runs on your laptop and on a server - only the variables change.
helpdesk-agent/
pyproject.toml
helpdesk_agent/
__init__.py
agent.py # the graph - written once
cli.py # door 1: the terminal
server.py # door 2: HTTP"""The agent: Lesson 3.4's ReAct loop, with settings read from the environment."""
import ast
import operator
import os
from typing import Literal, TypedDict
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, Field
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") # where the model runs
MODEL = os.environ.get("AGENT_MODEL", "llama3")
MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "6"))
WEATHER = {"mumbai": "Rainy, 27°C", "delhi": "Sunny, 34°C", "hyderabad": "Cloudy, 29°C"}
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
def get_weather(city: str) -> str:
return WEATHER.get(city.strip().lower(), "No weather data for that city.")
def calculate(expression: str) -> str:
def ev(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](ev(node.left), ev(node.right))
raise ValueError("only + - * / and numbers are allowed")
try:
return str(ev(ast.parse(expression, mode="eval").body))
except Exception as error:
return f"Error: {error}"
TOOLS = {"get_weather": get_weather, "calculate": calculate}
class Step(BaseModel):
action: Literal["calculate", "get_weather", "final_answer"]
action_input: str = Field(description="The expression, the city name, or the final answer text")
SYSTEM = (
"You answer questions using two tools: get_weather(city) and calculate(expression). "
"Choose ONE next action. Use a tool for each piece of information you do not have yet. "
"Never repeat an action that already has an Observation. "
"When the Observations contain everything needed, reply with final_answer."
)
class AgentState(TypedDict):
question: str
history: list
step: int
action: str
action_input: str
answer: str
def build_graph():
decide = ChatOllama(model=MODEL, base_url=OLLAMA_URL, temperature=0).with_structured_output(Step)
def agent(state: AgentState):
transcript = "\n".join([f"Question: {state['question']}"] + state["history"])
step = decide.invoke([("system", SYSTEM), ("human", transcript)])
update = {"step": state["step"] + 1, "action": step.action, "action_input": step.action_input}
if step.action == "final_answer":
update["answer"] = step.action_input
return update
def tools(state: AgentState):
call = f"Action: {state['action']}({state['action_input']!r})"
if call in state["history"]: # the same call again (Lesson 3.4)
done = state["history"][state["history"].index(call) + 1]
note = f"Observation: you already have this result ({done[13:]}). Do not repeat it; answer now."
return {"history": state["history"] + [call, note]}
result = TOOLS[state["action"]](state["action_input"])
return {"history": state["history"] + [call, f"Observation: {result}"]}
def give_up(state: AgentState):
return {"answer": f"Stopped after {state['step']} steps without an answer."}
def should_continue(state: AgentState) -> Literal["tools", "give_up", "__end__"]:
if state["answer"]:
return END
if state["step"] >= MAX_STEPS:
return "give_up"
return "tools"
builder = StateGraph(AgentState)
builder.add_node("agent", agent)
builder.add_node("tools", tools)
builder.add_node("give_up", give_up)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, ["tools", "give_up", END])
builder.add_edge("tools", "agent")
builder.add_edge("give_up", END)
return builder.compile()
def start_state(question: str) -> dict:
return {"question": question, "history": [], "step": 0, "action": "", "action_input": "", "answer": ""}Step 2 - the command-line door
cli.py follows the rules every good command follows:
- argparse reads the question and an optional --json flag, and makes --help for free.
- It checks Ollama FIRST, with a 3-second timeout, and stops with a clear message if it is down. No long Python traceback for the user.
- The answer goes to stdout. The "(3 steps, 6.0 s, llama3)" line goes to stderr - so a script that saves the output gets only the answer.
- Exit codes: 0 = answered, 1 = the agent gave up, 2 = the model server is unreachable. Other scripts can check these.
"""Command-line entry point: ask-agent "question" """
import argparse
import json
import sys
import time
import httpx
from helpdesk_agent.agent import MODEL, OLLAMA_URL, build_graph, start_state
def main() -> int:
parser = argparse.ArgumentParser(prog="ask-agent", description="Ask the helpdesk agent a question.")
parser.add_argument("question", help="the question, in quotes")
parser.add_argument("--json", action="store_true", help="print the result as JSON")
args = parser.parse_args()
try: # fail fast and clearly if Ollama is down
httpx.get(f"{OLLAMA_URL}/api/tags", timeout=3).raise_for_status()
except httpx.HTTPError:
print(f"error: cannot reach Ollama at {OLLAMA_URL} - is it running?", file=sys.stderr)
return 2
start = time.perf_counter()
result = build_graph().invoke(start_state(args.question), {"recursion_limit": 20})
seconds = round(time.perf_counter() - start, 1)
gave_up = result["answer"].startswith("Stopped after")
if args.json:
print(json.dumps({"answer": result["answer"], "steps": result["step"], "seconds": seconds, "model": MODEL}))
else:
print(result["answer"])
print(f"({result['step']} steps, {seconds} s, {MODEL})", file=sys.stderr)
return 1 if gave_up else 0
if __name__ == "__main__":
sys.exit(main())Step 3 - make it a real command
pyproject.toml describes the package. The important lines:
- dependencies: what pip must install, with version ranges (Lesson 4.3 explained why: a future major version must not break you).
- [project.scripts] ask-agent = "helpdesk_agent.cli:main" - pip creates a command called ask-agent that calls main().
- pip install -e . installs it in "editable" mode: you can keep changing the code without installing again.
[project]
name = "helpdesk-agent"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"langgraph>=1.2,<2",
"langchain-ollama>=1.1,<2",
"fastapi[standard]>=0.143",
"httpx",
]
[project.scripts]
ask-agent = "helpdesk_agent.cli:main" # the command name = module:function
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"$ pip install -e .
$ ask-agent --help
usage: ask-agent [-h] [--json] question
Ask the helpdesk agent a question.
positional arguments:
question the question, in quotes
options:
-h, --help show this help message and exit
--json print the result as JSON
$ ask-agent "Should I bring an umbrella in Mumbai, and what is 12 * 8?"
(3 steps, 6.0 s, llama3)
Based on the weather in Mumbai, it is likely to rain, so you should bring an umbrella. Additionally, the calculation 12 * 8 equals 96. Therefore, the final answer is: Bring an umbrella and the result of the calculation is 96.
$ ask-agent --json "What is 125 * 48?"
{"answer": "6000", "steps": 3, "seconds": 3.8, "model": "llama3"}
$ ask-agent "What is 250 / 4?" > answer.txt; echo "exit=$?"
(4 steps, 5.3 s, llama3)
exit=0
$ cat answer.txt
62.5
$ OLLAMA_URL=http://localhost:11999 ask-agent "hi"; echo "exit=$?"
error: cannot reach Ollama at http://localhost:11999 - is it running?
exit=2Reading the command-line output
- The info line "(3 steps, 6.0 s, llama3)" printed BEFORE the answer here. stderr is shown at once; stdout waits a moment when it is not a terminal. That is normal.
- > answer.txt saved only "62.5" - the info line went to stderr and stayed on the screen.
- With Ollama down, the user got one clear line and exit code 2 - not a 40-line traceback.
- --json is for other programs: one line, easy to parse.
Step 4 - the HTTP door
server.py turns the agent into a web service. Read it in five parts:
- lifespan: the graph is built ONCE when the server starts, not on every request.
- Question: a Pydantic model - the question must be 3 to 500 characters. Anything else gets 422 automatically (FastAPI Module 2).
- GET /health: checks that Ollama answers. Load balancers and monitors call this.
- busy = asyncio.Semaphore(2): at most 2 questions run at the same time; others wait their turn. A local model can only do so much at once.
- asyncio.wait_for(..., timeout): a run that takes too long gets 504, and Ollama being down gets 503 - not a crash.
"""HTTP entry point: fastapi run helpdesk_agent/server.py"""
import asyncio
import os
import time
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from helpdesk_agent.agent import MODEL, OLLAMA_URL, build_graph, start_state
TIMEOUT_SECONDS = float(os.environ.get("AGENT_TIMEOUT", "60"))
busy = asyncio.Semaphore(2) # at most 2 questions run at the same time
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.graph = build_graph() # build once, at startup - not on every request
yield
app = FastAPI(title="Helpdesk agent", lifespan=lifespan)
class Question(BaseModel):
question: str = Field(min_length=3, max_length=500)
class Answer(BaseModel):
answer: str
steps: int
seconds: float
@app.get("/health")
async def health():
try:
async with httpx.AsyncClient(timeout=3) as client:
(await client.get(f"{OLLAMA_URL}/api/tags")).raise_for_status()
except httpx.HTTPError:
raise HTTPException(status_code=503, detail="model server unreachable")
return {"status": "ok", "model": MODEL}
@app.post("/ask")
async def ask(body: Question) -> Answer:
start = time.perf_counter()
async with busy: # wait here if 2 runs are already going
try:
result = await asyncio.wait_for(
app.state.graph.ainvoke(start_state(body.question), {"recursion_limit": 20}),
timeout=TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="the agent took too long")
except httpx.ConnectError:
raise HTTPException(status_code=503, detail="model server unreachable")
return Answer(answer=result["answer"], steps=result["step"], seconds=round(time.perf_counter() - start, 1))$ fastapi run helpdesk_agent/server.py --port 8601
$ curl -i localhost:8601/health
HTTP/1.1 200 OK
{"status":"ok","model":"llama3"}
$ curl -X POST localhost:8601/ask -H 'content-type: application/json' \
-d '{"question": "Should I bring an umbrella in Mumbai, and what is 12 * 8?"}'
{"answer":"Based on the weather in Mumbai, it is likely to rain, so you should bring an umbrella. Additionally, the calculation 12 * 8 equals 96. Therefore, the final answer is: Bring an umbrella and the result of the calculation is 96.","steps":3,"seconds":5.9}
$ curl -X POST localhost:8601/ask ... -d '{"question": "hi"}' -> 422
$ AGENT_TIMEOUT=2 fastapi run ... then the umbrella question -> 504 {"detail":"the agent took too long"}
$ OLLAMA_URL=http://localhost:11999 fastapi run ... then GET /health -> 503 {"detail":"model server unreachable"}
and POST /ask -> 503 {"detail":"model server unreachable"}Step 5 - three questions at the same time
We sent three questions at once. With Semaphore(2), two ran together and the third waited:
- 17 * 23 + 9 finished first, after 6.3 s.
- Delhi weather finished at 13.1 s.
- 250 / 4 waited for a free place and finished last, at 15.8 s.
- Alone, each takes about 4 to 6 seconds. Together they share one local model, so each one gets slower. Measure this before you promise users a response time (Lesson 5.6).
{"answer":"400","steps":3,"seconds":6.3} <- What is 17 * 23 + 9?, total 6.286767s
{"answer":"Sunny, 34°C","steps":5,"seconds":13.1} <- Weather in Delhi?, total 13.142510s
{"answer":"62.5","steps":4,"seconds":15.8} <- What is 250 / 4?, total 15.798997sThe bug we found by testing the package
Our FIRST packaged agent did not have Lesson 3.4’s repeat guard - we left it out to keep the code short. The test results:
- "Weather in Delhi?" and "What is 250 / 4?" both answered "Stopped after 6 steps without an answer." - alone AND in parallel.
- The trace showed why: llama3 called get_weather('Delhi') six times in a row. The prompt rule "Never repeat an action" was not enough.
- We put the repeat guard back. Then every question was answered (the outputs above).
- The lesson: ship the version you tested. A shortcut made while packaging changed the behaviour.
agent {'step': 1, 'action': 'get_weather', 'action_input': 'Delhi'}
tools ["Action: get_weather('Delhi')", 'Observation: Sunny, 34°C']
agent {'step': 2, 'action': 'get_weather', 'action_input': 'Delhi'}
tools ["Action: get_weather('Delhi')", 'Observation: Sunny, 34°C']
agent {'step': 3, 'action': 'get_weather', 'action_input': 'Delhi'}
tools ["Action: get_weather('Delhi')", 'Observation: Sunny, 34°C']
agent {'step': 4, 'action': 'get_weather', 'action_input': 'Delhi'}
tools ["Action: get_weather('Delhi')", 'Observation: Sunny, 34°C']
agent {'step': 5, 'action': 'get_weather', 'action_input': 'Delhi'}
tools ["Action: get_weather('Delhi')", 'Observation: Sunny, 34°C']
agent {'step': 6, 'action': 'get_weather', 'action_input': 'Delhi'}
give_up {'answer': 'Stopped after 6 steps without an answer.'}Watch out: Keep a small set of test questions with known answers (Lesson 5.4) and run them against the packaged command or the running service - not only against your notebook.
Before real users
This service is a starting point. Before real users, add:
- Authentication: our /ask is open to anyone who can reach it (FastAPI course, security module).
- Streaming: for answers that take seconds, stream progress as in Lesson 3.9.
- Tracing and logs: record every run (Lesson 5.1) so you can see what went wrong.
- A container: package the service and its dependencies with Docker (see the Docker course) so it runs the same everywhere.
- A real model server: Ollama on a laptop is fine to learn; production needs a machine sized for the traffic you measured.
The recipe
- 1. Put the agent in one module with a build function. No input(), no print().
- 2. Read settings from environment variables, with safe defaults.
- 3. Add a command: argparse, stdout for results, stderr for messages, meaningful exit codes, [project.scripts].
- 4. Add a service: build once at startup, validate input, /health, a timeout, a limit on parallel runs, clear error codes.
- 5. Test the packaged version with known questions - alone and in parallel.
Packaging & deployment at a glance
Setting from outsideWith a default.
os.environ.get("OLLAMA_URL", "http://localhost:11434")A commandIn pyproject.toml.
[project.scripts] ask-agent = "helpdesk_agent.cli:main"
Install for developmentEditable.
pip install -e .
Messages for peopleNot mixed with results.
print(..., file=sys.stderr)
Exit codeFrom main().
sys.exit(main()) # return 0, 1 or 2
Build onceAt server start.
@asynccontextmanager async def lifespan(app): app.state.graph = build_graph(); yield
Limit parallel runsOthers wait.
busy = asyncio.Semaphore(2) async with busy: ...
Timeout504 when too slow.
await asyncio.wait_for(graph.ainvoke(...), timeout=60)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Run AGENT_MODEL=gemma3 ask-agent "..." - does structured output still work?”
“Set AGENT_MAX_STEPS=1 and ask the umbrella question. Which exit code do you get?”
“Change the Semaphore to 1 and send three questions together. How do the total times change?”
“Open localhost:8601/docs and call /ask from the browser.”
What usually goes wrong
Build it once at startup with lifespan and reuse it.
✗ @app.post("/ask")
async def ask(body):
graph = build_graph()✓ app.state.graph = build_graph() # in lifespanA hard-coded localhost URL breaks on the server. Read it from an environment variable.
A script that saves the output then gets your info lines too. Messages go to stderr.
One slow question - or many at once - can block the whole service. Add a timeout and a Semaphore.
Our packaged agent lost the repeat guard and gave up on easy questions. Test the thing you ship.
Key points
- Keep the agent in one module with a build function; add a command and a service around it.
- Read settings from environment variables, with safe defaults.
- [project.scripts] in pyproject.toml turns a function into a command.
- Commands: results to stdout, messages to stderr, meaningful exit codes.
- Services: build once at startup, validate input, /health, timeout (504), model down (503), a limit on parallel runs.
- Test the packaged version - ours lost the repeat guard and gave up until we did.
Quick check before you move on
Quiz
- 1.
A script runs ask-agent "..." > answer.txt. Why does the file contain only the answer?
- 2.
Which exit code did ask-agent return when Ollama was down, and why is that useful?
- 3.
Why build the graph in lifespan?
- 4.
What bug did testing the packaged agent find?
Interview questions
How would you expose an LLM agent as an API?
Keep the agent in a module with a factory, build it once at startup, validate requests, add a health check, enforce timeouts and concurrency limits, map failures to clear status codes, stream long answers, and add auth, tracing and evaluation before production.
Why limit concurrency for an agent service?
Each run holds model capacity for seconds; unlimited parallel runs overload a shared model server and make every request slower or fail. A semaphore or queue keeps latency predictable.
What belongs in configuration rather than code?
Model names, endpoints, limits, timeouts and secrets - read from environment variables or a secrets manager - so the same build runs in every environment.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...