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_fieldsstructured.keys()) return {summary: summary, fields: structured}6. 实测数据与踩坑复盘6.1 一次 A/B 测试的真实结果我在一个多轮订单查询场景里做过一次 A/B 测试同一批 200 条测试用例分别跑流水式和混合方案。结果如下指标流水式混合方案任务完成率71%93%平均轮次6.25.8约束丢失率24%3%平均单轮延迟0.8s1.1s流水式那 24% 的约束丢失率几乎全部集中在轮次超过 5 轮的用例上。混合方案多出的 0.3 秒延迟换来 22 个百分点的完成率提升这笔账怎么算都划算。但要注意这个结论只对多轮有状态的场景成立换成单轮问答两者完成率几乎没差别流水式的低延迟反而更优。6.2 那些文档里不会写的坑第一个坑是token 计数不准。不同模型的分词器不一样你用 A 模型算的 token 数换到 B 模型可能差出 20%。我的做法是留 15% 的安全余量并且用目标模型自己的分词器来计数别图省事用估算公式。第二个坑是工具返回结果里的隐藏字符。有些工具返回的 JSON 里带大量空白和转义字符看着不长实际 token 数惊人。我后来在工具返回后加了一步清洗去掉多余空白和冗余字段窗口占用直接降了三成。第三个坑是并发请求下的状态竞争。同一个用户快速发两条消息如果两条都触发状态更新可能互相覆盖。解决办法是给每个会话加锁或者用消息队列串行处理同一会话的请求。这个坑在压测时才会暴露上线前一定要模拟并发场景。6.3 监控指标怎么知道上下文管理出了问题上下文管理出问题往往是静默的所以监控必须到位。我常看的四个指标是上下文 token 占用的时间序列看是否持续逼近上限、约束丢失告警定期用探针问题测试关键约束是否还在、工具重复调用率突然升高说明工具结果被截断了、任务状态更新失败率汉堡式专属看状态层是否健康。其中约束丢失告警最容易被忽略但最有价值。我的做法是每隔几轮就插入一个探针比如问模型请复述一下当前的输出格式要求如果答错就说明约束丢了立即触发上下文重建。这个探针本身也占 token所以频率不能太高我一般设在每 5 轮一次。7. 我的选型决策清单如果你现在正面对一个具体项目不知道选哪条路线可以按下面这个清单走一遍任务是否跨多轮且轮次间有依赖否选流水式是进入下一步。是否存在一旦丢失就导致任务失败的硬约束否流水式加锚定即可是进入下一步。任务是否有明确的多阶段状态需要维护否分层流水够用是上汉堡式。是否需要跨会话记忆是汉堡式加长期记忆层否分层流水即可。团队是否有精力维护多层存储和调度否先用分层流水顶着别硬上汉堡式。这份清单不是教条而是帮你把拍脑袋选型变成有依据决策。我见过太多团队一上来就照着最复杂的架构搭结果维护成本压垮了迭代速度最后又退回简单方案。选型的本质是匹配当前阶段而不是追求架构上的先进。最后分享一个我自己的习惯无论选哪条路线我都会在项目初期埋一个上下文快照功能把每次请求实际发给模型的完整上下文存下来。出问题时翻快照比看任何日志都直观。这个功能开发成本极低但救过我至少三次命。上下文管理这件事说到底就是让该在的信息在让该走的信息走两条路线只是实现这个目标的不同手段理解了这个本质选型就不再纠结了。
阅读完成 · 觉得有帮助?