AI重复惩罚:这次怎么落地的
批量生成博客草稿时,同一个 prompt 连跑十几遍,头几次结构还正常,后面就开始出现整段复制:「首先…其次…最后」的骨架固定下来,连举例子的产品名都懒得换。长对话项目里我也见过模型自己接自己话茬,越聊越窄。
根因是生成时高概率 token 被反复强化,最后掉进自循环。这篇记我从粗暴加 penalty 到按场景调参的过程。
起因:为什么又要研究重复惩罚?
前几天在优化我的博客生成流程时,发现一个很奇怪的现象:同一个 prompt 让 GPT-4o 写技术文章,第一次生成质量还不错,连续跑了十几次后,输出的内容开始变得重复。
具体表现是:
- 段落结构和措辞开始固化
- 同样的技术解释在多个地方反复出现
- 甚至有些例子直接复制粘贴
这不是偶然。我在之前的对话式 AI 项目中也遇到过类似问题——模型在长对话中容易陷入"自循环"。
问题本质:AI 模型在生成时倾向于选择概率高的 token,而某些组合(尤其是开头和结尾)会因为贝叶斯增强效应变得越来越强,最终形成"死循环"。
这篇文章就是记录我如何从"简单粗暴"到"精细调优"的过程,把重复惩罚机制从理论落实到实践。
需求:到底要解决什么?
先明确一下实际场景和限制条件:
实际场景
- 批量生成:需要连续生成 50-100 篇类似主题的文章
- 长文档生成:单次生成 5000-10000 字的技术文档
- 对话场景:连续 20-30 轮对话,保持内容新鲜度
现实限制
- API 成本:不能无限制调参测试
- 生成质量:不能为了去重牺牲内容的连贯性
- 响应速度:后处理方案不能太慢
- 兼容性:需要适配不同模型(GPT、Claude、本地模型)
核心诉求
- 控制重复率:将 n-gram 重复率控制在 10% 以下
- 保持连贯性:不能因为强制去重导致句子逻辑断裂
- 性能可接受:增加的延迟不超过 20%
初版方案:直接用 repetition_penalty
最直接的方案是使用模型自带的 repetition_penalty 参数。
基本原理
Python 实现
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
def generate_with_repetition_penalty(model, tokenizer, prompt, penalty=1.1):
inputs = tokenizer(prompt, return_tensors="pt")
outputs = model.generate(
**inputs,
max_length=500,
repetition_penalty=penalty,
temperature=0.7,
top_p=0.9
)
return tokenizer.decode(outputs[0], skip_special_tokens=True)
# 测试不同惩罚值
penalties = [1.0, 1.1, 1.2, 1.5, 2.0]
results = {}
for p in penalties:
text = generate_with_repetition_penalty(model, tokenizer, test_prompt, p)
results[p] = calculate_repetition_rate(text)
第一次踩坑
直接用默认值 repetition_penalty=1.1,问题出现了:
现象:文章确实不重复了,但…
- 词汇变得"生僻",为了避开常用词开始用奇怪的表达
- 句子之间缺乏过渡词,读起来像"翻译腔"
- 技术术语被强行替换(如把"函数"改成"运算单元")
根本原因:简单地对所有重复 token 统一打折扣,没有区分:
- 结构性重复(如"首先…然后…最后…")——需要保留
- 内容性重复(如重复解释同一个概念)——需要惩罚
第二版方案:N-gram 检测 + 分层惩罚
意识到问题后,我改用了更精细的 n-gram 检测策略。
实现思路
- 分层检测:不同长度的 n-gram 用不同惩罚力度
- 位置感知:距离越近的重复惩罚越重
- 词性过滤:忽略虚词、标点等结构性元素
代码实现
import numpy as np
from collections import defaultdict
class SmartRepetitionPenalty:
def __init__(self,
n_gram_weights={2: 0.8, 3: 1.2, 4: 1.5},
distance_decay=0.1,
stop_words=None):
self.n_gram_weights = n_gram_weights
self.distance_decay = distance_decay
self.stop_words = stop_words or {'的', '了', '是', '在', '和', '或', '但'}
def calculate_penalty(self, text_tokens, current_pos, token):
total_penalty = 1.0
for n, weight in self.n_gram_weights.items():
# 检查以当前 token 结尾的 n-gram 是否重复
if current_pos >= n - 1:
n_gram = tuple(text_tokens[current_pos - n + 1: current_pos + 1])
# 检查历史出现位置
positions = self.n_gram_history.get(n_gram, [])
for pos in positions:
# 计算距离衰减
distance = current_pos - pos
decay = np.exp(-self.distance_decay * distance)
# 累加惩罚
total_penalty += weight * decay
return total_penalty
def update_history(self, text_tokens):
for n in self.n_gram_weights.keys():
for i in range(len(text_tokens) - n + 1):
n_gram = tuple(text_tokens[i:i + n])
if n_gram not in self.n_gram_history:
self.n_gram_history[n_gram] = []
self.n_gram_history[n_gram].append(i)
效果对比
| 方案 | 重复率 | 连贯性评分 | 生成速度 |
|---|---|---|---|
| 原始 | 25% | 8.5/10 | 1.0x |
| 简单惩罚(1.1) | 12% | 6.0/10 | 1.0x |
| 分层惩罚 | 8% | 7.8/10 | 1.3x |
踩坑记录:几个容易被忽略的问题
问题1:中英文混杂场景
现象:中文文章中的英文技术名词(如 “API”, “REST”, “JSON”)被错误惩罚。
原因:英文单词通常是单个 token,而中文是字级 token,导致检测粒度不统一。
解决:添加语言检测层,对不同语言用不同 n-gram 设置。
def detect_token_type(token):
# 简单检测:主要是英文
if token.encode('utf-8', errors='ignore').decode() == token:
return 'en' # 英文
return 'zh' # 中文
# 英文用 2-4 gram,中文用 4-8 gram
lang_ngrams = {'en': [2, 3, 4], 'zh': [4, 6, 8]}
问题2:技术术语被迫重写
场景:解释"HTTP 状态码"时,第二次提到变成"网络响应标识",读者理解成本增加。
解决:建立术语白名单,允许专业术语在一定范围内重复。
# 术语白名单(允许重复的词汇)
term_whitelist = {
'HTTP', 'API', 'REST', 'JSON', 'XML',
'函数', '变量', '类', '接口', '方法'
}
def is_term(token):
return token in term_whitelist or any(
token.startswith(t) for t in term_whitelist
)
问题3:性能瓶颈
实测:用上面的分层检测,生成长文本时延迟增加了 30%。
优化方向:
- 滑动窗口:只检测最近的 500 tokens
- 增量计算:只更新受影响的 n-gram 计数
- 向量化:用 numpy 向量操作代替循环
# 滑动窗口优化
WINDOW_SIZE = 500
def calculate_penalty_sliding(self, text_tokens, current_pos, token):
start = max(0, current_pos - WINDOW_SIZE)
window_tokens = text_tokens[start:current_pos]
# 只在窗口内检测
total_penalty = 1.0
for n, weight in self.n_gram_weights.items():
if current_pos >= n - 1:
n_gram = tuple(text_tokens[current_pos - n + 1: current_pos + 1])
# 检查窗口内出现次数
count = window_tokens.count(n_gram)
total_penalty += weight * count
return min(total_penalty, 2.0) # 封顶
最终方案:混合策略
经过多轮迭代,我最终采用了混合策略:
1. 生成前:Prompt Engineering
# 添加多样性指导
system_prompt = """
你是一个技术写作专家。在写作时请注意:
- 使用多样化的表达方式,避免重复用词
- 同一个概念可以用不同的角度解释
- 允许专业术语重复,但避免冗余描述
- 保持段落结构的多样性
"""
2. 生成时:轻量级惩罚
final_config = {
'repetition_penalty': 1.05, # 轻度惩罚
'temperature': 0.75, # 提高一点温度
'top_p': 0.92, # 稍微放宽采样范围
}
3. 生成后:后处理去重
def post_process_dedup(text, threshold=3):
"""后处理:检测并合并重复段落"""
paragraphs = text.split('\n\n')
processed = []
seen_hashes = set()
for para in paragraphs:
# 简单的段落指纹
para_hash = hash(tuple(para.split()[:5]))
if para_hash not in seen_hashes:
processed.append(para)
seen_hashes.add(para_hash)
else:
# 重复段落:保留但标记
processed.append(f"[已合并相似段落: {para[:20]}...]")
return '\n\n'.join(processed)
实际效果:数据说话
测试场景
- 任务:批量生成 50 篇技术文章
- 模型:GPT-4o
- 长度:每篇约 2000 字
对比数据
| 指标 | 原始方案 | 简单惩罚 | 混合策略 |
|---|---|---|---|
| 平均重复率 | 22% | 11% | 7% |
| 可读性评分 | 8.2/10 | 6.5/10 | 8.0/10 |
| 生成时间 | 基准 | 基准 | +5% |
| API 成本 | 基准 | 基准 | 基准 |
| 人工审核率 | 35% | 20% | 12% |
下表数据可视化后,三种方案在「降重复」与「保可读」之间的取舍一目了然:简单惩罚重复率降了但可读性明显下滑,混合策略两者兼顾。

混合策略把重复率压到 7% 的同时,可读性评分仍维持在 8.0/10,是成本与质量最平衡的选择。
质量评估示例
原始输出(有重复):
API 是应用程序编程接口。API 允许不同应用程序之间通信。
API 使用 REST 或 GraphQL 等协议。API 需要认证和授权。
简单惩罚(生硬):
API 是应用程序编程接口。应用程序编程接口让各异软件程序互通。
软件程序运用 REST 亦或 GraphQL 等规范。软件程序需要鉴证及许可。
混合策略(自然):
API 是应用程序编程接口,它允许不同应用之间无缝通信。
现代服务通常采用 REST 或 GraphQL 协议,并通过 API Key 或 OAuth 进行访问控制。
结语:没有银弹
折腾了几个月重复惩罚,我的最大感悟是:“去重"和"流畅"是一对矛盾统一体。
简单粗暴的惩罚虽然能降低重复率,但会牺牲内容的自然性。精细调优虽然效果好,但复杂度难以维护。
最终,我选择了"轻干预"的混合策略:
- 生成前通过 Prompt 指导(成本低)
- 生成时用轻度惩罚(平衡点)
- 生成后简单过滤(兜底)
这个方案不是完美的,但在我的场景下达到了成本、质量和性能的最佳平衡。
给你的建议:
- 先测再调:不同模型对重复惩罚的敏感度差异很大
- 关注上下文:短生成和长生成需要不同的策略
- 留人工空间:完全自动化的去重难免有瑕疵,保留人工审核环节
如果你也在做批量生成或长文本生成,希望这些经验能帮你少走一些弯路。
本文记录了我在优化 AI 生成流程中的实践,如果你有更好的方案或遇到了其他问题,欢迎交流讨论。
版权声明: 本文首发于 指尖魔法屋-AI重复惩罚:这次怎么落地的(https://blog.thinkmoon.cn/post/352-ai-repetition-penalty-loop-fluent-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。