上下文窗口的切分法则

一、先把预算定下来

窗口 128K token。但实测超过 32K 之后模型注意力开始下降——后面的约束条件会被"遗忘"。所以我把 32K 当作实际天花板,128K 只当安全余量。

定预算的第一步:搞清楚每一类内容花多少 token。

# 上下文预算表 —— 这是我几轮调试后定下来的数字
CONTEXT_BUDGET = {
    "system_prompt":    1500,   # 角色定义 + 行为规范
    "tool_schemas":      500,   # 动态注入的工具 Schema(见上一篇文章)
    "conversation":     8000,   # 最近 N 轮对话(超过则压缩)
    "rag_results":      1000,   # 向量检索 top-5 格式化后
    "tool_results":     1500,   # 单轮工具调用返回值的上限
    "reserved":         5000,   # 永远留着的缓冲,不触碰
}
# 合计:17500 token,占 32K 软限制的 55%

核心原则就一条:每一类内容都有硬上限,超了就裁,没有例外。 最容易失控的是 conversation 和 tool_results,下面逐个讲怎么管。

二、Context Builder:拼接上下文的统一入口

不让各处代码自行拼接上下文,所有上下文拼装走同一个函数:

import tiktoken

enc = tiktoken.encoding_for_model("gpt-4")  # DeepSeek 兼容此编码

class ContextBuilder:
    def __init__(self):
        self.sections = []       # [(name, content, priority), ...]
        self.total_tokens = 0

    def add(self, name: str, content: str, priority: int):
        """
        priority: 1=绝对不能裁, 2=尽量保留, 3=溢出了优先裁
        """
        tokens = len(enc.encode(content))
        self.sections.append((name, content, priority, tokens))
        self.total_tokens += tokens

    def build(self, max_tokens: int = 32000) -> str:
        """组装最终上下文,超出 max_tokens 则从低优先级开始裁"""
        if self.total_tokens <= max_tokens:
            return self._assemble(self.sections)

        # 超出预算了 —— 按优先级从低到高裁切
        trimmed = []
        remaining_budget = max_tokens

        # 高优先级(1)先塞,中优先级(2)再塞,低优先级(3)最后塞
        for priority in [1, 2, 3]:
            for name, content, pri, tokens in self.sections:
                if pri != priority:
                    continue
                if tokens <= remaining_budget:
                    trimmed.append((name, content, pri, tokens))
                    remaining_budget -= tokens
                else:
                    # 塞不下了就截断内容
                    truncated = self._truncate_text(content, remaining_budget)
                    trimmed.append((name, truncated, pri, remaining_budget))
                    remaining_budget = 0
                    break
            if remaining_budget <= 0:
                break

        return self._assemble(trimmed)

    def _truncate_text(self, text: str, max_tokens: int) -> str:
        """按 token 数截断文本,在句号处断开避免截断一半"""
        tokens = enc.encode(text)
        if len(tokens) <= max_tokens:
            return text
        # 取前 max_tokens 个 token,然后找到最后一个完整句子
        truncated = enc.decode(tokens[:max_tokens])
        last_period = truncated.rfind("。")
        if last_period > len(truncated) // 2:
            return truncated[:last_period + 1]
        return truncated + "…"

    def _assemble(self, sections) -> str:
        return "\n\n---\n\n".join(content for _, content, _, _ in sections)

每一轮对话都从头 build,不存在"加着加着就超了"的情况。 预算是硬约束。

三、对话历史压缩:什么时候压、怎么压

对话历史是增长最快的区域。每轮用户+助手+tool call 消耗 500-800 token,十轮就 8000 token。必须压缩。

我的策略:保留最近 4 轮完整对话,更早的轮次用"约束摘要"替代。

class ConversationCompressor:
    def __init__(self, max_full_rounds: int = 4):
        self.max_full_rounds = max_full_rounds

    def compress(self, messages: list[dict]) -> list[dict]:
        """
        messages 是完整的对话历史,每条是 {"role": ..., "content": ...}
        返回压缩后的消息列表
        """
        if len(messages) <= self.max_full_rounds * 2:
            return messages  # 太短,不压缩

        # 切分:最近 4 轮保留完整内容
        recent = messages[-(self.max_full_rounds * 2):]
        old = messages[:-(self.max_full_rounds * 2)]

        # 从旧对话中提取约束条件
        constraints = self._extract_constraints(old)
        # 生成摘要
        summary = self._summarize(old, constraints)

        # 组装:摘要放在最前面,最近对话接在后面
        compressed = [
            {"role": "system", "content": f"[历史对话摘要] {summary}"}
        ]
        compressed.extend(recent)
        return compressed

    def _extract_constraints(self, messages: list[dict]) -> list[str]:
        """
        从旧对话中提取用户明确提出的约束条件。
        这不是让 LLM 做开放性总结,而是用非常窄的 prompt 让 LLM 只输出约束列表。
        """
        user_msgs = [m["content"] for m in messages if m["role"] == "user"]
        combined = "\n".join(f"- {m}" for m in user_msgs)

        prompt = f"""从以下用户消息中提取所有对旅行的明确约束条件。
只输出约束条件列表,每行一条。如果没有约束条件,输出"无"。
约束条件包括但不限于:预算金额、日期范围、交通工具偏好、饮食禁忌、住宿要求。

用户消息:
{combined}

约束条件列表:"""

        response = call_llm(prompt, max_tokens=200)
        return [line.strip("- ") for line in response.split("\n") if line.strip()]

    def _summarize(self, messages: list[dict], constraints: list[str]) -> str:
        """生成极简摘要,核心目标是保留约束条件"""
        constraints_text = "\n".join(f"  - {c}" for c in constraints)
        # 只取对话中用户和助手的文本,去掉 tool call 的 JSON
        dialog_text = self._extract_dialog_only(messages)

        prompt = f"""将以下对话压缩为一段不超过 150 字的摘要。
重点保留:用户问了什么、Agent 给出了什么关键信息。
必须保留以下约束条件:
{constraints_text}

对话内容:
{dialog_text}

摘要:"""

        return call_llm(prompt, max_tokens=200)

    def _extract_dialog_only(self, messages: list[dict]) -> str:
        """只提取 user/assistant 的文本内容,丢弃 tool call 的 JSON"""
        lines = []
        for m in messages:
            if m["role"] == "user":
                lines.append(f"用户: {m['content']}")
            elif m["role"] == "assistant" and isinstance(m.get("content"), str):
                lines.append(f"助手: {m['content'][:200]}")  # 每条回复最多取 200 字
        return "\n".join(lines)

压缩效果的一个实例:

压缩前 8 轮旧对话: ~4200 token
压缩后摘要 + 约束: ~250 token
压缩比: 94%

约束条件提取是这个方案最关键的一步。丢了一般对话内容可以接受,丢了"预算 5000"或者"不吃辣"就得重来。

四、工具返回值的截断

search_hotels 返回 10 个酒店,每个酒店几十个字段。如果原封不动塞进上下文,轻轻松松吃掉 2000 token。GhatGPT 实际上只看名称、价格、评分这三个字段。

截断函数:

def truncate_tool_result(tool_name: str, result: dict, max_tokens: int = 500) -> str:
    """
    工具返回值截断,按工具类型采用不同的保留策略。
    返回 JSON 字符串,确保在 max_tokens 以内。
    """
    if tool_name == "search_hotels":
        # 每家酒店只保留 4 个关键字段
        essential_fields = ["name", "price_per_night", "rating", "short_description"]
        stripped = []
        for hotel in result.get("hotels", [])[:10]:  # 最多 10 家
            stripped.append({k: hotel.get(k, "") for k in essential_fields})
            # 描述截断到 80 字
            if stripped[-1]["short_description"]:
                stripped[-1]["short_description"] = stripped[-1]["short_description"][:80]
        return json.dumps({"hotels": stripped}, ensure_ascii=False)

    elif tool_name == "search_flights":
        # 航班只保留班次号、起降时间、价格
        essential_fields = ["flight_no", "departure_time", "arrival_time", "price", "airline"]
        stripped = []
        for flight in result.get("flights", [])[:5]:
            stripped.append({k: flight.get(k, "") for k in essential_fields})
        return json.dumps({"flights": stripped}, ensure_ascii=False)

    # 通用策略:截断到 max_tokens
    raw = json.dumps(result, ensure_ascii=False)
    tokens = enc.encode(raw)
    if len(tokens) <= max_tokens:
        return raw
    return enc.decode(tokens[:max_tokens]) + "…[已截断]"

对应的 ContextBuilder 调用:

builder = ContextBuilder()

# system prompt: priority=1(绝不能裁)
builder.add("system", system_prompt, priority=1)

# 工具 Schema: priority=2
builder.add("tools", tools_json, priority=2)

# 对话历史: priority=2
builder.add("conversation", conversation_text, priority=2)

# RAG 结果: priority=3(先裁这个)
builder.add("rag", rag_text, priority=3)

# 最新工具结果: priority=1(当前轮要用,不能裁)
builder.add("tool_result", latest_tool_result, priority=1)

final_context = builder.build(max_tokens=32000)

优先级设计逻辑:越靠近当前轮越重要,越远的越可以裁。 这条规则贯穿整个上下文管理。

五、RAG 检索结果格式化

向量库里存的 chunk 带着 markdown 格式、多余换行、代码块标记。直接塞给 LLM 浪费 token。我写了一个格式化器:

import re

def format_rag_results(results: list[dict], top_k: int = 5, max_total_tokens: int = 1000) -> str:
    """
    格式化 RAG 检索结果。

    做了三件事:
    1. 只保留 top-k
    2. 去掉 markdown 标记
    3. 每条加来源标记
    """
    formatted = []
    token_count = 0

    for r in results[:top_k]:
        # 去 markdown 格式
        text = strip_markdown_noise(r["content"])

        # 构建条目
        entry = f"[来源: {r['source']} | 相关度: {r['score']:.3f}]\n{text}"

        entry_tokens = len(enc.encode(entry))
        if token_count + entry_tokens > max_total_tokens:
            break

        formatted.append(entry)
        token_count += entry_tokens

    return "\n\n---\n\n".join(formatted)


def strip_markdown_noise(text: str) -> str:
    """去掉对 LLM 没用的 markdown 噪音"""
    # 去掉代码块标记
    text = re.sub(r'```\w*\n?', '', text)
    # 去掉图片语法
    text = re.sub(r'!\[.*?\]\(.*?\)', '[图片]', text)
    # 多余空白行压缩
    text = re.sub(r'\n{3,}', '\n\n', text)
    # 去掉纯符号分隔线
    text = re.sub(r'^[-=_]{3,}\s*$', '', text, flags=re.MULTILINE)
    return text.strip()

效果:检索结果从 ~1400 token 降到 ~800 token,去掉的全是 LLM 不需要的格式标记。

六、Token 监控:别等超了才发现

上下文管理最大的问题是看不见——你没法肉眼判断现在用了多少 token。我写了一个简单的监控:

class TokenMonitor:
    def __init__(self, warn_threshold: int = 25000):
        self.warn_threshold = warn_threshold

    def check(self, builder: ContextBuilder) -> dict:
        """返回当前上下文的 token 分布"""
        report = {
            "total": builder.total_tokens,
            "sections": {},
            "warning": None,
        }
        for name, _, priority, tokens in builder.sections:
            report["sections"][name] = {
                "tokens": tokens,
                "priority": priority,
                "pct": round(tokens / builder.total_tokens * 100, 1),
            }

        if builder.total_tokens > self.warn_threshold:
            report["warning"] = f"上下文已达 {builder.total_tokens} token,超过 {self.warn_threshold} 警戒线"

        return report

每次调 LLM 之前在日志里打一行:

monitor = TokenMonitor(warn_threshold=25000)
ctx_report = monitor.check(builder)
logger.info(f"[TokenMonitor] total={ctx_report['total']} "
            f"sys={ctx_report['sections']['system_prompt']['pct']}% "
            f"conv={ctx_report['sections']['conversation']['pct']}% "
            f"rag={ctx_report['sections']['rag']['pct']}%")

if ctx_report["warning"]:
    logger.warning(ctx_report["warning"])

七、窗口溢出三级降级策略

builder.total_tokens > 32000 时,按以下顺序裁切:

def apply_degradation(builder: ContextBuilder) -> ContextBuilder:
    """
    三级降级策略,每级做完重新检查,够了就停。
    """
    if builder.total_tokens <= 32000:
        return builder

    # 降级 1: 移除最旧的 RAG 结果(priority=3 的内容先裁)
    builder = _strip_old_rag(builder)
    if builder.total_tokens <= 32000:
        logger.info("降级1: 移除旧 RAG 结果后,窗口恢复正常")
        return builder

    # 降级 2: 压缩对话历史到只保留最近 2 轮完整 + 摘要
    builder = _aggressive_compress(builder)
    if builder.total_tokens <= 32000:
        logger.info("降级2: 激进压缩对话后,窗口恢复正常")
        return builder

    # 降级 3: 缩短 system prompt,只保留角色定义
    builder = _strip_system_prompt(builder)
    logger.warning("降级3: 已缩短 system prompt,Agent 行为可能不稳定")
    return builder


def _strip_old_rag(builder: ContextBuilder) -> ContextBuilder:
    """移除最早添加的 RAG 结果"""
    # 找到所有 rag section,只保留最新添加的两条
    rag_sections = [(i, s) for i, s in enumerate(builder.sections) if s[0] == "rag"]
    if len(rag_sections) <= 2:
        return builder
    # 保留最后两条
    keep_indices = {rag_sections[-1][0], rag_sections[-2][0]}
    builder.sections = [s for i, s in enumerate(builder.sections)
                        if s[0] != "rag" or i in keep_indices]
    builder.total_tokens = sum(s[3] for s in builder.sections)
    return builder


def _aggressive_compress(builder: ContextBuilder) -> ContextBuilder:
    """将对话历史压缩到最近 2 轮"""
    compressor = ConversationCompressor(max_full_rounds=2)
    # 从 builder.sections 中找到 conversation section 并压缩
    # ... (具体实现省略,思路同上文的 ConversationCompressor)
    return builder


def _strip_system_prompt(builder: ContextBuilder) -> ContextBuilder:
    """system prompt 只保留最核心的角色定义,砍掉示例和格式说明"""
    for i, (name, content, pri, tokens) in enumerate(builder.sections):
        if name == "system_prompt":
            # 只保留第一段(角色定义),砍掉后面的格式说明和示例
            core = content.split("\n\n")[0]
            builder.sections[i] = (name, core, pri, len(enc.encode(core)))
            break
    builder.total_tokens = sum(s[3] for s in builder.sections)
    return builder

触发频率很低——跑了几个月,降级 1 触发过几次(长对话 + 大量 RAG),降级 2 触发过两次(多模态图片 + 长对话),降级 3 从来没触发过。这个分布说明预算分配基本合理。

八、完整的 build_context 流程

把上面所有组件串起来,就是每次调 LLM 之前调用的函数:

def build_context(
    user_input: str,
    conversation_history: list[dict],
    rag_results: list[dict],
    tool_results: list[dict],
    matched_tools: list[str],
    target_agent: str,
) -> str:
    builder = ContextBuilder()

    # 1. 系统提示(动态裁切版,不是每次都一样)
    system_prompt = build_agent_prompt(target_agent, matched_tools)
    builder.add("system_prompt", system_prompt, priority=1)

    # 2. 对话历史(压缩后的)
    compressor = ConversationCompressor(max_full_rounds=4)
    compressed_conv = compressor.compress(conversation_history)
    conv_text = "\n".join(m["content"] for m in compressed_conv if isinstance(m.get("content"), str))
    builder.add("conversation", conv_text, priority=2)

    # 3. RAG 结果(格式化 + top-5)
    rag_text = format_rag_results(rag_results, top_k=5, max_total_tokens=1000)
    builder.add("rag", rag_text, priority=3)

    # 4. 工具返回值(截断后的)
    for tr in tool_results[-2:]:  # 只保留最近 2 个工具结果
        truncated = truncate_tool_result(tr["tool"], tr["result"])
        builder.add("tool_result", truncated, priority=2)

    # 5. 触发降级检查
    if builder.total_tokens > 32000:
        builder = apply_degradation(builder)

    return builder.build(max_tokens=32000)

九、投入产出

几个月的运行数据:

指标 数字
平均每轮上下文 token 8K-12K
复杂多轮对话峰值 25K-28K
降级 1 触发率 ~3%
降级 2 触发率 <1%
降级 3 触发率 0%
模型"遗忘约束"的投诉 改造前 ~1/15 次对话,改造后 ~1/60 次

核心经验就两条:

  1. 设硬上限——每一类内容都有 token 配额,超了就裁,不要"弹性"
  2. 按距离定优先级——离当前轮越近的内容越重要,越远越可以压缩