最近在做AI代理落地的项目,核心诉求很直接:让一个能理解自然语言的代理跑起来,能调用外部工具,还要能对外提供HTTP接口,最后能一键部署到服务器上。调研之后我确定了LangGraph、FastAPI、MCP和Docker这个组合,整套流程跑通之后收获很大,今天把完整的整合过程和技术取舍记录一下。这套技术栈的定位很清楚:LangGraph负责代理的工作流编排,解决"代理如何思考、如何调用工具、如何维护状态"的问题;FastAPI负责把代理能力封装成REST接口,让其他系统能通过HTTP调用;MCP是代理和外部工具之间的标准化协议,避免每个工具都写一套定制接入;Docker则把整个环境固化下来,解决依赖混乱和部署困难的问题。
如果你是后端工程师想做AI代理,或者已经在用LangChain/LangGraph但不知道怎么对外提供服务,又或者是在研究MCP怎么落地,这篇文章应该能帮你省不少折腾时间。我会从架构设计、环境准备、核心代码、容器化部署到问题排查,完整走一遍,把关键选择和踩过的坑都讲清楚。
1. 技术选型与整体架构设计
1.1 为什么是这四个组件
先聊LangGraph。做AI代理,我之前用过纯LangChain的AgentExecutor,也尝试过自己手写状态机。LangChain的AgentExecutor在简单场景下够用,但一旦涉及多步骤任务、条件回退、人工确认这类复杂流程,它的控制能力就很弱。LangGraph把代理建模成一张有向图,节点是处理步骤,边是状态转移,这个思路跟传统后端开发里的状态机非常像,只不过状态里存的是对话历史、任务上下文这些内容。有向图的结构意味着你可以精确控制代理每一步做什么、什么时候循环、什么时候结束,这在生产环境里是刚需。
FastAPI的选择比较轻松。Python生态里做API服务,FastAPI现在的成熟度已经很高了,异步原生支持、基于类型提示的请求校验、自动生成OpenAPI文档,对AI应用这种经常要挂长连接和流式响应的场景非常友好。而且LangGraph天然支持异步节点,FastAPI的异步支持能直接对接,不需要额外的适配层。我之前用Flask写过类似接口,遇到并发请求时性能差距还是挺明显的。
MCP(Model Context Protocol)解决的是工具接入标准化的问题。在MCP出现之前,代理要接一个数据库查询工具、接一个文件读取工具,每个工具都要手工写一套调用约定,工具多了以后维护成本很高。MCP定义了一套统一的协议:MCP Server暴露工具的元信息和执行入口,MCP Client按标准格式发起请求,代理只需要跟Client打交道。现在社区里已经有很多现成的MCP Server,比如连数据库的、连Figma设计的、连本地文件系统的,搭好就能直接用,这对加速开发很有帮助。
Docker就是最后的保障环节。Python项目最头疼的就是环境依赖,今天在这台机器上跑得好好的,换一台机器就各种报错。容器化之后,Python版本、系统库、模型文件路径这些都固化在镜像里,不再有"我本地是好的"这种问题。而且配合docker-compose,整个代理服务加MCP Server可以一键起停,部署成本降了一个量级。
1.2 整体架构拆解
这个项目的架构可以分成四层:入口层是FastAPI应用,负责接收HTTP请求,做鉴权、参数校验、请求转发;编排层是LangGraph代理,维护对话状态和任务状态,决定调用哪些工具、如何组合结果;工具层是MCP Server集群,提供标准化的工具能力,代理通过MCP Client调用它们;基础设施层是Docker容器,承载以上所有组件,通过docker-compose统一编排。
用一张简单的调用链路来说明:HTTP请求进来,FastAPI创建任务,把请求交给LangGraph代理,代理在图中流转,到工具节点时通过MCP Client调用对应工具,工具执行完毕返回结果,代理汇总后生成最终响应,FastAPI再把结果返回给调用方。
在实际落地时,我建议把代理、API服务、MCP Server拆成三个独立的容器。这样做的理由很实际:三个组件的资源消耗不同,独立容器可以分别设置CPU和内存上限;任何一个组件升级或宕机,不会拖垮整个系统;日志和监控也能按组件分开处理,排查问题的时候不用在一堆混合日志里翻找。如果你的项目规模不大,也可以先把MCP Server合并进代理容器,减少部署复杂度,等工具多了再拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础工具链
2.1 Python环境搭建与依赖管理
我这次用的是uv包管理器。之前用pip加venv管理依赖,项目一多就乱了,虚拟环境路径记不住,依赖版本经常冲突。uv的特点是速度快,而且pyproject.toml加uv.lock锁依赖,在容器里复现环境比requirements.txt要可靠得多。第一步先装uv,macOS或Linux用官方脚本,Windows直接pip安装。
bash复制# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows
pip install uv
然后是初始化项目并创建虚拟环境:
bash复制uv init langgraph-fastapi-mcp-demo
cd langgraph-fastapi-mcp-demo
uv venv
接下来添加核心依赖。我习惯把运行依赖和开发依赖分开,这样Docker镜像只装运行依赖,镜像能小不少。
bash复制uv add langgraph langchain-openai fastapi uvicorn mcp httpx
uv add --dev pytest
这里要提醒一句:langgraph和langchain的版本迭代很快,API变动频繁,建议锁定主版本。我写这篇文章时用的是langgraph 0.2.x,如果你下载的时候已经出现更新的主版本,先看官方迁移文档再动手,否则照着老代码写可能直接报模块不存在。另外,mcp这个库的Python实现还比较年轻,接口也有调整过,踩坑概率比LangGraph还高,建议安装时记下具体版本号。
2.2 Docker环境准备与常见启动问题
Docker这块,Windows用户最容易踩的坑就是Docker Desktop启动时报"Virtualization support not detected"或者"WSL 2 installation is incomplete"。这通常意味着BIOS里的虚拟化没有开启,或者WSL2内核没有更新。我按下面的顺序排查过几次,基本能解决:
- 进BIOS检查Intel VT-x或AMD-V是否启用,不同品牌电脑入口不一样,一般是开机按Del或F2。
- 执行
wsl --update更新WSL内核,确保WSL2可用。 - 在"启用或关闭Windows功能"里勾选"虚拟机平台"和"适用于Linux的Windows子系统"。
- 重启Docker Desktop。
装好后先验证一下:
bash复制docker --version
docker compose version
如果你在服务器上部署,一般不需要Docker Desktop,直接装Docker Engine就行。注意不要图省事把香港或国外的镜像源随便配到生产机器上,镜像源的可访问性和稳定性直接关系到部署成功率,在自己能控制的前提下用官方源或者自己搭建的镜像仓库最稳妥。Docker安装完成后,记得要用普通用户身份操作,不要动不动就sudo docker,权限没配置好反而会出一堆权限相关的怪问题。
3. LangGraph代理核心流程实现
3.1 状态图设计与节点定义
LangGraph的核心概念是StateGraph。状态是一个类型化的字典,图中每个节点执行完都会更新状态,边的逻辑根据状态决定下一步走到哪个节点。这个设计跟你写后端接口时用中间件传递request上下文有点像,只不过这里的状态是专门为代理设计的,可以放消息列表、工具结果、临时变量。
先定义状态类型。我用Annotated和add_messages来处理消息列表,这个注解的意思是:新消息追加到已有消息列表,而不是覆盖掉。这是LangGraph里比较关键的机制,如果你直接在节点里返回一个全新的列表,前面的对话历史就全丢了。
python复制from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
tool_results: dict
current_task: str
然后是构建图结构。整个流程是:起始点进入agent节点,agent决定是否需要调用工具;如果需要,走条件边到tools节点;tools节点执行完回到agent节点,让模型看到工具结果后生成最终回答;如果不需要工具,直接走到结束点。这就是ReAct模式的图实现。
python复制from langgraph.graph import StateGraph, START, END
def should_continue(state: AgentState):
last_message = state["messages"][-1]
if last_message.tool_calls:
return "continue"
return "end"
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tools_node)
graph.add_edge(START, "agent")
graph.add_conditional_edges(
"agent",
should_continue,
{"continue": "tools", "end": END}
)
graph.add_edge("tools", "agent")
app = graph.compile()
这里有三个关键点:第一,should_continue是路由函数,根据当前状态返回"continue"或"end",条件边是LangGraph相比LangChain AgentExecutor的核心优势,你可以完全掌控执行路径;第二,tools节点执行完必须回到agent节点,让模型看到工具结果再生成回答;第三,图的编译返回的是一个可调用的app对象,之后所有的对话都通过它来执行。
3.2 节点实现与工具调用机制
agent节点负责调用LLM。LangGraph本身不关心你用什么模型,它只负责流程调度。我这次用的是OpenAI兼容接口,因为模型走的是代理转发,没有直接调官方接口:
python复制from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.2,
base_url="https://your-compatible-api-endpoint",
api_key="your-api-key"
)
def agent_node(state: AgentState):
result = llm.invoke(state["messages"])
return {"messages": [result]}
tools节点负责执行工具调用。LangGraph的机制是:LLM返回的content里可能带有tool_calls,节点里要解析这些调用并逐个执行。下面的代码展示了一个简化的执行入口,真实场景里工具执行器要接MCP Client,后面第4节会细讲。
python复制def tools_node(state: AgentState):
last_message = state["messages"][-1]
results = []
if last_message.tool_calls:
for tool_call in last_message.tool_calls:
tool_result = tool_executor.execute(
tool_call["name"],
tool_call["args"]
)
results.append(
ToolMessage(
content=str(tool_result),
tool_call_id=tool_call["id"]
)
)
return {"messages": results}
这里有个必须注意的细节:工具返回结果一定要以ToolMessage的形式拼到messages里,否则模型看不到工具的输出,会凭空编造答案。我在项目早期就是漏了这一步,结果模型一本正经地告诉我"文件内容已经读取成功",实际上根本没读到内容。而且ToolMessage里必须带上tool_call_id,跟模型返回的tool call id对应上,否则消息校验会失败。
3.3 状态持久化与会话管理
LangGraph有一个杀手级特性叫检查点机制,可以把代理的执行状态持久化到存储里。默认用内存存储,进程一重启就丢;生产环境建议用SQLite或Postgres存储。启用方式很简单:
python复制from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("checkpoints.db") as checkpointer:
app = graph.compile(checkpointer=checkpointer)
有了检查点机制,调用时只要保证thread_id一致,代理就能自动恢复之前的会话上下文。这个机制是LangGraph做多轮对话的地基,没有它,每次请求都是孤立状态,代理根本记不住用户前面说了什么。
4. FastAPI服务层与MCP集成
4.1 FastAPI接口设计与CORS处理
FastAPI这部分的关键不是写路由,而是怎么把异步的LangGraph流程对接进Web请求。我采用的是直接在路由处理函数里调用编译好的LangGraph应用,利用FastAPI的异步支持,整个调用链路保持异步。
python复制from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI(title="AI Agent API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class ChatRequest(BaseModel):
session_id: str
message: str
class ChatResponse(BaseModel):
response: str
CORS这里要特别说明。如果前端页面用的是另一个端口的Vue或Layui页面,浏览器跨域请求会被直接拦截,后端完全不报错,前端控制台里显示CORS error,这个排查起来特别费神。开发阶段可以像我这样放开allow_origins=["*"],生产环境一定要收窄到具体域名。我见过不少项目上线后被人刷接口,就是因为CORS完全放开且没有鉴权。
对话接口的实现:
python复制@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
config = {"configurable": {"thread_id": req.session_id}}
result = await app_state.ainvoke(
{"messages": [{"role": "user", "content": req.message}]},
config
)
answer = result["messages"][-1].content
return ChatResponse(response=answer)
thread_id非常关键。LangGraph的检查点机制就是靠它来区分不同会话的状态,传同一个session_id就能维持连续对话,换一个id就是全新会话。我第一次做的时候没传config,结果每次请求都是独立状态,代理根本不记得上下文,问"我刚刚说了什么"它一脸茫然。
4.2 流式输出与前端对接
如果要对接流式输出,FastAPI也支持得很好。LangGraph的astream_events方法可以逐步产出模型的生成内容,配合FastAPI的StreamingResponse,可以实现打字机效果,这在对话类产品里几乎是标配。
python复制from fastapi.responses import StreamingResponse
@app.post("/chat/stream")
async def chat_stream(req: ChatRequest):
config = {"configurable": {"thread_id": req.session_id}}
async def event_generator():
async for event in app_state.astream_events(
{"messages": [{"role": "user", "content": req.message}]},
config=config,
version="v2"
):
if event["event"] == "on_chat_model_stream":
chunk = event["data"]["chunk"].content
if chunk:
yield f"data: {chunk}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream"
)
前端用EventSource或者fetch的ReadableStream接口就能消费。这里有个经验:流式输出一定要处理好连接断开的情况,用户关掉页面前端发起abort,后端如果没有捕获这个异常,任务会继续跑下去,浪费计算资源。建议在生成器外包裹try-finally,在finally里做资源清理。
4.3 MCP Server与Client的完整接入
MCP这块分Server和Client。Server暴露工具,Client调用工具。我先写了一个文件读取的MCP Server,用来验证整个链路。
python复制from mcp.server import Server
import mcp.types as types
# MCP Server 实例
app_mcp = Server("file-tool")
@app_mcp.list_tools()
async def list_tools():
return [
types.Tool(
name="read_file",
description="读取指定路径的文件内容",
inputSchema={
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"}
},
"required": ["path"]
}
)
]
@app_mcp.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "read_file":
with open(arguments["path"], "r", encoding="utf-8") as f:
content = f.read()
return [types.TextContent(type="text", text=content)]
raise ValueError(f"Unknown tool: {name}")
在LangGraph工具节点里接入MCP Client,标准做法是通过stdio_client启动MCP Server的进程,然后通过ClientSession发起调用。
python复制from mcp.client.stdio import stdio_client
from mcp.client.session import ClientSession
class MCPToolExecutor:
def __init__(self, server_cmd: list[str]):
self.server_cmd = server_cmd
async def execute(self, tool_name: str, args: dict):
async with stdio_client(self.server_cmd) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(tool_name, args)
return result.content[0].text
这里有个性能问题要提醒:每次调用都启动一个子进程来跑MCP Server,生产环境一定要复用Session,用连接池或常驻进程,否则并发一上来就崩。我在项目早期就是图省事每次新建,实测100并发直接把机器打挂,后来改成启动时预先建立好会话,任务执行时只做复用,性能才稳定下来。
4.4 配置管理的最佳实践
FastAPI项目配置管理,推荐用pydantic-settings读取环境变量。热词里有人问"fastapi 如何初始化读取配置文件",这个问题其实在AI项目中尤其重要,因为模型API地址、MCP Server地址、数据库连接串这些都是环境相关的,不能写死在代码里。
python复制from pydantic_settings import BaseSettings
class Settings(BaseSettings):
openai_api_key: str
openai_base_url: str = "https://api.openai.com"
model_name: str = "gpt-4o-mini"
mcp_server_command: str = "python -m app.mcp_servers.file_tool"
database_url: str = "sqlite:///./checkpoints.db"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
配置项建议全部放到环境变量或.env文件里,不要在代码里留任何明文密钥。Docker部署时通过compose文件注入环境变量,这样同一个镜像可以应对开发、测试、生产多个环境,只需要切换不同的env配置。
5. Docker容器化部署
5.1 多阶段Dockerfile的编写思路
Python服务用多阶段构建是比较规范的做法。第一阶段装依赖,第二阶段只拷贝环境和代码,镜像体积能小不少。我这次用的Dockerfile是这样:
dockerfile复制FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv \
&& uv sync --frozen --no-dev
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app/.venv ./.venv
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
多阶段构建的核心收益是镜像瘦身。基础Python镜像本身就200多MB,不加处理的话,加上依赖包和缓存很容易超过1GB。用多阶段构建,最终运行的镜像只包含虚拟环境和源码,体积能控制在500MB左右,传输和启动速度都快很多。
如果你的MCP Server是独立进程,需要单独写一个Dockerfile。比如文件工具服务可以用同一个基础镜像,但是入口命令改成python -m app.mcp_servers.file_tool。更重要的是,MCP Server往往需要额外的系统依赖,比如连接数据库可能需要数据库客户端库,这时候要在基础镜像里先通过apt安装这些依赖,再拷贝Python代码。
5.2 docker-compose编排与容器间通信
MCP Server作为独立的容器,需要在compose里注册。假设代理容器和服务端口分别是agent-api和file-mcp-server:
yaml复制version: "3.9"
services:
agent-api:
build: .
ports:
- "8000:8000"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- MCP_SERVER_COMMAND=python -m app.mcp_servers.file_tool
- DATABASE_URL=sqlite:///./checkpoints.db
volumes:
- ./data:/app/data
depends_on:
- file-mcp-server
file-mcp-server:
build:
context: .
dockerfile: Dockerfile.mcp
expose:
- "9000"
这一步踩过一个大坑:容器内的服务间通信不能用localhost,必须用compose里的服务名。我在代码里写死了localhost:9000,在宿主机上测试没问题,一进容器就连接拒绝,排查了半天才发现是host配置的问题。实际上,如果你用的是stdio协议启动MCP Server,agent-api容器里直接通过命令启动子进程,并不需要网络通信,只有用SSE或HTTP传输方式时才需要配置网络地址。这两种方式要区分清楚,stdio适合同容器或同机进程,网络传输适合跨容器跨机器场景。
5.3 依赖数据库的选择与初始化
如果项目需要用到MySQL或Redis,可以像热词里常问的那样在compose里直接编排。比如LangGraph的检查点存储想用Postgres而不是SQLite,可以加一个postgres服务:
yaml复制 postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER=agent
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=langgraph
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
pgdata:
然后对应修改DATABASE_URL的配置。这里要注意,容器启动顺序有讲究:agent-api的depends_on只是控制了容器启动的先后顺序,并不保证依赖服务已经就绪。Postgres容器虽然启动了,但可能还没有完成初始化,直接连接会报错。解决方法是加一个健康检查,或者用depends_on.condition: service_healthy,这需要你在postgres服务里定义healthcheck。这个小细节在本地开发时不容易暴露,因为初始化速度快,但在CI或服务器上经常出现。
6. 常见问题与排查技巧
6.1 问题速查表
我把这次项目里砸过的时间最久的坑整理成了一张表,方便你遇到问题时快速定位:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| FastAPI跨域请求被拦 | 未配置CORS或配置错误 | 添加CORSMiddleware,生产环境限制域名 |
| LangGraph对话不连贯 | 请求时未传thread_id | config里设置thread_id并保持稳定 |
| 模型编造工具结果 | 工具结果未写入messages | 构造ToolMessage并设置tool_call_id |
| 容器内调用MCP超时 | 用了localhost而不是服务名 | 使用docker-compose服务名 |
| Windows下Docker Desktop启动失败 | BIOS虚拟化未开启或WSL2内核旧 | 检查VT-x/AMD-V,执行wsl --update |
| 依赖版本冲突 | langgraph/client/httpx版本不兼容 | 锁版本,使用pyproject.toml锁定依赖 |
| FastAPI启动后自动文档空白 | 接口路径配置了前缀导致路径不匹配 | 检查router前缀和docs路径 |
| 模型返回内容为空但状态码200 | 流式事件解析版本不匹配 | 使用version="v2"并检查事件类型 |
6.2 排查思路的实操心得
我在排查LangGraph相关问题时,发现一个很实用的方法:用LangGraph的回放功能。LangGraph专门提供了get_state和update_state的方法,你可以在某次运行时把所有节点的状态变化录下来,之后反复看每一步的输入输出。这样即使代理的行为看起来是黑盒,你也能逐个节点定位到底是哪一步出了问题。
另一个经验是分层验证。状态图构建好后,先别急着接复杂工具,让代理只做一次无工具的普通对话,确认最基础流程能跑通,再逐步增加工具节点。每加一个工具,单独验证工具的MCP调用是否正常,再接入图中。这个增量验证思路能过滤掉大量"看起来是A问题其实是B问题"的干扰。初期遇到报错不要慌,先把LangGraph的调试日志打开,再看看有没有配置遗漏,很多时候模型返回的tool_calls格式和预期不一致,就是这个原因导致的。
还有一个很容易被忽略的问题:模型幻觉。做了很长时间,如果发现代理有时候回答的"工具调用结果"明显不对,先怀疑是不是模型没真正拿到工具返回值,而是自己编的。排查方法很简单:在tools节点里打日志,确认执行完工具后messages里是否真的有对应的ToolMessage。没有的话,问题就出在节点代码逻辑上,而不是模型本身。
6.3 MCP工具生态的实际效果
这轮项目让我对MCP的生态有了直观感受。热词里频繁出现的figma mcp、蓝湖mcp、通达信本地数据mcp,其实都是MCP Server的具体应用。以figma mcp为例,它把Figma的设计文件读取、图层操作封装成标准工具,代理只要配置好MCP Client就能直接调用,不再需要为Figma单独写一套API对接代码。这种标准化带来最直接的好处是,工具开发一次,所有使用MCP Client的代理都能复用。
但也要客观说,MCP还在快速演进中,协议版本和Python库的稳定性都有提升空间。如果你的工具只需要给一个代理用,且调用逻辑非常简单,直接封装成LangGraph工具函数可能比引入MCP更省事。MCP的价值在工具数量多、需要跨系统复用的时候才真正体现出来,选型时要结合实际情况,别为了用MCP而用MCP。
这次整合跑通之后,我的最大体会是:这四个技术各自都不算新东西,但把它们串起来的价值在于,它把"做一个AI代理"这件事从玩具级别推进到了工程级别。LangGraph让流程可控,FastAPI让能力可服务化,MCP让工具生态可复用,Docker让部署可复现。缺任何一个环节,项目落地都要多折腾不少。最后再分享一个小技巧:在项目里维护一份docs/architecture.md,把每次整合的调用链路、配置项、踩坑记录都写进去。别嫌麻烦,AI相关技术迭代太快,三个月后再回头看,很多细节都会忘,文档就是你和过去的自己最好的沟通桥梁。后续如果要把这套架构扩展到多代理协作、加入更多MCP工具集,这份文档会是你最值钱的资产。
