上下文压缩与分层记忆

M5
深入解析 · 记忆与上下文工程

上下文压缩:摘要、驱逐与分层记忆。

一个长期运行的智能体,其轨迹无界增长;窗口不会。压缩是一组在缩小历史的同时保留继续推进所需信息的技术。做得好它是隐形的;做得差它会让智能体在任务中途悄悄失忆。这是让真正长时程智能体成为可能的操作。

STEP 1

压缩阶梯:先用最便宜、可承受的技术。

压缩不是单一动作。它是一架损失逐级增大的技术阶梯;用能让你保持在预算内的最温和那一级,只有当它不够时才升级。

  • 截断——整批丢掉最旧的轮次。最便宜、损失最大,仅当被丢内容已持久化到长期记忆时才安全。绝不要把截断作为唯一机制。
  • 去重/剪枝——移除冗余的工具输出、重复的检索、被取代的计划版本。高价值、近乎零风险:你删的是噪声,不是信号。
  • 摘要——用一段模型生成的概要替换若干轮。主力。有损,但只要范围划得对就能保留信息。
  • 分层摘要——摘要的摘要,使得即便非常古老的历史也作为一条细线索存活。这是解锁实际上无界时程的技术。

顺序很重要。先剪枝再摘要(别花钱让模型去摘要重复垃圾)。先摘要再截断(别删掉你还没提炼的东西)。只截断那些既被摘要被持久化的内容。

STEP 2

保留智能体所需内容的范围化摘要。

一句通用的"摘要这段对话"恰恰会丢失智能体需要的东西:未闭合回路、决策及其理由、错误原因、硬约束。压缩摘要必须是任务结构化的,而非散文结构化的。

# memory/compact.py
COMPACT = """Compress the following agent turns into a
structured state summary. Preserve, do not narrate:

DECISIONS:  choices made and the reason for each
FACTS:      durable facts established (mark source turn)
OPEN:       unfinished sub-tasks / unanswered questions
ERRORS:     failures seen and their root cause
CONSTRAINTS:rules that must still hold

Drop: pleasantries, superseded plans, raw tool dumps
already reflected in FACTS. Be terse. No prose.

TURNS:
{turns}"""

def summarize_span(turns: list[dict], llm) -> str:
    body = "\n".join(fmt(t) for t in turns)
    return llm(COMPACT.format(turns=body), max_tokens=600)

结构化标题就是契约。一份让 OPENCONSTRAINTS 逐字忠实的摘要,可以替换 40 轮历史而智能体继续正确推进。一份对它们做了改述的摘要会丢失线头——经典的"智能体忘了它不许碰生产环境"失效。

绝不要摘要当前的未闭合回路。压缩作用于已解决或已老化的历史。活跃子任务和最近几轮以逐字形式留在工作集——摘要你正做到一半的东西,正是智能体丢失自己位置的方式。

STEP 3

分层记忆:MemGPT 式的分级。

决定性的思想,由 Packer 等在 MemGPT(2023)中阐明:把上下文窗口当作内存、把外部存储当作磁盘,由智能体自身(经由工具)管理各级之间的分页。三级:

TIER 0  WORKING   in-window, verbatim, fully attended
                  → current task, last k turns, scratchpad
TIER 1  RECALL    out-of-window, fast retrieval
                  → recent summaries, indexed episodics
TIER 2  ARCHIVE   out-of-window, cold, summary-of-summaries
                  → old-task traces, thin durable residue

paging: WORKING --evict--> summarize --> RECALL --age--> ARCHIVE
        ARCHIVE --recall on demand--> RECALL --promote--> WORKING

关键在于,智能体拥有在各级之间搬运数据的工具——它可以选择"记住这个"(写入 recall)、"我们关于 X 决定了什么"(搜索 archive),或"把这个换页回来"。记忆管理成为智能体动作空间的一部分,而不只是它背后一个隐形的框架。这正是时程实际上无界的原因:智能体总能伸手够到更早的内容,只是为此付出一次检索往返。

STEP 4

触发:在压力下压缩,而非按定时器。

压缩昂贵(一次模型调用)且有损。从预算压力触发它,带迟滞,这样你不会在边界上来回抖动。

# memory/compactor.py
class Compactor:
    def __init__(self, budget, hi=0.85, lo=0.60):
        self.budget = budget
        self.hi, self.lo = hi, lo   # hysteresis band

    def maybe_compact(self, history) -> list:
        used = ntok(history) / self.budget.working
        if used < self.hi:
            return history          # under pressure threshold
        keep_recent = tail_until(history, self.lo)
        old = history[:-len(keep_recent)]
        if not old:
            return history          # nothing safe to compact
        persist_to_longterm(old)    # write BEFORE you shrink
        digest = summarize_span(old, llm)
        return [{"role": "system",
                 "content": "[compacted history]\n" + digest}] \
               + keep_recent

迟滞带(在 85% 满时压缩,降到 60%)防止了那种病态情形:每一轮都把线轻轻推过去,触发一次新鲜、昂贵的摘要,却只换来勉强够用的余量。以能买到真正余量的块为单位压缩。

STEP 5

验证压缩,因为有损不等于损坏。

每一次压缩都是一个智能体可能悄悄丢失某个事实的地方,而你要到三轮之后它做了某件被禁止的事时才会发现。把压缩当作任何其他有损变换来对待:验证它。

  • 约束存活检查。在带外维护一份硬约束的显式清单。压缩后,断言每一条仍(语义上)出现在摘要中;若否,逐字重新注入它。
  • 未闭合回路存活检查。在压缩前后清点未闭合子任务数。下降是红旗,不是特性。
  • 往返评估。离线地,针对压缩后状态提问那些只能从压缩前历史回答的已知问题。把答对率作为一等指标跟踪(见 evaluating-memory)。

最便宜的稳健保障:把硬约束和当前未闭合回路清单放在工作集顶部一个小的、永不压缩的钉住块里。压缩就在物理上吃不掉它们,无论它多激进。把令牌花在这里——这是你拥有的杠杆率最高的预算。