面试官问:「上下文窗口都 200K 了,还要压缩吗?」——算完这笔账你就不会再问了

0 阅读16分钟

面试官问:「上下文窗口都 200K 了,还要压缩吗?」——算完这笔账你就不会再问了

《AI Agent 面试 30 问》· 第 07 问 第 04 问讲「信息从哪来」,第 06 问讲「怎么别把事情搞坏」。这篇讲一个更日常、也更烧钱的问题:信息装不下(或者装下了但没用)怎么办。

承接上一篇:稀缺的不是窗口,是注意力

第 06 问里提过一句:工具返回的大段原文不该塞进 Observation。这背后是 Agent 工程里最容易被低估的一件事——

上下文不是存储,是每轮都要重新付费的一片注意力。

第 04 问给过一张「压缩四招」的结论表。这篇把它展开成能落地的方案,并且补三个当时没展开、但面试里最值钱的东西:成本到底怎么涨的、压缩该在哪个时间点做、以及怎么验证压缩没把关键信息压坏。


零、先算笔账:Agent 的 token 消耗是超线性的

普通聊天是「一轮一轮加」,Agent 不一样:每一步都要把此前全部上下文重新发一次。所以 N 步的总 token 不是 O(N),是 O(N²)。

假设每步新增 2K token(一次工具结果 + 一轮 Thought 很轻松就到这个量级):

步数最后一次请求的上下文累计送进模型的 token
12K2K
510K30K
1020K110K
2040K420K

2K × (1+2+...+20) = 420K。一个看起来只是「跑了 20 步」的任务,账单是 40 万 token。

窗口从 8K 涨到 200K,解决的是「能不能装下」,没解决「装进去有没有用」。 三个成本都是窗口变大也消不掉的:

  1. 钱和延迟是线性的:prefill 每轮都得重算,首 token 延迟随上下文变长;
  2. 注意力是稀释的:lost in the middle——长上下文里,模型对开头和结尾利用最好,中间的信息容易被忽略;
  3. 缓存会失效:prompt cache 靠的是前缀完全相同,你每轮往中间插点东西,整段前缀重算,成本直接翻倍。

一句话定调:「压缩不是为了省 token,是为了决定模型把注意力花在哪。这是注意力工程,不是字符串截断。」


一、真题还原

「你们 Agent 跑长了上下文会不会爆?怎么处理的?现在窗口这么大,还需要压缩吗?」

三层考察意图:

  1. 知不知道膨胀源有哪几类。只想到「对话历史」的人,方案一定漏。
  2. 能不能给出分级手段,以及每种的失效场景。滚动摘要有什么代价?截断会丢什么?
  3. 有没有验收意识。压完怎么知道没压坏?这一问几乎没人主动讲,讲了就是压倒性优势。

第 3 层是 90 分和 95 分的分界。


二、60 分 vs 90 分

60 分

「超过一定长度就把最早的对话做个摘要,或者设置 max_tokens 截断一下。现在模型都有 200K 上下文,其实基本够用了。」

三个问题:

  • 只处理了一类膨胀源,Observation、工具 schema、检索注入全没管;
  • 没说摘要会丢什么,也没说什么时候触发;
  • 「基本够用」是最危险的一句——它把「装得下」等同于「用得好」,正好踩中面试官设的陷阱。

90 分

「窗口变大解决的是容量,没解决信噪比和成本,而且 Agent 的 token 是 O(N²) 涨的。 我先把膨胀源分四类:messages 历史、Observation 大块、工具 schema 固定开销、记忆与 RAG 注入,分别给不同手段——历史用滚动摘要和结构化 state,Observation 用相关性抽取和外置引用,schema 用分组路由,检索注入设独立预算。 实现上我把上下文当带优先级的预算来装填:system 硬约束和用户给的 ID、数字设为不可压缩区,其余按优先级先压缩再丢弃,并且把『压了什么、丢了什么』写进 trace。 触发点也有讲究:只在 Observation 落定后、下一次 LLM 调用前这个安全点做,不能压在工具调用中途;静态前缀永远不动,保住 prompt cache。 最后,压缩方案必须过对照 eval——同一批任务跑四组策略,比成功率而不是比省了多少 token;我还会专门放一类『第 1 步埋一个关键约束、第 15 步才用到』的探针题,压坏了的方案在这题上会原形毕露。」


三、先定位:膨胀源其实有四类

多数人只盯着第一类,所以怎么压都不见效。

膨胀源典型体积是否随步数增长主要手段
messages 历史(Thought / Action / Observation 全量回放)每步 1~3K超线性累积滚动摘要、结构化 state
Observation 大块(长文档、大 JSON、日志、网页正文)单条 5K~50K突发相关性抽取、头尾保留、外置引用
工具 schema / 系统提示20 个工具约 4K固定,但每步都付一次分组路由、精简描述
记忆与 RAG 注入每轮 2K~8K每轮重复独立预算、宁少勿多(第 04 问)

第二行和第三行的差别很关键:Observation 是尖峰,schema 是地板税。 前者靠单次治理,后者靠架构治理。

跨篇呼应:工具分组路由不只是为了「模型别选错工具」(第 03 问),也是为了省这笔地板税。 20 个工具的 schema 每步都发,跑 20 步就是 80K token 的固定开销。先选类目、再挂载该类目的 3~5 个工具,能砍掉大半。


四、四种手段,逐个说清代价

手段 1:滚动摘要

做法:system 硬约束 + 早期历史的摘要 + 最近 N 轮原文。中间那段压成一段,两头保持原样。

三个设计要点:

  1. 摘要记「结论和约束」,不记「过程」。「用户要求输出为表格、不要英文、金额单位是万元」必须留;「模型先查了 A 又查了 B」可以扔。
  2. 摘要本身要结构化,别写成一段自由散文。用 已确认事实 / 待办 / 硬约束 / 已排除假设 四个字段,模型读取时比从散文里找信息稳得多。
  3. 增量摘要,不要全量重算。每轮只把「新增的、要淘汰的那几轮」合并进旧摘要,省一次 LLM 调用,也避免反复重写导致漂移。

失效场景(一定要主动说):

风险表现对策
摘要漂移压缩再压缩,意思逐层走样增量 + 结构化字段
否定被吃掉「用户说不要发邮件」被压成「用户说发邮件」硬约束进不可压缩区
细节不可回溯第 3 步的一个订单号被压没了原文外置,摘要里留引用 id
数字/ID 变形2026-11-04 变成「上周」数字、ID、专名永不摘要化

加分句:「摘要不是记忆,是有损的中间表示。所以原文必须外置保存,摘要里只留引用——压缩做的是『暂时不放在眼前』,不是『删掉』。」

触发时机:按阈值触发(如窗口占用 70%),而且要卡在安全点——Observation 已经落定、下一次 LLM 调用之前。绝不能在工具调用中途压缩:那会破坏 messages 与工具结果的对应关系,还会打断 prompt cache 前缀。

手段 2:Observation 截断与外置引用

四种策略,从粗到细:

策略做法适用风险
头尾保留留前 500 字 + 后 500 字,中间标 [已省略 N 字]日志、长文关键信息常在中间
相关性抽取用当前 query 对结果做一次便宜检索,只留 top 片段文档、搜索结果多一次检索开销
结构化精简大 JSON 只留字段名 + 条数 + 统计 + 2 条样本API 返回、表格需要按工具定制
外置引用原文存 artifact,上下文只留 doc_id + 摘要 + 取回方式最通用、最推荐要提供取回工具

外置引用是最有效的一招,因为它把「压缩」变成了「按需检索」——信息根本没丢:

{
  "artifact_id": "art_8f2c",
  "summary": "2026 Q3 财报,共 42 页;营收同比 +12%,分业务明细在第 18-24 页",
  "size": "38KB",
  "read_with": "read_artifact(id, page_range)"
}

但有个必须配套的坑:外置了就得给模型 read_artifact 工具,否则它想取也取不回来,等于变相删除。而且取回时也别一次把 38KB 全塞回去,支持 page_range 或按关键词检索。

手段 3:结构化 state 替代 messages

这是长任务里最治本的一招。

{
  "goal": "整理 Q3 财报并生成简报",
  "constraints": ["输出表格", "金额单位万元", "不含内部往来"],
  "done": [
    {"step": "extract", "result_ref": "art_8f2c"},
    {"step": "validate", "result": "通过,2 处异常已标注"}
  ],
  "pending": ["按业务线汇总", "生成简报"],
  "key_vars": {"fiscal_quarter": "2026Q3", "row_count": 1284},
  "excluded": ["A 口径已被用户否定"]
}

加分句:「messages 是过程,state 是结论。模型下一步需要的是结论,不是把过程重读一遍。」

收益很直接:state 体积基本恒定,不随步数增长,token 消耗从 O(N²) 拉回接近 O(N);而且任务进度是显式字段,模型不用从 40 条消息里推断「我做到哪了」,准确率反而上升(第 04 问 L2 讲过这层)。

代价要说清:得设计 schema;还得维护 state 与 messages 的一致性——通常的做法是state 由代码更新(工具成功后写入),而不是让模型顺手更新,否则它会写出「已完成」但实际没做的假状态。

手段 4:prompt cache 友好的布局

这一招几乎没人提,但它是纯省钱、零风险的。

prompt cache 靠前缀完全一致。所以上下文要按「变动频率」排序:

[永不改动]  system prompt + 工具 schema + 硬约束
[低频变动]  结构化 state、长期记忆召回
[每步追加]  最近 N 轮 messages + 新 Observation
[最后一刻]  当前任务 / 当前指令

两条纪律:

  • 静态部分放在最前面,且一个字都别动;
  • 绝不在前缀里塞时间戳、session_id、随机示例——一个字符变了,整段缓存失效,每步重新计费。

顺带解决 lost in the middle:硬约束放开头,当前任务放结尾,检索结果夹中间——反正中间本来就是最容易被忽略的位置,把可丢弃的东西放那儿。


五、把上下文当「带优先级的预算」来装填

四种手段不该靠 if-else 到处撒,收进一个统一的装配器:

from dataclasses import dataclass
from typing import Callable

@dataclass
class Part:
    name: str
    text: str
    priority: int                      # 数字越小越不能丢
    compressible: bool                 # False = 不可压缩区
    tokens: int
    compress: Callable | None = None   # 该段专用的压缩函数

class ContextBudget:
    def __init__(self, limit):
        self.limit = limit

    def build(self, parts):
        report = {"compressed": [], "dropped": [], "total": 0}
        parts.sort(key=lambda p: p.priority)

        for p in parts:
            if report["total"] + p.tokens <= self.limit:
                report["total"] += p.tokens
                continue

            # 先压,再考虑丢
            if p.compressible and p.compress:
                before = p.tokens
                p.text = p.compress(p.text)      # 摘要 / 头尾 / 外置引用
                p.tokens = tokenize_len(p.text)   # 生产用真实 tokenizer,别按字符数估
                report["compressed"].append({"part": p.name, "before": before,
                                            "after": p.tokens})
            if report["total"] + p.tokens <= self.limit:
                report["total"] += p.tokens
            else:
                report["dropped"].append(p.name)

        text = "\n\n".join(p.text for p in parts
                           if p.name not in report["dropped"])
        return text, report          # report 必须进 trace

三个要点:

  1. 不可压缩区白名单:system 硬约束、用户原话里的数字/ID/专名、checklist、确认过的参数——标 compressible=False,再挤也不动。
  2. 按优先级装填,不是按时间顺序截断。检索注入和早期闲聊相比,显然该先丢后者。
  3. report 必须进 trace。

加分句:「压缩必须可观测。不知道丢了什么,就没法排查『它怎么突然忘了用户第 2 轮的要求』。」 线上这类 bug,八成不是模型变笨了,是某次压缩把约束丢掉了。

一份常用的预算分配(按 32K 窗口举例):

段占比备注
system + 工具 schema10%固定前缀,永不变动
硬约束 + 不可压缩区5%白名单
结构化 state10%体积恒定
记忆 + RAG 注入20%宁少勿多,带时间戳
最近 N 轮 messages30%可压、可丢
留给输出与缓冲25%输出也要占 token

六、怎么验收:压缩的唯一标准不是省了多少

这是全篇最该背下来的一段。

压缩方案必须过对照 eval:同一批任务,跑四组策略——① 不压缩(基线)② 只截断 ③ 滚动摘要 ④ 结构化 state,对比三个数:

指标看什么
端到端任务成功率唯一的一票否决项,掉了就不能上
平均 / P95 token省了多少
P95 首 token 延迟用户体验有没有变好

再单独造一类探针题:

「第 1 步埋一个关键约束(比如『输出一律用万元,不要四舍五入』),第 15 步才用到它。」

这类题能精准区分方案优劣:只做截断和朴素摘要的方案,会在这里大面积翻车;带不可压缩区和结构化 state 的方案稳得住。没有这类长程依赖探针,你的 eval 集会给出虚高的分数。

加分句:「压缩的唯一验收标准不是省了多少 token,是成功率有没有掉。省了 60% token、成功率掉 3 个点,这笔买卖是亏的。」


七、连环追问

Q1:窗口都 200K 了,为什么还要压? 三个理由,按重要性排:① 信噪比——装得下不等于看得见,lost in the middle 和注意力稀释不会因为窗口变大而消失,无关信息越多干扰越大;② 钱和延迟——prefill 是线性的,Agent 还是 O(N²) 累积;③ 缓存——前缀一变,折扣全没。很多时候压缩后的短上下文,效果反而比塞满的原始上下文更好。

Q2:摘要越压越歪怎么办? 四招叠加:增量摘要而非全量重写;结构化字段而非自由散文;数字、ID、否定句、硬约束进不可压缩区;原文外置保留、摘要里留 artifact_id 可回溯。另外加一条护栏:摘要本身也要能被校验——压完之后检查关键字段(金额、日期、ID、约束条数)是否还在,缺了就回退到原文。

Q3:什么时候触发压缩? 按占用阈值(70%)触发,且卡在安全点:Observation 落定后、下一次 LLM 调用前。不要在工具调用中途压(破坏 messages 与结果的对应),不要在流式输出中途压(前缀变了,缓存全废)。触发本身要记进 trace,方便复现。

Q4:压缩和第 04 问的记忆/RAG 怎么分工? 四层各管一段,别互相覆盖:压缩管 L1(当前窗口里哪些暂时不放在眼前)、结构化 state 管 L2(任务进度)、外置引用 + 按需检索管 L3(长期信息)。一个常见错误是把长期记忆和 RAG 结果也纳入滚动摘要——那等于把「可检索的事实」压成「有损的印象」,应该直接丢弃并按需重新召回,而不是摘要。

Q5:怎么防止关键信息被压掉? 白名单(不可压缩区)+ 关键信息显式提升为 state 字段 + 压缩后做字段完整性校验 + 探针题回归。核心原则:宁可丢掉十条已经用过的 Observation,也不要丢一条还没兑现的约束。

Q6:多 Agent 场景下上下文怎么共享? 传结论 + 引用,不传全量轨迹。子 Agent 之间共享 artifact 和结构化 state,别把父 Agent 的 messages 整体复制过去——N 个 Agent 各自维护一份完整轨迹,膨胀是乘积级的,而且互相污染上下文。这一点也是第 09 问 Multi-Agent 通信开销的主要来源。


八、加分项与踩坑

说出来就加分的词 O(N²) 累计 token、注意力工程、lost in the middle、不可压缩区、外置引用 / context offloading、结构化 state 由代码更新、prompt cache 前缀稳定、安全点触发、压缩对照 eval + 长程依赖探针。

别踩的坑

  • 说「200K 窗口够用了,不用压」——把容量问题当成了信噪比问题;
  • 用 messages[:-N] 一刀切——最先被切掉的往往是最开头的硬约束;
  • 让模型自己更新结构化 state——会写出「已完成」但没做的假状态;
  • 外置了原文却不给取回工具——等于变相删除信息;
  • 在 system 里放时间戳或随机数——prompt cache 全废,成本翻倍;
  • 压缩不可观测——出事之后完全没法排查;
  • 只汇报「省了 60% token」不报成功率——面试官一定会追问「那效果呢」。

九、面试前 30 秒版

「窗口变大解决的是容量,没解决成本和信噪比——Agent 每步都要重发全量上下文,token 是 O(N²) 涨的,20 步 2K 增量就是 40 万 token。 我把膨胀源分四类分别治:messages 用滚动摘要和结构化 state,Observation 用相关性抽取和外置引用,工具 schema 用分组路由砍掉每步的固定开销,记忆与 RAG 注入设独立预算。 实现上把上下文当带优先级的预算装填:硬约束、数字、ID 设不可压缩区,其余先压再丢,压了什么丢了什么写进 trace;触发只放在 Observation 落定后这个安全点,静态前缀永不变动以保住 prompt cache。 最后一点我觉得最关键:压缩要过对照 eval,看的是成功率有没有掉,不是省了多少 token。我会专门用『第 1 步埋约束、第 15 步才用』的探针题去打,压坏了的方案在这题上藏不住。」


小结与下期预告

这一题的锚点是**「预算」**:上下文不是容器,是每轮都要重新付费的注意力。压缩的目标不是更短,是让留下的每一段都值得被看见。

下一篇切到检索层:RAG 高频追问——切片、召回、rerank,以及怎么评估检索质量。第 04 问划清了「知识 vs 记忆」的边界,第 08 问就专门拆知识那条通道。


本系列默认以 OpenAI 兼容接口 + Python 为例,示例为教学用最小实现,生产代码需补充鉴权、并发与审计。