《AI Agent 开发入门》6.2 上下文裁剪与摘要

本节给 minagent 加上主动压缩:区别于 2.3 的被动截断,这里把更早的轮次交给模型压成一段摘要再拼接,实现 compact(messages, budget) 与可插拔的摘要器,真跑对比压缩前后的消息条数与近似 token,并给出「何时摘要、何时直接丢」的取舍表。

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 结构化输出约束与解析兜底 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距