Skip to content
Zegging's Tech Blog
Go back

如果把 Agent 的 Context 当成一块需要 GC 的内存

这两天总在想一个类比:Agent 发展到现在,Context 和 Memory 的各种管理手段,越来越像一门带 GC 语言的 Runtime。 程序想一直跑下去,但物理内存是有限的;Agent Loop 本质就是一个无尽循环,但是模型单次能接收的 Context Window 同样有一个确定的、强制不可突破的上限。这样说来两边其实在试图解决同一个问题:

怎么把一段看起来可以无限延续的运行,依靠一份有限的资源上?

刚开始这只是个“感觉很像”的直觉:Context 像内存,Message 像对象,Compaction 像 GC。如果两个事物有这么多的相似之处,那么肯定有一些本质上的共性。Agent Runtime 和 Context Window 的优化还是一个没有定论的东西,通过将 GC 模型和 Context 模型的映射比较或许能得到一些有趣的思路和方向。Context GC 和编程语言 GC 最关键的地方,或许不在它们相似的部分,而在于它们从哪一步开始不再相似

编程语言 GC 概念映射到 Context

在一门带 GC 的语言里(下文我们都使用 JVM 中的概念近似地理解),程序不断创建对象,对象占用内存,又通过引用连接成一张图。GC 从一组 Root 出发遍历这张图:能够从 Root 到达的对象仍然活着,无法到达的对象则已经不可能再被程序访问,可以安全回收。

Agent 运行时也在不断产生东西:

  • system prompt;
  • user message;
  • assistant message;
  • tool call;
  • tool result;
  • 当前计划;
  • 工具定义

这些信息最后都在争夺同一块有限资源:

Tokens(Serialize(Mt))B\operatorname{Tokens}(\operatorname{Serialize}(M_t))\le B

MtM_t 是第 tt 轮真正进入模型的 Context,BB 是这一轮不可突破的 Token Budget。

在我之前写的 一种 LLM Agent Runtime 的形式化模型 中,一轮 Agent Loop 被写成:

RtSBMtΠvendor,tDtCatX(Et,δt)OtURt+1R_t \xrightarrow{\,S_B\,} M_t \xrightarrow{\,\Pi_{\mathrm{vendor},t}\,} D_t \xrightarrow{\,C\,} a_t \xrightarrow{\,X(E_t,\delta_t)\,} O_t \xrightarrow{\,U\,} R_{t+1}

RtR_t 是 Runtime 保存并可访问的全部内部状态,MtM_t 则只是这一轮真正送给 LLM 的有限输入。两者并不相同:

RtMtR_t\neq M_t

Runtime 可以保存很多东西,但模型这一轮真正能够“想到”的,只能来自 MtM_t。LLM 模型某种程度上来说是海量概念地压缩,本身就能在一个更为广阔的上下文中工作(这个后面会用 Git 的例子说明)。

如果暂时把一条独立 Message 当成一个对象,那么 Context GC 至少有四种很自然的操作:

  1. 完整保留;
  2. 压缩内容;
  3. 彻底删除;
  4. 把多条 Message 合并成一个新的表示。

到这里为止,一切都很像 GC:Context Window 是有限堆空间,Message 是对象,新的对话和工具结果是分配,删除和压缩则是在回收空间。不过显然编程 GC 没有压缩和合并这个概念,有的话也更像是更底层虚拟内存物理内存的优化操作。严格来说只有完整保留和彻底删除两种操作。

传统 GC 回收的是确定无用的对象 Context GC 中没有确定无用的对象

传统 tracing GC 的安全性是确定的。一个对象只要无法从任何 GC Root 到达,程序就再也没有办法访问它,回收它不会改变程序未来的任何行为

¬ReachableFromRoots(x)SafeToCollect(x)\neg\operatorname{ReachableFromRoots}(x) \Rightarrow \operatorname{SafeToCollect}(x)

Context 里的信息没有这种好事。删掉一句旧的用户约束、把一段 Tool Result 压成摘要,甚至只是这一轮不把某条 Message 递给模型,都可能改变模型下一步输出的概率分布:

Πvendor(Mt)\Pi_{\mathrm{vendor}}(\cdot\mid M_t)

这正是有 GC 语言的 GC 和 Agent Context 之间最关键的差异:程序 GC 销毁对象而回收的内存,是确定对未来毫无影响的数据,它们不被任何 GC Root 触达,无法被访问的数据自然不会产生影响,回收是安全的;而 Context 的处理永远是有损的,只是损失大小有差异(这个差异的度量就是我们设定的那些 Agent 指标:任务成功率、约束违反率、副作用安全、恢复成本、请求或中止后的人工接入等等)。Context GC 不能得到 JVM GC 那种严格的语义透明。它能争取的“无感知”,只能是从 Agent 外部看过去,GC 前后的任务行为近似不变。损失是一定的,只不过可以讨论“损失了多少、这个损失能不能接受”这类问题。

信息离开 Context 真的无法再访问了吗?

把两件事分开:信息还在 RtR_t、只是这一轮没进 MtM_t,这像 Paging;信息从 RtR_t 里永久删掉,这才像真正的 GC。看起来似乎是成立的,但站在 LLM 的视角,这个区分却并不成立。

一条信息哪怕还躺在数据库里,只要模型不知道它曾经存在过,也不知道它在哪里、Runtime 不会再主动把它装载回来,它和被删掉就没有区别:物理上存在,却已经不属于这段计算了。

反过来也一样,一个巨大的 Tool Result 完全可以这样处理:

LargeToolResult(x)Summary(x)+Handle(h)+Read(h)\operatorname{LargeToolResult}(x) \longrightarrow \operatorname{Summary}(x) + \operatorname{Handle}(h) + \operatorname{Read}(h)

原始结果被移到文件、数据库或者对象存储中,Context 只留下摘要、Hash、地址和读取方式。从 Context Window 看,原文已经被 GC、Token 已经释放;从 Agent 的能力看,它随时能通过 Handle 把原文取回来。

所以 Context GC 不该按“数据存在哪块物理介质”来定义,而要按“Agent 还有没有一条有效的取回路径”来定义。这条路径也不只是一个指针,它可能是:

  • Context 中的直接引用;
  • Artifact Handle;
  • Runtime 检索;
  • 重新读取文件;
  • 从其他信息重新推导;
  • Runtime 在合适时机自动重新注入。

在一个代码 Repo 中,Context 里早已没有上一次调用 git 的记录(可能所有和 git 相关的信息都被丢弃了),但 LLM 依然可能根据当前目标主动再调一次 git,重新拿到 Commit 信息。

不过但这里藏着一个细小却要命的区别:重新调用 git 拿到的是当前事实,未必能还原过去那次观察。万一对历史的 commit 做了合并呢?所以至少要分开两件事:

LiveRep(x,t)\operatorname{LiveRep}(x,t)

原始 Message、历史事件和证据还能不能恢复;以及

LiveSem(p,t)\operatorname{LiveSem}(p,t)

它所表达的语义命题还能不能重新得到。“重新知道现在是什么”和“还原过去发生过什么”不是一回事

良好定义的 Context GC Root

顺着 GC 的思路往下,绕不开的问题是:Context GC 的 Root 是什么?

按 Message 的 Role 来回答最省事:System 和 User 是 Root,Assistant 和 Tool 不是。这也是很多激进的压缩方法的实现。但这个答案显然不对:旧的 User Message 可能已经被新要求撤销;Assistant Message 里可能埋着“改完代码我会跑测试”这种还没兑现的承诺;某条 Tool Result 可能是“安全约束已经满足”的唯一证据。

Root 没法被定义为 Message 的类型。

在我的设想中,Root 真正的定义是:

Context GC Root 是 Agent Runtime 当前尚未解除的义务。

把理论上真实、完整的义务集合写成:

Ot={oo 在时刻 t 仍未解除}\mathcal O_t^* = \{o\mid o\text{ 在时刻 }t\text{ 仍未解除}\}

这些义务可能来自很多地方:

  • 当前必须完成的目标;
  • 用户明确给出的约束;
  • System Policy 和安全边界;
  • 尚未完成的计划;
  • LLM 已经作出但尚未兑现的承诺;
  • 结果未知、需要验证或回滚的外部副作用。

比如用户说:

修复登录问题,不得修改公开 API;修改完成以后运行测试,并告诉我问题的原因。

这句话至少产生四项义务:

  • 修复登录问题;
  • 不得修改公开 API;
  • 修改后运行测试;
  • 完成后解释原因。

只要这些义务还没有解除,它们就是 Context GC Roots。

而且 Root 不能因为 Message 太旧、太长或者“看起来相关性不高”就被 GC 算法自行删除。义务只能通过自己的生命周期解除:它被满足、被用户取消、被更高优先级的要求替代,或者任务以符合约定的方式终止。如果执行失败,义务甚至不会简单消失,而可能变成新的义务:

o执行失败o报告/恢复o_{\text{执行}} \xrightarrow{\text{失败}} o_{\text{报告/恢复}}

这个设想的启发来自于 Agent Context 压缩的目的:更好地完成用户地需求。Context GC 只是手段,手段为目的服务。GC Root 就是为这个目的服务的最小原子化的语义信息,这些语义信息被我称为“义务”:为了解决用户需求而需要的必要信息。

Root 不是整条 Message

一条 Message 里可能同时有目标、约束、猜测、情绪、重复描述和寒暄。如果其中一个约束仍然存活,不代表整条 Message 的每一个字都必须永久保留。Root 真正到达的是这条 Message 中与义务有关的语义投影:

Poi(m)P_{o_i}(m)

可以把依赖关系写成:

oiqiPoi(m)o_i \xrightarrow{\,q_i\,} P_{o_i}(m)

qiq_i 是履行义务所需的最低信息保真度。有的内容必须保留原文,有的必须保留精确数值和来源,有的只需要语义等价,有的保留一个摘要就够了,还有的只需要留下有效的恢复路径。于是“可达”不再只是一个简单的布尔值,而是带着保真度要求:

保真度含义
verbatim必须保留原始措辞
exact数值、标识符和语义必须精确
provenance必须保留来源、时间和证据关系
semantic保持等价含义即可
summary保留主要结论即可
recoverable保留有效恢复路径即可

这也把四种操作统一了起来:

  • 没有 Root 再依赖某项语义,可以删除;
  • Root 只需要较低保真度,可以压缩;
  • Root 需要原始证据,只能完整保留;
  • 多条 Message 的必要语义可以由一个新表示共同承载,可以合并。

“未来可能有用”是没有用的

在 Context GC 中我们还有一个绕不过去的话题:我们不知道未来。如果把存活定义成“只要未来可能再用到”,那所有历史信息都应该永久保留——用户以后也许会突然问起任何一句话,环境也可能发生任意变化。这显然会让 GC 彻底停摆。信息活性只能相对于一个被允许考虑的未来集合:

Ftadm\mathcal F_t^{\mathrm{adm}}

这个集合由当前未解除义务、已知环境、合法动作、时间与成本范围共同限定。也就是说,Context 里不存在脱离任务的绝对性。某段信息是否还需要保留,必须写成类似:

Live(mOt,Ftadm,q,ε)\operatorname{Live} \left( m \mid \mathcal O_t^*, \mathcal F_t^{\mathrm{adm}}, q, \varepsilon \right)

同一段信息,在一个任务里可能是必须精确保留的证据,在另一个任务里可能只是随时可以丢弃的噪声。

不同的 Root 配不同的风险预算

“不得泄露密钥”和“最后尽量简短总结”不应该共享同一个容错标准。所以每个义务都可以带上自己的保真度、风险预算和风险计算方式:

oi=ci,qi,εi,ρi,di,σio_i= \left\langle c_i, q_i, \varepsilon_i, \rho_i, d_i, \sigma_i \right\rangle

其中:

  • cic_i:必须满足的条件;
  • qiq_i:最低信息保真度;
  • εi\varepsilon_i:可以接受的行为损失;
  • ρi\rho_i:怎样看待未来分支中的风险;
  • did_i:义务的解除条件;
  • σi\sigma_i:义务当前所处的状态。

安全约束可能关注最坏情况,普通目标更关心期望损失,某些场景则需要特别关注低概率、高损失的尾部风险:

ρi={sup最坏情况CVaRα高风险尾部E期望损失\rho_i= \begin{cases} \sup & \text{最坏情况}\\ \operatorname{CVaR}_{\alpha} & \text{高风险尾部}\\ \mathbb E & \text{期望损失} \end{cases}

于是 Context GC 的“无感知”可以更准确地写成:

MtOt,FtadmMtM_t' \approx_{\mathcal O_t^*,\mathcal F_t^{\mathrm{adm}}} M_t

它不是说 GC 前后模型一定输出完全相同,而是说对于每一项未解除义务:

ρi(ΔLossoiFtadm)εi\rho_i \left( \Delta\operatorname{Loss}_{o_i} \mid \mathcal F_t^{\mathrm{adm}} \right) \le\varepsilon_i

这里的 \approx 并不是严格的数学等价。带阈值的近似关系未必有传递性,连续多次“每次看起来只损失一点”的摘要,最后仍然可能把原始语义修改地面目全非。更准确的说法是:GC 后的 Context 仍处在相对于原始信息的可接受行为偏差范围里。

Runtime 做 GC 的时候并不知道未来,度量和运行需要分开

还有一个麻烦:Runtime 真正执行 GC 的那一刻并不知道未来,自然也算不出任务成功率到底会掉多少。这就得把真正运行的因果策略和对策略的评价度量两件事拆开。

运行时地策略

Gϕ:ItMt\mathcal G_{\phi}:\mathcal I_t\rightarrow M_t'

It\mathcal I_t 只包含时刻 tt 已知的信息。GC 算法可以根据 Message 类型、年龄、长度、义务依赖、恢复路径等作出判断,但不能偷看未来。

对使用该策略的 GC 的事后评价:

J=f(任务成功,约束违反,副作用安全,恢复成本,人工介入)J= f( \text{任务成功}, \text{约束违反}, \text{副作用安全}, \text{恢复成本}, \text{人工介入} )

也就是说,未来行为指标定义了“什么是一个好的 Context GC”,却不是 Runtime 执行回收时能直接拿到的输入。传统 GC 通常可以在运行时精确判断一个对象是否从 Root 可达;Context GC 的语义安全性只能靠事先定好的策略去近似,再在未来轨迹里接受检验。不确定和确定,是 Agent 区分于以往软件开发最深的一道分界线。

那么,Context GC 到底是什么?

把这些条件放在一起,Context GC 可以被写成一个受约束的表示优化问题:

minMt[Tokens(Mt)+λRecoveryCost(Mt)]\min_{M_t'} \left[ \operatorname{Tokens}(M_t') + \lambda\operatorname{RecoveryCost}(M_t') \right]

要求:

Tokens(Mt)B\operatorname{Tokens}(M_t')\le B

并且对所有仍未解除的义务:

oiOt,ρi(ΔLossoiFtadm)εi\forall o_i\in\mathcal O_t^*, \qquad \rho_i \left( \Delta\operatorname{Loss}_{o_i} \mid \mathcal F_t^{\mathrm{adm}} \right) \le\varepsilon_i

一句话:

在不让 Agent 忘记自己没有完成什么的前提下,尽量减少当前 Context 的 Token 占用,同时控制重新获取信息的成本和未来行为风险。

再回头看,Context GC 和传统 GC 的对应关系大概是这样:

GC 语言Agent Context GC相同与不同
有限堆空间有限 Context Window都要支撑潜在无限运行
堆对象Message 或派生信息表示Message 还有类型、顺序和自然语言语义
GC Root未解除的 Runtime 义务前者是结构引用起点,后者是任务和行为约束
对象引用义务对语义投影的依赖Context 的边还可能包含工具、检索和动作
Mark找到义务相关语义从精确图遍历变成语义判断
Sweep删除 Message 或语义片段可能改变模型输出分布
Compact压缩、合并 Message传统 Compact 无损,Context 压缩通常有损
Moving / Paging原文外移,Context 保留 Handle重新装载需要时间、Token、权限和工具
Memory Leak无有效路径却仍占据 Runtime 的信息Root 过度保守也会造成 Context 膨胀
GC 透明性Agent 行为近似保持一个接近确定保证,一个只能做风险约束

整个关系可以画成这样:

flowchart TD
    A["Agent Runtime 状态 R_t"] --> B["未解除义务 O*_t"]
    A --> C["Context 中的 Message 对象"]
    B --> D["义务相关语义投影 P_o(m)"]
    C --> D
    B --> E["允许未来 F_adm"]
    B --> F["保真度 q、损失预算 ε、风险函数 ρ"]
    D --> G["Context GC 策略 G_φ"]
    E --> G
    F --> G
    G --> H["保留 / 压缩 / 删除 / 合并"]
    H --> I["新的 Context M'_t"]
    I --> J["未来 Agent 行为"]
    J --> K["成功率、约束违反、安全性、恢复成本"]

结论

最初我只是觉得 Context 很像内存。现在我更愿意把 Context GC 定义成:

Context GC 是在未解除运行时义务的约束下,对 LLM 可见信息以行为近似保持为目的而进行的 LLM 可见上下文优化。

它不只是传统意义上的 GC,而是把 GC、虚拟内存、缓存、分层存储和有损压缩混在了一起。但 GC 仍然是一个很有力量的出发点,因为从这个模型触发需要我们认真回答两个问题:哪些信息还活着?我们凭什么认为删掉它是安全的?

传统 GC 的答案是“从 Root 不可达,所以程序永远不可能再访问”。Context GC 的答案要模糊得多:

这项信息已经不再支撑任何未解除义务;或者即使降低它的保真度,也不会让未来行为风险超过我们愿意接受的范围。

这当然没有 JVM GC 那么干净,甚至注定拿不到同样强的安全证明。但也正因如此,Context GC 恐怕不只是一个工程优化问题——它其实在逼我们回答一件更根本的事:对一个持续行动的 Agent 来说,什么仍然值得记住,什么已经可以忘记,以及“忘记”究竟意味着什么。


Share this post on:

Next Post
一种 LLM Agent Runtime 的形式化模型