1. 从一次上下文溢出事故说起
去年冬天,我接手了一个内部知识库问答助手的调优工作。上线第一周就出了状况:用户连续追问七八轮之后,Agent 开始答非所问,甚至把三轮之前已经确认过的订单编号又当成新信息重新询问。日志里最扎眼的一行是上下文长度逼近模型窗口上限,系统自动截断了最早的一批消息——而那批消息里恰好包含用户最初设定的约束条件。
这次事故让我彻底意识到,Agent 的上下文管理不是"把历史消息拼起来丢给模型"这么简单。它本质上是一道资源调度题:窗口是有限的,信息是无限的,你得决定什么留下、什么压缩、什么丢弃、什么外置。业内围绕这道题分化出了两条截然不同的路线,我习惯把它们叫做流水式和汉堡式。前者像一条不断向前流淌的河,只保留最近的活水;后者像一层层叠起来的汉堡,把不同性质的信息分层固定。
这篇文章不打算给你一个"标准答案",因为选型从来不是非黑即白。我会把两条路线的底层逻辑、适用边界、实测表现和踩坑经验摊开讲清楚,让你在面对自己的业务场景时能做出有依据的判断。无论你是刚接触 Agent 开发的新手,还是已经被上下文问题折磨过几轮的老手,应该都能从中找到能直接抄作业的部分。
2. 流水式上下文:把对话当成一条不断截断的河
2.1 流水式的核心机制与设计哲学
流水式的思路非常朴素:维护一个先进先出的消息队列,新消息进来,旧消息出去。当累计 token 接近阈值时,从队首开始丢弃,直到腾出足够空间。它假设"最近说的话最重要",历史信息会随着时间自然衰减。
这个假设在很多场景下是成立的。比如闲聊型助手、实时客服的第一轮应答、单轮工具调用,用户关心的是"你现在能不能解决我的问题",而不是"你记不记得我十分钟前说过什么"。流水式的实现成本极低,一个collections.deque加上 token 计数就能跑起来,调试时也容易定位问题——因为上下文里有什么,一眼就能看全。
但它的软肋同样明显。一旦任务需要跨多轮保持约束(比如"全程用中文回答""金额单位统一用万元"),这些早期设定会在某次截断中悄无声息地消失。更麻烦的是,这种消失是静默的,模型不会报错,只会开始"失忆",而你在日志里看到的只是一条正常的请求。
2.2 滑动窗口的三种截断策略对比
同样是流水式,截断策略不同,效果差异很大。我实测过三种常见做法,整理成下面这张表:
| 策略 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 按条数截断 | 保留最近 N 条消息 | 实现最简单 | 长消息会瞬间吃满窗口 | 消息长度均匀的短对话 |
| 按 token 截断 | 累计 token 超限即丢队首 | 窗口利用率高 | 可能把一条长消息截成半句 | 通用场景 |
| 按轮次截断 | 保留最近 K 轮问答 | 语义完整,不会截断半句 | 轮次长度差异大时浪费空间 | 多轮问答 |
我个人的经验是:按轮次截断 + 按 token 兜底的组合最稳。先按轮次保留,如果还是超限,再对最早的那一轮整体丢弃。这样既保证了语义完整,又不会因为某一轮特别长而撑爆窗口。具体实现时,我会给每一轮打一个turn_id,丢弃时以turn_id为单位,而不是以单条消息为单位。
def trim_by_turn(messages, max_tokens, count_tokens): # 按 turn_id 分组,保证一轮问答同进同出 turns = group_by_turn(messages) kept = [] total = 0 for turn in reversed(turns): turn_tokens = sum(count_tokens(m) for m in turn) if total + turn_tokens > max_tokens: break kept.insert(0, turn) total += turn_tokens return flatten(kept)注意:按轮次截断时,系统提示词(system prompt)必须单独拎出来,永远不参与丢弃。我见过有人把 system prompt 也塞进队列,结果某次截断后模型直接"忘了自己是谁",开始用完全不同的语气回答。
2.3 流水式最容易踩的三个坑
第一个坑是静默失忆。前面提到的约束条件丢失就是典型。我的应对办法是在关键约束上做"锚点标记",比如给 system prompt 里的硬性规则打上pinned: true,截断逻辑跳过所有 pinned 消息。这样即使对话很长,核心规则也不会丢。
第二个坑是工具调用结果被截断。Agent 调用工具后返回的 JSON 往往很长,如果这条结果正好处在队首附近,很容易被整条丢掉,导致模型"忘了自己刚查过什么",然后重复调用同一个工具。我的做法是给工具返回结果做摘要压缩,只保留关键字段,把原始完整结果外置到文件或缓存里,需要时再按 ID 取回。
第三个坑是截断点落在 assistant 消息中间。有些实现按 token 数硬切,会把一条 assistant 的回复切成两半,模型看到半句话会非常困惑。解决办法很简单:截断时以消息为最小单位,宁可少留一条,也不要切半句。
3. 汉堡式上下文:分层固定,各司其职
3.1 汉堡式的分层结构长什么样
汉堡式的核心思想是"不同性质的信息放在不同层,各层有不同的生命周期和更新策略"。一个典型的汉堡结构从上到下大致是:
- 顶层:系统指令层。包含角色设定、硬性规则、输出格式要求。这一层几乎不变,永远保留。
- 次层:长期记忆层。包含用户画像、历史偏好、跨会话的稳定事实。通过检索按需注入,不是全量塞入。
- 中层:任务状态层。包含当前任务的进度、已确认的参数、待办事项。随任务推进动态更新。
- 底层:近期对话层。包含最近几轮的原始消息,允许被截断。
这个结构的好处是职责清晰。系统指令不会因为对话变长而丢失,长期记忆不会挤占近期对话的空间,任务状态可以精确控制。代价是实现复杂度上去了,你需要为每一层设计独立的存储、更新和注入逻辑。
3.2 长期记忆层的检索注入时机
汉堡式里最容易做砸的是长期记忆层。很多人一上来就把用户所有历史记录全量拼进上下文,结果窗口瞬间被占满,效果还不如流水式。正确的做法是按需检索:只在当前问题确实需要历史信息时才去查,查到后只注入最相关的几条。
判断"是否需要"可以用一个轻量分类器,也可以让模型自己决定。我常用的是一个折中方案:在 system prompt 里告诉模型"你可以调用recall_memory工具来查询历史信息",把是否检索的决策权交给模型。这样既避免了无脑全量注入,又不会因为规则写死而漏掉该查的情况。
检索回来的记忆也要做压缩。原始记忆可能是"用户上周三下午提到过他偏好用表格展示数据",注入时压缩成"偏好:表格展示"。信息密度上去了,窗口占用下来了。
3.3 任务状态层的更新与一致性维护
任务状态层是汉堡式里最考验工程能力的一层。它需要回答三个问题:状态什么时候更新、更新时如何避免冲突、状态过期了怎么办。
我的做法是给任务状态定义一个显式的 schema,比如:
{ "task_id": "order_query_001", "confirmed_params": {"order_id": "A123", "date_range": "last_30d"}, "pending_items": ["确认退款金额", "选择退款方式"], "last_updated": "turn_7" }每次模型输出后,用一个轻量的解析步骤判断状态是否需要变更,变更时以task_id为锁做串行更新,避免并发写坏。状态过期则通过last_updated和当前轮次的差值来判断,超过阈值就标记为"待确认",下次注入时提醒模型"这个信息可能已过时,请和用户核实"。
提示:任务状态层千万不要让模型自由发挥去写。我早期试过让模型自己维护一个 JSON 状态,结果它经常漏字段、改错键名,甚至编造不存在的参数。后来改成"模型只负责提议变更,由代码校验后落库",稳定性提升了一个档次。
4. 两条路线的正面对撞:一张选型决策表
4.1 六个维度的横向对比
光讲原理不够,选型要看具体指标。我把两条路线在六个关键维度上的表现整理如下:
| 维度 | 流水式 | 汉堡式 |
|---|---|---|
| 实现复杂度 | 低,几十行代码 | 高,需要多层存储与调度 |
| 窗口利用率 | 高,几乎不浪费 | 中,分层有固定开销 |
| 长程一致性 | 差,早期信息易丢 | 好,关键信息分层固定 |
| 调试难度 | 低,上下文一目了然 | 高,需追踪多层注入 |
| 单轮延迟 | 低 | 略高,多一次检索/组装 |
| 适用任务长度 | 短对话、单轮任务 | 长任务、多轮复杂流程 |
这张表不是让你照着打分选高分项,而是帮你识别你的场景最在意哪个维度。如果你的业务是"用户问一句答一句",流水式的低延迟和易调试就是压倒性优势,汉堡式的分层纯属过度设计。反过来,如果你的 Agent 要陪用户走完一个跨几十轮的下单流程,流水式的静默失忆会要了命。
4.2 什么场景该选流水式
我总结了三类明确适合流水式的场景。第一类是实时性优先的问答,比如搜索助手、翻译助手,用户要的是秒回,历史信息价值极低。第二类是单轮工具调用,比如"帮我查一下明天的天气",查完就结束,没有跨轮状态。第三类是原型验证阶段,你还没想清楚业务需要什么记忆,先用流水式跑通主流程,等需求明确了再升级。
这里有个反直觉的点:不是所有长对话都需要汉堡式。如果长对话的每一轮都是独立的(比如用户连续问十个不相关的问题),流水式完全够用,强行上汉堡式只会增加延迟和 bug 面。
4.3 什么场景必须上汉堡式
必须上汉堡式的信号有三个。第一个信号是存在跨轮硬约束,比如"全程用某种格式输出""金额单位统一",这些约束一旦丢失任务就失败。第二个信号是任务有明确的多阶段状态,比如订票流程要经过选航班、填乘客、选座位、支付四个阶段,每个阶段依赖前序阶段的确认结果。第三个信号是需要跨会话记忆,比如用户今天说过的偏好,明天再来时要能想起来。
只要命中其中任意一个,流水式就会在某个时刻让你付出代价。我见过最惨的案例是一个客服 Agent,用户投诉到第三轮时,Agent 把用户第一轮提供的订单号忘了,开始重新索要,用户直接炸了。
5. 混合方案:我实际项目里最常用的折中打法
5.1 分层流水:把汉堡的层用流水的方式管
纯流水和纯汉堡都有极端,实际项目里我用得最多的是分层流水。具体做法是:把上下文分成"固定层"和"流动层"两部分。固定层放 system prompt 和任务状态,永远保留;流动层放近期对话,用流水式截断。这样既拿到了汉堡式的关键信息保护,又保留了流水式的低复杂度。
实现上,固定层和流动层在拼装时是分开的,截断逻辑只作用于流动层。固定层如果太长(比如任务状态膨胀),单独做压缩,不影响流动层的截断节奏。这个方案我在三个不同项目里用过,稳定性明显好于纯流水,复杂度又远低于完整汉堡式。
5.2 关键信息锚定:让该留的永远留
混合方案里最关键的一步是锚定。我会在对话过程中动态识别"关键信息",给它打上锚点,锚点信息不参与截断。识别方式有两种:一种是规则匹配,比如包含订单号、金额、日期这类结构化信息的消息自动锚定;另一种是让模型在回复时显式标注"这条信息很重要,请保留"。
锚定信息要定期清理,否则锚点会越积越多,最后把窗口占满。我的清理策略是:任务结束时清空该任务的所有锚点,任务进行中如果锚点总数超过阈值,就把最老的锚点降级为普通消息,参与正常截断。
5.3 上下文压缩:用摘要换空间
当窗口实在紧张时,压缩是最后的武器。把最早的一批对话交给模型做摘要,用摘要替换原文,能省下大量 token。但摘要有个致命问题:摘要会丢细节,而细节往往正是后面要用的。
我的经验是,摘要只对"已经完成且不会再引用"的对话做。比如任务的第一阶段已经结束,那这个阶段的详细对话可以压缩成一句"第一阶段已完成,确认了 A、B、C 三个参数"。正在进行的阶段绝不压缩,宁可多占点窗口。另外,摘要要保留结构化字段(参数、ID、金额),只压缩自然语言的寒暄和解释部分。
def compress_completed_stage(stage_messages): # 只压缩已完成阶段,保留结构化字段 structured = extract_structured_fields(stage_messages) summary = llm_summarize(stage_messages, keep_fields=structured.keys()) return {"summary": summary, "fields": structured}6. 实测数据与踩坑复盘
6.1 一次 A/B 测试的真实结果
我在一个多轮订单查询场景里做过一次 A/B 测试,同一批 200 条测试用例,分别跑流水式和混合方案。结果如下:
| 指标 | 流水式 | 混合方案 |
|---|---|---|
| 任务完成率 | 71% | 93% |
| 平均轮次 | 6.2 | 5.8 |
| 约束丢失率 | 24% | 3% |
| 平均单轮延迟 | 0.8s | 1.1s |
流水式那 24% 的约束丢失率,几乎全部集中在轮次超过 5 轮的用例上。混合方案多出的 0.3 秒延迟,换来 22 个百分点的完成率提升,这笔账怎么算都划算。但要注意,这个结论只对"多轮有状态"的场景成立,换成单轮问答,两者完成率几乎没差别,流水式的低延迟反而更优。
6.2 那些文档里不会写的坑
第一个坑是token 计数不准。不同模型的分词器不一样,你用 A 模型算的 token 数,换到 B 模型可能差出 20%。我的做法是留 15% 的安全余量,并且用目标模型自己的分词器来计数,别图省事用估算公式。
第二个坑是工具返回结果里的隐藏字符。有些工具返回的 JSON 里带大量空白和转义字符,看着不长,实际 token 数惊人。我后来在工具返回后加了一步清洗,去掉多余空白和冗余字段,窗口占用直接降了三成。
第三个坑是并发请求下的状态竞争。同一个用户快速发两条消息,如果两条都触发状态更新,可能互相覆盖。解决办法是给每个会话加锁,或者用消息队列串行处理同一会话的请求。这个坑在压测时才会暴露,上线前一定要模拟并发场景。
6.3 监控指标:怎么知道上下文管理出了问题
上下文管理出问题往往是静默的,所以监控必须到位。我常看的四个指标是:上下文 token 占用的时间序列(看是否持续逼近上限)、约束丢失告警(定期用探针问题测试关键约束是否还在)、工具重复调用率(突然升高说明工具结果被截断了)、任务状态更新失败率(汉堡式专属,看状态层是否健康)。
其中"约束丢失告警"最容易被忽略但最有价值。我的做法是每隔几轮就插入一个探针,比如问模型"请复述一下当前的输出格式要求",如果答错就说明约束丢了,立即触发上下文重建。这个探针本身也占 token,所以频率不能太高,我一般设在每 5 轮一次。
7. 我的选型决策清单
如果你现在正面对一个具体项目,不知道选哪条路线,可以按下面这个清单走一遍:
- 任务是否跨多轮且轮次间有依赖?否,选流水式;是,进入下一步。
- 是否存在一旦丢失就导致任务失败的硬约束?否,流水式加锚定即可;是,进入下一步。
- 任务是否有明确的多阶段状态需要维护?否,分层流水够用;是,上汉堡式。
- 是否需要跨会话记忆?是,汉堡式加长期记忆层;否,分层流水即可。
- 团队是否有精力维护多层存储和调度?否,先用分层流水顶着,别硬上汉堡式。
这份清单不是教条,而是帮你把"拍脑袋选型"变成"有依据决策"。我见过太多团队一上来就照着最复杂的架构搭,结果维护成本压垮了迭代速度,最后又退回简单方案。选型的本质是匹配当前阶段,而不是追求架构上的先进。
最后分享一个我自己的习惯:无论选哪条路线,我都会在项目初期埋一个"上下文快照"功能,把每次请求实际发给模型的完整上下文存下来。出问题时翻快照,比看任何日志都直观。这个功能开发成本极低,但救过我至少三次命。上下文管理这件事,说到底就是"让该在的信息在,让该走的信息走",两条路线只是实现这个目标的不同手段,理解了这个本质,选型就不再纠结了。