1. 简化requirements.txt内容,仅保留了几个必需手动安装的库,删除了其他依赖库的版本。

2. 修改md教程,简化了安装流程。去掉了conda的部分内容,将环境和制作的镜像替换为pytorch2.3版本。
3. 代码文件添加了较为详细的注释,没有给每个函数的参数添加文档注释,但是做了尽量详细的行注释。
This commit is contained in:
mimicat
2024-11-15 22:54:43 +08:00
parent 5ab14a4124
commit f2f495e4a0
4 changed files with 139 additions and 135 deletions
@@ -15,19 +15,20 @@ from transformers import (
GenerationConfig,
)
from transformers.utils import is_flash_attn_2_available
from transformers.modeling_utils import get_first_parameter_dtype
from accelerate import init_empty_weights
from accelerate.utils import calculate_maximum_sizes, convert_bytes
from accelerate.commands.estimate import create_ascii_table
# copy from qwen_vl_utils.process_vision_info
MIN_PIXELS = 4 * 28 * 28
MAX_PIXELS = 16384 * 28 * 28
VIDEO_MIN_PIXELS = 128 * 28 * 28
VIDEO_MAX_PIXELS = 768 * 28 * 28
VIDEO_TOTAL_PIXELS = 24576 * 28 * 28
MIN_PIXELS = 4 * 28 * 28 # 一张图最小占4个token
MAX_PIXELS = 16384 * 28 * 28 # 一张图最大占16384个token
VIDEO_MIN_PIXELS = 128 * 28 * 28 # 一个视频里一帧最小占128个token
VIDEO_MAX_PIXELS = 768 * 28 * 28 # 一个视频里一帧最大占768个token
VIDEO_TOTAL_PIXELS = 24576 * 28 * 28 # 一个视频里所有帧总共占最多24576个token
# default
DEFAULT_CKPT_PATH = "./Qwen2-VL-2B-Instruct"
DEFAULT_CKPT_PATH = "path/to/Qwen2-VL-2B-Instruct"
VIDEO_EXTENSIONS = [
".mp4",
".avi",
@@ -39,38 +40,47 @@ VIDEO_EXTENSIONS = [
".mpeg",
]
IMAGE_EXTENSIONS = [".png", ".jpg"]
print(f"单张图片最大/最小token长度限制:{MAX_PIXELS//28*28}/{MIN_PIXELS//28*28}")
# end default
print("*" * 60)
print("*Qwen2-vl 图片视频模态token限制如下:")
print(f"*单张图片最大/最小token长度限制:{MAX_PIXELS//(28*28)}/{MIN_PIXELS//(28*28)}")
print(
f"单个视频最大/最小/总token长度限制:{VIDEO_MAX_PIXELS//28*28}/{VIDEO_MIN_PIXELS//28*28}/{VIDEO_TOTAL_PIXELS//28*28}"
f"*单个视频最大/最小/总token长度限制:{VIDEO_MAX_PIXELS//(28*28)}/{VIDEO_MIN_PIXELS//(28*28)}/{VIDEO_TOTAL_PIXELS//(28*28)}"
)
print("*" * 60, end="\n\n")
# modify from https://github.com/huggingface/accelerate/blob/c0552c9012a9bae7f125e1df89cf9ee0b0d250fd/src/accelerate/commands/estimate.py#L285
def cal_model_size(args):
"""计算模型在各种数据类型下的存储占用"""
# modify from https://github.com/huggingface/accelerate/blob/c0552c9012a9bae7f125e1df89cf9ee0b0d250fd/src/accelerate/commands/estimate.py#L285
"""计算模型在各种数据类型下的存储占用
主要计算方法是
借助calculate_maximum_sizes函数计算所有参数数量在特定下的存储->float32,float16,int8,int4分别进行进一步乘除即可.
convert_bytes: 将计算结果转为不超过1024的TB/GB/MB/KB等单位下的结果表示.
"""
model_name = Path(args.model_path).name
model_path = Path(args.model_path).as_posix()
# 空加载模型, 可以几乎免去对存储空间的占用, 只记录每层有几个参数, 而不实际去申请内存初始化这些参数, 在加载大模型时有很多好处, 比如这里用来计算模型存储空间的占用, 毕竟加载一次大模型还是挺费时间的~
with init_empty_weights():
model = Qwen2VLForConditionalGeneration.from_pretrained(
model_path, torch_dtype="auto"
)
) # 这里auto时会加载bfloat16格式,占用和float16一致
total_size, largest_layer = calculate_maximum_sizes(model)
data = []
for dtype in ["float32", "float16", "int8", "int4"]:
dtype_total_size = total_size
dtype_largest_layer = largest_layer[0]
# dtype_training_size = estimate_training_usage(dtype_total_size, dtype) # buxuyao
if dtype == "float16":
if dtype == "float32":
dtype_total_size *= 2
dtype_largest_layer *= 2
elif dtype == "float16":
pass
elif dtype == "int8":
dtype_total_size /= 2
dtype_largest_layer /= 2
elif dtype == "int8":
elif dtype == "int4":
dtype_total_size /= 4
dtype_largest_layer /= 4
elif dtype == "int4":
dtype_total_size /= 8
dtype_largest_layer /= 8
row = [dtype, dtype_largest_layer, dtype_total_size]
for i, item in enumerate(row):
if isinstance(item, (int, float)):
@@ -89,6 +99,7 @@ def cal_model_size(args):
def _get_args() -> Namespace:
"""命令行参数解析为命名空间(可以看作可以用.来访问的字典)"""
parser = ArgumentParser()
parser.add_argument(
@@ -130,7 +141,12 @@ def _get_args() -> Namespace:
class LazyModelLoader:
"""延迟加载模型以达到快速显示页面的目的"""
"""延迟加载模型以达到快速显示页面的目的
延迟加载需要用到多线程,主线程执行web页面的时候,用子线程去加载模型,只需要记录好模型的引用对象即可.
(利用延迟加载,主线程中不加载而是放到子线程中,这样而等到页面渲染好,
用户输入完提问后,取出模型做推理时,子线程已经加载好模型.)
"""
def __init__(self, args):
self.args = args
@@ -145,6 +161,9 @@ class LazyModelLoader:
print(f"Loading model: {self.args.model_path}")
try:
model, proc = self._load_model_processor()
# model不一定是存有dtype变量的nn.Module类,
# 因此可以用这个函数来快速获取里面第一个参数的dtype。
dtype = get_first_parameter_dtype(model)
except Exception:
self.lock.release()
import traceback
@@ -154,11 +173,17 @@ class LazyModelLoader:
self.model = model
self.proc = proc
print(f"Model {self.args.model_path} loaded")
print(f"{model.device=}")
print(f"{model.device=} model.dtype={dtype}")
def _load_model_processor(
self,
) -> tuple[Qwen2VLForConditionalGeneration, Qwen2VLProcessor]:
"""Qwen2-vl 加载模型时需要加载两个东西:
1. 模型, 对应Qwen2VLForConditionalGeneration类
2. processor(一个对图片和文本进行处理,转换为模型输入的预处理工具),对应AutoProcessor类
借助from_pretrained方法,我们可以在加载模型,预处理器时自动处理某些步骤(比如一般加载模型的流程是:初始化->从文件中加载权重并复制到初始化后的类中)而直接返回结果.
"""
args = self.args
device_map = "cpu" if args.cpu else "auto"
use_fa2 = (
@@ -208,7 +233,33 @@ def _transform_messages(
user_tag="user",
assistant_tag="assistant",
) -> List[Dict[str, Any]]:
"""gradio的messages格式与qwen2的conversation不一致,需要转换"""
"""gradio的messages格式与qwen2的conversation不一致,需要转换
模型的问答是按轮次来划分的:
第一轮: <提问>-><回答>-> 第二轮: <提问>-><回答>-> ...
(即便是加入文件,也是放在提问里面.)
具体来说:
1. gradio目前有多种`对话`的处理格式, 本代码中采用的格式为:
[
[(<文件1>,<文件2>, ...), None], # 如果传入文件,那么没有对应回答,如果这一行是文件,那么下一行跟用户提问
[<提问>, <回答>], # 注意,和上面的区别是提问是一个字符串,而上一行同样位置是一个存储文件的tuple.
[("xxx1.jpg","xxx2.jpg"), None],
["描述下这两张图片", "这张图片xxx"],
...
]
2. Qwen中的格式采用:
[
# 这里角色可以包括: system, user, assistant, 内容则是对应角色的提问或回答.
{"role":<角色>, "content":<内容>},
# 针对图片和视频的传输, Qwen2-vl 在 user 的 <内容> 部分会进一步处理, 因此我们可以将这两类文件放到其 <内容> 中:
{"role":"user", "content":"你是谁?"}, # 纯文字
{"role":"user", "content":[{"type":"image", "image": "xxx.jpg"}, {"type":"text", "text": "这张图里有什么?"}]}, # 图片+文字
{"role":"user", "content":[{"type":"video", "video": "xxx.mp4"}, {"type":"text", "text": "这个视频讲了什么?"}]}, # 视频+文字
...
]
(值得注意的是, 对视频或图片的token限制也可以加在content里面. 可以参考下面的处理)
3. 发现了吗,上面两种对话格式不统一,因此送入模型的预处理器前还需要做一次处理,将gradio格式转为qwen预处理支持的格式.而gradio中文件和提问是放在多个列表里的,对话轮次的切换仅通过回答是否是None来判断.
"""
transformed_messages = [{"role": user_tag, "content": []}]
for message in messages:
q = message[0]
@@ -237,7 +288,7 @@ def _transform_messages(
else:
transformed_messages[-1]["content"] = q
if message[1]:
if message[1]: # 如果回答里有值,说明当前轮对话完成,接下来做下一轮对话的处理。
transformed_messages.extend(
[
{"role": assistant_tag, "content": message[1]},
@@ -255,7 +306,7 @@ def _gc():
torch.cuda.empty_cache()
# modify from https://github.com/gradio-app/gradio/blob/4e1f7dbcb2ea2a0cc29bb76faf5758a9f4afcd6d/demo/chatbot_examples/run.py#L1
# modify from https://github.com/gradio-app/gradio/blob/4e1f7dbcb2ea2a0cc29bb76faf5758a9f4afcd6d/demo/chatbot_examples/run.py#L1, 参考这里可以看到gradio给出的带文件传输的chatbot实现
def print_like_dislike(x: gr.LikeData) -> None:
print(f"{x.index=} {x.value=}{x.liked=}")
@@ -263,10 +314,17 @@ def print_like_dislike(x: gr.LikeData) -> None:
def add_message(
history: List[List[str | Tuple[str, ...]]], message: Dict
) -> tuple[List[List[str | Tuple[str, ...]]], gr.MultimodalTextbox]:
"""
Params:
history: gradio的一种对话格式, 可以参考 `_transform_messages` 的文档注释.
message: gr.MultimodalTextbox类, 可以当作字典访问,里面有file和text,分别表示提供的文件和提问.
"""
for x in message["files"]:
history.append(((x,), None))
if message["text"] is not None:
history.append((message["text"], None))
history.append(
(message["text"], None)
) # 这里填空是因为还需要把history数据转换后给模型进行回复,然后才能赋值到这里。
return history, gr.MultimodalTextbox(value=None, interactive=False)
@@ -278,12 +336,19 @@ def _pred(
processor: Qwen2VLProcessor,
model: Qwen2VLForConditionalGeneration,
):
"""模型对话的主要逻辑, 这段代码参考了Qwen2-vl官方的 web demo的一部分流程.
先转换出qwen2-vl需要的格式
然后将文本和图像/视频分别送入预处理器(在此之前,图像/视频要借助官方提供的process_vision_info函数resize为28*28的倍数)
然后送入模型进行推理,模型推理的结果作为回答."""
messages = _transform_messages(messages)
# 这里首先把messages对话格式转为纯文本的特殊格式
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
# 这里对图片/视频做resize处理,主要是模型内视觉层对图片的宽高有特定限制。
image_inputs, video_inputs = process_vision_info(messages)
# 开始通过预处理器, 将文本和图片/视频作为输入, 处理出模型需要的数据: token_id列表 和 特定形状的一堆像素点
inputs = processor(
text=[text],
images=image_inputs,
@@ -291,18 +356,25 @@ def _pred(
padding=True,
return_tensors="pt",
)
inputs = inputs.to(model.device)
inputs = inputs.to(model.device) # 结果送入模型所在的设备(CPU或某GPU卡)
streamer = TextIteratorStreamer(
processor.tokenizer, timeout=20.0, skip_prompt=True, skip_special_tokens=True
)
) # 借助TextIteratorStreamer可以提供一个流式的接口,是的模型每生成一个token就返回这个token对应的文本。
# 模型在生成token前有一个后处理,这里简单介绍贪心解码和采样解码:
# 当使用贪心解码时,设置do_sample = False, 对于下一个token,模型总会选择预测的概率最大的那个。
# 当使用采样时,字如其名,就是随机的选择。首先会对输出的下一个token的概率分布做一些简单变换(比如temperature越大,可以让概率分布越平均, topK和topP则减小待采样的词表),然后对剩余的词表进行加权的随机选择(因为加权,所以概率大的还是有大的机率被选中,但是如果temperature设置过大,反而把剩余所有词表的概率平均化了,这样大家的权重都接近1:1)
# 因此也可以说,temperature控制模型的创造性,越大,模型采样到不同词的可能越大,模型的回答便越发散。
_gen_kwargs = (
dict(temperature=temperature, top_p=topp, top_k=topk)
if temperature
else dict(do_sample=False)
)
# max_new_tokens主要限制模型回答的最大token长度,当超过这个token就会停止。
gen_config = GenerationConfig(max_new_tokens=512, **_gen_kwargs)
# 使用子线程启动模型的推理,结果会自动添加到streamer接口中。
thread = Thread(
target=model.generate,
kwargs=dict(
@@ -322,10 +394,12 @@ def bot(
topk: int,
topp: float,
) -> Generator[List[List[str | Tuple[str, ...]]], Any, None]:
_gc()
"""这里是输入提问并点击提交后触发回答的逻辑"""
_gc() # 可以清除一下上一次回答的存储碎片
# 然后将提问与之前轮次的对话送入_pred让模型针对这些上文进行推理
model, proc = loader.get_model(), loader.get_processor()
# 这里会返回一个流式的接口,通过for循环即可获取接口里新添加进去的回答,然后拼接到history里流式的返回给gradio即可.
stream = _pred(history, temperature, topk, topp, processor=proc, model=model)
# stream = "Test"
history[-1][1] = ""
for it in stream:
history[-1][1] += it
@@ -333,6 +407,7 @@ def bot(
def web_demo(args: Namespace):
"""创建gradio应用程序"""
with gr.Blocks(fill_height=True) as demo:
with gr.Column(scale=6):
chatbot = gr.Chatbot(
@@ -350,9 +425,11 @@ def web_demo(args: Namespace):
)
with gr.Column(scale=1):
with gr.Accordion("Gen Config", open=False):
# 一个隐藏的选项,可以控制 Temperature、top p、top k
temperature = gr.Slider(0.0, 1.0, step=0.01, label="Temperature")
topk = gr.Slider(-1, 1000, step=2, label="Top K") # need?
topp = gr.Slider(0.0, 1.0, step=0.01, label="Top P") # need?
# 多模态的输入会先调用 add_message,然后调用 bot,最后清除输入框中的内容(因为已经显示在chatbot里了)
chat_msg = chat_input.submit(
add_message, [chatbot, chat_input], [chatbot, chat_input]
)
@@ -361,19 +438,22 @@ def web_demo(args: Namespace):
)
bot_msg.then(lambda: gr.MultimodalTextbox(interactive=True), None, [chat_input])
# 这里主要是给chatbot的每个回答绑定一个用户偏好反馈的结果打印
chatbot.like(print_like_dislike, None, None)
demo.launch(max_threads=2, server_name=args.host, server_port=args.port)
if __name__ == "__main__":
args = _get_args()
# 在这里检测flash-attn是否安装和启用
print("flash-attn 已安装" if is_flash_attn_2_available() else "flash-attn 未安装")
print(
"flash-attn 已安装"
"flash-attn 已启用"
if args.flash_attn2 and is_flash_attn_2_available()
else "flash-attn 未安装"
else "flash-attn 未启用"
)
cal_model_size(args)
cal_model_size(args) # 在每次启动模型时会先显示模型占用
if args.cal_size is False:
loader = LazyModelLoader(args)
loader.get_model()
web_demo(args)
loader.get_model() # 在这里手动提前触发一下模型加载
web_demo(args) # 运行web demo
@@ -1,84 +1,10 @@
# # requirements.txt
accelerate==1.1.0
aiofiles==23.2.1
annotated-types==0.7.0
anyio==4.6.2.post1
av==13.1.0
certifi==2024.8.30
charset-normalizer==3.4.0
click==8.1.7
exceptiongroup==1.2.2
fastapi==0.115.5
ffmpy==0.4.0
filelock==3.16.1
fsspec==2024.10.0
gradio==5.5.0
gradio_client==1.4.2
h11==0.14.0
httpcore==1.0.6
httpx==0.27.2
huggingface-hub==0.26.2
idna==3.10
Jinja2==3.1.4
markdown-it-py==3.0.0
MarkupSafe==2.1.5
mdurl==0.1.2
mpmath==1.3.0
networkx==3.4.2
numpy==2.1.3
nvidia-cublas-cu12==12.4.5.8
nvidia-cuda-cupti-cu12==12.4.127
nvidia-cuda-nvrtc-cu12==12.4.127
nvidia-cuda-runtime-cu12==12.4.127
nvidia-cudnn-cu12==9.1.0.70
nvidia-cufft-cu12==11.2.1.3
nvidia-curand-cu12==10.3.5.147
nvidia-cusolver-cu12==11.6.1.9
nvidia-cusparse-cu12==12.3.1.170
nvidia-nccl-cu12==2.21.5
nvidia-nvjitlink-cu12==12.4.127
nvidia-nvtx-cu12==12.4.127
orjson==3.10.11
packaging==24.2
pandas==2.2.3
pillow==11.0.0
psutil==6.1.0
pydantic==2.9.2
pydantic_core==2.23.4
pydub==0.25.1
Pygments==2.18.0
python-dateutil==2.9.0.post0
python-multipart==0.0.12
pytz==2024.2
PyYAML==6.0.2
qwen-vl-utils==0.0.8
regex==2024.11.6
requests==2.32.3
rich==13.9.4
ruff==0.7.3
safehttpx==0.1.1
safetensors==0.4.5
semantic-version==2.10.0
shellingham==1.5.4
six==1.16.0
sniffio==1.3.1
starlette==0.41.2
sympy==1.13.1
tokenizers==0.20.3
tomlkit==0.12.0
torch==2.5.1
torchvision==0.20.1
tqdm==4.67.0
# requirements.txt
qwen_vl_utils==0.0.8
transformers==4.46.2
triton==3.1.0
typer==0.13.0
typing_extensions==4.12.2
tzdata==2024.2
urllib3==2.2.3
uvicorn==0.32.0
websockets==12.0
accelerate==1.1.1
gradio==5.5.0
torchvision==0.19.0
modelscope==1.20.0
# # 如果安装了flash-attn,则会多出这两个库
# einops==0.8.0
# flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.0.post2/flash_attn-2.7.0.post2+cu11torch2.5cxx11abiFALSE-cp310-cp310-linux_x86_64.whl
# # 本底导出会显示下面这个样式,实际上要确认对应python、pytorch、cuda版本后下载下载上面这个
# # flash-attn @ file:///root/flash_attn-2.7.0.post2%2Bcu11torch2.5cxx11abiFALSE-cp310-cp310-linux_x86_64.whl#sha256=7ccce59f987f422d8210587383914057cf7db219988427ef71287e89153ad2fa
# flash-attn==2.7.0
@@ -8,40 +8,35 @@
ubuntu 22.04
python 3.10
cuda 11.8
pytorch 2.3.0
----------------
```
# 环境安装
```python
# 创建环境
# conda init bash
conda create -n qwen2vl_wb python=3.10
conda activate qwen2vl_wb
# 换源
python -m pip install --upgrade pip
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
# 需要安装的库
# 安装transformers时会依赖最新的torch版本, 目前为 torch2.5.1
pip install qwen_vl_utils==0.0.8 transformers==4.46.2 accelerate==1.1.0 gradio==5.5.0
pip install torchvision # 会匹配对应torch版本的依赖
# torchvision需要安装匹配对应torch版本
pip install qwen_vl_utils==0.0.8 transformers==4.46.2 accelerate==1.1.0 gradio==5.5.0 torchvision==0.18.0 av==13.1.0
# 如需使用魔搭(国内推荐)下载模型
# 如需使用魔搭(国内推荐)下载模型, 需安装这个库
pip install modelscope==1.20.0
# 安装flash-attn
# 安装flash-attn(可选)
# 如显卡支持flash-attn,在确认对应python、pytorch、cuda版本后, 下载对应的release版本.
wegt https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.0.post2/flash_attn-2.7.0.post2+cu11torch2.5cxx11abiFALSE-cp310-cp310-linux_x86_64.whl
wegt https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.0.post2/flash_attn-2.7.0.post2+cu12torch2.3cxx11abiFALSE-cp312-cp312-linux_x86_64.whl
# 镜像加速链接:
# wget https://github.moeyy.xyz/https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.0.post2/flash_attn-2.7.0.post2+cu11torch2.5cxx11abiFALSE-cp310-cp310-linux_x86_64.whl
pip install flash_attn-2.7.0.post2+cu11torch2.5cxx11abiFALSE-cp310-cp310-linux_x86_64.whl
# wget https://github.moeyy.xyz/https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.0.post2/flash_attn-2.7.0.post2+cu12torch2.3cxx11abiFALSE-cp312-cp312-linux_x86_64.whl
pip install flash_attn-2.7.0.post2+cu12torch2.3cxx11abiFALSE-cp312-cp312-linux_x86_64.whl
```
> 完整的pip列表(包含依赖)请参考[02-Qwen2-VL-2B-Instruct Web Demo 参考代码/requirements.txt](./02-Qwen2-VL-2B-Instruct%20Web%20Demo%20参考代码/requirements.txt)
# 下载模型
## 借助 modelscope 下载
# 下载模型(两种下载方法二选一即可~)
## 1. 借助 modelscope 下载
使用 `modelscope` 中的 `snapshot_download` 函数下载模型,第一个参数为模型名称,参数 `cache_dir` 为模型的下载路径。
新建 `model_download.py` 文件输入以下代码,并运行 `python model_download.py` 执行下载。
@@ -56,9 +51,9 @@ model_dir = snapshot_download('Qwen/Qwen2-VL-2B-Instruct', cache_dir='/root/auto
> 注意:请记得修改 `cache_dir` 为你自己的模型下载路径 ~
## lfs 下载
## 2. 借助 git lfs 下载
```python
# 进入autodl-tmp/
# 进入autodl-tmp/ 或者你要保存的路径
cd autodl-tmp/
# 首先安装lfs,便于通过git直接下载模型。
@@ -74,7 +69,7 @@ MODEL=Qwen2-VL-2B-Instruct
# URL="https://huggingface.co/Qwen/"
# git clone "${URL}/${MODEL}"
# 魔搭下载
# 魔搭下载(国内推荐)
URL="https://www.modelscope.cn/Qwen"
git clone "${URL}/${MODEL}.git"
@@ -85,14 +80,17 @@ cd ..
# 运行Demo
```python
# Ampere/Ada/Hopper架构显卡可以启用flash attn2加速推理,autodl要通过6006端口对外访问。
# 可以使用 python mm_qwen2vl.py -h 或查看代码来查看命令帮助
# Ampere/Ada/Hopper架构显卡可以启用flash attn2加速推理,autodl要通过6006端口对外访问。(没安装flash-attn库的忽略)
# python mm_qwen2vl.py --flash-attn2 --model-path ./autodl-tmp/Qwen2-VL-2B-Instruct --host 0.0.0.0 --port 6006
python mm_qwen2vl.py --model-path ./autodl-tmp/Qwen2-VL-2B-Instruct --host 0.0.0.0 --port 6006
```
> 完整代码请参考[mm_qwen2vl.py](./02-Qwen2-VL-2B-Instruct%20Web%20Demo%20参考代码/mm_qwen2vl.py)
> 完整代码及详细注释请参考[mm_qwen2vl.py](./02-Qwen2-VL-2B-Instruct%20Web%20Demo%20参考代码/mm_qwen2vl.py)
# 测试效果
## 图片
![image.png](./images/02-1.png)
## 视频
![image.png](./images/02-2.png)
> 如果觉得2B理解能力较差, 建议用7B以上模型.
Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB