Token-Budget 尾部保护:别再按"轮次"保护上下文了
专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏
本文是模块八第 5 篇,讲解从固定轮次到 token-budget 的精确保护策略。
作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"
- 项目地址:https://github.com/wyg5208/weclaw.git
- 官网地址:https://weclaw.link
- 作者 CSDN:https://blog.csdn.net/yweng18
摘要
本文结构概览: 本文从"保护最近 12 轮"这个看似合理的策略出发,揭示它在实际场景中的粒度问题,然后介绍 token-budget 尾部保护的设计与实现,包括边界对齐、用户消息锚定和 soft ceiling 机制。
背景:上下文压缩时,需要决定"哪些消息保留原文,哪些消息压缩为摘要"。保留的部分称为"尾部",保留策略的质量直接决定压缩后对话的连贯性。
核心问题:固定轮次策略(如"保留最近 12 轮")为什么不够好?如何按 token 预算精确控制保留范围?
解决方案:基于 token-budget 的尾部保护——按实际 token 消耗而非消息条数来划定保护边界,并向前对齐到 user 消息边界。
关键成果:
- 保护精度提升:一条 50KB 文件内容和一条 100 字符消息不再被同等对待
- 边界对齐:永远不会切断 tool_call/tool_result 配对
- 用户锚定:最后一条 user 消息永远不被摘要吞掉
适合读者:LLM Agent 开发者,关注上下文压缩质量优化
阅读时长:约 10 分钟
关键词:尾部保护、Token Budget、边界对齐、用户消息锚定、上下文压缩
一、"保护最近 12 轮"有什么问题?
1.1 一个极端的场景
消息 1 (user): 请帮我分析这个文件
消息 2 (tool): [read_file 返回了 50KB 的文件内容]
消息 3 (assistant): 文件分析如下...
消息 4 (user): 再搜索一下相关新闻
消息 5 (tool): [web_search 返回了 500 字符的摘要]
消息 6 (assistant): 相关新闻如下...
...
消息 23 (user): 好的,总结一下
如果"保护最近 12 轮"意味着保留最后 12 条消息(消息 12-23),那么:
- 消息 2 被压缩:50KB 的文件内容变成摘要——合理,因为文件内容本身可以被摘要保留
- 但如果消息 2 在"保护范围"内:50KB 的文件内容完整保留,挤占了大量窗口空间,导致更多早期消息被压缩
1.2 核心矛盾
| 消息 | Token 数 | "12 轮"策略 | 实际价值 |
|---|---|---|---|
| 文件读取结果 | ~15000 | 保护(完整保留) | 低(可摘要) |
| 用户最新请求 | ~50 | 保护(完整保留) | 高(活跃任务) |
| 搜索结果 | ~500 | 保护(完整保留) | 中 |
问题:固定轮次策略对所有消息"一视同仁",无法按实际 token 消耗和消息价值区分。
二、Token-Budget 设计
[图片: 两种保护策略对比 | 生成方式: 文生图 PROMPT: "Side-by-side comparison of two context protection strategies: Left shows fixed-round protection cutting through a tool call pair with a red dashed line, Right shows token-budget protection with clean boundary at user message highlighted in green, annotated with token counts at each message, technical diagram style, clean white background"]
2.1 核心思想
不是"保留多少条消息",而是"保留多少 token 的消息"。
# Token-budget 尾部保护
TAIL_BUDGET_RATIO = 0.15 # 尾部预算 = 总阈值的 15%
def locate_preservation_boundary(messages, token_limit):
"""从后向前累积 token,找到保护边界
Args:
messages: 消息列表
token_limit: 总 token 阈值
Returns:
int: 保护边界的索引位置(此位置之后的消息保留原文)
"""
tail_budget = int(token_limit * TAIL_BUDGET_RATIO)
accumulated = 0
boundary = len(messages)
# 从后向前遍历
for i in range(len(messages) - 1, -1, -1):
msg_tokens = estimate_tokens(messages[i])
accumulated += msg_tokens
if accumulated > tail_budget:
boundary = i + 1
break
return boundary
2.2 实际效果对比
以 100K token 阈值的对话为例:
旧方案(固定 12 轮):
保留最后 12 条消息 = 约 45000 tokens(包含 30KB 文件内容)
浪费:30000 tokens 给了低价值的文件内容
新方案(token-budget 15%):
tail_budget = 100K * 0.15 = 15000 tokens
从后向前累积,恰好在第 8 条消息处达到 15000 tokens
精确保护了最近 8 条消息,释放了更多空间给摘要
三、边界对齐:不切在 tool_call 中间
3.1 问题
Token-budget 的边界可能恰好落在 tool_call 和 tool_result 之间:
消息 5 (assistant): tool_calls: [call_001] ← 边界在这里
消息 6 (tool): tool_call_id: call_001 ← 这条被截掉了!
结果:tool_call 没有对应的 tool_result,产生孤儿消息。
3.2 向前对齐到 user 消息
def align_to_user_boundary(messages, raw_boundary):
"""向前对齐到最近的 user 消息边界
确保保护范围从一条 user 消息开始,
不会切断 assistant(tool_call) → tool(tool_result) 配对
"""
boundary = raw_boundary
# 向前查找最近的 user 消息
while boundary > 0 and messages[boundary].get("role") != "user":
boundary -= 1
# 如果找不到 user 消息,至少保证不从 tool 消息中间开始
if boundary == 0:
boundary = raw_boundary # 保持原始边界
return boundary
3.3 对齐效果
对齐前:
[msg5: assistant+tool_calls] | [msg6: tool_result] [msg7: user] [msg8: assistant]
↑ 边界(切断了配对!)
对齐后:
[msg5: assistant+tool_calls] [msg6: tool_result] | [msg7: user] [msg8: assistant]
↑ 边界(从 user 消息开始)
四、用户消息锚定:最后一条 user 消息永不被吞
4.1 极端场景
如果用户的最后一条消息很长(比如粘贴了一大段代码),token-budget 可能在到达这条消息之前就耗尽了:
消息 10 (user): 请分析这段代码 [10000 tokens 的代码]
消息 11 (assistant): [分析中...]
消息 12 (user): 继续
tail_budget = 15000 tokens
从后向前:消息 12 (5 tokens) + 消息 11 (200 tokens) + 消息 10 (10000 tokens) = 10205
... 但如果消息 10 是 20000 tokens,就会超出 budget
4.2 锚定机制
def ensure_last_user_preserved(messages, boundary):
"""确保最后一条 user 消息在保护范围内
如果最后一条 user 消息被划入了"待压缩"区域,
将边界前移到它之前
"""
# 找到最后一条 user 消息的位置
last_user_pos = None
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user_pos = i
break
if last_user_pos is not None and last_user_pos < boundary:
# 最后一条 user 消息在边界之前 → 需要调整
boundary = last_user_pos
return boundary
4.3 Soft Ceiling:防止单条大消息独占
如果最后一条 user 消息有 50000 tokens,它会独占整个 tail_budget:
SOFT_CEILING_RATIO = 1.5 # soft ceiling = budget * 1.5
def apply_soft_ceiling(messages, boundary, tail_budget):
"""如果保护区域过大,限制单条消息的 token 贡献"""
soft_ceiling = int(tail_budget * SOFT_CEILING_RATIO)
# 计算实际保护区域的 token 数
protected_tokens = sum(estimate_tokens(m) for m in messages[boundary:])
if protected_tokens > soft_ceiling:
logger.info(f"Soft ceiling triggered: {protected_tokens} > {soft_ceiling}")
# 标记超大消息需要截断(但不删除)
# 后续由压缩引擎决定如何处理
return boundary
五、完整流程:从预算到边界
def compute_tail_protection(messages, token_limit):
"""计算尾部保护的完整流程
Returns:
int: 最终的保护边界索引
"""
# Step 1: 计算 token budget
tail_budget = int(token_limit * TAIL_BUDGET_RATIO) # 15%
# Step 2: 从后向前累积,找到原始边界
accumulated = 0
raw_boundary = len(messages)
for i in range(len(messages) - 1, -1, -1):
accumulated += estimate_tokens(messages[i])
if accumulated > tail_budget:
raw_boundary = i + 1
break
# Step 3: 向前对齐到 user 消息边界
aligned_boundary = align_to_user_boundary(messages, raw_boundary)
# Step 4: 确保最后一条 user 消息被保护
final_boundary = ensure_last_user_preserved(messages, aligned_boundary)
# Step 5: Soft ceiling 保护
final_boundary = apply_soft_ceiling(messages, final_boundary, tail_budget)
return final_boundary
5.1 可视化
[图片: token-budget 累积过程 | 生成方式: Python matplotlib 脚本,水平条形图展示消息列表,从右向左着色累积(绿色=保护, 红色=待压缩), 标注 budget 线位置和对齐后的边界]
# Python 绘图脚本
import matplotlib.pyplot as plt
import numpy as np
messages = [
("user", 200, "请分析文件"),
("tool", 15000, "read_file 结果"),
("assistant", 800, "文件分析..."),
("user", 100, "搜索新闻"),
("tool", 500, "search 结果"),
("assistant", 600, "新闻摘要..."),
("user", 50, "总结一下"),
("assistant", 400, "综合分析..."),
]
budget = 3000 # 假设 tail_budget = 3000 tokens
roles = [m[0] for m in messages]
tokens = [m[1] for m in messages]
labels = [m[2] for m in messages]
# 从后向前累积
accumulated = []
total = 0
for t in reversed(tokens):
total += t
accumulated.insert(0, total)
fig, ax = plt.subplots(figsize=(14, 5))
colors = ['green' if acc <= budget else 'red' for acc in accumulated]
bars = ax.barh(range(len(messages)), tokens, color=colors, alpha=0.7)
# 标注
for i, (bar, label) in enumerate(zip(bars, labels)):
ax.text(bar.get_width() + 50, bar.get_y() + bar.get_height()/2,
f'{label} ({tokens[i]} tok)', va='center', fontsize=9)
ax.axvline(x=budget, color='blue', linestyle='--', label=f'Budget ({budget} tok)')
ax.set_yticks(range(len(messages)))
ax.set_yticklabels([f'{r} #{i}' for i, r in enumerate(roles)])
ax.set_xlabel('Tokens')
ax.set_title('Token-Budget Tail Protection (right-to-left accumulation)')
ax.legend()
plt.tight_layout()
plt.savefig('tail_budget.png', dpi=150)
六、总结与展望
6.1 核心要点回顾
- 固定轮次是"穷人版"保护:无法区分消息的 token 消耗和价值
- Token-budget 按实际需求保护:15% 的预算足够覆盖近期对话
- 边界对齐防止配对断裂:永远不从 tool_call/tool_result 中间切割
- 用户锚定保证连贯性:最后一条 user 消息是"锚点",不能被吞掉
6.2 一个设计决策
"Token-budget 的 15% 比例是如何确定的?"
这个值来自 Hermes-Agent 的实践。太小(如 5%)在短对话中可能只保护 1-2 条消息;太大(如 30%)会挤占摘要空间。15% 在大多数场景下能保护 5-10 条近期消息,是一个不错的平衡点。
下期预告:《结构化摘要:从 6 字段到 10 字段的信息保全术》
- 为什么"活跃任务"字段是最重要的新增
- 10 字段模板的设计思路
- 双语约束前缀的作用与原理
敬请期待!
版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。