Multiturn Dialogue

摘要:本文介绍了为视觉语言模型(VLM)增加多轮对话能力的实践。主要内容包括:1)将传统的单轮问答数据集转换为支持多轮对话的格式,特别是OpenAI的Chat格式;2)实现数据集的格式转换逻辑,利用COCO Caption和VQA数据集中每张图片的多个描述/问答构造连续对话;3)修改Dataset和Collator以支持多轮对话训练,正确处理图像嵌入和标签掩码;4)实现推理时的多轮对话上下文管理;5)验证模型在多轮对话任务上的初步效果,并指出当前方案的局限性(如历史长度限制、不能中途插入图片等)及后续优化方向。

文中代码: https://github.com/wz940216/From0to1-MLLM-StudyLog.git

上一篇为模型增加了多任务支持,让minillava能够同时训练多任务数据:

从零到一 | CV转多模态大模型 | week10 | Multitask Data构建多任务数据支持(Caption + VQA + QA)

第十一周,为模型增加多轮对话能力。之前我们的 qa 数据集主要还是用户输入图片,输入问题,模型给出回答的简单一问一答形式。而自然的对话理应是多轮的,可以随时上传图片,随时提问不同的问题,模型应该结合完整上下文进行回答。

常见的多轮对话数据格式主要有以下两种:

{
  "id": "000001",
  "image": "images/000001.jpg",
  "conversations": [
    {
      "from": "human",
      "value": "<image>\n请描述这张图片。"
    },
    {
      "from": "gpt",
      "value": "图片中有一只橘猫趴在电脑键盘上。"
    },
    {
      "from": "human",
      "value": "猫在做什么?"
    },
    {
      "from": "gpt",
      "value": "它似乎正在睡觉。"
    }
  ]
}

这种格式 llava 最经典的多轮对话格式,也是我们能够从之前的 qa 版本直接扩展的格式。

{
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "image",
          "image": "images/demo.jpg"
        },
        {
          "type": "text",
          "text": "图中有什么?"
        }
      ]
    },
    {
      "role": "assistant",
      "content": "图中是一名正在骑自行车的人。"
    },
    {
      "role": "user",
      "content": "他穿的什么颜色衣服?"
    },
    {
      "role": "assistant",
      "content": "蓝色上衣。"
    }
  ]
}

还有一种是 openai 的 chat 格式,目前主流的 vlm 模型越来越趋向于这种 chat 格式。
因为它更容易扩展视频、多图、tool call、grounding。

既然是主流,我们这期将 dataset 中的 qa 格式转换成 openai 的 chat 格式。
具体思路是:利用已有的 coco caotion 和 VQA 数据集中的一张图片有多个描述的和多个 QA 的特点,构造一个持续的多轮对话数据格式。
例如:

{
    "id": "abstract_v002_val2015_000000024714_000",
    "messages": [
      {
        "role": "user",
        "content": [
          {
            "type": "image",
            "image": "scene_img_abstract_v002_val2017/abstract_v002_val2015_000000024714.png"
          },
          {
            "type": "text",
            "text": "Is the football being used right now?"
          }
        ]
      },
      {
        "role": "assistant",
        "content": "no"
      },
      {
        "role": "user",
        "content": "Is the grill on?"
      },
      {
        "role": "assistant",
        "content": "yes"
      }
    ],
    "source_ids": [
      "0247142",
      "0247140"
    ]
  },

转换示例:

def build_multiturn_samples(items, min_turns, max_turns, shuffle, seed, rewrite_repeated_questions, image_prefix):
    grouped = defaultdict(list)
    skipped = 0

    for item in items:
        qa = extract_first_qa(item)
        if qa is None:
            skipped += 1
            continue
        grouped[qa["image"]].append(qa)

    rng = random.Random(seed)
    output = []
    dropped_below_min = 0

    for image, qas in grouped.items():
        if shuffle:
            rng.shuffle(qas)

        for chunk_idx, start in enumerate(range(0, len(qas), max_turns)):
            chunk = qas[start:start + max_turns]
            if len(chunk) < min_turns:
                dropped_below_min += len(chunk)
                continue

            messages = []
            seen_questions = set()
            source_ids = []
            image_ref = join_image_ref(image_prefix, image)

            for turn_idx, qa in enumerate(chunk):
                question = qa["question"]
                if rewrite_repeated_questions:
                    question = maybe_rewrite_repeated_question(question, seen_questions, turn_idx)

                append_user_message(
                    messages,
                    question=question,
                    image_ref=image_ref if turn_idx == 0 else None,
                )
                append_assistant_message(messages, qa["answer"])
                if qa["id"]:
                    source_ids.append(qa["id"])

            sample_id = f"{os.path.splitext(os.path.basename(image))[0]}_{chunk_idx:03d}"
            output.append({
                "id": sample_id,
                "messages": messages,
                "source_ids": source_ids,
            })

    return output, {
        "input_samples": len(items),
        "image_groups": len(grouped),
        "output_samples": len(output),
        "skipped_invalid_samples": skipped,
        "dropped_turns_below_min_turns": dropped_below_min,
    }

格式转换的思路是利用每张图片的多个描述或多个 qa 进行多轮对话合并构造。
第一轮对话会保留 image,用于后期替换成图片 embeding,后面轮次只保留文本,对于 coco 中的 caption 构造了几种不同的提问方式,随机进行提问。

格式对齐多轮对话之后,将 dataset.py 中加载数据集 json 文件的部分修改成支持 OpenAI messages的格式,同时兼容 LLaVA 的 conversations 格式。

class LlavaPretrainDataset(Dataset):
    """读取图文对话数据的 Dataset。

    这个类现在同时支持旧 LLaVA conversations 和 OpenAI chat messages。
    每条样本只返回 PIL 图片和结构化 turns;
    tokenize、padding、labels mask 都在 LlavaCollator 里完成。
    """

    def __init__(self, dataset_path, image_dir, annotation_file, max_samples=None, task_name=None):
        self.dataset_path = dataset_path
        self.image_dir = image_dir
        self.task_name = task_name or os.path.basename(os.path.normpath(dataset_path))
        annotation_path = os.path.join(self.dataset_path, annotation_file)
        self.samples = _read_json(annotation_path)
        if max_samples is not None:
            self.samples = self.samples[:max_samples]

    def __len__(self):
        return len(self.samples)

    def __getitem__(self, index):
        item = self.samples[index]

        if "messages" in item:
            turns, image_ref = extract_openai_chat_turns(item["messages"])
            image_path = resolve_image_path(self.dataset_path, self.image_dir, item, image_ref=image_ref)
        else:
            turns = extract_llava_dialogue_turns(item["conversations"])
            image_path = resolve_image_path(self.dataset_path, self.image_dir, item)

        image = Image.open(image_path).convert("RGB")
        return {
            "image": image,
            "turns": turns,
            "image_path": image_path,
            "task_name": self.task_name,
            "sample_id": item.get("id", str(index)),
        }

同时,不要忘记在 LlavaCollator 中将多轮对话第一轮保存 image 字段,同时屏蔽掉 pad 区域和 user 部分,将这几块区域的 label设置为 -100。

class LlavaCollator:
    """把 Dataset 返回的原始样本拼成可训练 batch。

    labels 构造规则:
    - USER 问题、"USER:"、"ASSISTANT:" 前缀都只是条件输入,label 置为 -100。
    - 每一轮 assistant 答案都是训练目标,label 保留真实 token id。
    - eos token 也作为答案结束标记参与训练。
    - padding 位置置为 -100,避免 padding token 影响 loss。
    """

    tokenizer: object
    max_length: int = 512

    def __post_init__(self):
        if self.tokenizer is None:
            raise ValueError("LlavaCollator 需要传入 tokenizer,不能为 None。")

    def _tokenize_segment(self, text):
        """tokenize 单个文本片段,不额外插入 BOS/EOS。"""
        return self.tokenizer(
            text,
            add_special_tokens=False,
        ).input_ids

    def _encode_one_sample(self, turns):
        """把一条样本的多轮 turns 编码成 input_ids 和 labels。"""
        eos = self.tokenizer.eos_token or ""
        segments = build_training_segments(turns, eos_token=eos)

        input_ids = []
        labels = []
        for segment in segments:
            token_ids = self._tokenize_segment(segment["text"])
            input_ids.extend(token_ids)

            if segment["train"]:
                labels.extend(token_ids)
            else:
                labels.extend([IGNORE_INDEX] * len(token_ids))

        # 当前保持和原项目一致的右截断策略。后续如果对话很长,可以改成
        # “保留第一轮 <image> + 最近若干轮”的上下文截断策略。
        input_ids = input_ids[:self.max_length]
        labels = labels[:self.max_length]
        attention_mask = [1] * len(input_ids)
        return input_ids, attention_mask, labels

    def __call__(self, features):
        images = [x["image"] for x in features]
        task_names = [x.get("task_name", "unknown") for x in features]
        sample_ids = [x.get("sample_id", "") for x in features]

        encoded = [self._encode_one_sample(x["turns"]) for x in features]
        input_id_lists, _, _ = zip(*encoded)

        pad_token_id = self.tokenizer.pad_token_id
        if pad_token_id is None:
            pad_token_id = self.tokenizer.eos_token_id
        if pad_token_id is None:
            raise ValueError("tokenizer 既没有 pad_token_id 也没有 eos_token_id,无法 padding。")

        batch_max_len = max(len(ids) for ids in input_id_lists)
        padded_input_ids = []
        padded_attention_masks = []
        padded_labels = []

        for input_ids, attention_mask, labels in encoded:
            pad_len = batch_max_len - len(input_ids)
            padded_input_ids.append(input_ids + [pad_token_id] * pad_len)
            padded_attention_masks.append(attention_mask + [0] * pad_len)
            padded_labels.append(labels + [IGNORE_INDEX] * pad_len)

        return {
            "images": images,
            "input_ids": torch.tensor(padded_input_ids, dtype=torch.long),
            "attention_mask": torch.tensor(padded_attention_masks, dtype=torch.long),
            "labels": torch.tensor(padded_labels, dtype=torch.long),
            "task_names": task_names,
            "sample_ids": sample_ids,
        }

推理时,由于是多轮对话了,也要变化一下逻辑。从只能问一句,到能多轮追加提问。
一种很直觉的方式是保存之前的对话内容,每次更新历史记录。
在 infer.py 中通过 build_context_prompt() 函数更新历史,保证每次提问后将之前的全部对话和新问题同时送到模型中。
我尝试在 prompt 中让模型以 json 的格式返回,但是失败了,模型并没有使用 json 格式。可能是因为微调数据集中没有 json 格式的分布,模型学到的多半是看图回答问题的任务模式,模仿训练数据里的回答风格。

def answer_one_turn(model, image, history, question, gen_config):
    """执行一轮带上下文的图文对话推理,并返回 assistant 回答。"""
    prompt = build_context_prompt(history, question)
    outputs = model.generate(
        images=[image],
        prompts=[prompt],
        max_new_tokens=int(gen_config["MAX_NEW_TOKENS"]),
        temperature=float(gen_config["TEMPERATURE"]),
        do_sample=bool(gen_config["DO_SAMPLE"]),
        top_p=float(gen_config["TOP_P"]),
        top_k=int(gen_config["TOP_K"]),
        repetition_penalty=float(gen_config["REPETITION_PENALTY"]),
    )
    return normalize_generated_text(outputs[0], prompt)

当然这种方式如果一直拼接的话会使得对话非常长,主流的解决方案有几种,在实际生产过程中比较常见:

1、历史记录只保存第一轮和最近几轮对话
2、将早期历史自动摘要
3、多模态内容结构化存储
4、向量检索相关历史

例子中就简单的将所有历史对话全部保存了。

初步验证:模型在 vqa 和 coco caption 的 val 集上微调了三个 epoch 后看下效果:


python week11_multiturn_dialogue/code/infer.py   --image dataset/coco128/images/train2017/000000000025.jpg   --interactive   --context-file week11_multiturn_dialogue/outputs/context/demo_chat.json


进入多轮对话。输入 exit/quit/q 结束,输入 clear 清空上下文。
USER: What color is the main object?
ASSISTANT: Green
USER: Please describe this image.
ASSISTANT: A tall giraffe standing in a green field.
USER: q

模型能够实现多轮对话了,但还有很多不完美。

比如一直拼接,history 会越来越长,导致最后模型的输入 token 不够用。模型目前只能在第一轮对话中输入图片,后续轮对话中不能随时传文件,这和构建数据集的方式有关,我们的数据集是利用 caption 和 qa 强行改造的多轮对话数据集。实际生产过程中的数据里,其他模态数据理应是可以穿插在任何轮对话的任何位置中的。

多轮对话的原理还是在数据和工程上做了些文章,本期并不涉及到模型方面的改动。至此 minillava 已经从简单的图像caption能力扩展到能同时训练多任务、多轮对话的多模态模型了,初见成效。

在这个过程中也逐渐看清了多模态的大模型的具体流程。还有很多可优化的细节,我们下期继续。

以上笔记来源于我的仓库: https://github.com/wz940216/From0to1-MLLM-StudyLog.git

我正在连载一个从零到一的多模态大模型学习笔记。
如果你对多模态大模型感兴趣,或者也在准备往大模型方向转
可以点赞/Fork我的仓库: https://github.com/wz940216/From0to1-MLLM-StudyLog.git

也可评论区留言交流,后面我会继续把每周的学习记录、踩坑经验陆续更新到仓库和这里。

更多推荐