每次看到 Hacker News 上带 "Show HN" 前缀的项目,我都会多留个心眼,因为这类东西往往带着真实的工程痛点和极客式的解题思路。这次这个"Ephemeral runner for JEV-style models"更是让我眼前一亮——它解决的恰好是视觉-语言模型落地时最容易被忽略、又最让人头疼的"临时跑个推理"场景。
所谓 JEV-style models,我这里先解释清楚,通常理解成 Joint Embedding Vision 风格的一组模型,典型代表像 CLIP、ALIGN、SigLIP 这类双塔结构,核心特点是把图像和文本分别编码到同一个向量空间里,然后用余弦相似度衡量匹配程度。这类模型在图像检索、图文匹配、零样本分类里用得非常多,但实际工程中有一个很尴尬的问题:模型本身动辄几百 MB 甚至上 GB,加载一次耗时非常夸张,而很多时候你仅仅是想验证一张图和一句描述是不是匹配,或者想在 CI/CD 流程里跑个图文相似度检查,根本没必要起一个常驻服务。如果每次都在 Python 解析器里反复导入 torch、加载权重,内存和显存的损耗会让人抓狂。
这个项目的思路就非常直接:把推理过程封装成一个临时进程,启动一次、加载模型、跑完推理、输出结果、整个进程退出,所有资源自动回收。我把它理解成"用完即走"的模型运行器。所以这篇文章,我会从动机开始,逐步拆解整个工具的设计思路、核心实现、参数取舍,以及那些只在实际运行中才会暴露的坑,希望能帮到同样被图文模型推理折磨的朋友。
1. 项目整体设计与思路拆解
1.1 临时运行器到底解决了什么问题
先聊聊我做图文匹配时的亲身体验。有一次想批量验证一批商品图片和描述文本的匹配分数,数据量不大,大约几百条,但用的是 CLIP 的 ViT-B/32 版本。最开始我天真地写了个 Python 脚本,直接导入 CLIP 模型,循环处理数据。结果第一条推理跑完大概用了 1.5 秒,当时还觉得挺快的,但没想到后面越来越慢,到最后一条跑了接近 6 秒。我看了一下内存占用,接近 2 GB,显存更是居高不下。原因在于 torch 的 CUDA caching allocator 默认会占着显存不放,再加上 Python 里各种缓存对象,进程一旦跑起来,资源就很难降回去。这就是典型的"常驻进程魔咒":你明明只需要一次性结果,却被迫承担了长期运行的开销。
这个项目把"常驻"变成了"临时",每次运行都通过命令行参数指定输入图片和文本,运行器创建一个全新的进程,加载模型、执行前向计算、打印相似度、立即退出。由于操作系统会在进程结束后回收全部内存和显存,你根本不用担心缓存的累积问题。这就好比以前你为了喝一杯水,专门开了一家自来水厂,现在变成了用一次性纸杯接一杯水,喝完杯子一扔,干净利落。
1.2 为什么选择"进程隔离"而不是"批处理循环"
可能有朋友会问:我直接在脚本里循环处理多张图不就行了吗?何必每次都要重新加载模型?这个问题问得好,因为如果你的场景是批量处理几万张图,那每次重新加载模型确实是个愚蠢的决定。但 ephemeral runner 的定位恰恰在于"轻量、临时、间歇性"的推理需求,尤其是那种嵌入在自动化流水线里的调用场景。
举个例子,我在 CI/CD 里加了图文一致性检查,每次代码更新后要跑一百条测试用例,判断生成的营销图是否与文案相匹配。如果我用常驻服务的方式,就需要额外维护一个 API server,还要处理连接池、超时、健康检查、部署扩展等一堆问题。但用 ephemeral runner 就简单得多——每个测试用例独立调用一次这个运行器,跑完就退出,CI 环境里不需要任何常驻后台进程。从部署角度看,这个方案的优势非常明显:无状态、无端口、无守护进程,直接把可执行文件或脚本丢进去就能用。而且进程隔离天然提供了环境隔离:即使某个调用因为模型崩溃或显存溢出而挂掉,也不影响整个 CI 流程的其他环节。
1.3 JEV-style 模型的技术本质
JEV 风格模型,我这里用最直白的方式解释。你可以把这类模型想象成一个"双人翻译组":一边是图像编码器,它的工作是把一张图片"翻译"成一串固定长度的向量,比如 512 维或 768 维的浮点数;另一边是文本编码器,它把一段文字翻译成同样长度的向量。训练的时候,模型会让"内容匹配"的图片向量和文本向量靠得尽可能近,让"不匹配"的向量离得尽可能远。推理的时候,只需要计算两个向量的余弦相似度,得到的分数就是图文匹配程度。
这种设计的核心优势在于"共享向量空间"。因为图像和文本被映射到同一个空间,你不需要训练任何额外的分类头,可以用文本向量去匹配海量图片向量,或者反过来。这也就是零样本分类的基础。而这个运行器要做的,就是把这种向量计算的流程封装好,让你不用关心模型内部是 12 层 Transformer 还是 24 层,也不用关心 image prompt 的细节,只要输入图片路径和文本,就能拿到结果。
2. 核心实现细节与实操要点
2.1 运行器的整体架构
这个 ephemeral runner 的核心架构,我用一个非常精简的流程来描述:
- 命令行入口接收参数,包括图片路径、文本、模型名称或路径、输出格式等。
- 初始化模型:加载权重、准备设备(CPU/GPU),这里会做一次预热推理,把 CUDA kernel 加载好。
- 预处理图片和文本:图片要做 resize、归一化、通道转换,文本要经过 tokenizer 变成 token ID 序列。
- 前向传播:分别得到图像 embedding 和文本 embedding,做 L2 归一化后计算余弦相似度。
- 输出结果:支持标准输出、JSON 文件输出,或者直接返回匹配标签。
- 进程退出,资源回收。
这个流程看起来简单,但每一步都有很多细节坑。我在复现的时候,发现图片预处理顺序不对会导致相似度分数差出 0.1 甚至更多;tokenizer 的 padding 策略不一致也会影响结果。后面我会专门展开讲。
2.2 命令行接口设计
我个人的习惯是,面向这种临时调用的工具,CLI 参数宁可多不可少,因为你要覆盖各种使用场景。基本参数至少要有这些:
python ephemeral_runner.py \ --model "ViT-B/32" \ --image "./images/cat.jpg" \ --text "a photo of a cat" \ --device "cuda" \ --output "json" \ --topk 5--model支持两种形式:一种是像ViT-B/32这样直接对应官方预训练权重名称的别名,另一种是本地路径./models/my_model.pt,后者可扩展性更强,方便你加载自己微调过的模型。--image支持单张图片,也可以传入一个目录,运行器会自动遍历目录下的所有图片。--text支持单条文本,也支持从文件读取多条。--topk这个参数用于当文本有多条时,只输出相似度最高的前 K 个。
这里面有个细节:输出格式--output我强烈建议默认用 JSON。因为临时运行器通常是要被其他程序调用的,JSON 结构化程度高,可以直接被jq处理或者反序列化成对象。如果你只输出纯文本,后续解析会平白增加一层工作。而且 JSON 里同时包含相似度分数和标签,调试的时候一眼就能看出问题。
2.3 模型加载与预热推理
模型加载是这里最容易出幺蛾子的环节。常规操作是:
import torch import clip model, preprocess = clip.load("ViT-B/32", device=device) model.eval()但这里有个隐藏的坑:clip.load默认会把模型放到目标 device 上,但如果你是 CPU 环境,预训练权重中dtype是 float32,这没问题;如果是 GPU 环境,权重会被自动转成 CUDA 张量。但如果你后续还要做半精度推理,需要在加载后手动调用model.half()。而且eval()模式必不可少,否则 BatchNorm 和 Dropout 的行为会跟你预期完全不同。
预热推理是极其重要的一步。很多人第一次跑的时候,会发现第一次推理特别慢,后面就快了。这是因为 CUDA 在第一次运行时会建立 context、编译 kernel、申请显存,这些操作可能要花几秒到十几秒。如果你是在做自动化调用,每次都是新进程,不预热的话,时间开销会全部摊到第一次推理上,造成很大的延迟波动。
我的做法是在加载模型后,先跑一次 dummy 推理,比如构造一个全零的假图片 tensor 和假文本 token,让模型走一遍完整的前向计算。这一步大概会增加 2 到 3 秒的启动时间,但换来的是后面每次推理都稳定在几十毫秒的级别。在做 CI 批量调用的时候,这个预热实际是帮你省了总时间,因为所有后续调用都不必再承担 CUDA 初始化的开销。
2.4 图像预处理的关键参数
图像预处理对最终相似度分数的影响,怎么强调都不为过。以 CLIP 为例,官方预训练时的预处理流程是这样的:
- 将短边缩放到 224 像素,同时保持宽高比。
- 中心裁剪出 224×224 的区域。
- 转换为 RGB 张量。
- 归一化,mean 为 [0.48145466, 0.4578275, 0.40821073],std 为 [0.26862954, 0.26130258, 0.27577711]。
- 数值范围从 [0, 255] 缩小到 [0, 1]。
这里最容易错的坑就是:直接对图片做 resize 而不是保持宽高比地缩放,会导致物体比例失真,编码器提取的特征出现偏差。第二个坑是归一化参数写错,CLIP 的归一化参数和 ImageNet 的归一化参数是不一样的,搞混之后相似度分数会明显下降。第三个坑是图片通道,读取图片时如果用了 OpenCV 的默认 BGR 格式,没有转换成 RGB,模型的编码结果基本等于废了。
所以我在代码里会强制用 PIL 读取图片,并且明确做.convert("RGB"),再手动调用preprocess。preprocess这个对象来自clip.load,它是已经封装好的完整预处理流水线,不建议自己手工搭一套,除非你真的知道自己在做什么。
2.5 文本处理的 tokenizer 细节
文本端的 tokenizer 也需要细致处理。CLIP 自带的 tokenizer 是基于 BPE 的,它会将文本拆分成 subword token,然后加上[SOS]和[EOS]标记,并 padding 到固定长度(默认 77)。这个 77 是 CLIP 训练时设定的文本序列最大长度。
实际使用中,我遇到过一个让人困惑的场景:我输入同样的一句话,但一次用的是小写,一次用了大写,结果相似度分数从 0.91 降到了 0.79。原因很简单,CLIP 的 tokenizer 是大小写敏感的。所以如果你的数据源文本格式不统一,强烈建议在输入前做统一的文本规范化,比如全部转小写,或者至少保证训练和推理时的文本风格一致。
另一个细节是:如果文本太长,超过了 77 个 token,tokenizer 会截断多余的部分。这在长描述场景下很致命。因为关键信息可能刚好在截断的位置被丢掉。我的应对策略是先跑一个 tokenizer 统计脚本,看看实际数据里的 token 长度分布,如果普遍超过 70,就考虑换用文本序列长度更大的模型,比如 SigLIP 支持更长的文本,或者在预处理时做关键信息提前。
3. 实操过程与核心环节实现
3.1 环境准备与依赖
我复现这个项目的时候,环境比较简单:Python 3.10,PyTorch 2.0.1,torchvision 0.15.2,open_clip_torch 2.16.0。如果你只是想跑官方 CLIP,直接用clip库就行,安装方式如下:
pip install git+https://github.com/openai/CLIP.git pip install open_clip_torch pip install pillow pip install numpy如果是用 GPU,还得确保 CUDA 版本和 PyTorch 匹配。我这边之前踩过一个坑:在全新环境里直接pip install torch装的是 CPU 版本,跑模型的时候报 CUDA 不可用,查了半天才发现。所以建议用官方命令装指定 CUDA 的版本:
pip install torch==2.0.1+cu118 --index-url https://download.pytorch.org/whl/cu1183.2 核心推理代码实现
下面是我根据这个项目思路写的精简版实现,可以直接跑:
import argparse import json import os import torch import clip from PIL import Image def parse_args(): parser = argparse.ArgumentParser(description="Ephemeral runner for JEV-style models") parser.add_argument("--model", type=str, default="ViT-B/32", help="Model name or path to local weights") parser.add_argument("--image", type=str, required=True, help="Path to image file or directory") parser.add_argument("--text", type=str, required=True, help="Text query or path to text file") parser.add_argument("--device", type=str, default="cuda", choices=["cuda", "cpu"], help="Device to run inference on") parser.add_argument("--output", type=str, default="json", choices=["json", "text"], help="Output format") parser.add_argument("--topk", type=int, default=5, help="Number of top results to show") return parser.parse_args() def load_model(model_name, device): if os.path.exists(model_name): model, preprocess = clip.load(model_name, device=device) else: model, preprocess = clip.load(model_name, device=device) model.eval() if device == "cuda": model = model.half() # Warm-up inference with torch.no_grad(): dummy_image = torch.zeros(1, 3, 224, 224, device=device) dummy_text = clip.tokenize(["warmup"]).to(device) model.encode_image(dummy_image) model.encode_text(dummy_text) return model, preprocess def infer(model, preprocess, image_path, text_query, device): image = preprocess(Image.open(image_path).convert("RGB")).unsqueeze(0).to(device) text = clip.tokenize([text_query]).to(device) with torch.no_grad(): image_features = model.encode_image(image) text_features = model.encode_text(text) similarity = torch.cosine_similarity(image_features, text_features) return float(similarity.cpu().numpy()[0]) def main(): args = parse_args() device = args.device if torch.cuda.is_available() else "cpu" model, preprocess = load_model(args.model, device) if os.path.isdir(args.image): image_paths = [os.path.join(args.image, f) for f in os.listdir(args.image) if f.lower().endswith((".jpg", ".jpeg", ".png"))] else: image_paths = [args.image] if os.path.exists(args.text): with open(args.text, "r", encoding="utf-8") as f: texts = [line.strip() for line in f if line.strip()] else: texts = [args.text] results = [] for img in image_paths: for txt in texts: score = infer(model, preprocess, img, txt, device) results.append({ "image": os.path.basename(img), "text": txt, "similarity": round(score, 4) }) results.sort(key=lambda x: x["similarity"], reverse=True) results = results[:args.topk] if args.output == "json": print(json.dumps({"results": results}, ensure_ascii=False, indent=2)) else: for r in results: print(f"{r['image']}\t{r['text']}\t{r['similarity']:.4f}") if __name__ == "__main__": main()这个实现最核心的地方有三块:load_model里的预热推理、infer里的预处理和前向传播、main里的路径与文本灵活性处理。我把图片支持目录、文本支持文件,这样你在批处理场景下就不需要写额外的循环逻辑,一条命令就能扫完一个目录下的所有图片并与多条文本做交叉匹配。
3.3 路径与文本输入的设计取舍
上面代码里对--image和--text都做了"路径或字面值"的双重支持。这个设计思路来自我实际使用中的体验:如果这个工具只能接受单张图片和单条文本,那么它的复用价值会大打折扣。因为很多场景下你并不知道输入会以什么形式存在——可能是单独一张图,可能是一个文件夹,可能在命令行里直接传一个短字符串,也可能给的是一个存放查询文本的文件。
从代码逻辑看,判断策略很简单:如果os.path.isdir(args.image)成立,就遍历目录下所有图片文件;如果os.path.exists(args.text)成立,就按行读取文本文件。这种启发式判断足够稳健,而且几乎不会误判,因为正常情况下你不会把一个目录路径误当成文本查询。唯一要注意的是文本文件编码,最好统一用 UTF-8,否则遇到中文内容时命令行输出或 JSON 序列化会出现乱码。
3.4 半精度与设备选择的参数计算过程
这里的设备选择,我用了device = args.device if torch.cuda.is_available() else "cpu"。这个逻辑看起来简单,但实际上有一个细节:如果你显式传入--device cuda,但当前环境没有 GPU,clip.load会直接报错。加上这个判断后,可以自动降级到 CPU,避免在无 GPU 环境直接崩溃。
半精度的问题需要多说两句。我在load_model里加了model.half(),这是把模型从 float32 转成 float16 或 bfloat16。精度降低理论上会带来一点信息损失,但对相似度计算这类任务,影响往往在 0.001 量级,肉眼几乎看不出来。而收益是显存占用直接减半,推理速度在某些 GPU 上可以提升 30% 到 50%。如果你注意的是极端精确的分数,比如你要用这些分数作为训练数据的伪标签,建议不要用半精度,而是保持 float32,用更高的数值稳定性和一致性。
这里还有一个容易被忽略的点:用model.half()之后,输入图片张量也必须转成同样的 dtype,否则会在前向传播时报错。所以我在infer函数里会额外加一句image = image.half()或者image = image.to(device=device, dtype=torch.float16)。如果你不转,torch 会出现 dtype 不匹配的崩溃,而且崩溃信息有时候相当隐晦,不是直接告诉你是 dtype 问题,而是报一个维度不匹配或者操作不支持的错误,排查起来非常耗时间。
4. 常见问题与排查技巧实录
4.1 显存和内存不释放问题
这是我使用临时运行器后遇到的最大困扰。第一次运行完,发现nvidia-smi里显存占用还是停留在原来的高位,明明进程已经退出了。查了一圈,发现原因不在 Python 进程本身,而是torch在 CUDA context 初始化之后,即使进程退出,在某些情况下也不会立刻释放显存,尤其是如果你在同一个 shell 环境里反复调用不同脚本时,驱动可能会保留一部分缓存。
解决办法最有效的是用完全隔离的子进程方式调用。我在 CI 脚本里是这样包装的:
python ephemeral_runner.py --model ViT-B/32 --image a.jpg --text "a cat" --device cuda每次调用都是独立的进程,退出后操作系统会回收进程的全部地址空间。我实测下来,这种方式下显存几乎可以做到"用完即清"。如果你在同一个主进程里用多线程或者反复 import 模型,才会遇到显存不释放的问题。
另外,我注意到 PyTorch 2.0 引入了torch.cuda.memory._set_allocator_settings("expandable_segments:True")这种设置,可以改善显存碎片化,但在临进程场景里意义不大,这里就不展开了。
4.2 冷启动时间太长怎么办
临时运行器最常见的槽点就是冷启动时间。加载一个 ViT-B/32 模型大约需要 3 到 5 秒,如果加上预热推理和 tokenizer 初始化,新手跑第一次可能要等 10 秒。这在自动化流程里会显得非常慢。
我做了几个优化,总体效果明显:
第一,用clip.load缓存的权重文件。CLIP 的权重默认下载到~/.cache/clip/,如果你多次运行不同脚本,只要模型名称相同,权重不会重复下载。这能省下大量网络时间。
第二,使用 ONNX 导出。如果你对于特定模型结构非常熟悉,可以把 PyTorch 模型导出成 ONNX 格式。ONNX Runtime 在 CPU 上的加载速度比 PyTorch 快得多,因为不需要初始化完整的 torch 运行时。但 ONNX 的缺点是灵活性差,如果你要改模型结构或者插入额外的预处理逻辑,就需要重新导出。整体来说,对于 CLI 工具这种场景,ONNX Runtime 是很值得考虑的加速方案。
第三,把预热推理的结果缓存到磁盘。有人会问:预热推理的目的是初始化 CUDA kernel,这个能缓存吗?实际上你可以把第一次预热后的模型状态保存下来,下次加载绕过预热。不过这需要额外做模型序列化,铁定会引入更多麻烦,我自己的经验是没必要,因为预热本身只花 2 秒左右,省下来意义不大。
4.3 结果与官方示例不一致
这个问题的出现频率非常高。你想跑一个官方 README 里给的例子,比如图片是一只猫,文本是 "a photo of a cat",期望相似度达到 0.9 以上,但实际结果只有 0.6 甚至更低。这时候就要从预处理环节逐个排查。
我整理了一个排查清单:
| 问题现象 | 可能原因 | 验证方法 |
|---|---|---|
| 相似度偏低 | 图片没有做中心裁剪,而是直接拉伸 | 检查 preprocess 的逻辑,确认 resize 和 center crop 的顺序 |
| 相似度偏低 | 图片颜色空间用了 BGR | 检查是否调用了convert("RGB") |
| 相似度偏低 | 文本大小写不一致 | 尝试统一转小写,看分数变化 |
| 相似度波动 | 模型没有处于 eval 模式 | 打印 model.training 确认 |
| 相似度全是 NaN | 输入包含了非法值 | 检查图片是否为损坏文件,文本是否为空 |
| 相似度几乎为 0 | 使用了错误的归一化参数 | 检查 mean/std 是否正确 |
我在实际排查中还遇到过一个特别隐蔽的问题:clip.tokenize的默认上下文长度是 77,如果你的文本在 token 化之前包含了大量空格或特殊符号,tokenizer 会占用额外 token,导致实际语义 token 被挤掉。这种情况下,文本编码结果和训练时的预期分布有很大偏差。解决方法是做 text cleaning,比如压缩连续空格、去掉无意义的特殊字符、将数字标准化。
4.4 文件编码与中文支持
中文文本处理是很多工具容易翻车的地方。我的运行器直接用了ensure_ascii=False输出 JSON,这样中文不会被转成\uXXXX的 Unicode 转义序列,方便直接阅读。但如果你在 Windows 环境跑,终端默认编码可能是 GBK,输出中文时会出现乱码甚至 UnicodeEncodeError。我一般会设置环境变量:
export PYTHONIOENCODING=utf-8或者在 Python 代码里重配标准输出:
import sys sys.stdout.reconfigure(encoding="utf-8")这个细节在调试中文图文匹配时非常关键,不然结果里面全是乱码,连排错都没法做。
4.5 多模型扩展与自定义模型支持
如果你不只是想跑官方 CLIP 权重,而是想加载自己微调的模型,或者换成 SigLIP、EVA-CLIP 这类变体,怎么办?我的做法是给运行器增加了一个--model-type参数,指定clip、open_clip或custom三种类型。对于open_clip,可以用下面的加载逻辑:
import open_clip model, _, preprocess = open_clip.create_model_and_transforms( model_name="ViT-B-32-quickgelu", pretrained="laion2b_s34b_b79k" )这样它就变成了一个非常通用的图文编码运行器,而不仅仅局限于某个固定模型。我还会在加载自定义模型时额外传入一个--checkpoint参数,用于覆盖预训练权重路径。实际使用中,这个扩展能力帮我省了很多事,因为不同模型之间的输出维度、归一化方式、图片分辨率要求各不相同,如果不做这层抽象,每换一个模型就要改一遍代码。
5. 进一步扩展的方向与个人经验
5.1 从单机到批处理:通过 subprocess 并行调用
前面说的都是单次调用的场景,其实 ephemeral runner 在批处理上也有很大潜力。我倾向于把它设计成"一个进程处理一个或几个输入",然后在外层用 shell 脚本或 Python 的subprocess模块来并行调度。比如我有 100 组图片-文本对需要计算相似度,可以开 8 个并行的子进程,每个进程处理一部分,利用多核 CPU 或多卡 GPU 并行加速。
这种做法的好处是进程间完全隔离,不会互相影响,也不会因为某个子进程崩溃导致整个任务失败。缺点是因为每个进程都要重新加载模型,所以并行度越高,模型加载的额外开销也越明显。但如果你用 GPU,有多卡或者有充裕的显存,这个问题可以忽略。
我在实际场景中,用 4 块 GPU 并行跑了 80 组图文匹配,总耗时大约 40 秒。如果单进程顺序跑,同一批数据可能需要 3 分钟以上。所以即使有重复加载模型的开销,并行收益依旧非常可观。
5.2 与 CI/CD 流水线的集成
这是我个人觉得最实用的方向。在 CI 流程里,图文一致性检查可以作为一个 test 步骤,运行器返回相似度分数,然后通过退出码决定流水线是否通过。比如我设定一个阈值 0.75,低于这个分数视为失败。在代码里可以这样操作:
if score < 0.75: sys.exit(1) else: sys.exit(0)这种无状态的设计天然适合容器化。你可以把整个运行器打成一个 Docker 镜像,在 CI 里跑一个临时容器,跑完就销毁,不留任何残留。和那些需要数据库、需要消息队列的常驻服务相比,这样的工具在 Ops 层面简直太轻松了。
5.3 一点个人经验总结
我从这个项目里学到的核心思路其实是一种工程态度:不是所有应用都需要常驻服务,很多时候一个临时进程反而是最优解。你需要评估的是调用频率、启动开销、资源回收这三者之间的均衡。如果调用频率低,启动几秒可以接受,那么 ephemeral runner 模式就是最好的选择;如果调用频率高到每秒几十次,那你还是应该考虑常驻服务加缓存了。
另外,我强烈建议所有做视觉-语言模型落地的朋友都亲手搭一次这种 CLI 运行器。因为你会发现很多你在 notebook 里完全注意不到的工程细节,比如进程退出、显存释放、编码输出、临时文件清理等。这些细节才是真正决定一个 demo 能否转成生产级工具的分水岭。这个项目的代码量不大,却把 JEV-style 模型的推理路径完整走了一遍,踩的坑也都是典型的工程坑,非常值得参考。如果你也打算做类似工具,希望上面这些经验能让你少走几段弯路。