6.2 上下文裁剪与摘要
2.3 节给 minagent 装上了 BudgetedModel,它会在超预算时丢掉最旧的消息,只留一条「已裁剪 N 条」的占位。这解决了「撑爆窗口」,却留下一个隐患:被丢掉的是内容,不是长度——那些结论、数字、未决问题一起没了。这一节把「丢」升级成「压」。
本节把 minagent 推进到「会主动做笔记」:用
compact(messages, budget)保留 system 与最近若干轮,把更早的轮次交给一个可插拔的摘要器压成一段要点。到本节结束,长对话既能收进预算,又不会把关键信息整段抹掉。
6.2.1 从被动截断到主动摘要
先厘清 6.2 与 2.3 的分工,两者互补而非替代:
| 维度 | 2.3 BudgetedModel(被动截断) | 6.2 compact(主动摘要) |
|---|---|---|
| 触发 | 撞上预算上限 | 撞上预算上限 |
| 对旧内容的处理 | 直接丢弃 + 占位 | 交给模型压成摘要 |
| 信息损失 | 高(细节全丢) | 中(保留要点) |
| 额外成本 | 无 | 一次摘要调用 |
| 适合 | 快速兜底、单条超长 | 长任务、结论需延续 |
一句话:截断保长度,摘要保信息。2.3 是「实在放不下就砍」,6.2 是「砍之前先记一笔」。工程上两者常叠在一起——先 compact 摘要,若摘要后仍超预算,再用 trim_to_budget 兜底。
为什么不能只靠摘要、彻底删掉截断?因为摘要也可能失败:摘要器超时、返回空、或压完还是太长。这时必须有一条「无论如何都能收进预算」的兜底路径,trim_to_budget 就是它。稳健的上下文管理是「摘要优先、截断兜底」,而不是二选一。
6.2.2 什么该进摘要、什么该丢
不是所有历史都值得摘要。判断标准是**「后续轮次还会不会用到」**:
| 内容 | 处理 | 理由 |
|---|---|---|
| 已确认的决定、结论 | 进摘要 | 后续推理依赖它们 |
| 关键数字、ID、路径 | 进摘要 | 丢了要重查,代价高 |
| 未决问题、TODO | 进摘要 | 是「接下来要做什么」的线索 |
| 寒暄、重复确认 | 直接丢 | 无信息量 |
| 已失效的中间尝试 | 直接丢 | 只增加噪声 |
这张表的用法是:摘要器只负责把「该留的」提取出来,其余自然被略过。别让摘要器去逐句转述,那样摘要会跟原文一样长,失去压缩的意义。
还有一个常被低估的点:工具结果往往是压缩的大头。一次读取文件、一次网页抓取,动辄几千 token,而它里面真正被后续用到的可能只有几行。这类内容不是「整条摘要」,而是「先抽取再摘要」——把工具结果里的关键字段提出来,再压成一句。这与第 10 章「带引用回答」的思路相通:保留可回溯的锚点,而非全文。
6.2.3 摘要提示词的设计
摘要调用本身也是一次模型调用,它有自己的 system。要点有四:① 给字数上限,否则模型倾向于写长;② 明确要保留的类别(决定、数字、未决问题);③ 禁止编造,摘要只能来自原文;④ 用第二人称或第三人称陈述,避免它把摘要写成对用户的回复。
# minagent/compact.py
SUMMARY_SYSTEM = (
"你是对话摘要器。把较早的对话压缩成不超过 120 字的要点,"
"保留已确认的决定、关键数字与未决问题,不要引入原文没有的新信息。"
)
注意它是独立于主 Agent 的 system 的——摘要器是一个专职小模型调用,不该被主任务的角色设定干扰。这也是分层思想(6.1)的又一次应用:不同职责的调用,配不同的指令层。
为什么强调「不要引入新信息」?因为摘要器在补全缺失上下文时,会不自觉地「合理推测」,把没发生的事写进摘要。一旦这条编造进入历史,后续轮次就会当真,且很难被发现——它看起来就是一段正常的对话记录。所以摘要的措辞要克制,宁可留白,不可脑补。
6.2.4 实现 compact
from __future__ import annotations
from typing import Callable
from minagent.types import Message
from minagent.budget import count_messages
def needs_compaction(messages: list[Message], budget: int) -> bool:
return count_messages(messages) > budget
def render_for_summary(messages: list[Message]) -> str:
return "\n".join(f"{m.role}: {m.content}" for m in messages)
def make_model_summarizer(model) -> Callable[[list[Message]], str]:
"""把「一段被丢弃的对话」交给模型压成摘要文本。"""
def summarize(dropped: list[Message]) -> str:
prompt = [
Message(role="system", content=SUMMARY_SYSTEM),
Message(role="user", content=render_for_summary(dropped)),
]
return model.complete(prompt, tools=[]).message.content
return summarize
make_model_summarizer 返回一个闭包:它接收被丢弃的消息列表,返回一段摘要字符串。把「摘要器」抽象成 Callable[[list[Message]], str] 而不是硬编码某次模型调用,是为了让 compact 可测试——测试时塞一个返回固定字符串的假摘要器即可,不必真的跑模型。
核心的 compact 如下:
def compact(messages: list[Message], budget: int, keep_recent: int = 4,
summarizer: Callable[[list[Message]], str] | None = None
) -> tuple[list[Message], list[Message]]:
"""保留 system + 最近 keep_recent 条,更早的消息压成一条摘要。
返回 (压缩后的消息, 被压缩的原始消息)。未超预算时原样返回、第二项为空。
"""
if not needs_compaction(messages, budget):
return messages, []
system = [m for m in messages if m.role == "system"]
convo = [m for m in messages if m.role != "system"]
if len(convo) <= keep_recent:
return messages, []
old, recent = convo[:-keep_recent], convo[-keep_recent:]
if summarizer is None:
text = f"较早 {len(old)} 条对话已省略(未接摘要器)。"
else:
text = summarizer(old)
note = Message(role="user", content=f"[历史摘要] {text}")
return system + [note] + recent, old
四个设计点。① 未超预算就不动——compact 是幂等的「按需动作」,不是每次都压缩。② system 与最近轮永远保留——与 2.3 的规则一致,保证当前任务的直接上下文不丢。③ 摘要插在 system 之后、最近轮之前——位置固定,避免模型把摘要误当成最新指令。④ 返回被压缩的原文——便于观测与测试断言「到底压掉了哪些」。
6.2.5 真跑:压缩前后对比
构造一段 14 条消息的会议对话,预算 150 token,接一个假摘要器:
# run62.py
import sys
sys.path.insert(0, "/tmp/ab6work")
from minagent.types import Message, ModelResponse
from minagent.models import ScriptedModel
from minagent.budget import count_messages
from minagent.compact import compact, make_model_summarizer, needs_compaction
convo = [Message(role="system", content="你是会议助理,负责跟踪任务进度。")]
for i in range(1, 7):
convo.append(Message(role="user", content=f"第{i}轮:请汇总里程碑 {i} 的状态与风险。"))
convo.append(Message(role="assistant",
content=f"第{i}轮结论:里程碑 {i} 完成度 60%,风险是接口联调。"))
convo.append(Message(role="user", content="现在把整体进度压成 3 条要点。"))
budget = 150
print(f"压缩前:{len(convo)} 条,约 {count_messages(convo)} tokens;预算 {budget}")
print(f"需要压缩吗?{needs_compaction(convo, budget)}\n")
summary = "前 6 轮里程碑均完成度约 60%,共同风险是接口联调,尚未关闭。"
summarizer = make_model_summarizer(
ScriptedModel([ModelResponse(message=Message(role="assistant", content=summary))])
)
new, old = compact(convo, budget, keep_recent=4, summarizer=summarizer)
print(f"被压缩 {len(old)} 条 → 1 条摘要")
print(f"压缩后:{len(new)} 条,约 {count_messages(new)} tokens\n")
for m in new:
print(f" {m.role:9} {m.content[:40]}")
真跑输出:
压缩前:14 条,约 264 tokens;预算 150
需要压缩吗?True
被压缩 9 条 → 1 条摘要
压缩后:6 条,约 124 tokens
system 你是会议助理,负责跟踪任务进度。
user [历史摘要] 前 6 轮里程碑均完成度约 60%,共同风险是接口联调,尚未关闭。
assistant 第5轮结论:里程碑 5 完成度 60%,风险是接口联调。
user 第6轮:请汇总里程碑 6 的状态与风险。
assistant 第6轮结论:里程碑 6 完成度 60%,风险是接口联调。
user 现在把整体进度压成 3 条要点。
结果符合预期:14 条、约 264 token 被压成 6 条、约 124 token,压缩率约 53%。对比 2.3 的直接截断(那里 17 条压到 4 条、丢 14 条),这里的压缩率没那么激进,但**「前 6 轮完成度 60%、风险是接口联调」这条结论被保住了**——这正是摘要相对截断的价值。
ScriptedModel 在这里演的是「摘要器」:剧本里只有一条 ModelResponse,就是那句摘要。真实场景里它换成任意 Model,compact 的代码一行不用改。摘要器可替换,是这一节最重要的接口设计。
6.2.6 何时摘要 vs 何时直接丢
有了两种手段,选择就成了关键。下面这张表可以直接当决策清单:
| 场景 | 选择 | 原因 |
|---|---|---|
| 结论/数字需要延续到后续轮 | 摘要 | 丢了就得重算 |
| 寒暄、重复确认 | 直接丢 | 无信息量,摘要反而费钱 |
| 单条消息就超预算 | 直接丢或先切分 | 摘要也可能塞不下 |
| 工具结果特别长 | 摘要 | 大结果往往是最占 token 的 |
| 一次性问答、无后续依赖 | 直接丢 | 摘要没有下游收益 |
| 长任务、多轮规划 | 摘要 | 前文是后续推理的前提 |
一条经验:摘要有成本(一次额外模型调用),所以要「值回票价」才用。当被压缩的内容下游根本不会再引用时,直接丢更快更省。反过来,当 Agent 正在做多步规划、后续步骤依赖前面结论时,摘要几乎是必须的。
把这个判断写成一个纯函数,就能被测试、被复用:
SUMMARY_MIN_TOKENS = 80
def plan_action(dropped: list[Message], has_summarizer: bool) -> str:
"""决定对一段旧消息是「摘要」还是「直接丢」。"""
if not dropped:
return "none"
if count_messages(dropped) < SUMMARY_MIN_TOKENS:
return "drop"
return "summarize" if has_summarizer else "drop"
真跑两组样本:
阈值: 80 tokens
短对话:约 4 tokens -> 有摘要器 drop;无摘要器 drop
长对话:约 114 tokens -> 有摘要器 summarize;无摘要器 drop
逻辑一目了然:短内容不值得摘要(4 < 80,直接丢);长内容在有摘要器时走摘要(114 ≥ 80),没摘要器时退回直接丢。这条阈值把「摘要的性价比」变成了可调参数,而不是凭感觉决定。
6.2.7 边界与取舍
- 摘要会失真。模型压缩时可能合并、简化甚至轻微篡改,关键数字要单独校验(第 15 章会做成本核算,第 16 章用评测集量摘要质量)。
- 别压 system。system 是指令不是历史,压掉它等于丢了身份。
- 摘要位置固定。永远在 system 之后、最近轮之前,见 6.2.4 的设计点 ③。
- 压缩要可观测。把「压了几条、压前压后 token」记进
usage,与 2.3 的做法一致,方便第 15 章聚合。 - 摘要器要有超时与降级。摘要调用失败时,退回「直接丢」而不是让整个请求挂掉——这是第 14 章的容错思路。
还有一条容易被忽略:摘要本身也要占 token。如果被压缩的原文很短,摘要可能和原文一样长甚至更长,这时摘要纯属浪费。所以实践里会设一个下限:只有「被压缩内容超过阈值」时才走摘要,否则直接丢。
最后提醒一句关于压缩时机的选择:可以「每轮检查、超了就压」(简单,但压缩频率高),也可以「留出安全水位,到水位才压」(压缩次数少,但要有余量判断)。前者容易实现、后者更省调用,minagent 先采用前者,把水位的调优留给第 16 章的对比实验去量。
真实模型下会怎样
真实摘要器(任意供应商的模型)和本节假模型有两个差别。第一,它真的会改写内容——可能把「完成度 60%」写成「约六成」,这种失真在小数、ID、路径上尤其危险,所以关键字段要么单独保留,要么在摘要提示词里点名「原样保留」。第二,它有成本与延迟——一次摘要调用多花一次 token 与几百毫秒,长任务里累积起来可观,所以摘要策略要跟预算策略一起调。本节的 compact 是一个能跑、能测、摘要器可替换的骨架,真实增强都挂在它上面,与 2.3 的 BudgetedModel 共同构成 minagent 的上下文护栏。
阅读导航:上一节:6.1 系统提示与指令分层 · 下一节:6.3 结构化输出约束与解析兜底 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。