☰
AI Agent上下文工程实战:三层记忆架构与Token压缩方案
2026/9/26 12:02:21 网站建设 项目流程

1. 上下文工程到底在解决什么问题

做 Agent 开发的人,十有八九都经历过这样的场景:本地跑得好好的智能体,一上生产环境就开始胡言乱语,或者干脆报一个agent execution terminated due to error,翻日志发现是上下文超了。更隐蔽的情况是,上下文没超,但模型开始"忘事"——前面明确说过的约束,聊到第十轮就丢了。这类问题表面看是模型能力问题,根子上其实是上下文工程没做好。

所谓上下文工程,说白了就是管理好每一次送进模型的那些 token。它跟提示词工程不是一回事。提示词工程关心的是"这句话怎么写模型才听得懂",上下文工程关心的是"在有限的窗口里,我该放什么、放多少、什么时候放、什么时候扔"。一个 Agent 跑几十轮工具调用,每轮都往上下文里塞工具返回结果,如果不做管理,token 消耗是指数级往上走的,成本和延迟都会失控。

这套东西适合谁看?如果你正在搭 AI Agent、写多 Agent 协作框架、或者在做 Agent 记忆相关的选型,那这篇基本就是给你准备的。如果你只是调调 API 写个问答机器人,可能用不上这么重的东西,但了解一下分层记忆的设计思路也没坏处。我下面会从整体设计讲到具体实现,包括 token 怎么算、上下文怎么压、记忆怎么分层,尽量给到能直接抄作业的方案。

2. 整体架构设计与方案选型思路

2.1 为什么不能"全塞进去"了事

最朴素的做法是把所有历史消息原封不动塞进上下文。早期窗口小的时候大家还收敛点,现在动辄 128K、200K 的窗口,很多人就放飞了。我实测过一个客服 Agent,不做任何管理,跑到第 30 轮对话时单次请求的输入 token 已经到 6 万多,响应延迟从 1.2 秒涨到 8 秒多,成本翻了十几倍,而且模型对早期关键信息的召回率反而下降了——这就是典型的"上下文过载"。

大模型处理长上下文有个特点:中间部分的信息容易被忽略,业内叫"lost in the middle"。你把 6 万 token 塞进去,真正被有效利用的可能就头尾那部分。所以上下文工程的核心目标不是"塞更多",而是"塞得更准"。基于这个判断,我倾向于把整个上下文拆成几个职责分明的层,各层用不同的策略管理。

2.2 三层记忆的职责划分

我把 Agent 的记忆分成三层,这个划分方式在社区里也比较常见,但每层的具体实现差异很大:

层级存储内容生命周期典型实现
短期记忆当前会话的原始消息流单次会话内存队列 + 滑动窗口
工作记忆压缩后的会话摘要、当前任务状态单次会话结构化摘要对象
长期记忆跨会话的事实、偏好、经验持久化向量库 + 结构化存储

短期记忆保证"最近发生了什么"不丢,工作记忆保证"当前任务的目标和进度"清晰,长期记忆保证"这个用户是谁、以前聊过什么"能跨会话延续。三层各管一段,互不干扰,这是整个设计的地基。

2.3 为什么选"压缩 + 检索"而不是"纯检索"

有人会问:既然向量检索这么成熟,为什么不干脆把所有历史都存向量库,每次按需检索?我试过纯检索方案,问题在于对话的时序性和连贯性会丢。用户说"就按刚才那个方案改一下","刚才那个方案"在向量检索里很难精准命中,因为它依赖的是紧邻的上下文,不是语义相似度。

所以我的方案是混合的:近期消息保留原文(滑动窗口),中期消息做压缩摘要,远期和跨会话信息走检索。这样既保住了连贯性,又控制了 token 总量。这个取舍很关键,后面所有实现都围绕它展开。

3. Token 管理的核心细节与实操要点

3.1 Token 到底怎么算才准

很多人用len(text) / 4粗略估算 token,英文场景勉强能用,中文直接崩。中文一个汉字通常占 1 到 2 个 token,标点和特殊符号另算。我建议直接用对应模型的 tokenizer,比如 tiktoken 或者模型厂商提供的计数接口。

import tiktoken def count_tokens(text: str, model: str = "gpt-4") -> int: enc = tiktoken.encoding_for_model(model) return len(enc.encode(text))

但要注意,工具调用的返回结果、JSON 结构、系统提示词都要算进去,很多人只算用户消息,结果实际请求超了。我一般会预留 15% 到 20% 的 buffer,因为模型输出也要占窗口,而且不同厂商对 function call 的 token 计算方式有差异。

3.2 预算分配:给每一层留多少

假设模型窗口是 128K,我的分配策略是这样的:

  • 系统提示词 + 工具定义:固定占用,通常 2K 到 5K,这部分不压缩
  • 长期记忆检索结果:最多 2K,按相关性取 top-k
  • 工作记忆摘要:最多 3K,结构化压缩
  • 短期记忆原文:剩余全部,但设一个硬上限,比如 60K
  • 输出预留:至少 8K

这样算下来,实际输入控制在 70K 左右,留足余量。为什么要留这么多?因为工具返回结果不可控,一个网页抓取可能就返回几万 token,必须提前设防。

提示:预算分配不是拍脑袋,要基于你的实际业务统计。我建议先跑一周日志,统计 P95 的单轮 token 消耗,再倒推分配比例。

3.3 动态裁剪的触发时机

裁剪不能等超了才做,要在每次组装上下文之前就判断。我的做法是维护一个 token 计数器,每加一条消息就累加,一旦超过短期记忆的预算上限,就触发压缩流程:把最老的一批消息交给模型做摘要,摘要结果并入工作记忆,原始消息从窗口移除。

这里有个坑:摘要本身也要消耗 token 和一次模型调用。如果每轮都触发摘要,成本反而更高。我的经验是设置一个"水位线",比如短期记忆用到 80% 预算时才触发一次批量压缩,一次压掉 30% 的量,避免频繁调用。

4. 上下文压缩的具体实现方案

4.1 摘要压缩:怎么压才不丢关键信息

最直接的压缩方式就是让模型总结。但"总结一下"这种提示词效果很差,模型会丢掉具体的数字、ID、约束条件。我用的是一套结构化摘要模板:

SUMMARY_PROMPT = """ 请将以下对话压缩为结构化摘要,严格保留: 1. 用户明确提出的约束和偏好(原话保留关键部分) 2. 已确认的事实和决策(含具体数值、ID、名称) 3. 当前任务的进度和待办事项 4. 未解决的问题 丢弃:寒暄、重复确认、已被推翻的中间方案。 对话内容: {conversation} 输出格式: - 约束与偏好: - 已确认事实: - 任务进度: - 待解决问题: """

实测下来,这种结构化摘要能把 10K token 的对话压到 800 到 1200 token,关键信息保留率明显高于自由总结。核心思路是告诉模型"什么必须留、什么可以扔",而不是让它自己判断。

4.2 工具结果的压缩策略

Agent 场景里 token 消耗大户往往是工具返回。一次搜索返回 20 条结果,一次网页抓取返回整页 HTML,这些直接塞进去就是灾难。我的处理分三步:

  1. 结构化提取:工具返回后先用规则或小模型提取关键字段,比如搜索只留标题、URL、摘要
  2. 截断保护:对超长文本做首尾截断,保留开头和结尾,中间用省略标记
  3. 按需展开:完整结果存到外部存储,上下文里只放引用 ID,模型需要时再通过工具取回

第三步是关键,这叫"引用式上下文"。模型看到的是[搜索结果已缓存,ID: search_001,共 20 条],需要细节时调用fetch_detail("search_001", index=3)。这样上下文里永远只有轻量的引用,token 占用极小。

4.3 压缩的边界:什么绝对不能压

有些东西压了就出事。系统提示词里的安全约束、工具调用的参数格式、当前正在编辑的代码或文档原文,这些必须原样保留。我踩过一次坑:把工具 schema 也做了摘要,结果模型生成的参数格式全错,排查了半天才发现是压缩惹的祸。

判断标准很简单:如果这段内容被改写后会导致模型行为出错,就不能压。压缩只针对"信息密度低、冗余度高"的部分,比如闲聊、重复确认、已废弃的中间推理。

5. 分层记忆的落地实现

5.1 短期记忆:滑动窗口 + 优先级队列

短期记忆我不用简单的 FIFO 队列,而是带优先级的。每条消息打一个重要性分数,用户明确指令、工具关键返回分数高,寒暄分数低。窗口满时优先淘汰低分消息。

class ShortTermMemory: def __init__(self, max_tokens: int): self.messages = [] # (message, importance, tokens) self.max_tokens = max_tokens self.current_tokens = 0 def add(self, message, importance=1.0): tokens = count_tokens(message["content"]) self.messages.append((message, importance, tokens)) self.current_tokens += tokens if self.current_tokens > self.max_tokens: self._evict() def _evict(self): # 按重要性升序淘汰,保护最近的消息 self.messages.sort(key=lambda x: (x[1], -len(self.messages))) while self.current_tokens > self.max_tokens * 0.7: msg, imp, tok = self.messages.pop(0) self.current_tokens -= tok

注意淘汰目标是降到 70% 而不是刚好卡线,留出缓冲避免频繁触发。

5.2 工作记忆:结构化的任务状态对象

工作记忆不是一段文本,而是一个结构化对象,我通常包含这些字段:

working_memory = { "task_goal": "帮用户完成季度报表分析", "constraints": ["只统计华东区", "排除退货订单"], "confirmed_facts": {"数据源": "sales_2024Q3.csv", "口径": "含税"}, "progress": ["已加载数据", "已完成清洗"], "pending": ["计算同比", "生成图表"], "summary": "用户需要华东区季度报表..." }

每次组装上下文时,把这个对象序列化成紧凑文本注入。它的好处是模型一眼就能看到任务全貌,不会因为历史消息被压缩而丢失目标感。这个对象由模型在每轮结束后更新,我用一个专门的"状态更新"提示词来驱动。

5.3 长期记忆:向量检索 + 结构化双写

长期记忆我采用双写策略:事实性信息进结构化库(如用户偏好、账号信息),经验性信息进向量库(如历史解决方案、相似案例)。检索时两路并行,结构化结果直接注入,向量结果按相似度取 top-3。

向量库的选型上,小规模用 FAISS 就够,上规模再考虑 Milvus 或 Qdrant。关键不是选哪个库,而是 embedding 的质量和分块策略。我的经验是:按语义单元分块,别按固定字数切,否则检索出来的片段经常断头断尾。

注意:长期记忆写入要有去重和冲突检测。我遇到过用户改了偏好,但旧偏好还在库里,检索时两条都命中,模型就懵了。解决办法是给每条记忆加时间戳和状态标记,检索时优先取最新的有效记录。

6. 常见问题与排查技巧实录

6.1 典型问题速查表

现象可能原因排查方向
模型忘记早期约束短期记忆淘汰过早检查重要性评分,提高约束类消息权重
响应突然变慢上下文 token 激增打印每轮 token 数,定位是哪类消息膨胀
工具参数格式错误工具 schema 被压缩确认 schema 未进入压缩流程
摘要后信息丢失摘要提示词不够结构化改用结构化模板,明确保留字段
跨会话记忆串味长期记忆未按用户隔离检查检索时的过滤条件
报错 execution terminated上下文超窗口加 token 预检,超限前强制压缩

6.2 几个我踩过的坑

坑一:摘要的摘要。如果压缩后的摘要又被再次压缩,信息会逐层衰减,几轮下来就面目全非。我的做法是摘要只压原始消息,不压已有摘要,工作记忆里的摘要始终保持一份,新的压缩结果合并进去而不是覆盖。

坑二:token 计数和实际不符。不同厂商对 system message、function call 的计费方式不一样,本地算的和服务端算的经常对不上。上线前一定要用真实请求验证,别信本地估算。

坑三:压缩触发太频繁。早期我把水位线设得太低,结果每两轮就压一次,模型调用成本反而上升。后来改成"用量到 80% 且至少积累 10 轮"才触发,成本降了一半。

6.3 调试上下文的小技巧

我习惯在开发环境把每轮实际发送的上下文完整打印出来,包括各层占用的 token 数。这样一眼就能看出哪层在膨胀。生产环境则记录 token 统计到监控,设置告警阈值。没有可观测性的上下文工程就是盲人摸象,这一步千万别省。

另外,我会准备一组"回归测试对话",每次调整压缩策略后跑一遍,看关键信息是否还在。这比拍脑袋调参靠谱得多。

7. 一些实操中的个人体会

这套方案我在几个项目里跑下来,最深的体会是:上下文工程没有银弹,全是权衡。压缩率高了丢信息,低了省不下 token;检索 top-k 取多了噪声大,取少了漏关键。这些参数都得基于自己的业务数据去调,别人的配置只能当起点。

还有一个反直觉的点:有时候增加上下文反而降低效果。我做过对比,同一个任务,塞 50K token 和塞 15K 精准 token,后者准确率更高。所以别迷信大窗口,把该放的放准才是正道。

最后分享一个我常用的自检方法:把组装好的上下文单独喂给模型,问它"当前任务是什么、有哪些约束、下一步该做什么",如果它答得清楚,说明上下文质量过关;如果答得含糊,那就是压缩或分层出了问题。这个方法简单但特别有效,建议你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询