三级裁剪:零 LLM 成本的旧 Tool 结果瘦身术
专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏
本文是模块八第 11 篇,讲解压缩前的预处理裁剪流水线。
作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,就能用代码讲清楚"
- 项目地址:https://github.com/wyg5208/weclaw.git
- 官网地址:https://weclaw.link
- 作者 CSDN:https://blog.csdn.net/yweng18
摘要
本文结构概览: 本文讲解在调用 LLM 生成摘要之前,如何通过三级无损预处理裁剪旧的工具结果,大幅减少 LLM 的输入 token,降低压缩成本。三级分别为 MD5 去重、信息性摘要替换、大参数截断。
背景:上下文压缩需要调用 LLM 生成摘要。但旧消息中可能包含大量冗余的工具输出(重复的文件内容、相似的搜索结果),直接交给 LLM 摘要既浪费 token 又增加成本。
核心问题:如何在不调用 LLM 的前提下,预先减少 40-60% 的旧消息 token?
解决方案:三级裁剪流水线——Pass 1 MD5 去重 + Pass 2 信息摘要替换 + Pass 3 参数截断
关键成果:
- LLM 摘要的输入 token 减少 40-60%
- 零额外 LLM 调用成本
- 近期消息(token-budget 保护区域内)不受影响
适合读者:LLM Agent 开发者,关注成本控制和大窗口模型优化
阅读时长:约 10 分钟
关键词:预处理裁剪、MD5去重、信息摘要、Token优化、成本控制
一、为什么要在压缩前先裁剪?
1.1 数据说话
在一次典型的长对话中,旧消息的 token 分布可能是这样的:
| 消息类型 | Token 数 | 占比 | 信息价值 |
|---|---|---|---|
| 文件读取结果 | 45,000 | 45% | 低(已被模型分析过) |
| 搜索结果 | 15,000 | 15% | 低(重复/过期信息) |
| 代码执行结果 | 10,000 | 10% | 中 |
| 对话内容 | 20,000 | 20% | 高 |
| 工具调用参数 | 10,000 | 10% | 低(旧参数无需保留) |
60% 的 token 给了工具结果,而这些结果的"原始细节"在压缩后只需要一个摘要就够了。
1.2 成本计算
不裁剪: 100K tokens 输入 LLM 生成摘要 → 费用 $0.50
裁剪后: 50K tokens 输入 LLM 生成摘要 → 费用 $0.25
节省: 50%
对于高频使用的应用,这个节省非常可观。
二、三级裁剪流水线
[图片: 三级裁剪流水线图 | 生成方式: 文生图 PROMPT: "A three-stage pipeline diagram for tool result pruning: Stage 1 Deduplication with filter icon showing duplicate messages being removed, Stage 2 Summary Replacement with compress icon showing long text being replaced by one-line summaries, Stage 3 Argument Truncation with scissors icon showing long parameters being shortened, with before/after token counts shown at each stage, clean technical pipeline style, light background"]
原始消息 (100K tokens)
↓
[Pass 1: MD5 去重] → 移除重复的工具结果
↓ (90K tokens)
[Pass 2: 信息摘要替换] → 用一行摘要替换旧的工具结果
↓ (55K tokens)
[Pass 3: 大参数截断] → 截断超过 500 字符的 tool_call 参数
↓ (50K tokens)
↓
LLM 摘要 (输入 50K tokens,比不裁剪节省 50%)
2.1 Token-budget 保护区域
重要:裁剪只针对旧消息,近期消息受 token-budget 保护,不参与裁剪。
PROTECTED_RATIO = 0.30 # 最近 30% token 预算内的消息不裁剪
def get_prunable_range(messages, token_limit):
"""获取可裁剪的消息范围"""
protected_budget = int(token_limit * PROTECTED_RATIO)
accumulated = 0
protected_start = len(messages)
# 从后向前计算保护区域
for i in range(len(messages) - 1, -1, -1):
accumulated += estimate_tokens(messages[i])
if accumulated > protected_budget:
protected_start = i + 1
break
return range(0, protected_start) # 只有这个范围内的消息参与裁剪
三、Pass 1:MD5 去重
3.1 场景:同一个文件被读取多次
Step 1: read_file("/src/main.py") → 返回 8000 tokens 的文件内容
Step 3: read_file("/src/main.py") → 又返回 8000 tokens(内容相同)
Step 7: read_file("/src/main.py") → 再次返回 8000 tokens
同一个文件在长对话中被多次读取(比如每次修改后重新读取验证),产生了 24000 tokens 的冗余。
3.2 实现
import hashlib
def pass1_dedup(messages, prunable_range):
"""Pass 1: MD5 去重——相同内容只保留最后一条"""
seen_hashes = {} # hash → 最后出现的位置
to_remove = set()
MIN_DEDUP_LENGTH = 200 # 只去重超过 200 字符的消息
# 从后向前遍历(保留最新的)
for i in reversed(range(prunable_range.start, prunable_range.stop)):
msg = messages[i]
if msg.get("role") != "tool":
continue
content = msg.get("content", "")
if len(content) < MIN_DEDUP_LENGTH:
continue
# 计算 MD5
content_hash = hashlib.md5(content.encode()).hexdigest()
if content_hash in seen_hashes:
# 重复!标记为删除
to_remove.add(i)
else:
seen_hashes[content_hash] = i
# 移除重复消息
return [m for i, m in enumerate(messages) if i not in to_remove]
3.3 去重效果
去重前: 3 次 read_file("/src/main.py") = 24000 tokens
去重后: 1 次 read_file("/src/main.py") = 8000 tokens
节省: 16000 tokens (67%)
四、Pass 2:信息性摘要替换
4.1 设计思路
对于不同类型的工具,用专门的一行摘要替换完整的工具结果:
[图片: 10 个高频工具摘要示例表 | 生成方式: Markdown 表格]
| 工具 | 原始结果 | 一行摘要 |
|---|---|---|
search_web | 5 条搜索结果(2000 字符) | 搜索 "python async": 返回 5 条结果 |
read_file | 文件内容(8000 字符) | 读取 /src/main.py: 245 行 Python 文件 |
shell_exec | 命令输出(3000 字符) | 执行 "pytest": 37 passed, 0 failed |
write_file | 写入确认(200 字符) | 写入 /src/utils.py: 成功 |
stock_query | 行情数据(1500 字符) | 查询 AAPL: $185.23 (+1.2%) |
web_fetch | 网页内容(5000 字符) | 获取 example.com: 标题 "Example Page" |
analyze_pdf | PDF 解析结果(10000 字符) | 解析 report.pdf: 42 页, 提取 15 段文本 |
list_dir | 目录列表(500 字符) | 列出 /src/: 12 个文件, 3 个子目录 |
create_chart | 图表数据(1000 字符) | 生成图表: 折线图, 5 个数据点 |
translate | 翻译结果(800 字符) | 翻译 EN→ZH: 200 字符文本 |
4.2 实现
# 工具摘要生成器映射
TOOL_SUMMARIZERS = {
"search_web": _summarize_search,
"read_file": _summarize_read_file,
"shell_exec": _summarize_shell,
"write_file": _summarize_write,
"stock_query": _summarize_stock,
# ... 其他工具
}
def pass2_summary_replace(messages, prunable_range):
"""Pass 2: 信息性摘要替换"""
result = list(messages)
for i in range(prunable_range.start, min(prunable_range.stop, len(result))):
msg = result[i]
if msg.get("role") != "tool":
continue
# 查找对应的 tool_call 获取工具名
tool_name = _find_tool_name(messages, msg.get("tool_call_id", ""))
if not tool_name:
continue
# 使用专门的摘要器
summarizer = TOOL_SUMMARIZERS.get(tool_name)
if summarizer:
summary = summarizer(msg.get("content", ""), tool_name)
result[i] = {**msg, "content": summary}
return result
def _summarize_read_file(content, tool_name):
"""read_file 的专门摘要"""
lines = content.count('\n') + 1
# 提取文件路径(通常在结果开头)
path_match = re.search(r'[/\\][\w/\\.-]+\.\w+', content[:200])
path = path_match.group(0) if path_match else "unknown"
# 推断文件类型
ext = path.rsplit('.', 1)[-1] if '.' in path else "unknown"
return f"[read_file] 读取 {path}: {lines} 行 {ext} 文件"
五、Pass 3:大参数截断
5.1 场景:tool_call 的 arguments 过长
{
"role": "assistant",
"tool_calls": [{
"id": "call_001",
"function": {
"name": "write_file",
"arguments": "{\"path\": \"/src/main.py\", \"content\": \"import os\\nimport sys\\n...[5000字符的代码]...\"}"
}
}]
}
tool_call 的 arguments 字段包含了完整的文件内容(5000+ 字符),但在压缩时,这个细节已不需要——write_file 的 tool_result 已经确认写入成功。
5.2 实现
MAX_ARGUMENT_LENGTH = 500 # 超过 500 字符的参数截断
def pass3_truncate_args(messages, prunable_range):
"""Pass 3: 大参数截断"""
result = list(messages)
for i in range(prunable_range.start, min(prunable_range.stop, len(result))):
msg = result[i]
if msg.get("role") != "assistant":
continue
if "tool_calls" not in msg:
continue
for tc in msg["tool_calls"]:
fn = tc.get("function", {})
args = fn.get("arguments", "")
if len(args) > MAX_ARGUMENT_LENGTH:
fn["arguments"] = args[:MAX_ARGUMENT_LENGTH] + "...[truncated]"
return result
六、完整流水线
def prune_tool_results_advanced(messages, token_limit):
"""三级裁剪的完整流水线
Args:
messages: 消息列表
token_limit: 总 token 阈值
Returns:
裁剪后的消息列表(不影响原始列表)
"""
prunable_range = get_prunable_range(messages, token_limit)
result = list(messages)
# Pass 1: MD5 去重
result = pass1_dedup(result, prunable_range)
# 注意:去重可能改变索引,需要重新计算范围
prunable_range = get_prunable_range(result, token_limit)
# Pass 2: 信息摘要替换
result = pass2_summary_replace(result, prunable_range)
# Pass 3: 大参数截断
result = pass3_truncate_args(result, prunable_range)
return result
6.1 实测效果
| 指标 | 裁剪前 | Pass 1 后 | Pass 2 后 | Pass 3 后 | 总计节省 |
|---|---|---|---|---|---|
| Token 数 | 100K | 85K | 55K | 50K | 50% |
| 消息数 | 120 | 105 | 105 | 105 | 12.5% |
| LLM 摘要成本 | $0.50 | $0.43 | $0.28 | $0.25 | 50% |
七、总结与展望
7.1 核心要点回顾
- 预处理裁剪是"免费午餐":零 LLM 成本减少 50% 的摘要输入 token
- 三级流水线层层递进:去重 → 摘要替换 → 参数截断
- 保护区域不裁剪:最近 30% token 预算内的消息完整保留
- 工具专门分支提升摘要质量:不同工具用不同的摘要格式
下期预告:《实战踩坑录:上下文管理的 10 个反直觉 Bug》
- 从阈值失效到正则陷阱
- 从占位累积到 API 不兼容
- 一份完整的工程踩坑清单
敬请期待!
版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。