不读代码就无法识别模型垃圾输出:从重复文本到全零向量的排查实战
很多人在调模型时都遇到过这类情况模型加载正常、推理命令也执行了但输出的内容就是不能看——要么是一长串重复字符要么是!|endoftext|这类特殊令牌要么向量结果全是nan或者全部相同的数值。这类“垃圾输出”最迷惑人的地方在于它看起来像模型能力问题但绝大多数时候根因都藏在调用模型的代码里。换句话说如果你不读代码、只看输出你很难判断到底是模型没训练好还是预处理、推理参数、后处理逻辑出了毛病。这篇文章从“垃圾输出”这个现象出发梳理一套识别与排查方法重点讲清楚为什么必须回到代码层去定位问题。文章会包含两个完整的 Python 实战案例覆盖文本生成模型和向量模型并在最后给出高频问题的排查清单。1. 为什么识别模型垃圾输出必须回到代码层1.1 “垃圾输出”到底是什么先给“垃圾输出”一个可操作的定义凡是模型在前向推理之后返回的结果无法直接使用或者与业务预期明显不符我们都可以把它归入垃圾输出的范畴。具体到不同模型表现不太一样文本生成模型输出大量重复字符、输出空字符串、输出一堆特殊令牌或者生成内容语义完全错乱。向量模型返回的向量全部相同、全部为0、充满nan或者维度与预期不一致。排序/重排模型返回的分数全部等于0或者排序结果和真实质量没有相关性。分类模型所有样本被分到同一个类别概率输出集中在某个固定值。这些现象有一个共同特征它们不是推理失败而是“推理了但结果不可用”。正因为推理过程没有直接报错很多人才会误以为是模型本身质量差。1.2 模型本身背了多少锅实际排查下来大部分“垃圾输出”的根因并不在模型权重而在模型的外部调用代码。我见过的典型情况包括输入侧代码把 prompt 拼错了例如中文文本被当作英文 tokenizer 处理。推理参数配置不当temperature设置过高导致生成内容发散或者repetition_penalty没有开启导致疯狂重复。后处理代码没有跳过特殊令牌导致!|endoftext|被直接打印出来。模型没有切换到eval()模式dropout 层仍然生效导致每次输出的向量都不一样。池化代码没有考虑attention_mask把 padding 位置的无效表示也算进了最终向量。这些问题的共同点是模型还是那个模型但代码把它“用坏了”。所以识别垃圾输出不能只看输出结果必须去读喂给模型的数据、模型推理时的参数、以及模型输出之后的处理代码。这就是“不读代码就无法识别模型垃圾输出”这句话的真正含义。1.3 读代码要读什么读代码不是漫无目的地翻文件而是带着三个问题去读输入代码到底给模型传了什么。推理代码到底用哪些参数调用了模型。输出代码到底从模型结果里提取了什么。如果这三个问题都能在代码里找到明确答案绝大多数垃圾输出的成因就藏不住了。2. 模型输出链路拆解垃圾产生的五个环节为了系统化排查可以把一次模型调用拆成五个环节2.1 输入构造环节输入代码负责把一条原始文本、一段查询或者一张图片加工成模型能接受的张量。常见错误包括文本没有经过正确的分词预处理。超长文本没有截断导致特征张量溢出或显存不足。多条样本没有统一 padding导致 batch 内张量形状不一致。batch 内 padding 方向错误导致模型把 padding token 当作有效内容。2.2 预处理环节预处理负责把文本变成 token id把 token id 变成input_ids同时生成attention_mask。这一环最容易踩的坑是tokenizer.encode与tokenizer()的混用。encode返回的是 token id 列表而tokenizer()返回的是包含input_ids、attention_mask等键的字典。如果后续代码使用了return_tensorspt但又用错了变量类型张量形状就会出错模型输出自然也会异常。2.3 模型推理环节模型的调用代码看似简单但隐藏的问题不少是否调用了model.eval()。是否在torch.no_grad()下运行。generate方法的解码参数是否合理。模型精度是fp32还是fp16是否与硬件能力匹配。是否传入了pad_token_id、eos_token_id等控制参数。2.4 后处理环节后处理代码负责把模型输出的张量还原成可读内容。常见问题decode时没有设置skip_special_tokensTrue。直接取last_hidden_state的第一个 token 作为句子向量而不是使用池化策略。对向量做了归一化但归一化的维度错了。输出分数没有经过softmax或sigmoid业务方拿到的是 logits。2.5 日志与环境环节这个环节经常被忽略。环境变量、版本号、依赖差异、加速卡算子支持情况都会影响模型输出。例如同样一段代码在 CPU 上跑正常换到特定加速卡上就出现nan这时候如果不看启动日志和算子适配信息很难定位到底是不是代码问题。3. 环境准备与排查工具3.1 运行环境说明本文的实战案例以常见的 Python 深度学习环境为例重点演示排查思路。版本需要根据你的项目实际情况调整下面是一个参考组合Python 3.9 PyTorch 2.x transformers 4.x sentence-transformers 可选如果你的项目使用paddlepaddle、tensorflow或者其他国产框架整体排查思路仍然适用只是具体的 API 名称需要替换。3.2 排查需要哪些工具排查垃圾输出不需要特别复杂的工具有几个顺手的小工具就够了print打印关键张量形状在模型调用前后观察张量尺寸与数据类型。tokenizer.convert_ids_to_tokens把 token id 还原成 token查看输入侧到底切出了什么。torch.isnan/torch.isinf检查输出张量是否包含非法数值。日志文件模型启动时的 warning 和 error 往往比输出结果更有价值。工具越简单反而越容易直击问题本质。4. 实战一文本生成模型输出乱码如何通过读代码定位4.1 复现现象与错误代码假设我们用transformers加载一个中文 GPT 模型目标是输入一句“请介绍一下北京”让模型生成一段介绍。结果模型输出的内容变成了类似下面这样请介绍一下北京北京北京北京北京北京北京北京北京北京北京 !|endoftext|这段输出看起来像是模型在复读又带着一个特殊令牌明显属于垃圾输出。下面是不合理的调用代码# bad_generate.py from transformers import AutoTokenizer, AutoModelForCausalLM tokenizer AutoTokenizer.from_pretrained(uer/gpt2-chinese-cluecorpussmall) model AutoModelForCausalLM.from_pretrained(uer/gpt2-chinese-cluecorpussmall) prompt 请介绍一下北京 inputs tokenizer.encode(prompt, return_tensorspt) outputs model.generate( inputs, do_sampleTrue, top_k50, max_new_tokens128 ) print(tokenizer.decode(outputs[0]))只看输出很多人第一反应是“这个模型太差了”。但如果我们开始读代码会发现问题一串接一串。4.2 读代码第一步模型加载部分先看模型加载model AutoModelForCausalLM.from_pretrained(uer/gpt2-chinese-cluecorpussmall)这行代码本身没有语法问题但缺少两个关键点第一没有调用model.eval()。如果模型包含 dropout 层在推理阶段仍然处于训练模式每次生成结果都会受到随机影响输出的稳定性会很差。第二没有检查 tokenizer 是否包含pad_token。对于很多 GPT 类模型pad_token_id是None但generate方法在生成过程中可能需要 padding此时就会使用默认值甚至直接报错。4.3 读代码第二步输入构造部分继续读输入构造inputs tokenizer.encode(prompt, return_tensorspt)这里埋了一个比较隐蔽的坑tokenizer.encode返回的是 token id 列表转换成的张量它不会返回attention_mask。在generate方法里如果输入长度不够需要 padding模型就缺少足够的掩码信息可能会把 padding token 当成正常内容一起参与计算。更稳妥的写法是使用tokenizer()返回包含input_ids和attention_mask的字典inputs tokenizer(prompt, return_tensorspt, paddingTrue, truncationTrue)4.4 读代码第三步解码参数部分再看生成参数outputs model.generate( inputs, do_sampleTrue, top_k50, max_new_tokens128 )这段代码没有设置pad_token_id、temperature、repetition_penalty。没有pad_token_id时generate内部可能用eos_token_id代替导致生成提前结束。temperature默认是1.0对于中文生成任务来说随机性偏大语言模型容易在局部循环。没有repetition_penalty模型在生成长文本时容易陷入重复这是“复读机”输出的常见原因。4.5 读代码第四步后处理解码部分最后看解码print(tokenizer.decode(outputs[0]))decode默认skip_special_tokensFalse所以生成的!|endoftext|会被原样打印出来。如果业务要求的是干净文本这一步就产生了明显的垃圾输出。4.6 修复后的完整代码结合上面几步整理出修复后的代码# fix_generate.py from transformers import AutoTokenizer, AutoModelForCausalLM import torch tokenizer AutoTokenizer.from_pretrained(uer/gpt2-chinese-cluecorpussmall) model AutoModelForCausalLM.from_pretrained(uer/gpt2-chinese-cluecorpussmall) if tokenizer.pad_token_id is None: tokenizer.pad_token tokenizer.eos_token model.eval() prompt 请介绍一下北京 inputs tokenizer( prompt, return_tensorspt, paddingTrue, truncationTrue, max_length256 ) with torch.no_grad(): outputs model.generate( inputs[input_ids], attention_maskinputs[attention_mask], pad_token_idtokenizer.pad_token_id, do_sampleTrue, top_k50, top_p0.95, temperature0.7, repetition_penalty1.2, max_new_tokens128 ) result tokenizer.decode(outputs[0], skip_special_tokensTrue) print(result)这段修复代码里有四个关键改动检查并补齐pad_token。显式调用model.eval()。使用tokenizer()构造输入保留attention_mask。生成时设置temperature和repetition_penalty解码时跳过特殊令牌。这些改动没有改变模型权重却往往能直接解决“复读机”和“输出特殊令牌”这类垃圾输出。5. 实战二Embedding 模型输出全零向量问题出在哪里5.1 场景说明在 RAG 项目中embedding 模型负责把文本变成向量用于后续相似度检索。某个场景里开发同学反馈两条完全不同的文本向量相似度计算结果约等于1.0把向量打印出来发现全是0。这显然属于垃圾输出。下面我们通过读代码定位原因。5.2 错误代码与现象假设使用transformers加载sentence-transformers/all-MiniLM-L6-v2模型下面是错误的推理代码# bad_embedding.py from transformers import AutoTokenizer, AutoModel import torch tokenizer AutoTokenizer.from_pretrained(sentence-transformers/all-MiniLM-L6-v2) model AutoModel.from_pretrained(sentence-transformers/all-MiniLM-L6-v2) texts [今天天气不错, 明天可能下雨] encoded tokenizer(texts, paddingTrue, truncationTrue, return_tensorspt) output model(**encoded) embeddings output.last_hidden_state[:, 0, :] print(embeddings)这段代码把last_hidden_state的第一个 token 位置当作整个句子的向量。但问题也出在这里。5.3 从模型代码中找问题第一个问题模型没有调用eval()。all-MiniLM-L6-v2模型内部包含 dropout 层。如果没有eval()模型在推理时仍然会随机丢弃部分节点导致每次输出的向量都不同。这个随机性会对下游检索造成很大干扰。第二个问题只取了第一个 token 位置的向量没有考虑attention_mask。当输入是 batch 时短句子会被 padding 到和长句子一样长。第一个 token 位置通常是[CLS]对于这个模型来说直接用第一个位置不一定能代表整个句子。更通用的做法是 mean pooling也就是对所有有效 token 的向量求平均。第三个问题没有把 padding 位置屏蔽掉。如果使用 mean pooling必须结合attention_mask把 padding 位置的向量权重置为0否则被 padding 的部分会拉低整个句子的向量质量。5.4 修复后的代码# fix_embedding.py from transformers import AutoTokenizer, AutoModel import torch import torch.nn.functional as F tokenizer AutoTokenizer.from_pretrained(sentence-transformers/all-MiniLM-L6-v2) model AutoModel.from_pretrained(sentence-transformers/all-MiniLM-L6-v2) model.eval() texts [今天天气不错, 明天可能下雨] encoded tokenizer(texts, paddingTrue, truncationTrue, return_tensorspt) with torch.no_grad(): output model(**encoded) last_hidden output.last_hidden_state attention_mask encoded[attention_mask] mask attention_mask.unsqueeze(-1).float() pooled (last_hidden * mask).sum(dim1) / mask.sum(dim1) embeddings F.normalize(pooled, p2, dim1) print(embeddings)修复后的代码做了三件事添加model.eval()和torch.no_grad()。使用 mean pooling 聚合句子向量并用attention_mask屏蔽 padding 位置。对向量做 L2 归一化保证后续余弦相似度计算稳定。读代码到这里问题就清楚了不是模型输出垃圾而是我们完全没有按照模型推荐的方式去调用它。6. 进阶场景启动模型失败时读日志和读框架代码的思路6.1 现象加速卡环境无法启动 embedding/reranker 模型前面两个例子是模型推理成功但输出异常。还有一种更让人头疼的情况模型压根启动不了。例如在某些国产加速卡服务器上想通过 vLLM 启动 embedding 向量模型和 reranker 模型结果一启动就失败只留下一段报错日志。以昇腾 910b-a2 服务器为例网上经常能搜到“无法通过 vLLM 启动 embedding 和 reranker 模型”的类似反馈。面对这种情况只看“模型文件”或“输出结果”是没有用的因为模型根本没有跑起来。正确思路是去读启动日志和框架源码。6.2 为什么启动日志比模型输出更重要启动日志记录了框架从加载模型、分配显存、编译算子到执行推理的完整过程。失败信息通常会指出具体是哪一个模块不兼容。如果直接去看业务层面的调用代码可能绕过了框架内部真正的约束条件。比如 vLLM 本身是一个偏文本生成场景的推理框架对于 embedding 和 reranker 这类模型不同版本的算子支持和模型类型支持范围差异很大。这时候必须回到框架源码和官方文档去确认而不是盲目认为是模型文件损坏。6.3 通用排查步骤遇到启动失败建议按下面的步骤走停止使用封装脚本直接使用最小命令启动模型缩小问题范围。完整记录启动日志重点看第一个报错出现的模块。查看日志中是否出现了不支持的算子或模型类型例如not supported、unsupported model type之类的关键字。到框架官方文档中确认该模型类型是否在支持列表内。如果框架本身不支持改用原始transformers或者硬件厂商提供的运行时验证模型本身是否正常。这个过程本质上还是读代码——读启动代码、读框架源码、读官方适配清单而不是盯着模型输出猜原因。7. 高频垃圾输出场景与排查清单7.1 典型问题对照表问题现象常见原因解决思路生成文本大量重复缺少repetition_penalty或temperature过高设置temperature0.7左右开启重复惩罚输出包含特殊令牌解码时没有跳过特殊令牌decode时设置skip_special_tokensTrue模型输出每次都不一样没有调用model.eval()dropout 生效在推理前调用model.eval()并用torch.no_grad()向量结果全是 0池化方式错误或 padding 掩码未使用使用 mean pooling结合attention_mask屏蔽 padding向量结果包含nan输入超长、精度切换异常或算子不支持检查输入长度、模型精度和加速卡算子日志embedding/reranker 模型启动失败框架不支持该模型类型硬件算子不匹配读启动日志到官方文档核对支持范围排序分数全是 0对 logits 做了错误处理或测试数据格式异常检查后处理逻辑确认是否要过sigmoid或softmax7.2 排查 checklist遇到垃圾输出按下面清单逐项检查输入文本是否被正确编码token 序列是否包含异常字符。是否构建了attention_maskpadding 是否合理。模型是否处于eval()模式。推理是否包裹在torch.no_grad()中。解码参数是否适合当前任务是否设置了重复惩罚。后处理是否跳过了特殊令牌。输出张量是否包含nan或inf。是否记录了模型版本、依赖版本和日志信息。这套 checklist 基本覆盖了垃圾输出的绝大多数成因。8. 最佳实践把垃圾输出消灭在代码审查阶段8.1 调用方必须做的 5 件事与其等垃圾输出出现了再排查不如在写调用代码时就把规则定清楚。第一把模型加载封装成单独函数统一处理eval()、精度、设备映射。这样所有推理入口都复用同一套初始化逻辑避免某个入口忘记eval()。第二输入构造统一使用 tokenizer 对象不要混用encode和__call__。如果必须用encode要确认返回结果是否包含attention_mask。第三推理参数做成显式配置不要依赖默认值。temperature、top_p、repetition_penalty、max_new_tokens这些参数都要写清楚。第四后处理固定使用skip_special_tokensTrue并对特殊令牌做二次过滤。第五关键张量输出前打印形状和统计量比如向量的均值、方差、是否有nan。这些信息可以快速判断推理是否正常。8.2 记录与复现垃圾输出问题最大的难点在于不可复现。同样的代码今天没毛病明天就出问题或者在这台机器上有问题另一台机器正常。解决思路是记录三样东西模型文件路径和版本号。推理代码对应的 git commit 或版本标签。运行日志和关键参数快照。有了这三样即使问题再次出现也能快速恢复到当时的现场而不是靠“记性”去排查。8.3 从源头减少垃圾输出还有一类“垃圾输出”来自 prompt 设计本身。比如业务方希望模型做整理却给了几个互相矛盾的指令模型输出自然左右摇摆。这类问题同样不能靠看输出解决必须去读构造 prompt 的代码。检查 prompt 模板里是否包含预期以外的占位符检查历史对话是否按正确顺序拼接检查系统提示是否被用户输入覆盖。这些都在代码里。9. 总结模型垃圾输出是每个算法工程师和开发人员都会遇到的问题但它很少真的由模型权重本身引起。大多数情况下问题出在调用代码的某个细节pad_token_id没设置、eval()没调用、attention_mask没传、后处理没跳过特殊令牌或者推理框架根本不支持当前模型类型。“不读代码就无法识别模型垃圾输出”不是一句口号而是一条实际排查经验。模型输出永远只代表结果真正决定结果质量的是从输入构造到模型推理再到后处理整条链路上的每一行代码。下次再遇到模型输出不对劲先把报错和输出放一边打开调用代码从输入开始逐行往下读。当你把整条链路在脑子里过完一遍垃圾输出的位置通常也就自然浮出来了。