Vol. 24 · Application Engineering
Mini-Repository: FastAPI + LangGraph + LangChain as a Service
This volume does not answer the question “how to write another agent”, but the question “how to make a live external application”. At the center is one solid Python mini-repo: ‘FastAPI’ as a public HTTP/SSE layer, ‘LangGraph’ as runtime, ‘LangChain’ as a layer of models, tools and middleware, external persistence for threads, scaling, auth, health/readiness and a separate place for ‘MCP’ as a tool/data plane.
The Big Picture of Service
01ArchitecturePublic application = ‘FastAPI’ outside, ‘LangGraph’ inside, ‘LangChain’ as a layer of components
If you remove the excess magic, the external LLM microservice looks like a normal backend. FastAPI accepts queries and gives answers/streams. LangGraph manages stateful execution: threads, routing, interrupts, checkpoints. LangChain provides models, messages, tools, middleware and structured output. Everything else is the infrastructure around this core.
textExternal layer and internal RUNTIME
browser / frontend / other service
↓
FastAPI
routes / auth / schemas / stream
↓
Agent Service
↓
LangGraph
state / checkpoints / interrupts
↓
LangChain
model / tools / middleware
↓
DB / vector DB / queue / object storage / MCP / external APIs02ArchitectureWhat makes a service a “microservice”
It's not code size or Docker. Microservice is made by an external contract, a separate deploy lifecycle, its own health/readiness rules, its own persistence logic and the ability to safely serve customers from the outside as an independent service.
minimum set
- stable API
- separate deploy
- state/storage
- auth / monitoring / retries
03ArchitectureThis service usually has three modes of interaction.
Almost all production scenarios fit into three forms: the usual synchronous request/response, streaming response, and long-lived flow with interrupt/resume or background processing. If you think through all three modes in advance, the architecture becomes more sustainable.
text1. sync:
I have been waiting for JSON to come back.
2. stream:
request → on the way to give tokens / updates
3. durable:
Request → saved state → pause / worker / resume later04ArchitectureOutside, it is better to expose business operations, not internal node-s of the count.
The client doesn’t usually need to know which “retrieve node”, “review node” or “tool router” you have. Externally, it is better to give APIs like "chat", "resume review", "search knowledge", "summarize document". The internal graph remains your implementation, not part of a public contract.
signal
- API = business activities
- API = internal node names
05ArchitectureThread id is quickly becoming part of an external API.
As soon as the service supports multi-turn, streaming history or ‘interrupt/resume’, the outside world starts to refer to specific threads. This means that ‘thread id’, ‘run id’ or their counterparts should be designed consciously, rather than generating randomly ‘inside’.
textclient sends:
thread_id? maybe existing
service decides:
new thread or continue old thread
response returns:
thread_id
maybe interrupt payload
maybe run metadataOne Whole Mini-Repo
06RepoOne of the normal layouts for this repository
The purpose of the structure is to separate the public API, LangGraph runtime, LangChain components and infrastructure. Then the service remains readable and does not turn into one giant 'main.py' with routes, prompts, tools and SQL in a jumble.
textREPO TREE
app/
main.py
core/
settings.py
security.py
observability.py
api/
deps.py
routers/
chat.py
resume.py
health.py
agent/
contracts/
state.py
context.py
schemas.py
components/
models.py
prompts.py
tools.py
middleware.py
mcp_clients.py
graph/
nodes.py
routes.py
build.py
services/
chat_service.py
stream_service.py
infra/
persistence.py
queue.py
mcp_server.py
tests/
Dockerfile
pyproject.toml07Repo'main.py' must raise resources once through 'lifespan'
Graph, checkpointer, store, database pool, model clients and service objects are better collected at the start of the application, not for every request. For this, FastAPI has a ‘lifespan’, and it is one of the most important practical pieces for a production service.
python@asynccontextmanager
async def lifespan(app: FastAPI):
settings = load_settings()
checkpointer, store = await build_persistence(settings)
graph = build_graph(checkpointer=checkpointer, store=store)
app.state.chat_service = ChatService(graph=graph, settings=settings)
yield
app = FastAPI(lifespan=lifespan)08Repo‘api/routers/*.py’ – a thin layer above services
An HTTP route does not need to know how a graph works. Its job is simpler: fail the input, pull out the auth/tenant context, invoke the service method, and return the JSON or stream. This keeps the HTTP code small and replaceable.
good way
- schema validation
- auth dependency
- service call
- complex orchestration logic
09Repo‘agent’ is the heart of the product, not the technical folder
Inside the “agent” live domain contracts, models, tools, middleware, graph nodes and service orchestration. This folder is responsible for the business behavior of the application. This is where most of the real value of the service is concentrated.
textcontracts -> state / context / response schema
components -> model / tools / middleware / prompt layer
graph -> nodes / routes / build
services -> invoke / stream / resume orchestration10Repo‘infra/’ is best highlighted explicitly because production dependencies live there.
Persistence, queue, MCP server/client glue, metrics, logging setup, and other operational code are growing rapidly. If you do not take them into a separate layer, they will mix with the graph code and begin to complicate both the development and the depot.
often lives infra
- checkpointer/store builders
- queue adapters
- MCP server bootstrap
- logging/tracing setup
Public HTTP API
11HTTP API‘POST /v1/chat’ is the most understandable sync input
If the user needs a regular response without streaming, it is convenient to have one sync endpoint. Inside, it builds a 'thread id', runtime context, calls 'graph.invoke(...)' through the service, and returns a validated 'response model' along with metadata.
python@router.post("/v1/chat", response_model=ChatResponse)
async def chat(req: ChatRequest, svc = Depends(get_chat_service), ctx = Depends(get_request_context)):
return await svc.chat(req, ctx)12HTTP APIStreaming is a separate endpoint rather than a “magic option”.
LangGraph can stream tokens and state updates. Outwardly, it is convenient to give it through “StreamingResponse”, usually in the format of “text/event-stream” or chunked JSON lines. A separate endpoint makes the contract clearer and doesn’t force sync clients to understand the streaming protocol.
python@router.post("/v1/chat/stream")
async def chat_stream(req: ChatRequest, svc = Depends(get_stream_service), ctx = Depends(get_request_context)):
return StreamingResponse(
svc.stream(req, ctx),
media_type="text/event-stream",
)13HTTP APIIf there is an ‘interrupt’, there is a ‘resume’ endpoint.
This is one of the most common flaws. People add approval-node with ‘interrupt()’, but forget to design the external path of continuation. Production requires a separate endpoint that accepts 'thread id' and payload for 'Command'.
python@router.post("/v1/threads/{thread_id}/resume")
async def resume(thread_id: str, req: ResumeRequest, svc = Depends(get_chat_service), ctx = Depends(get_request_context)):
return await svc.resume(thread_id=thread_id, payload=req.payload, ctx=ctx)14HTTP API`/healthz`, `/readyz`, `/version`, `/openapi.json` are not trifles
A live service needs service entry points. Liveness says “process is alive,” readiness says “you can take traffic,” version says “what’s locked in right now,” OpenAPI says “how customers integrate.” These endpoints are quickly becoming a must for the platform team.
minimum
- GET /healthz
- GET /readyz
- GET /version
- GET /openapi.json
15HTTP APIAuth, Tenant Context and CORS are better solved at FastAPI
If a service lives on the outside, it almost always needs API keys, bearer tokens or other auth-mechanism. It usually gives rise to the request context: “user id”, “tenant id”, roles, feature flags. For browser clients, CORS is added. All this is more convenient to decorate the dependencies layer from FastAPI.
textAuthorization header
→ verify principal
→ build RequestContext(user_id, tenant_id, scopes)
Put it in the service/graph context16HTTP API'Idempotency-Key' is especially important if a graph can cause side effects.
This is one of the “non-obvious but highly relevant” production units. If the client repeats the request because of the timeout, and inside the graph sends an email, creates a ticket or writes in CRM, without idempotence, you will easily get duplicate actions.
particularly
- resume after timeout
- tool with external side effect
- repeat client retries
LangGraph and LangChain Inside the Service
17LangGraph`state.py`, `context.py`, `schemas.py` are the main contracts of the service
The internal runtime should be typed as well as the external HTTP API. “state” describes the living state of the graph, “context” – immutable launch data like “user id” and tenancy, “schemas” – the boundaries of structured output and API. This makes graph code less fragile and easier to test.
pythonclass AgentState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
retrieved_docs: list[str]
final_answer: dict | None
approval_required: bool | None18LangGraphThe `LangChain' within such a repos are models, tools, middleware and output contracts.
Even if you have orchestration on LangGraph, LangChain is still very appropriate as a layer of finished components. It is home to ‘init chat model’, tools with ‘ToolRuntime’, middleware for model/tool hooks, messages and structured output schemes.
textmodel = init_chat_model(...)
tools = [search_docs, create_ticket, get_profile]
middleware = [dynamic_prompt, tool_error_handler]
response_schema = AnswerSchema19LangGraphThe graph should be compiled once at the start, not for every HTTP request.
`builder.compile(...)' assembles runnable graph with checkpointer and store. If you do this for every call, you’ll get extra complexity, lost productivity, and unstable resource initialization. The service usually receives a compiled graph from lifespan.
pythondef build_graph(checkpointer, store):
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("review", review_node)
builder.add_conditional_edges("agent", route_after_agent)
return builder.compile(checkpointer=checkpointer, store=store)20LangGraphChatService links HTTP layer and graph runtime
This is the place where HTTP payloads are born ‘inputs’, ‘config’ and runtime ‘context’, and then called ‘graph.invoke(...)’ or ‘graph.astream(...)’. Such a service layer is useful because it is convenient to hold thread policy, metadata, error mapping and response shaping.
typescriptconfig = {"configurable": {"thread_id": thread_id}}
context = RequestContext(...)
result = await graph.ainvoke(
{"messages": [{"role": "user", "content": req.message}]},
config=config,
context=context,
)21LangGraphInterrupt() is no longer an internal feature of the graph, but a part of the UX and API design.
Once the graph can settle for human approval, the service should be able to beautifully return the payload to the UI or external client. This means a well-thought-out JSON contract: interrupt type, question text, draft answer, acceptable actions, and the way to resume.
come back
- thread_id
- interrupt type
- payload for UI
- resume action schema
State, Scaling and Long Life
22DeployIn order for the service to live, the state must be removed from the process.
One of the most important production shifts: InMemorySaver and local dicts are well suited only for laptop and local development. A live service that restarts, scales and maintains multiple instances must store thread state and memory in external persistence.
textProper separation
app instance A ─┐
app instance B ─┼──→ external checkpointer / store / DB
app instance C ─┘
Then any copy can continue threading thread id23DeployHorizontal scaling breaks if memory lives only in the workman
If one query hits worker A and the next thread id hits worker B, the in-memory state disappears. Therefore, for multi-worker and multi-replica scripts, checkpointer/store should be shared, and service instances should be as stateless as possible.
red flag
- thread history only in the RAM process
- Local disk as the only storage
24DeployNot every LLM-flow needs to live inside an HTTP request lifecycle.
This is another non-obvious, but really important block. If processing can take minutes, call a lot of tools, do reindex or generate a report, it is better to highlight the background job/worker pattern. HTTP endpoint then creates a run and puts the task in a queue, and the client then reads the status or streams.
textPOST /v1/jobs
→ validate request
→ create run record
→ enqueue task
→ return 202 Accepted + run_id
worker
→ loads same graph/service layer
→ executes durable flow
→ writes status/results25DeployDon’t put important artifacts on a local container disk.
Chats, traces, prompt snapshots, uploaded files, generated reports and other useful artifacts should be stored in an external storage: SQL / NoSQL, object storage, vector DB. The container or process does not have to be the only place where user data lives.
dataplane
- SQL / Postgres
- object storage
- vector DB
- queue broker
26DeployTimeout budgets, cancellations and backpressure are required in advance.
The real service has limits: upstream LLM timeout, tool timeout, client timeout, ingress timeout, limit on simultaneous streams. If they are not designed in advance, the service will begin to hang, accumulate hanging connections and fall under load.
think through
- request timeout
- tool timeout
- queue depth limit
- cancel on client disconnect
Where is MCP Really Needed?
27MCPMCP usually lies next to the microservice, rather than replacing its public API.
One of the most useful shifts in understanding is that FastAPI and MCP are different. A public HTTP API is needed for browsers, backends, and business integrations. MCP is needed where LLM hosts and agent systems want standardized tools, resources, and prompts. It's not the same thing.
textTwo adjacent layers
public app API:
browser / backend / mobile → FastAPI
tool/data plane:
host / agent platform / IDE → MCP server
Often the same product has both layers.28MCPPattern A: Your service is an MCP client to an external system n
This is a good pattern if your LangGraph service needs standardized tools from the outside world: GitHub, Slack, files, CRM, internal knowledge, etc. Then your ‘LangChain’ tool layer can run through MCP clients rather than holding dozens of custom SDKs.
textFastAPI request
→ graph node
→ LangChain tool
→ MCP client
→ remote MCP server
→ tool result back into graph29MCPPattern B: Your repository exports the MCP server to other agents
If your product itself knows useful operations or has important data, you can add “mcp server.py” and give some of the features out through MCP. Then IDEs, external hosts or other agent platforms will be able to use your tools/resources/prompts in a standard way.
sqlfrom mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-agent-platform")
@mcp.tool
def search_internal_docs(query: str) -> str:
...
@mcp.resource("kb://policy/{name}")
def get_policy(name: str) -> str:
...30MCP'FastAPI' vs 'MCP' vs both at once
It is better not to confuse these interfaces. FastAPI is good as a business API for applications and services. MCP is a standardized tool/data protocol for hosts and agents. Very often, the correct answer is to have both interfaces, but with different audiences and different areas of responsibility.
| Interface. | Better for you. |
|---|---|
| FastAPI | frontend, backend, product API, auth, business flows |
| MCP | tools/resources/prompts for hosts and agent systems |
| Both | A platform that people and other agents need. |
31MCPMCP has its own security and its own trust boundary.
If you are picking up a remote MCP server, think not only about transport, but also about auth, origin checks, tool descriptions, prompt injection through resources and rights to side-effect tools. An MCP server is a login to your tool/data plane, not just another JSON endpoint.
really important
- Streamable HTTP transport
- auth / OAuth
- least privilege tools
- prompt injection via tool/resource content
Relevant production blocks that are often forgotten
32OpsObservability should go all the way: 'request id', 'thread id', 'run id'
Logs “received the request” and “response” are not enough. For an LLM service, it is very useful to carry correlation identifiers through the entire stack: HTTP request id, thread id, run id, tool call id. Then you can associate a specific client failure with a specific graph run and a specific execution tool.
textrequest_id -> HTTP request
thread_id -> conversation / state line
run id -> specific graph launch
tool call id -> external tool invocation33OpsVersioning not only API, but also graph behavior
Even if the endpoint is called the same, the behavior of the service may change due to a new prompt, new tool rules, different routing logic, or other output schema. Therefore, it is useful to have a clear version of the API, prompt/graph revision and migration discipline for customers.
version
- HTTP API version
- response schema
- graph/prompt revision
34OpsTests are required on three levels: HTTP, service, graph
If you test only the endpoint, it will not be clear if the auth, service wiring, routing graph, or specific tool is broken. Therefore, unit tests tools/middleware, integration tests compiled graph and API tests for schemas, auth and stream/resume endpoints are useful here.
textTEST LAYERS
HTTP tests
↓
service tests
↓
graph integration tests
↓
tool / interrupt / resume tests35OpsDeploy Path: Docker → reverse proxy / ingress → env-configured workers
For external life, the service is usually containerized, raised behind proxy/ingress with HTTPS, throwing env vars and scale policy. An important engineering idea: once the state is outdoors, the application becomes much easier to scale and survive restarts.
textlocal dev
→ Docker image
→ staging with real DB/checkpointer
→ prod behind ingress / load balancer
→ replicas + shared persistence
In many clusters, it is easier to scale replicas than to build complex multiprocesses within a single container.36BridgeThe main summary of the volume
If you compress everything to the most useful engineering framework, the result is: FastAPI gives an external contract, LangGraph gives a long-lasting runtime, LangChain gives convenient building blocks, external persistence makes the service live, and MCP adds a standardized tool/data interface for other hosts and agents if necessary.
textOne main picture
public clients
↓
FastAPI API
↓
service layer
↓
LangGraph runtime
↓
LangChain components
↓
external state + tools + MCP + data plane
Such a service can not only be run locally, but also actually operate.No dead end
Keep moving through the map.
Continue in sequence, switch to a related guide, or return to the seven-track learning map.