第1728篇:提示词压缩技术——在保持效果的前提下减少Token消耗
第1728篇:提示词压缩技术——在保持效果的前提下减少Token消耗
有一个数字,我在给团队分享的时候经常提到:我们某个业务线,最高峰一天处理120万次AI对话请求,每次对话的平均系统提示长度是2400个Token。
单单系统提示这一项,一天就是28.8亿Token。
按GPT-4的定价算,这是多少钱?而且这是2024年上半年的数据,那时候大模型还没降到现在这个价格。
这个数字让我非常认真地对待Prompt压缩这个课题。
为什么Prompt会变胖
先说说Prompt是怎么一步步变胖的,这个过程很多团队都经历过。
第一阶段:写了一个简洁的系统提示,效果勉强够用。
第二阶段:发现一类边界情况处理不对,加了一段处理指令,Prompt增加了50字。
第三阶段:又发现另一个问题,加了一段,又增加了80字。
第四阶段:客户投诉某种格式不规范,加了格式说明,增加了120字。
...
第N阶段:系统提示已经有3000字了,里面有大量相互重叠的约束,有些已经过时但没人敢删,有些措辞极其冗长,有些内容直接复制粘贴了两次。
这不是夸张,这是我实际见过的真实情况。Prompt的膨胀是一个自然过程,需要主动去对抗。
压缩前的诊断
在压缩之前,先要诊断Prompt里有哪些类型的"脂肪":
@Service
public class PromptDiagnosticService {
private final LLMClient llmClient;
/**
* 对Prompt进行全面诊断,找出可优化的部分
*/
public DiagnosticReport diagnose(String prompt) {
String diagnosisPrompt = """
你是一位提示词优化专家,请对以下提示词进行全面诊断。
待诊断的提示词:
%s
请从以下角度分析并找出问题:
1. 冗余信息:
- 重复表达同一意思的语句(即使措辞不同)
- 不必要的客套语、过渡语
- 能合并的相似约束条件
2. 过度详细:
- 对于模型本身就会做的事情的过度解释
- 显而易见的规则说明(如"不要输出无关内容")
3. 低效表达:
- 可以用更短的方式表达的长句子
- 可以用结构化格式替代的散文描述
4. 过时内容:
- 可能是历史遗留的、与当前任务不相关的指令
请对每个发现的问题,指出:
- 问题类型
- 具体位置(引用原文片段)
- 预估可节省的Token数
- 修改建议
以JSON格式输出诊断报告。
""".formatted(prompt);
String result = llmClient.complete(diagnosisPrompt);
return parseDiagnosticReport(result);
}
/**
* 计算实际Token数(使用tiktoken近似算法)
*/
public int countTokens(String text) {
// 对中文来说,大约每个汉字1-1.5个token
// 对英文来说,大约每4个字符1个token
// 这里用一个简化的估算方法
long chineseCount = text.chars()
.filter(c -> Character.UnicodeBlock.of(c) == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS)
.count();
long otherCount = text.length() - chineseCount;
return (int)(chineseCount * 1.2 + otherCount / 4.0);
}
}五种压缩技术
技术一:语义去重
找出表达相同意思但措辞不同的语句,保留一个,删除其他的。
这是最安全的压缩方式,因为删掉冗余内容不会丢失任何语义信息。
public class SemanticDeduplicator {
private final EmbeddingClient embeddingClient;
private static final double DUPLICATION_THRESHOLD = 0.92; // 相似度超过0.92认为是冗余
/**
* 检测Prompt中的语义重复片段
*/
public List<DuplicationGroup> detectDuplicates(String prompt) {
// 按句子或段落拆分
List<String> segments = splitIntoSegments(prompt);
List<float[]> embeddings = segments.stream()
.map(embeddingClient::embed)
.collect(Collectors.toList());
List<DuplicationGroup> groups = new ArrayList<>();
boolean[] processed = new boolean[segments.size()];
for (int i = 0; i < segments.size(); i++) {
if (processed[i]) continue;
DuplicationGroup group = new DuplicationGroup();
group.addSegment(i, segments.get(i));
processed[i] = true;
for (int j = i + 1; j < segments.size(); j++) {
if (processed[j]) continue;
double similarity = cosineSimilarity(embeddings.get(i), embeddings.get(j));
if (similarity >= DUPLICATION_THRESHOLD) {
group.addSegment(j, segments.get(j));
processed[j] = true;
}
}
if (group.size() > 1) {
groups.add(group);
}
}
return groups;
}
}真实案例:我分析过一个Prompt,里面有这三句话出现在不同位置:
// 第12行:
"如果用户问到与产品无关的问题,请委婉拒绝并引导回产品话题。"
// 第28行:
"对于超出服务范围的问题,不要直接回答,而是告知用户这超出了你的职责范围。"
// 第45行:
"你只负责回答关于我们产品的问题,其他话题不在你的服务范围内。"三句话说的本质上是同一件事,只保留一句最清晰的就够了。光这一处就能节省约80个Token。
技术二:结构化压缩
把散文式描述改成结构化的列表或表格格式。同样的信息量,结构化表达通常更紧凑。
改前(散文式,约120 Token):
你是一位专业的客服助手,你需要以礼貌、专业的方式回答用户的问题。
在回答时,你应该准确理解用户的意图,给出针对性的解答。
如果用户的问题不清晰,你可以主动询问以获得更多信息。
回答时要简洁明了,避免过于冗长。
对于不确定的信息,不要猜测,而是如实告知用户你需要确认。改后(结构化,约80 Token):
角色:专业客服助手
行为规范:
- 准确理解用户意图,给出针对性解答
- 问题不清晰时主动询问
- 回答简洁明了
- 不确定的信息如实告知,不猜测节省约33%的Token,语义完全保留。
技术三:条件压缩
把"如果...那么...否则..."的条件逻辑压缩为更紧凑的表达。
改前:
如果用户问的是技术问题,请提供详细的技术解答,并且可以包含代码示例。
如果用户问的是账单问题,请提供清晰的账单说明,必要时引导用户联系财务部门。
如果用户问的是使用教程,请一步一步地说明操作流程。
如果用户问的是其他问题,请根据情况灵活处理。改后:
根据问题类型处理:技术问题→详细解答含代码示例;账单问题→清晰说明含财务引导;教程问题→逐步操作流程;其他→灵活处理。Token节省约40%,信息完整保留。
技术四:隐式知识消除
有些指令是在告诉模型它"本来就知道"的事情,可以删掉。
常见的可以删掉的指令:
- "请使用清晰、易懂的语言"(模型默认就会这么做)
- "确保你的回答是相关的"(废话,模型不会故意不相关)
- "注意语法和拼写正确"(现代大模型基本不需要这个提醒)
- "回答要有帮助"(这是废话级别的指令)
不过要注意:有些"显而易见"的指令在特定场景下是必要的。比如"回答要简洁"在某些模型或某些场景下确实有明显效果,不一定能删。要测试后再决定。
技术五:占位符抽象
把重复出现的长内容抽象成占位符,运行时再展开:
@Service
public class PromptTemplateCompressor {
// 长的常量内容(如详细的格式规范、多条相似的指令)抽象为占位符
private static final Map<String, String> TEMPLATE_CONSTANTS = Map.of(
"{{JSON_FORMAT_RULES}}", """
输出格式:严格JSON,不要markdown代码块,
字符串值用双引号,布尔值不加引号,
null字段仍然输出(值为null),不要省略
""",
"{{HALLUCINATION_GUARD}}", "对于不确定的信息,输出null而非猜测",
"{{CHINESE_PUNCTUATION_RULES}}", "使用全角标点(,。!?:;),代码内用半角"
);
/**
* 构建使用占位符的压缩版Prompt
* 占位符在实际调用时被真实内容替换
*/
public String buildCompressedPrompt(String templatePrompt) {
// 找出可以被占位符替换的长文本片段
String compressed = templatePrompt;
for (Map.Entry<String, String> entry : TEMPLATE_CONSTANTS.entrySet()) {
String placeholder = entry.getKey();
String fullText = entry.getValue().strip();
compressed = compressed.replace(fullText, placeholder);
}
return compressed;
}
/**
* 展开占位符(实际调用LLM之前)
*/
public String expandPlaceholders(String templatePrompt) {
String expanded = templatePrompt;
for (Map.Entry<String, String> entry : TEMPLATE_CONSTANTS.entrySet()) {
expanded = expanded.replace(entry.getKey(), entry.getValue());
}
return expanded;
}
}这个技术的价值在于:在存储和管理层面使用压缩版本(更易阅读,更易维护),在实际调用层面使用展开版本(保证效果)。
自动压缩工具
把以上技术整合成一个自动化工具:
@Service
public class AutoPromptCompressor {
@Autowired
private SemanticDeduplicator deduplicator;
@Autowired
private LLMClient llmClient;
@Autowired
private PromptEvaluator evaluator;
/**
* 自动压缩Prompt,并验证压缩后效果未明显下降
*/
public CompressionResult compress(
String originalPrompt,
List<EvaluationCase> testCases,
double maxAccuracyDrop) {
int originalTokens = countTokens(originalPrompt);
double originalScore = evaluator.evaluate(originalPrompt, testCases).getOverallScore();
log.info("压缩前 - Token数: {}, 效果评分: {}", originalTokens, originalScore);
// 第一步:语义去重
String afterDedup = applySemanticDedup(originalPrompt);
// 第二步:AI辅助压缩
String compressed = llmAssistedCompression(afterDedup);
// 第三步:验证效果
double compressedScore = evaluator.evaluate(compressed, testCases).getOverallScore();
int compressedTokens = countTokens(compressed);
double scoreDrop = originalScore - compressedScore;
if (scoreDrop > maxAccuracyDrop) {
log.warn("压缩后效果下降过多({} > {}),尝试温和压缩", scoreDrop, maxAccuracyDrop);
// 效果下降过多,用更保守的压缩策略重试
compressed = conservativeCompression(originalPrompt);
compressedScore = evaluator.evaluate(compressed, testCases).getOverallScore();
compressedTokens = countTokens(compressed);
}
double compressionRatio = 1.0 - (double)compressedTokens / originalTokens;
log.info("压缩后 - Token数: {}, 效果评分: {}, 压缩率: {:.1f}%",
compressedTokens, compressedScore, compressionRatio * 100);
return CompressionResult.builder()
.originalPrompt(originalPrompt)
.compressedPrompt(compressed)
.originalTokenCount(originalTokens)
.compressedTokenCount(compressedTokens)
.compressionRatio(compressionRatio)
.originalScore(originalScore)
.compressedScore(compressedScore)
.scoreDrop(originalScore - compressedScore)
.build();
}
private String llmAssistedCompression(String prompt) {
String compressionPrompt = """
请对以下提示词进行压缩优化,目标是在不改变语义的前提下,尽可能减少Token数量。
压缩策略(按优先级):
1. 删除重复表达相同意思的语句
2. 将散文描述改为简洁的列表或结构化格式
3. 删除模型本身已知的常识性指令
4. 简化过于冗长的表达方式
5. 合并相似的约束条件
重要:不得改变任何实质性要求,不得删除重要约束,不得降低信息密度。
原始提示词:
%s
请直接输出压缩后的提示词,不要任何解释。
""".formatted(prompt);
return llmClient.complete(compressionPrompt);
}
}实测数据
我对我们三个不同业务线的Prompt做了压缩测试:
| 业务场景 | 压缩前Token | 压缩后Token | 压缩率 | 效果变化 |
|---|---|---|---|---|
| 客服对话 | 1850 | 920 | -50.3% | +1.2%(更清晰反而效果微升) |
| 代码审查 | 2400 | 1380 | -42.5% | -0.8%(可接受范围内) |
| 合同抽取 | 3200 | 1650 | -48.4% | -2.1%(需要补回部分内容) |
平均压缩率接近47%,按我们之前的体量计算,每天节省约13.5亿Token,成本降低将近一半。
压缩的边界:不能压的地方
有些内容绝对不能在压缩中被简化:
精确的输出格式规范:如果你的下游系统依赖JSON的特定字段名,格式规范一个字都不能改。
边界情况的处理逻辑:这些通常是经过反复调试才加进去的,代表着真实的业务需求。
安全性相关的约束:涉及数据安全、合规要求的指令,宁可冗余也不能删。
关键的负面约束("不要做X"):有时候模型的默认行为就是会做X,这个约束是必要的。
// 在自动压缩中标记不可压缩的区域
String promptWithProtectedSections = """
你是一位专业客服助手。
<!-- PROTECT-START: format-spec -->
输出必须严格遵循以下JSON格式:
{
"intent": "用户意图分类",
"sentiment": "positive/negative/neutral",
"response": "回复内容",
"escalate": true/false
}
<!-- PROTECT-END: format-spec -->
回答风格要专业友好……(这部分可以压缩)
""";压缩与效果的权衡
最后说一个容易被忽视的问题:压缩率不是越高越好。
我见过有团队把压缩做成了一个KPI,结果把Prompt压缩到了300个Token,效果从90%掉到了75%。表面上省了钱,实际上用户体验变差,最终还是要花更多钱在客服人工介入上。
合理的压缩目标是:在效果不明显下降的前提下,尽量压缩。"不明显下降"通常可以设为准确率下降不超过1-2%。
超过这个阈值的压缩,除非有极其强烈的成本压力,否则不建议推行。
小结
Prompt压缩不是一次性的优化,而是需要定期进行的维护工作。随着业务迭代,Prompt会不断膨胀,需要周期性地"清理脂肪"。
建议的实践:每季度对关键业务的Prompt做一次压缩诊断,目标是保持Prompt在合理的长度范围内,同时效果不退步。
