面试官问:「上下文窗口都 200K 了,还要压缩吗?」——算完这笔账你就不会再问了
《AI Agent 面试 30 问》· 第 07 问 第 04 问讲「信息从哪来」,第 06 问讲「怎么别把事情搞坏」。这篇讲一个更日常、也更烧钱的问题:信息装不下(或者装下了但没用)怎么办。
承接上一篇:稀缺的不是窗口,是注意力
第 06 问里提过一句:工具返回的大段原文不该塞进 Observation。这背后是 Agent 工程里最容易被低估的一件事——
上下文不是存储,是每轮都要重新付费的一片注意力。
第 04 问给过一张「压缩四招」的结论表。这篇把它展开成能落地的方案,并且补三个当时没展开、但面试里最值钱的东西:成本到底怎么涨的、压缩该在哪个时间点做、以及怎么验证压缩没把关键信息压坏。
零、先算笔账:Agent 的 token 消耗是超线性的
普通聊天是「一轮一轮加」,Agent 不一样:每一步都要把此前全部上下文重新发一次。所以 N 步的总 token 不是 O(N),是 O(N²)。
假设每步新增 2K token(一次工具结果 + 一轮 Thought 很轻松就到这个量级):
| 步数 | 最后一次请求的上下文 | 累计送进模型的 token |
|---|---|---|
| 1 | 2K | 2K |
| 5 | 10K | 30K |
| 10 | 20K | 110K |
| 20 | 40K | 420K |
2K × (1+2+...+20) = 420K。一个看起来只是「跑了 20 步」的任务,账单是 40 万 token。
窗口从 8K 涨到 200K,解决的是「能不能装下」,没解决「装进去有没有用」。 三个成本都是窗口变大也消不掉的:
- 钱和延迟是线性的:prefill 每轮都得重算,首 token 延迟随上下文变长;
- 注意力是稀释的:
lost in the middle——长上下文里,模型对开头和结尾利用最好,中间的信息容易被忽略; - 缓存会失效:prompt cache 靠的是前缀完全相同,你每轮往中间插点东西,整段前缀重算,成本直接翻倍。
一句话定调:「压缩不是为了省 token,是为了决定模型把注意力花在哪。这是注意力工程,不是字符串截断。」
一、真题还原
「你们 Agent 跑长了上下文会不会爆?怎么处理的?现在窗口这么大,还需要压缩吗?」
三层考察意图:
- 知不知道膨胀源有哪几类。只想到「对话历史」的人,方案一定漏。
- 能不能给出分级手段,以及每种的失效场景。滚动摘要有什么代价?截断会丢什么?
- 有没有验收意识。压完怎么知道没压坏?这一问几乎没人主动讲,讲了就是压倒性优势。
第 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 轮原文。中间那段压成一段,两头保持原样。
三个设计要点:
- 摘要记「结论和约束」,不记「过程」。「用户要求输出为表格、不要英文、金额单位是万元」必须留;「模型先查了 A 又查了 B」可以扔。
- 摘要本身要结构化,别写成一段自由散文。用
已确认事实 / 待办 / 硬约束 / 已排除假设四个字段,模型读取时比从散文里找信息稳得多。 - 增量摘要,不要全量重算。每轮只把「新增的、要淘汰的那几轮」合并进旧摘要,省一次 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
三个要点:
- 不可压缩区白名单:system 硬约束、用户原话里的数字/ID/专名、checklist、确认过的参数——标
compressible=False,再挤也不动。 - 按优先级装填,不是按时间顺序截断。检索注入和早期闲聊相比,显然该先丢后者。
report必须进 trace。
加分句:「压缩必须可观测。不知道丢了什么,就没法排查『它怎么突然忘了用户第 2 轮的要求』。」 线上这类 bug,八成不是模型变笨了,是某次压缩把约束丢掉了。
一份常用的预算分配(按 32K 窗口举例):
| 段 | 占比 | 备注 |
|---|---|---|
| system + 工具 schema | 10% | 固定前缀,永不变动 |
| 硬约束 + 不可压缩区 | 5% | 白名单 |
| 结构化 state | 10% | 体积恒定 |
| 记忆 + RAG 注入 | 20% | 宁少勿多,带时间戳 |
| 最近 N 轮 messages | 30% | 可压、可丢 |
| 留给输出与缓冲 | 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 为例,示例为教学用最小实现,生产代码需补充鉴权、并发与审计。