判断一套 Agent 的上下文装配做得好不好,有个很省事的办法:看它的输入 token 数是稳定的,还是忽高忽低。
忽高忽低通常说明没有预算概念——装配逻辑是"有什么塞什么",靠截断兜底。这类系统在短会话里看不出问题,一旦对话变长或检索命中变多,就会出现两种典型症状:要么超窗被硬截,把最早的系统指令裁掉;要么延迟和成本随会话长度线性上涨,而业务价值并没有跟着涨。
上下文不是仓库,是账。
我倾向于把上下文窗口当成一份需要预先分配的预算,而不是一个先填后截的容器。做法是划几个固定账户:
顺序很重要:先给前面的账户设上限,再让对话历史吃剩下的。反过来做——历史优先——几乎必然导致系统指令被裁,因为历史是唯一会无限增长的那一项。
每个账户的上限怎么定?不是拍一个 token 数,而是问一个更前置的问题:这一段内容缺失时,最坏会发生什么?
按这个问法排一遍,"必须留"的部分其实很少,而很多实现恰恰把它们和可丢弃内容混在一个池子里,靠先进先出去淘汰。
上下文压缩最常见的实现是"超过阈值就把中间段落做摘要"。问题出在阈值——多数是拍的。
我倾向于按信息半衰期决定压缩粒度,而不是按 token 数。同一个会话里不同类型的消息,衰减速度差得很远:
所以压缩要按类型分层,不是按位置一刀切:
这里有个容易做错的地方:约束类消息要"钉住",不能随位置被淘汰。一个在第 3 轮说过的金额阈值,到第 30 轮仍然生效。把这类消息单独抽出来放在固定位置,比让它散落在历史里靠模型自己找要可靠得多。
检索到的片段怎么用,比检索到什么更重要。
两个观察值得记住。第一,放在最后一个用户回合之前,通常比放在系统指令里更有效——模型对离生成位置更近的内容权重更高。第二,一次注入多个可能互相冲突的片段时,不要在提示里写"请综合判断",而要在装配阶段就做冲突消解:同一字段出现多个取值时,按来源可信度或时间新旧择一,只把选中的那个注进去。
把冲突留给模型判断,结果是它在两个答案之间摇摆,而且摇摆方式不可复现。
还有去重。多路检索(向量、关键词、图)很容易命中同一段内容的不同切块,装配层要做一次基于内容指纹的去重,否则预算会被同义内容重复吃掉。
上下文缓存能省的成本和延迟都相当可观,但它有一个很硬的前提:前缀必须逐字节稳定。
很多实现命中率低到没有意义,原因就那么几个,而且都很低级:
改法也简单:把所有易变内容移到最末尾。稳定的部分——系统指令、工具定义、固定示例——放最前且顺序写死,对话历史和检索结果排在后面。
代价是装配逻辑必须保证确定性。同一组逻辑输入要产出逐字节相同的装配结果,这同时也是评测可复现的前提,两者本来就是同一件事。
缓存不是免费开关,命中率取决于前缀在总输入里的占比。可以直接推。
设前缀长度为 P,可变部分为 V,命中时前缀部分按更低单价计(多数平台缓存输入的单价约为标准输入的十分之一量级,以你实际使用的定价为准)。相对不用缓存:
成本比 ≈ (P × 0.1 + V) / (P + V)P/(P+V) 越大,省得越多。前缀占比 20% 时成本只降不到两成;前缀占比 80% 时能降到三分之一以下。
这个式子还说明一件事:为了缓存把更多内容塞进稳定前缀是划算的——比如固定的 few-shot 示例、工具定义、输出格式约束。反直觉的地方在于,它和"省 token"的目标其实是同向的,只要那些内容本来就要带。
一个容易被忽略的设计点:装配层应该输出结构,而不是一段拼好的字符串。
{
"stable_prefix": [...], # 逐字节稳定,参与缓存
"volatile_tail": [...], # 每次变化
"budget_report": { # 每段实际占用
"system": 0, "tools": 0,
"retrieval": 0, "history": 0, "reserved_output": 0,
},
"dropped": [...], # 被裁掉的内容及其原因
"conflicts": [...], # 装配阶段解决的冲突
}dropped 这一项我强烈建议保留。线上出现"模型忘了前面说过的事"这类问题时,你要能直接回答"那句话是被压缩了还是根本没进上下文",而不是翻日志猜。conflicts 同理——它记录了装配层替模型做的决定,出问题时这是第一批要看的证据。
budget_report。这一步不改行为,只让占用变得可见。上下文装配是个典型的"看起来只是拼字符串、实际上是资源调度"的模块。它做得好不好,不体现在某一次回答有多好,而体现在输入 token 数稳不稳、缓存命中率高不高,以及出错时你能不能立刻说清哪一段被丢掉了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。