一、前言
我们在应用大模型服务时,应该都有过这种直观感受:大模型回答问题,总感觉 “慢吞吞” 的。明明算力服务器配置很高,显存、算力都没跑满,可每多等一秒,屏幕就只多出一个字,体验特别拖沓。
正如我们现在遇到的场景,现在的编辑器基本是我们提交了需求了,就开始自动生成代码,在这个过程中,模型要逐字逐 token 串行输出代码片段,短短十几行代码就要等待好几秒;这种现象做过大模型部署和推理优化应该都清楚,这不是显卡性能不够,而是自回归生成的天生短板:传统大模型只能一个Token一个Token依次生成,每生成一个字就要完整跑一次前向推理,GPU大量算力被闲置,算力利用率极低。
之前我们常说的量化、KV 缓存、模型蒸馏,都是在“压缩单次推理成本”,没有改变串行生成的底层逻辑,提速效果很有限。今天我们重点讲讲投机解码,彻底换了一套新的思路:不让昂贵的大模型傻傻逐字生成,找一个超快的小模型提前 “预判猜答案”,大模型只负责批量审核纠错,用极小的算力损耗,实现多Token并行生成,也是目前比较实用、性价比最高的大模型推理加速方案。
二、什么是投机解码
1. 核心概念定义
投机解码Speculative Decoding,是一种不修改主模型权重、基于双模型协同的生成加速算法,核心思路是 “用轻量小模型提前猜文本,大容量主模型批量批量核验猜测结果”。
传统原生大模型生成逻辑为串行单Token循环:输入上下文→推理 1 次输出 1 个 Token→拼接上下文重复循环,每一步仅处理单个待验证 Token,算力利用率极低。
投机解码拆分出两个独立模型:Draft草稿小模型、Target目标主模型,形成两段流水线。
- 小模型算力开销极低,一次性提前预测连续k个候选 Token;
- 主模型不逐一枚举校验,而是把k个候选Token打包成批量输入并行推理,一次性判断整段序列是否符合主模型真实分布,批量接收匹配片段,截断错误Token后迭代循环。
简单类比:考试答题:
- 主模型是严谨资深阅卷老师,答题速度慢、判断精准;
- Draft 小模型是提前预习的学生,快速一次性写出连续 k 句答案。
- 老师不用逐句单独阅卷,一次性读完学生写的全部内容,一次性标记全部正确句子,遇到错误直接停止,学生基于正确内容重新续写,大幅减少老师重复阅卷次数。
2. 基础知识说明
想要吃透投机解码,需要先了解清楚2个大模型生成基础能力:
2.1 自回归生成基础逻辑
主流大模型均为自回归架构,文本输出遵循context → next token逻辑,已有文本作为输入上下文,模型输出词汇表中概率最高的Token,拼接进上下文后再次迭代,不存在并行生成原生能力,这也是投机解码诞生的底层前提。
原生生成每轮仅1个待预测 Token,GPU 计算单元大量闲置。
2.2 批量并行推理原理
GPU擅长并行矩阵运算,单次前向推理批量处理N条文本,算力分摊后单条样本平均耗时远低于逐条推理。投机解码核心收益,就是利用批量并行能力,一次性同时校验k个候选 Token,把k次串行推理压缩为1次批量推理。
3. 核心技术优势
- 零主模型改动:无需微调、蒸馏、量化主模型,仅额外部署轻量小模型,存量推理服务改造成本极低;
- 通用全场景适配:对话、代码生成、长文本摘要、端侧本地大模型均可使用,不依赖特定模型架构;
- 正向性能增益:只要小模型预测准确率高于阈值,推理时延必然下降,准确率不会低于原生主模型;
- 资源灵活可控:可自定义单次预生成候选Token数量k,根据服务器显存、QPS需求动态调参。
三、双模型架构:Draft与Target分工
核心基础:Draft小模型与Target主模型共享同一套词表 Tokenizer,二者输入输出Token编码完全统一,否则无法完成候选序列拼接、校验匹配,这是应用落地的硬性要求。
1. Target 主模型(基准模型)
Target是业务最终输出结果的基准大模型,也就是常规业务使用的大参数量模型,具备完整语义理解、逻辑生成能力,输出结果为业务标准标准答案。
核心职责
- 全局上下文编码,承载完整语义推理;
- 批量校验Draft生成的全部候选Token,计算每个候选位置真实概率分布;
- 判定候选Token是否合规,截取连续匹配的有效文本片段;
- 对截断后的位置输出全新基准Token,作为下一轮迭代起点。
主模型唯一短板:参数量大、单次前向推理显存占用高、推理速度慢,无法高频循环逐Token生成。
2. Draft 草稿小模型(投机模型)
Draft是轻量化小型模型,参数量通常为主模型1/10~1/100,例如 7B 主模型搭配0.5B、1B小模型,推理速度是主模型5~20倍。
核心职责:
- 基于当前上下文,快速自回归预生成连续k个候选Token序列;
- 输出粗略候选文本,承担“预猜测”工作,分摊主模型重复生成压力;
- 仅做快速预测,不直接对外输出结果,所有候选必须经过主模型校验。
小模型局限性:语义精度不足,长逻辑、复杂指令容易预测出错,但短片段、高频接续文本预测准确率很高,刚好适配投机解码短序列预生成场景。
3. 双模型协同底层约
- Tokenizer统一:词表、分词规则、特殊符号完全一致,避免编码不匹配导致校验失效;
- 推理设备同平台:可同GPU部署,也可分布式拆分,小模型优先占用低显存设备;
- 概率分布兼容:无需分布完全对齐,仅要求高频接续Token重合度高,重合度越高加速效果越好;
- 独立前向链路:两个模型推理链路完全分离,互不影响权重与计算逻辑。
四、完整业务执行流程
完整一轮投机解码分为4大固定步骤,循环迭代直至生成终止符(EOS),下面分步拆解每一步细节、输入输出、计算逻辑。
1. Draft模型批量预生成k个候选Token
输入:当前全局上下文文本,初始为用户Prompt,迭代后为上一轮校验通过的有效文本
执行流程:
- 1. 将上下文编码为Token Id序列,送入Draft小模型;
- 2. 开启小模型自回归循环,连续生成k个Token,组成候选序列draft_tokens = [t1, t2 ... tk];
- 3. 拼接上下文与候选序列,得到完整待校验长序列 full_seq = context + draft_tokens;
- 参数说明:k 为预生成长度,实际应用常用取值3~10,k越大单次校验Token越多,但小模型出错概率同步提升,存在性能平衡点。
应用示例:用户输入 Prompt “写一段春天风景”,Draft一次性预生成5个候选Token对应文字:“万物复苏,溪水叮咚”,k=5,送入下一步批量校验。
2. Target主模型批量并行校验候选序列
此步骤是投机解码核心性能关键点,区别于原生串行推理。
输入:拼接后的完整序列 full_seq = context + [t1,t2...tk]
执行逻辑:
- 1. Target一次性对整条长序列做完整前向推理,并行计算每一个候选Token位置对应的真实概率分布;
- 2. 针对每一个位置i(1~k),获取主模型预测概率最高 Token target_top_token[i];
- 3. 批量对比:draft_tokens[i] 是否等于 target_top_token[i],批量记录所有匹配、不匹配位置。
原生方案需要单独跑k次主模型推理,投机解码仅执行1次主模型前向,GPU批量并行能力完全释放,算力开销大幅降低。
3. 截断匹配序列,提取有效输出片段
批量对比完成后,从第一个候选Token依次向后遍历,持续收集匹配Token,直到遇到第一个不匹配Token立即停止截断。
两种分支场景:
- 1. 前m个Token全部匹配(m ≤ k):直接接收前m个Token 作为有效输出,追加至全局上下文;本轮无需主模型单独生成新Token,直接进入下一轮Draft预生成;
- 2. 第m位Token不匹配:仅接收前m-1个匹配Token,追加上下文;在第m位置,使用Target主模型真实预测Token替换错误候选,追加至上下文,本轮迭代结束。
边界特殊情况:k个候选Token全部不匹配,m=0,无任何有效片段,仅使用主模型输出1个基准 Token,等价于原生单Token生成,无加速收益。
4. 循环迭代,终止判定
更新全局上下文后,判断当前最新Token是否为终止符 EOS
- 若命中 EOS:停止迭代,拼接全部上下文对外输出最终文本;
- 未命中 EOS:回到步骤1,使用更新后的上下文,再次调用Draft生成k个候选Token,重复完整流程。
5. 完整流程示例
简化实操演示,k=3:、
- 1. 初始上下文:[春天],Draft 预生成 3 候选:[花开,风暖]
- 2. Target 批量校验:位置 1 匹配、位置 2 匹配、位置 3 不匹配
- 3. 截断有效片段[花开,],追加至上下文;位置 3 使用主模型预测正确 Token[鸟鸣],更新上下文为[春天,花开,鸟鸣]
- 4. 无 EOS,重新调用 Draft 生成 3 个新候选,循环直至输出结束。
五、基础逻辑说明
1. 批量一次性校验多Token逻辑
自回归模型每一个位置的预测仅依赖前文上下文,互不干扰。长度为k的候选序列,每个Token位置的输入上下文完全独立,主模型一次前向推理可以同时输出全部k个位置的预测分布,天然支持并行批量计算,不存在计算依赖冲突。
2. 匹配判定的概率逻辑
Draft小模型输出Token概率记为P_d,Target主模型真实概率记为P_t。
- 投机解码基础匹配规则:当Draft输出的Token,在Target对应位置概率分布中为最大值,则判定匹配。
- 进阶优化版本:不严格限制最大概率,通过概率比值采样接受候选,进一步提升m,即有效匹配长度,拉高加速比。
- 核心逻辑:只要小模型猜测的Token在主模型分布中概率不低,文本语义不会失真,输出质量和原生主模型完全对齐,不会出现回答跑偏、逻辑错误。
3. 加速比核心影响因子
- 单次预生成长度k:k越大,理论单次可校验Token越多,上限提升;
- 平均匹配长度m:每轮有效通过Token均值,m越接近k,加速效果越强;
- 双模型推理速度差:Draft推理速度远快于Target,预生成开销可忽略,加速收益显著;
- 小模型预测精度:高频接续文本重合度越高,平均m越大,线上QPS提升越明显。
4. 无精度损失的保障
所有对外输出文本,全部经过Target主模型校验,小模型错误预测会被直接截断替换。最终输出的每一个Token,都严格遵循主模型原始概率分布,不存在小模型错误文本流出,保证生成质量和原生推理完全一致,不会牺牲回答准确性换取速度。
六、应用实践分析
我们使用ModelScope加载两个独立的Qwen1.5模型,其中0.5B作为轻量Draft预生成候选Token,1.8B作为主模型批量并行校验。Draft 基于上下文自回归贪心解码快速生成k个候选,Target一次前向传播后按位对比并修正错误位置。通过匹配Token直接复用、不匹配Token由主模型纠正,演示投机解码在不改变生成质量的前提下,以"大小模型协同"实现多步推进的加速原理。
# -*- coding: utf-8 -*- import torch import torch.nn as nn import pandas as pd import numpy as np import matplotlib.pyplot as plt from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments, Trainer from modelscope import snapshot_download from datasets import Dataset import os import json from tqdm import tqdm # 设置中文字体 plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial'] plt.rcParams['axes.unicode_minus'] = False print("=== Qwen1.5-0.5B-Chat医疗专业能力改进 ===\n") class MedicalModelImprover: def __init__(self): self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"使用设备: {self.device}") model_name="qwen/Qwen1.5-1.8B-Chat" cache_dir = "D:\\modelscope\\hub" local_model_path = snapshot_download(model_name, cache_dir=cache_dir) # 加载模型和分词器 self.tokenizer = AutoTokenizer.from_pretrained(local_model_path) if self.tokenizer.pad_token is None: self.tokenizer.pad_token = self.tokenizer.eos_token print("加载基础模型...") try: self.model = AutoModelForCausalLM.from_pretrained( local_model_path, torch_dtype=torch.float16, device_map="auto" ) if not hasattr(self, 'model') or self.model is None: raise RuntimeError("模型加载失败,请检查模型路径或配置。") print("✅ 模型加载成功") except Exception as e: print(f"❌ 模型加载失败: {e}") raise # 训练记录 self.train_losses = [] def create_simulated_medical_data(self): """创建模拟医疗数据""" print("创建模拟医疗训练数据...") medical_data = [ # 糖尿病相关 { "instruction": "糖尿病患者空腹血糖控制目标是多少?", "output": "糖尿病患者空腹血糖控制目标为4.4-7.0mmol/L,餐后血糖应低于10.0mmol/L,糖化血红蛋白控制在7.0%以下。具体目标需根据患者年龄、并发症情况个体化调整。" }, { "instruction": "胰岛素治疗的起始剂量如何计算?", "output": "基础胰岛素起始剂量为0.1-0.2单位/公斤体重,根据空腹血糖水平调整。餐时胰岛素根据碳水化合物摄入量计算,通常每10-15克碳水化合物需要1单位胰岛素。需在医生指导下个体化调整。" }, { "instruction": "二甲双胍的用法用量?", "output": "二甲双胍起始剂量为500mg每日一次或二次,随餐服用。最大剂量2550mg/日。主要副作用为胃肠道反应,肾功能不全(eGFR<45)患者禁用。用药期间需定期监测肾功能。" }, # 高血压相关 { "instruction": "高血压的诊断标准是什么?", "output": "高血压诊断标准:非同日3次诊室血压测量,收缩压≥140mmHg和/或舒张压≥90mmHg。24小时动态血压监测平均血压≥130/80mmHg。家庭自测血压≥135/85mmHg应考虑高血压。" }, { "instruction": "常用降压药物有哪些类别?", "output": "一线降压药物包括:ACEI类(如培哚普利)、ARB类(如缬沙坦)、钙通道阻滞剂(如氨氯地平)、利尿剂(如氢氯噻嗪)。选择需根据患者合并症个体化决定。" }, { "instruction": "老年高血压患者的血压控制目标?", "output": "65-79岁老年高血压患者血压目标<140/90mmHg,如能耐受可降至<130/80mmHg。≥80岁老年人血压目标<150/90mmHg。降压过程应平稳,避免过快过低。" }, # 心脏病相关 { "instruction": "心肌梗死的典型症状有哪些?", "output": "心肌梗死典型症状:胸骨后压榨性疼痛,可放射至左肩、下颌、背部,持续20分钟以上,伴出汗、恶心、呼吸困难。不典型表现可表现为牙痛、上腹痛等,老年人及糖尿病患者症状可能不典型。" }, { "instruction": "冠心病患者如何进行二级预防?", "output": "冠心病二级预防:阿司匹林100mg每日一次,他汀类药物强化降脂(LDL-C<1.8mmol/L),β受体阻滞剂,ACEI/ARB。同时控制血压<140/90mmHg,血糖达标,戒烟限酒,规律有氧运动。" }, # 用药安全相关 { "instruction": "头孢类药物使用注意事项?", "output": "头孢类药物使用前需询问青霉素过敏史,用药期间及停药后7天内禁止饮酒,可能发生双硫仑样反应(面部潮红、头痛、呕吐、呼吸困难,严重可致死)。肾功能不全者需调整剂量。" }, { "instruction": "华法林的监测指标和目标?", "output": "华法林治疗需监测INR(国际标准化比值),目标范围通常为2.0-3.0,机械瓣膜患者为2.5-3.5。初始治疗需频繁监测,稳定后每4周监测一次。注意多种药物和食物会影响药效。" }, # 诊断鉴别相关 { "instruction": "胸痛的鉴别诊断有哪些?", "output": "胸痛需鉴别:心源性(心绞痛、心肌梗死、心包炎)、呼吸系统(肺栓塞、肺炎、气胸)、消化系统(胃食管反流、食管痉挛)、 musculoskeletal(肋软骨炎)、焦虑症等。需结合疼痛性质、持续时间、诱发缓解因素判断。" }, { "instruction": "腹痛的定位诊断意义?", "output": "右上腹痛:肝胆疾病;上腹痛:胃十二指肠、胰腺;右下腹痛:阑尾炎;左下腹痛:降结肠、妇科疾病;弥漫性腹痛:肠梗阻、腹膜炎。结合体征、实验室检查和影像学综合判断。" }, # 检验指标相关 { "instruction": "肝功能检查主要指标和意义?", "output": "ALT、AST反映肝细胞损伤,正常值<40U/L;ALP、GGT反映胆道疾病;总胆红素正常值<21μmol/L;白蛋白反映肝脏合成功能,正常值35-55g/L。异常需结合临床表现和影像学检查。" }, { "instruction": "肾功能评估指标有哪些?", "output": "血肌酐正常值44-133μmol/L,eGFR(估算肾小球滤过率)正常值>90ml/min/1.73m²。尿蛋白/肌酐比值<30mg/g为正常。慢性肾病分期基于eGFR和尿蛋白水平。" }, # 急诊处理相关 { "instruction": "高热惊厥的急诊处理?", "output": "高热惊厥急诊处理:保持呼吸道通畅,侧卧位防止误吸,地西泮0.3-0.5mg/kg静脉或直肠给药止惊,物理降温,对乙酰氨基酚10-15mg/kg退热。查找感染源,严重或持续惊厥需住院治疗。" }, { "instruction": "过敏性休克的急救措施?", "output": "过敏性休克急救:立即肌注肾上腺素0.3-0.5mg,保持呼吸道通畅,吸氧,建立静脉通道快速补液,糖皮质激素和抗组胺药物辅助治疗。密切监测生命体征,必要时气管插管。" } ] print(f"创建了 {len(medical_data)} 条医疗训练数据") return medical_data def create_precision_test_data(self): """创建精度测试数据""" test_cases = [ { "category": "数值精度", "question": "正常成人的血压范围是多少?", "expected_keywords": ["120", "80", "mmHg", "139", "89"], "description": "测试具体数值记忆" }, { "category": "专业术语", "question": "ACEI类药物的作用机制是什么?", "expected_keywords": ["血管紧张素", "转换酶", "抑制", "醛固酮"], "description": "测试专业术语理解" }, { "category": "安全警示", "question": "头孢类药物可以和酒精一起使用吗?", "expected_keywords": ["禁止", "双硫仑样反应", "7天", "致死"], "description": "测试安全警告" }, { "category": "诊断思路", "question": "右下腹痛可能是什么疾病?", "expected_keywords": ["阑尾炎", "妇科疾病", "泌尿系", "鉴别"], "description": "测试诊断思维" }, { "category": "用药指导", "question": "二甲双胍的禁忌症有哪些?", "expected_keywords": ["肾功能", "eGFR", "禁忌", "乳酸酸中毒"], "description": "测试用药知识" } ] return test_cases def evaluate_model(self, test_cases): """评估模型表现""" print("\n🔍 开始模型评估...") self.model.eval() results = [] for i, test in enumerate(test_cases, 1): prompt = f"你是一名专业的医疗AI助手,请根据以下医学问题提供准确、专业的回答。\n医学问题: {test['question']}\n医学回答要求:\n1. 回答必须包含所有关键医学术语和数值。\n2. 回答必须清晰、准确,避免模糊描述。\n3. 如果问题涉及禁忌或安全警示,必须明确提示。\n医学回答:" inputs = self.tokenizer(prompt, return_tensors="pt", padding=True, truncation=True).to(self.device) with torch.no_grad(): outputs = self.model.generate( **inputs, max_new_tokens=600, temperature=0.8, do_sample=True, pad_token_id=self.tokenizer.eos_token_id, top_k=80, top_p=0.85, num_beams=4, early_stopping=True, repetition_penalty=1.2 ) response = self.tokenizer.decode(outputs[0], skip_special_tokens=True) print(f"模型生成结果: {response}") # 调试输出 answer = response.split("医学回答:")[-1].strip() if not answer: answer = "模型未能生成有效回答,请检查输入或模型状态。" # 计算关键词匹配度 matched_keywords = [] missing_keywords = [] for keyword in test['expected_keywords']: if keyword in answer: matched_keywords.append(keyword) else: missing_keywords.append(keyword) score = len(matched_keywords) / len(test['expected_keywords']) * 100 print(f"\n{i}. [{test['category']}] {test['question']}") print(f" 回答: {answer}") print(f" 得分: {score:.0f}%") print(f" 匹配: {matched_keywords}") print(f" 缺失: {missing_keywords}") results.append({ 'category': test['category'], 'score': score, 'answer': answer, 'matched': matched_keywords, 'missing': missing_keywords }) return results def tokenize_function(self, examples): """分词函数""" prompts = [f"医学问题: {q}\n医学回答:" for q in examples['instruction']] answers = examples['output'] # 编码输入和目标 model_inputs = self.tokenizer( prompts, text_target=answers, max_length=384, padding="max_length", truncation=True, return_tensors="pt" ) return model_inputs def train_model(self, training_data, epochs=3): """训练模型""" print(f"\n🎯 开始模型训练 ({epochs}个epochs)...") # 转换为dataset格式 train_dataset = Dataset.from_list(training_data) tokenized_dataset = train_dataset.map(self.tokenize_function, batched=True) # 训练参数 training_args = TrainingArguments( output_dir="./medical_model_checkpoints", per_device_train_batch_size=2, num_train_epochs=epochs, learning_rate=2e-5, warmup_steps=100, logging_steps=50, save_steps=200, save_total_limit=2, prediction_loss_only=True, remove_unused_columns=False, fp16=True, ) # 自定义训练器以记录损失 class CustomTrainer(Trainer): def __init__(self, *args, **kwargs): self.outer_class = kwargs.pop('outer_class') super().__init__(*args, **kwargs) def log(self, logs): super().log(logs) if 'loss' in logs: self.outer_class.train_losses.append(logs['loss']) trainer = CustomTrainer( model=self.model, args=training_args, train_dataset=tokenized_dataset, outer_class=self ) # 开始训练 trainer.train() print("✅ 训练完成") return trainer def plot_training_progress(self): """绘制训练进度图""" if not self.train_losses: print("没有训练损失数据可绘制") return plt.figure(figsize=(12, 4)) # 损失曲线 plt.subplot(1, 2, 1) plt.plot(self.train_losses, 'b-', alpha=0.7, linewidth=1) plt.title('训练损失曲线') plt.xlabel('训练步数') plt.ylabel('损失值') plt.grid(True, alpha=0.3) # 移动平均 plt.subplot(1, 2, 2) if len(self.train_losses) > 10: window = 10 moving_avg = np.convolve(self.train_losses, np.ones(window)/window, mode='valid') plt.plot(range(window-1, len(self.train_losses)), moving_avg, 'r-', linewidth=2) plt.title('损失移动平均') plt.xlabel('训练步数') plt.ylabel('损失值') plt.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('./training_progress.png', dpi=300, bbox_inches='tight') plt.show() def compare_performance(self, initial_results, final_results): """对比改进效果""" print("\n📊 性能改进对比报告") print("=" * 60) initial_scores = {r['category']: r['score'] for r in initial_results} final_scores = {r['category']: r['score'] for r in final_results} categories = set(initial_scores.keys()) | set(final_scores.keys()) improvement_data = [] for category in categories: initial = initial_scores.get(category, 0) final = final_scores.get(category, 0) improvement = final - initial improvement_data.append({ 'category': category, 'initial': initial, 'final': final, 'improvement': improvement }) print(f"{category:12} | {initial:5.1f}% → {final:5.1f}% | 提升: {improvement:+.1f}%") # 绘制对比图 self.plot_comparison(improvement_data) return improvement_data def plot_comparison(self, improvement_data): """绘制对比图""" categories = [item['category'] for item in improvement_data] initial_scores = [item['initial'] for item in improvement_data] final_scores = [item['final'] for item in improvement_data] x = np.arange(len(categories)) width = 0.35 plt.figure(figsize=(12, 6)) plt.bar(x - width/2, initial_scores, width, label='改进前', alpha=0.7, color='red') plt.bar(x + width/2, final_scores, width, label='改进后', alpha=0.7, color='green') plt.xlabel('能力类别') plt.ylabel('得分 (%)') plt.title('医疗专业能力改进对比') plt.xticks(x, categories, rotation=45) plt.legend() plt.grid(True, alpha=0.3) # 添加数值标签 for i, (init, final) in enumerate(zip(initial_scores, final_scores)): plt.text(i - width/2, init + 1, f'{init:.0f}%', ha='center', va='bottom') plt.text(i + width/2, final + 1, f'{final:.0f}%', ha='center', va='bottom') improvement = final - init plt.text(i, max(init, final) + 5, f'+{improvement:.0f}%', ha='center', va='bottom', fontweight='bold', color='blue') plt.tight_layout() plt.savefig('./improvement_comparison.png', dpi=300, bbox_inches='tight') plt.show() def save_model(self, output_dir="./improved_medical_model"): """保存改进后的模型""" print(f"\n💾 保存模型到: {output_dir}") os.makedirs(output_dir, exist_ok=True) self.model.save_pretrained(output_dir) self.tokenizer.save_pretrained(output_dir) # 保存训练信息 training_info = { 'base_model': 'Qwen/Qwen1.5-0.5B-Chat', 'training_data_size': len(self.train_losses) * 2, # 估算 'final_loss': self.train_losses[-1] if self.train_losses else None, 'training_epochs': 3, 'improvement_details': '医疗专业能力增强训练' } with open(os.path.join(output_dir, "training_info.json"), 'w', encoding='utf-8') as f: json.dump(training_info, f, ensure_ascii=False, indent=2) print("✅ 模型保存完成") def demo_improved_capability(self): """展示改进后的能力""" demo_questions = [ "糖尿病患者应该如何制定饮食计划?", "高血压急症如何处理?", "胸痛患者需要做哪些检查?" ] print("\n🎯 改进后能力演示") print("=" * 50) self.model.eval() for i, question in enumerate(demo_questions, 1): prompt = f"医学问题: {question}\n医学回答:" inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device) with torch.no_grad(): outputs = self.model.generate( **inputs, max_new_tokens=250, temperature=0.7, do_sample=True, pad_token_id=self.tokenizer.eos_token_id ) response = self.tokenizer.decode(outputs[0], skip_special_tokens=True) answer = response.split("医学回答:")[-1].strip() print(f"\n{i}. 问题: {question}") print(f" 回答: {answer}") print("-" * 80) def main(): """主函数""" try: print("🚀 开始Qwen1.5-0.5B-Chat医疗专业能力改进流程") # 初始化改进器 improver = MedicalModelImprover() # 1. 创建训练数据 print("\n" + "="*50) print("步骤1: 准备训练数据") training_data = improver.create_simulated_medical_data() # 2. 初始评估 print("\n" + "="*50) print("步骤2: 初始能力评估") test_cases = improver.create_precision_test_data() initial_results = improver.evaluate_model(test_cases) initial_avg_score = np.mean([r['score'] for r in initial_results]) print(f"\n📈 初始平均得分: {initial_avg_score:.1f}%") # 3. 模型训练 print("\n" + "="*50) print("步骤3: 专业能力训练") improver.train_model(training_data, epochs=3) # 4. 训练进度可视化 improver.plot_training_progress() # 5. 最终评估 print("\n" + "="*50) print("步骤4: 改进后评估") final_results = improver.evaluate_model(test_cases) final_avg_score = np.mean([r['score'] for r in final_results]) print(f"\n📈 最终平均得分: {final_avg_score:.1f}%") # 6. 性能对比 print("\n" + "="*50) print("步骤5: 性能改进分析") improvement_data = improver.compare_performance(initial_results, final_results) # 7. 能力演示 improver.demo_improved_capability() # 8. 保存模型 print("\n" + "="*50) print("步骤6: 保存改进模型") improver.save_model() # 总结报告 print("\n🎉 改进完成总结") print("=" * 50) print(f"初始平均得分: {initial_avg_score:.1f}%") print(f"最终平均得分: {final_avg_score:.1f}%") print(f"总体提升: {final_avg_score - initial_avg_score:+.1f}%") print(f"提升比例: {(final_avg_score - initial_avg_score) / initial_avg_score * 100:.1f}%") return improver.model, improver.tokenizer except Exception as e: print(f"❌ 错误: {e}") import traceback traceback.print_exc() if __name__ == "__main__": result = main() if result is not None: improved_model, improved_tokenizer = result else: print("❌ 主函数未返回有效结果,请检查错误日志。")
重点说明:
1. 双模型协同架构:Draft 使用Qwen1.5-0.5B(小模型)快速预测,Target使用Qwen1.5-1.8B(大模型)校验,不再共用同一模型
2. Draft 自回归贪心生成:基于上下文逐token做 top-1 贪心解码,不加人工扰动,小模型与大模型的参数差异自然导致预测分歧
3. Target一次前向批量校验:将context + draft_tokens拼接后一次前向,从对应位置的logits取 argmax与draft逐位对比,得出匹配标记和标准Token
4. 匹配复用 + 错误修正:从首位开始截断连续匹配的draft token直接追加,遇到首个不匹配则用 Target 的标准token替换,保证最终文本由大模型拍板
5. 四步调度流水:SpeculativeDecoder 每轮依次执行Draft生成 → Target 校验 → 截断匹配/修正错误 → 终止判断(EOS或200 token上限)
输出结果:
我们采用“今天天气”开头进行生成,截取前10轮结果进行详细分析;
正在加载本地Qwen模型...
使用设备: cpu
加载 Draft 小模型(Qwen1.5-0.5B-Chat)...
✅ Draft 模型 Qwen1.5-0.5B-Chat 加载成功
加载 Target 大模型(Qwen1.5-1.8B-Chat)...
✅ Target 模型 Qwen1.5-1.8B-Chat 加载成功
初始输入Prompt:今天天气,编码Token前10位:[100644, 104307]...(总长2)
===== 第1轮投机解码 =====
Draft预生成候选Token序列(前10):[105212, 100106, 3837]
各位置匹配标记:[True, True, True]...,主模型标准Token:[105212, 100106, 3837]...
本轮有效匹配Token数量:3,已追加至上下文
本轮所有候选Token全部匹配,无需主模型单独生成
当前已生成文本:今天天气晴朗,
===== 第2轮投机解码 =====
Draft预生成候选Token序列(前10):[106447, 106551, 3837]
各位置匹配标记:[False, False, True]...,主模型标准Token:[104166, 99340, 3837]...
本轮有效匹配Token数量:0,已追加至上下文
第1位预测错误,替换为主模型标准Token:104166
当前已生成文本:今天天气晴朗,阳光
===== 第3轮投机解码 =====
Draft预生成候选Token序列(前10):[117716, 3837, 106447]
各位置匹配标记:[True, True, False]...,主模型标准Token:[117716, 3837, 105786]...
本轮有效匹配Token数量:2,已追加至上下文
第3位预测错误,替换为主模型标准Token:105786
当前已生成文本:今天天气晴朗,阳光明媚,我和
===== 第4轮投机解码 =====
Draft预生成候选Token序列(前10):[102644, 101039, 85336]
各位置匹配标记:[False, False, True]...,主模型标准Token:[110961, 110926, 85336]...
本轮有效匹配Token数量:0,已追加至上下文
第1位预测错误,替换为主模型标准Token:110961
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈
===== 第5轮投机解码 =====
Draft预生成候选Token序列(前10):[110926, 102077, 99366]
各位置匹配标记:[True, True, False]...,主模型标准Token:[110926, 102077, 109280]...
本轮有效匹配Token数量:2,已追加至上下文
第3位预测错误,替换为主模型标准Token:109280
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈一起去公园游玩
===== 第6轮投机解码 =====
Draft预生成候选Token序列(前10):[1773, 151645, 198]
各位置匹配标记:[False, False, True]...,主模型标准Token:[8997, 102077, 198]...
本轮有效匹配Token数量:0,已追加至上下文
第1位预测错误,替换为主模型标准Token:8997
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈一起去公园游玩。
===== 第7轮投机解码 =====
Draft预生成候选Token序列(前10):[97639, 101140, 107988]
各位置匹配标记:[False, True, True]...,主模型标准Token:[102077, 101140, 107988]...
本轮有效匹配Token数量:0,已追加至上下文
第1位预测错误,替换为主模型标准Token:102077
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈一起去公园游玩。
公园
===== 第8轮投机解码 =====
Draft预生成候选Token序列(前10):[102073, 109458, 100601]
各位置匹配标记:[False, False, True]...,主模型标准Token:[69249, 109398, 100601]...
本轮有效匹配Token数量:0,已追加至上下文
第1位预测错误,替换为主模型标准Token:69249
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈一起去公园游玩。
公园里
===== 第9轮投机解码 =====
Draft预生成候选Token序列(前10):[101194, 109458, 3837]
各位置匹配标记:[False, False, True]...,主模型标准Token:[99679, 105664, 3837]...
本轮有效匹配Token数量:0,已追加至上下文
第1位预测错误,替换为主模型标准Token:99679
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈一起去公园游玩。
公园里绿
===== 第10轮投机解码 =====
Draft预生成候选Token序列(前10):[99613, 12857, 111600]
各位置匹配标记:[True, True, True]...,主模型标准Token:[99613, 12857, 111600]...
本轮有效匹配Token数量:3,已追加至上下文
本轮所有候选Token全部匹配,无需主模型单独生成
当前已生成文本:今天天气晴朗,阳光明媚,我和爸爸妈妈一起去公园游玩。
公园里绿树成荫
结果分析:
| 轮次 | 匹配标记 | 有效命中 | 产出 |
| 1 | [T,T,T] ✅ |
3 | "今天天气晴朗," |
| 2 | [F,F,T] |
0 | "阳光" |
| 3 | [T,T,F] |
2 | "明媚,我和" |
| 4 | [F,F,T] |
0 | "爸爸妈妈" |
| 5 | [T,T,F] |
2 | "一起去公园游玩" |
| 6 | [F,F,T] |
0 | "。" |
| 7 | [F,T,T] |
0 | "公园" |
| 8 | [F,F,T] |
0 | "里" |
| 9 | [F,F,T] |
0 | "绿" |
| 10 | [T,T,T] ✅ |
3 | "树成荫" |
1. 匹配趋势:先强后弱再收敛
- 第1轮:Draft和Target在"今天天气晴朗,"完全一致,大小模型对高频句式认知高度对齐
- 第2~9轮:连续8轮首 token全部不匹配,说明0.5B在细节选词上与1.8B 有系统性偏差,"阳光" vs 其他形容词、"绿" vs 其他修饰词等
- 第10轮:全对匹配,在上下文"公园里绿"之后两个模型都预测"树成荫",固定中文搭配面前,大小模型重新归一到同一路径
2. 为什么连续多轮首 token 都不对
这是0.5B和1.8B参数量级差距的真实反映
- 大模型对低频/特定语境 token 有更精确的分布估计
- 小模型倾向于输出更常见的替代 token,导致首 token 高频偏差
3. 整体效率
10轮产出约20个token,其中draft直接命中10个(第1轮3个+第3轮2个+第5轮2个+第10轮3个)。相当于节省了10次大模型单步推理,约2倍加速,符合k=3时投机解码的理论加速比 ≈(1+αk)/(1+α)(α为单token接受率约50%)。
总的来说,0.5B和1.8B之间既有高频句式上的共识,第1/10轮全命中,也有细节选词上的分歧,第2~9轮首token持续不匹配,最终输出文本流畅自然、由大模型全程自动生成。
七、总结
投机解码本质是用低成本猜测换并行批量校验,依靠Draft小模型提前批量生成候选文本,借助GPU并行算力一次性完成多Token核验,打破原生大模型逐字串行生成的性能瓶颈。
投机解码不是完美无缺的万能优化方案,复杂推理场景存在性能衰减,但在绝大多数线上高频生成场景中,它都是平衡速度、输出质量、改造成本的最优选择,也是现阶段大模型推理优化体系中不可或缺的核心并行技术。对我们开发部署过程中,是一个尝试的优选,当然技术道路千千万,找到合适的才是最重要的,做一个简单的了解也是个不错的累积。