主题
本指南要带你把 LlamaIndex 与一个名为 Delphic 的生产就绪 Web 应用起步模板一起使用。文中所有代码示例都可以在 Delphic 仓库里找到
我们要构建什么
下面是 Delphic 开箱即用功能的一段演示:
https://user-images.githubusercontent.com/5049984/233236432-aa4980b6-a510-42f3-887a-81485c9644e6.mp4
架构总览
Delphic 借助 LlamaIndex 这个 Python 库,让用户创建自己的文档集合,随后在一个响应迅速的前端里对这些集合提问。
我们挑选的技术栈兼顾响应速度与健壮性,能够(1)编排复杂的 Python 处理任务,同时提供(2)一个现代、响应式的前端,以及(3)一个可在此基础上继续扩展功能的安全后端。
核心库有:
- Django
- Django Channels
- Django Ninja
- Redis
- Celery
- LlamaIndex
- Langchain
- React
- Docker 与 Docker Compose
得益于这套建立在极其稳定的 Django Web 框架之上的现代技术栈,Delphic 起步应用拥有顺手的开发者体验、内置的认证与用户管理、异步的向量库处理,以及为响应式 UI 提供的基于 WebSocket 的查询连接。此外,前端用 TypeScript 编写,基于 MUI React,以获得响应式的现代界面。
系统要求
Celery 无法在 Windows 上运行。也许可以借助 Windows Subsystem for Linux 部署,但配置它已超出本教程范围。因此,建议只在 Linux 或 OSX 上跟着本教程做。部署应用需要装好 Docker 与 Docker Compose;本地开发还需要 node 版本管理器(nvm)。
Django 后端
项目目录概览
Delphic 应用的后端目录结构遵循常见的 Django 项目惯例。从仓库根目录看,位于 ./delphic 子目录下的主要目录有:
contrib:存放对 Django 内置contrib应用的定制或扩展。indexes:存放与文档索引和 LLM 集成相关的核心功能,其中包括:
admin.py:该应用的 Django admin 配置apps.py:应用配置models.py:应用的数据库模型migrations:存放该应用数据库 schema 迁移的目录signals.py:定义应用的各类信号tests.py:应用的单元测试
tasks:存放用 Celery 做异步处理的任务。其中index_tasks.py包含创建向量索引的任务。users:这个目录专用于用户管理,其中包括:utils:存放应用各处使用的工具模块与函数,例如自定义存储后端、路径助手以及与集合相关的工具。
数据库模型
Delphic 应用有两个核心模型:Document 与 Collection。它们代表应用在「用 LLM 索引并查询文档」时所处理的核心实体。它们定义在 ./delphic/indexes/models.py。
Collection:
api_key:指向 API key 的外键,用于把任务关联到发起它的 API key。title:字符字段,给出集合的标题。description:文本字段,给出集合的描述。status:字符字段,保存集合的处理状态,取值来自CollectionStatus枚举。created:日期时间字段,记录集合的创建时间。modified:日期时间字段,记录集合的最后修改时间。model:文件字段,保存与集合关联的模型。processing:布尔字段,表示集合当前是否正在处理中。
Document:
collection:指向集合的外键,表示文档与集合之间的归属关系。file:文件字段,保存上传的文档文件。description:文本字段,给出该文档的描述。created:日期时间字段,记录文档的创建时间。modified:日期时间字段,记录文档的最后修改时间。
这些模型为「文档集合」以及由它们借助 LlamaIndex 创建的索引打下了扎实的基础。
Django Ninja API
Django Ninja 是一个用 Django 和 Python 3.7+ 类型提示来构建 API 的 Web 框架。它提供了一种简洁、直观且富有表现力的方式来定义 API 端点,并借助 Python 的类型提示自动生成输入校验、序列化与文档。
在 Delphic 仓库里,./config/api/endpoints.py 包含 API 路由以及各端点的逻辑。下面简要说明 endpoints.py 中每个端点的用途:
/heartbeat:一个简单的 GET 端点,用于确认 API 是否在运行。API 可访问时返回True。这对 Kubernetes 部署很有用——它会主动查询你的容器来确认其存活。/collections/create:创建新Collection的 POST 端点。接受title、description以及一组files等表单参数,为每个文件创建Collection与Document实例,并调度一个创建索引的 Celery 任务。
@collections_router.post("/create")
async def create_collection(
request,
title: str = Form(...),
description: str = Form(...),
files: list[UploadedFile] = File(...),
):
key = None if getattr(request, "auth", None) is None else request.auth
if key is not None:
key = await key
collection_instance = Collection(
api_key=key,
title=title,
description=description,
status=CollectionStatusEnum.QUEUED,
)
await sync_to_async(collection_instance.save)()
for uploaded_file in files:
doc_data = uploaded_file.file.read()
doc_file = ContentFile(doc_data, uploaded_file.name)
document = Document(collection=collection_instance, file=doc_file)
await sync_to_async(document.save)()
create_index.si(collection_instance.id).apply_async()
return await sync_to_async(CollectionModelSchema)(...)/collections/query—— 用 LLM 查询某个文档集合的 POST 端点。接受包含collection_id与query_str的 JSON 载荷,返回通过查询该集合生成的回答。我们的聊天界面并没有真的使用这个端点(我们走 WebSocket,见下文),但你完全可以做一个应用通过这条 REST 端点来查询指定集合。
@collections_router.post(
"/query",
response=CollectionQueryOutput,
summary="Ask a question of a document collection",
)
def query_collection_view(
request: HttpRequest, query_input: CollectionQueryInput
):
collection_id = query_input.collection_id
query_str = query_input.query_str
response = query_collection(collection_id, query_str)
return {"response": response}/collections/available:返回使用该用户 API key 创建的全部集合列表的 GET 端点。输出通过CollectionModelSchema序列化。
@collections_router.get(
"/available",
response=list[CollectionModelSchema],
summary="Get a list of all of the collections created with my api_key",
)
async def get_my_collections_view(request: HttpRequest):
key = None if getattr(request, "auth", None) is None else request.auth
if key is not None:
key = await key
collections = Collection.objects.filter(api_key=key)
return [{...} async for collection in collections]/collections/{collection_id}/add_file:向既有集合添加文件的 POST 端点。接受collection_id路径参数,以及file、description等表单参数。会把该文件作为与指定集合关联的Document实例加入。
@collections_router.post(
"/{collection_id}/add_file", summary="Add a file to a collection"
)
async def add_file_to_collection(
request,
collection_id: int,
file: UploadedFile = File(...),
description: str = Form(...),
):
collection = await sync_to_async(Collection.objects.get)(id=collection_id)WebSocket 入门
WebSocket 是一种通信协议,它让客户端与服务端在一条长期存活的连接上进行双向、全双工通信。WebSocket 协议被设计为与 HTTP/HTTPS 使用相同端口(分别是 80 和 443),并采用类似的握手过程来建立连接。连接一旦建立,数据就能以“帧”的形式双向收发,而不必像传统 HTTP 请求那样每次都重新建立连接。
使用 WebSocket 有很多理由,尤其是当你要对接「载入内存很慢、但载入之后运行很快」的代码时:
- 性能:WebSocket 免去了为每个请求反复打开与关闭连接所带来的开销,降低了延迟。
- 效率:WebSocket 支持实时通信而无需轮询,资源利用更高效、响应也更敏捷。
- 可扩展性:WebSocket 能处理大量并发连接,非常适合需要高并发的应用。
就 Delphic 应用而言,使用 WebSocket 非常合理,因为 LLM 载入内存的代价可能很高。建立起 WebSocket 连接后,LLM 可以一直驻留在内存中,后续请求因而能被快速处理,不必每次都重新加载模型。
ASGI 配置文件 ./config/asgi.py 规定了应用如何处理传入连接:使用 Django Channels 的 ProtocolTypeRouter,按协议类型来路由连接。这里有两个协议类型:“http” 与 “websocket”。
“http” 协议类型使用标准的 Django ASGI 应用来处理 HTTP 请求;“websocket” 协议类型则使用自定义的 TokenAuthMiddleware 为 WebSocket 连接做认证。其中的 URLRouter 为 CollectionQueryConsumer 定义了一条 URL 模式,由它负责处理与查询文档集合相关的 WebSocket 连接。
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"websocket": TokenAuthMiddleware(
URLRouter(
[
re_path(
r"ws/collections/(?P<collection_id>\w+)/query/$",
CollectionQueryConsumer.as_asgi(),
),
]
)
),
}
)有了这套配置,客户端就能与 Delphic 应用建立 WebSocket 连接,高效地用 LLM 查询文档集合,而不必每次请求都重新加载模型。
WebSocket 处理器
位于 config/api/websockets/queries.py 的 CollectionQueryConsumer 类负责处理与查询文档集合相关的 WebSocket 连接。它继承自 Django Channels 提供的 AsyncWebsocketConsumer 类。
CollectionQueryConsumer 类有三个主要方法:
connect:在 WebSocket 作为连接过程一部分进行握手时被调用。disconnect:在 WebSocket 因任何原因关闭时被调用。receive:当服务器从 WebSocket 收到消息时被调用。
WebSocket 的 connect 监听器
connect 方法负责建立连接:从连接路径中取出集合 ID,加载集合模型,然后接受该连接。
async def connect(self):
try:
self.collection_id = extract_connection_id(self.scope["path"])
self.index = await load_collection_model(self.collection_id)
await self.accept()
except ValueError as e:
await self.accept()
await self.close(code=4000)
except Exception as e:
passWebSocket 的 disconnect 监听器
这里的 disconnect 方法是空的,因为 WebSocket 关闭时不需要做任何额外动作。
WebSocket 的 receive 监听器
receive 方法负责处理从 WebSocket 收到的消息:拿到传入消息、解码,然后用给出的查询去查询已加载的集合模型。回答会被格式化成 markdown 字符串,再通过 WebSocket 连接发回客户端。
async def receive(self, text_data):
text_data_json = json.loads(text_data)
if self.index is not None:
query_str = text_data_json["query"]
modified_query_str = f"Please return a nicely formatted markdown string to this request:\n\n{query_str}"
query_engine = self.index.as_query_engine()
response = query_engine.query(modified_query_str)
markdown_response = f"## Response\n\n{response}\n\n"
if response.source_nodes:
markdown_sources = (
f"## Sources\n\n{response.get_formatted_sources()}"
)
else:
markdown_sources = ""
formatted_response = f"{markdown_response}{markdown_sources}"
await self.send(json.dumps({"response": formatted_response}, indent=4))
else:
await self.send(
json.dumps(
{"error": "No index loaded for this connection."}, indent=4
)
)加载集合模型用的是 load_collection_model 函数,它位于 delphic/utils/collections.py。该函数按给定的集合 ID 取出集合对象,检查集合模型对应的 JSON 文件是否存在、不存在就创建一个,然后在配置好 LLM 与 Settings 之后,用这个缓存文件加载 VectorStoreIndex。
from llama_index.core import Settings
async def load_collection_model(collection_id: str | int) -> VectorStoreIndex:
"""
Load the Collection model from cache or the database, and return the index.
Args:
collection_id (Union[str, int]): The ID of the Collection model instance.
Returns:
VectorStoreIndex: The loaded index.
This function performs the following steps:
1. Retrieve the Collection object with the given collection_id.
2. Check if a JSON file with the name '/cache/model_{collection_id}.json' exists.
3. If the JSON file doesn't exist, load the JSON from the Collection.model FileField and save it to
'/cache/model_{collection_id}.json'.
4. Call VectorStoreIndex.load_from_disk with the cache_file_path.
"""
# Retrieve the Collection object
collection = await Collection.objects.aget(id=collection_id)
logger.info(f"load_collection_model() - loaded collection {collection_id}")
# Make sure there's a model
if collection.model.name:
logger.info("load_collection_model() - Setup local json index file")
# Check if the JSON file exists
cache_dir = Path(settings.BASE_DIR) / "cache"
cache_file_path = cache_dir / f"model_{collection_id}.json"
if not cache_file_path.exists():
cache_dir.mkdir(parents=True, exist_ok=True)
with collection.model.open("rb") as model_file:
with cache_file_path.open(
"w+", encoding="utf-8"
) as cache_file:
cache_file.write(model_file.read().decode("utf-8"))
# define LLM
logger.info(
f"load_collection_model() - Setup Settings with tokens {settings.MAX_TOKENS} and "
f"model {settings.MODEL_NAME}"
)
Settings.llm = OpenAI(
temperature=0, model="gpt-3.5-turbo", max_tokens=512
)
# Call VectorStoreIndex.load_from_disk
logger.info("load_collection_model() - Load llama index")
index = VectorStoreIndex.load_from_disk(
cache_file_path,
)
logger.info(
"load_collection_model() - Llamaindex loaded and ready for query..."
)
else:
logger.error(
f"load_collection_model() - collection {collection_id} has no model!"
)
raise ValueError("No model exists for this collection!")
return indexReact 前端
概述
Delphic 项目的前端我们选择 TypeScript + React + Material-UI(MUI),有两个原因。其一,MUI 是最流行的前端框架(React)里最流行的组件库,这个组合能让本项目触达庞大的开发者群体。其二,React 如今已经成熟且普遍受欢迎,它用虚拟 DOM 提供了很有价值的抽象,同时相对稳定,而且我们觉得它相当好上手——这同样降低了门槛。
前端项目结构
前端位于仓库的 /frontend 目录,其中与 React 相关的组件在 /frontend/src。你会注意到 frontend 目录里有一个 DockerFile,以及若干用于配置前端 Web 服务器 —— nginx —— 的目录与文件。
/frontend/src/App.tsx 是应用的入口文件。它定义了若干主组件,比如登录表单、抽屉布局以及创建集合的模态框。这些主组件会根据用户是否已登录、是否持有认证令牌来条件渲染。
DrawerLayout2 组件定义在 DrawerLayour2.tsx 文件里。它负责管理应用的布局,提供导航区与主内容区。
由于应用逻辑比较简单,我们不必引入 Redux 这类复杂的状态管理方案,用 React 的 useState hook 就够了。
从后端拉取集合列表
登录用户可见的集合会在 DrawerLayout2 组件里被取出并展示,整个过程可以拆成下面几步:
- 初始化状态变量:
const [collections, setCollections] = useState<CollectionModelSchema[]>([]);
const [loading, setLoading] = useState(true);这里我们初始化两个状态变量:collections 用于存放集合列表,loading 用于标记集合是否正在拉取。
- 通过
fetchCollections()函数为登录用户拉取集合:
const
fetchCollections = async () = > {
try {
const accessToken = localStorage.getItem("accessToken");
if (accessToken) {
const response = await getMyCollections(accessToken);
setCollections(response.data);
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};fetchCollections 函数带着用户的访问令牌调用 getMyCollections API,取回该用户的集合,然后用拿到的数据更新 collections 状态,并把 loading 置为 false 表示拉取完成。
展示集合
最新的集合在抽屉里这样展示:
< List >
{collections.map((collection) = > (
< div key={collection.id} >
< ListItem disablePadding >
< ListItemButton
disabled={
collection.status != = CollectionStatus.COMPLETE | |
!collection.has_model
}
onClick={() = > handleCollectionClick(collection)}
selected = {
selectedCollection & &
selectedCollection.id == = collection.id
}
>
< ListItemText
primary = {collection.title} / >
{collection.status == = CollectionStatus.RUNNING ? (
< CircularProgress
size={24}
style={{position: "absolute", right: 16}}
/ >
): null}
< / ListItemButton >
< / ListItem >
< / div >
))}
< / List >你会注意到,每个集合的 ListItemButton 是否 disabled,取决于该集合状态是不是 CollectionStatus.COMPLETE、或者它是否没有模型(!collection.has_model)。只要这两个条件之一成立,按钮就被禁用,避免用户选中尚未处理完或没有模型的集合。当 CollectionStatus 处于 RUNNING 时,我们还会在按钮上叠一个加载转圈。
在另一个 useEffect hook 里,我们检查 collections 状态中是否存在状态为 CollectionStatus.RUNNING 或 CollectionStatus.QUEUED 的集合。若有,就设置一个定时器,每 15 秒(15,000 毫秒)重复调用一次 fetchCollections 来刷新集合状态。这样应用会周期性检查是否有集合处理完成,处理完毕时 UI 也随之更新。
useEffect(() = > {
let
interval: NodeJS.Timeout;
if (
collections.some(
(collection) = >
collection.status == = CollectionStatus.RUNNING | |
collection.status == = CollectionStatus.QUEUED
)
) {
interval = setInterval(() = > {
fetchCollections();
}, 15000);
}
return () = > clearInterval(interval);
}, [collections]);聊天视图组件
frontend/src/chat/ChatView.tsx 里的 ChatView 组件负责实现并展示聊天界面,让用户与某个集合交互。该组件建立一条 WebSocket 连接,与服务端实时收发消息。
ChatView 组件的主要能力包括:
- 与服务端建立并维护 WebSocket 连接。
- 以聊天形式展示用户与服务端的消息。
- 处理用户输入并把消息发给服务端。
- 根据服务端返回的消息更新消息状态与界面。
- 展示连接状态与错误,例如正在加载消息、正在连接服务端,或加载集合时出错。
这些能力合在一起,让用户能以非常顺滑、低延迟的方式与自己选定的集合交互。
聊天 WebSocket 客户端
ChatView 组件里的 WebSocket 连接用于建立客户端与服务端之间的实时通信。这条连接的建立与管理过程如下:
首先,我们要初始化 WebSocket 的引用:
const websocket = useRef`<WebSocket | null>`(null);
这里用 useRef 创建了一个 websocket 引用,用来持有负责通信的 WebSocket 对象。useRef 是 React 的一个 hook,它创建一个跨多次渲染持续存在的可变引用对象。当你需要持有某个可变对象(比如 WebSocket 连接)却又不想引发多余渲染时,它特别合适。
在 ChatView 组件里,WebSocket 连接需要在整个组件生命周期内保持存在,并且连接状态变化时不应触发重新渲染。用 useRef 就能把连接作为引用保留下来,组件只在真正发生状态变化(比如更新消息、展示错误)时才重新渲染。
setupWebsocket 函数负责建立 WebSocket 连接,并设置处理各类 WebSocket 事件的处理器。
总的来说,setupWebsocket 函数长这样:
const setupWebsocket = () => {
setConnecting(true);
// Here, a new WebSocket object is created using the specified URL, which includes the
// selected collection's ID and the user's authentication token.
websocket.current = new WebSocket(
`ws://localhost:8000/ws/collections/${selectedCollection.id}/query/?token=${authToken}`,
);
websocket.current.onopen = (event) => {
//...
};
websocket.current.onmessage = (event) => {
//...
};
websocket.current.onclose = (event) => {
//...
};
websocket.current.onerror = (event) => {
//...
};
return () => {
websocket.current?.close();
};
};注意其中有不少地方,我们依据 WebSocket 客户端提供的信息来驱动界面更新。
组件初次打开、我们尝试建立连接时会触发 onopen 监听器。在其回调里,组件更新状态以反映连接已建立、清除先前的错误,并标记没有等待回复的消息:
websocket.current.onopen = (event) => {
setError(false);
setConnecting(false);
setAwaitingMessage(false);
console.log("WebSocket connected:", event);
};当通过 WebSocket 连接收到服务端的新消息时触发 onmessage。回调里解析收到的数据,并把来自服务端的新消息更新进 messages 状态:
websocket.current.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("WebSocket message received:", data);
setAwaitingMessage(false);
if (data.response) {
// Update the messages state with the new message from the server
setMessages((prevMessages) => [
...prevMessages,
{
sender_id: "server",
message: data.response,
timestamp: new Date().toLocaleTimeString(),
},
]);
}
};当 WebSocket 连接被关闭时触发 onclose。回调里会检查特定的关闭码(4000),据此弹出警告提示并相应更新组件状态,同时记录这条关闭事件:
websocket.current.onclose = (event) => {
if (event.code === 4000) {
toast.warning(
"Selected collection's model is unavailable. Was it created properly?",
);
setError(true);
setConnecting(false);
setAwaitingMessage(false);
}
console.log("WebSocket closed:", event);
};最后,当 WebSocket 连接出错时触发 onerror。回调里更新状态以反映错误,并记录这条错误事件:
websocket.current.onerror = (event) => {
setError(true);
setConnecting(false);
setAwaitingMessage(false);
console.error("WebSocket error:", event);
};渲染聊天消息
在 ChatView 组件里,布局由 CSS 样式与 Material-UI 组件共同决定。主布局是一个 flex 显示、方向为列(column-oriented flexDirection)的容器,从而让容器内容纵向排列。
布局内有三个主要区域:
- 聊天消息区:占据大部分可用空间,展示用户与服务端之间的消息列表。它的 overflow-y 设为 ‘auto’,内容超出可用空间时可以滚动。每条消息用
ChatMessage组件渲染,等待服务端回复时则用ChatMessageLoading组件展示加载状态。 - 分隔线:用 Material-UI 的
Divider组件把聊天消息区与输入区分开,形成清晰的视觉分界。 - 输入区:位于底部,让用户输入并发送消息。它包含 Material-UI 的
TextField组件,设置为可多行输入、最多 2 行。输入区还有一个用于发送消息的Button组件。用户既可以点击 “Send” 按钮发送,也可以按键盘上的 “Enter”。
ChatView 组件接收的用户输入,就是用户在 TextField 里键入的文本消息。组件处理这些文本输入,并通过 WebSocket 连接把它们发给服务端。
部署
前提条件
部署应用需要装好 Docker 与 Docker Compose。如果你用的是 Ubuntu 或其他常见 Linux 发行版,DigitalOcean 有一篇很棒的 Docker 教程,以及另一篇关于 Docker Compose的教程可以照着做。如果这两篇不合适,试试 Docker 官方文档。
构建与部署
该项目基于 django-cookiecutter,把它部署到一台虚拟机上并为指定域名提供 HTTPS 服务并不难。不过配置过程相对繁琐——这不是本项目的问题,而是配置证书、DNS 等本身就是一项内容较多的工作。
本指南我们就只在本地把它跑起来。之后也许会出一篇生产部署指南。在此之前,可以先看 Django Cookiecutter 项目文档入门。
本指南假定你的目标是把应用跑起来使用。如果你想做开发,多半不会用 --profiles fullstack 启动整个 compose 栈,而是会用 node 开发服务器来启动 React 前端。
部署前,先克隆仓库:
git clone https://github.com/yourusername/delphic.git进入项目目录:
cd delphic复制示例环境变量文件:
mkdir -p ./.envs/.local/
cp -a ./docs/sample_envs/local/.frontend ./frontend
cp -a ./docs/sample_envs/local/.django ./.envs/.local
cp -a ./docs/sample_envs/local/.postgres ./.envs/.local编辑 .django 与 .postgres 配置文件,填入你的 OpenAI API key,并为数据库用户设一个独立密码。你还可以在 .django 文件里设置回答的 token 上限,或改用你想用的 OpenAI 模型。GPT4 是支持的,前提是你有访问权限。
用 --profiles fullstack 标志构建 docker compose 栈:
sudo docker-compose --profiles fullstack -f local.yml buildfullstack 标志会让 compose 从 frontend 目录构建一个 docker 容器,并与后端各容器一起启动。但构建生产级 React 容器非常耗时,因此不建议用这种方式开发。开发环境的配置方式请照 项目 readme.md 中的说明来做。
最后,把应用启动起来:
sudo docker-compose -f local.yml up现在在浏览器里访问 localhost:3000 就能看到前端,并在本地使用 Delphic 应用了。
使用应用
设置用户
要真正使用这个应用(目前需要登录;我们打算之后允许把某些模型分享给未认证用户),你需要一个账号,超级用户或普通用户都行。但无论哪种,都得先有人在命令行里创建一个 superuser:
为什么要设置 Django superuser? Django 的 superuser 拥有应用内的全部权限,可以管理系统方方面面,包括创建、修改和删除用户、集合及其他数据。设置 superuser 让你能完全掌控并管理这个应用。
如何创建 Django superuser:
1 运行以下命令创建 superuser:
sudo docker-compose -f local.yml run django python manage.py createsuperuser
2 系统会提示你输入 superuser 的用户名、邮箱和密码。按要求填写即可。
如何用 Django admin 创建更多用户:
- 按上面的部署说明在本地启动你的 Delphic 应用。
- 在浏览器里访问
http://localhost:8000/admin打开 Django admin 界面。 - 用刚才创建的 superuser 凭据登录。
- 在 “Authentication and Authorization” 一节下点击 “Users”。
- 点击右上角的 “Add user +” 按钮。
- 填写新用户必需的信息,例如用户名与密码,点击 “Save” 创建用户。
- 若要给新用户额外权限或把他设为 superuser,可在用户列表里点击他的用户名,滚动到 “Permissions” 一节按需配置权限,然后保存修改。