返回博客列表
技术教程2026-05-3016 分钟阅读

Token-Budget 尾部保护:别再按"轮次"保护上下文了

为什么按“轮次”保护上下文是错的:Token-Budget 尾部保护策略详解。

Token-Budget 尾部保护:别再按"轮次"保护上下文了


专栏信息

《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏

本文是模块八第 5 篇,讲解从固定轮次到 token-budget 的精确保护策略。


作者与项目

作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


摘要

本文结构概览: 本文从"保护最近 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 核心要点回顾

  1. 固定轮次是"穷人版"保护:无法区分消息的 token 消耗和价值
  2. Token-budget 按实际需求保护:15% 的预算足够覆盖近期对话
  3. 边界对齐防止配对断裂:永远不从 tool_call/tool_result 中间切割
  4. 用户锚定保证连贯性:最后一条 user 消息是"锚点",不能被吞掉

6.2 一个设计决策

"Token-budget 的 15% 比例是如何确定的?"

这个值来自 Hermes-Agent 的实践。太小(如 5%)在短对话中可能只保护 1-2 条消息;太大(如 30%)会挤占摘要空间。15% 在大多数场景下能保护 5-10 条近期消息,是一个不错的平衡点。


下期预告:《结构化摘要:从 6 字段到 10 字段的信息保全术》

  • 为什么"活跃任务"字段是最重要的新增
  • 10 字段模板的设计思路
  • 双语约束前缀的作用与原理

敬请期待!


版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。