尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Agent上下文管理实战:从失忆到抗50轮对话的架构设计

Agent上下文管理实战:从失忆到抗50轮对话的架构设计 1. 从“失忆”现象说起为什么你的 Agent 聊到 20 轮就崩了如果你正在做 Agent 开发大概率遇到过这个场景前 5 轮对话Agent 对答如流上下文衔接自然到第 10 轮开始出现轻微的前后矛盾到第 20 轮它彻底“失忆”——忘了最初的任务目标忘了你明确说过的约束条件甚至开始重复之前已经否定过的方案。你检查代码逻辑没问题你换模型情况稍好但依然存在你加大上下文窗口从 8K 换到 128K问题只是被推迟了并没有被解决。这个现象在 Agent 开发圈子里太常见了常见到很多人把它当成“模型能力不足”来接受。但我想说的是问题大概率不在模型而在你对“上下文”的管理方式。你管理的是历史高手管理的是上下文——这两者之间的差距就是 Agent 能不能扛住 50 轮、100 轮甚至更长对话的关键。先把这个概念掰开。历史History是什么是你和 Agent 之间所有消息的线性堆叠用户说了什么、Agent 回了什么、工具调用了什么一条一条按时间顺序排好整包塞给模型。上下文Context是什么是模型在当前这一轮推理时真正需要看到的信息集合。它可能来自历史可能来自外部检索可能来自摘要压缩也可能来自结构化的状态记录。历史是原料上下文是成品。绝大多数 Agent 框架的默认行为就是把历史当上下文用。LangChain 的ConversationBufferMemory、AutoGPT 的默认记忆、Dify 工作流里直接拼接的对话记录都是这个思路。短对话没问题一旦轮次上去历史线性膨胀上下文窗口被塞满模型注意力被稀释关键信息被淹没在大量冗余对话里。这就是“失忆”的根因。注意上下文窗口用完和上下文窗口不够用是两回事。前者是物理限制后者是管理问题。把 128K 窗口塞满垃圾信息效果远不如 8K 窗口里放精准内容。这篇文章面向的是正在做 Agent 开发、已经踩过或即将踩到上下文管理坑的工程师。我会从架构设计、核心机制、实操实现、问题排查四个层面把 Context Editing、Compaction、Memory Tool 这几个关键词背后的东西讲透。不管你是用 Dify 搭工作流还是用 LangChain、Spring AI、ADK 自己写框架思路是通用的。2. 上下文管理的整体设计思路从“堆历史”到“管状态”2.1 为什么线性历史必然失败先算一笔账。假设你的 Agent 每轮对话平均产生 300 token 的用户输入、500 token 的模型回复、800 token 的工具调用结果合计 1600 token。20 轮下来就是 32000 token。这还没算系统提示词、工具定义、few-shot 示例。如果你的系统提示词有 2000 token工具定义有 3000 token那 20 轮对话的总上下文就是 37000 token 左右。看起来 128K 窗口完全装得下对吧但问题在于注意力衰减。Transformer 架构对上下文中不同位置的 token 注意力权重是不均匀的。中间位置的信息——也就是第 5 轮到第 15 轮之间的对话——最容易被忽略。这就是著名的“Lost in the Middle”现象。你把 20 轮历史全塞进去模型真正“看到”的往往是开头系统提示词和结尾最近几轮中间的关键约束被淹没了。更致命的是线性历史里充斥着大量低信息密度的内容。用户说“好的”“继续”“嗯”Agent 说“我理解了”“让我想想”“接下来我会”工具返回了一大段 JSON 但你只需要其中一个字段。这些内容占据了宝贵的上下文空间却对当前推理几乎没有贡献。2.2 上下文管理的三层架构高手管理上下文核心思路是分层。我把它总结为三层即时层、工作层、持久层。即时层Immediate Context是当前这一轮推理必须看到的信息。包括用户最新输入、当前任务状态、最近 2-3 轮的对话摘要、当前需要调用的工具定义。这一层控制在 2000-4000 token 以内保证模型注意力高度集中。工作层Working Context是当前任务周期内的关键信息。包括任务目标、已确认的约束条件、已完成步骤的摘要、待办事项列表。这一层用结构化格式存储比如 JSON 或 Markdown 表格而不是原始对话。控制在 1000-2000 token。持久层Persistent Memory是跨任务、跨会话的长期记忆。包括用户偏好、领域知识、历史决策记录。这一层不直接进上下文而是通过检索按需注入。用向量数据库或键值存储管理。这三层的比例大概是即时层 60%、工作层 30%、持久层 10%按需。对比一下默认的“全量历史”方案同样是 20 轮对话分层管理后的上下文可能只有 5000 token但关键信息一个不少。2.3 Context Editing、Compaction、Memory Tool 的分工这三个热词经常被混在一起说但它们的职责完全不同。Context Editing是“编辑”——在历史进入上下文之前对它做增删改。比如删除寒暄内容、合并重复信息、修正错误记录。它发生在上下文组装的阶段是主动的、规则驱动的。Compaction是“压缩”——把长内容压成短内容把多轮对话压成摘要。它是有损的但保留核心语义。Compaction 可以发生在历史积累到一定长度时触发也可以每轮都做增量压缩。Memory Tool是“外挂”——把信息存到上下文窗口之外需要时再取回来。它是无损的理论上但取回有延迟和检索误差。Memory Tool 解决的是“上下文窗口物理装不下”的问题而 Context Editing 和 Compaction 解决的是“装下了但模型看不到重点”的问题。一个成熟的 Agent 上下文管理系统三者缺一不可。只用 Compaction摘要会越来越失真只用 Memory Tool检索命中率不稳定只用 Context Editing遇到真正超长的内容还是没辙。3. 核心机制拆解Compaction 怎么做才不丢信息3.1 摘要压缩的触发时机与粒度Compaction 最常见的实现是“对话摘要”——把前 N 轮对话交给模型让它生成一段摘要然后用摘要替换原始对话。但这里有几个关键决策点做错了效果天差地别。触发时机不要等上下文满了才压缩。我的经验是当历史 token 数达到上下文窗口的 40% 时就开始触发。留出 60% 的空间给当前推理和工具调用结果。如果你用的是 128K 窗口那 50K 左右就该动手了。等塞到 90% 再压缩模型已经在“失忆”边缘了压缩质量也会下降。压缩粒度不要一次性把 20 轮全压成一段。分段压缩每 5-8 轮压一次保留多个摘要节点。这样检索时可以按时间范围定位而不是只有一个全局摘要。比如[摘要-1] 第1-6轮用户要求搭建一个数据清洗流程确认使用Python输入格式为CSV输出需要去重和空值填充。 [摘要-2] 第7-12轮完成了去重逻辑空值填充策略从均值改为中位数用户要求增加异常值检测。 [摘要-3] 第13-18轮异常值检测用IQR方法实现用户反馈运行超时优化了pandas操作。这种分段摘要比一段 500 字的全局摘要信息密度高得多而且方便后续按需展开。3.2 摘要提示词的设计要点摘要提示词直接决定压缩质量。我见过太多人用“请总结以下对话”这种泛泛的指令结果摘要里全是“用户提出了需求助手进行了回复”这种废话。好的摘要提示词要明确告诉模型保留什么、丢弃什么、用什么格式。我的常用模板是这样的COMPACTION_PROMPT 你是一个对话压缩器。请将以下对话压缩为结构化摘要严格遵循以下规则 必须保留 1. 用户明确提出的目标、约束、偏好 2. 已确认的技术选型、参数、配置 3. 未解决的问题和待办事项 4. 关键决策及其理由 必须丢弃 1. 寒暄、确认性回复好的明白了 2. 重复表述的内容 3. 工具调用的原始返回只保留结论 4. 已被推翻的方案 输出格式 - 目标[一句话] - 约束[列表] - 已完成[列表含关键参数] - 待办[列表] - 关键决策[列表含理由] 对话内容 {conversation} 这个模板的关键在于结构化输出。结构化摘要比自然语言摘要更容易被后续检索和拼接也更容易发现信息缺失。你可以把它存成 JSON需要时只取相关字段注入上下文。3.3 增量压缩与全量压缩的取舍增量压缩是每轮或每几轮做一次小压缩全量压缩是积累到一定程度做一次大压缩。两者各有优劣。增量压缩的优点是延迟低、信息新鲜度高缺点是压缩次数多了会“摘要的摘要”信息逐层失真。全量压缩的优点是信息完整、失真少缺点是计算成本高、延迟大。我的做法是混合策略日常用增量压缩每 5 轮做一次小摘要当小摘要积累到 5 个以上时触发一次全量压缩把小摘要合并成中摘要。这样既控制了延迟又避免了过度失真。具体阈值可以根据你的 token 预算调整。实操心得压缩后的摘要一定要带上时间戳或轮次范围。我踩过的坑是摘要没标范围后来想回溯某条信息来自哪几轮完全找不到。4. Memory Tool 的落地把信息存到窗口外面4.1 Memory Tool 的存储结构设计Memory Tool 的本质是给 Agent 一个“笔记本”上下文窗口是“桌面”笔记本是“抽屉”。桌面上只放当前要用的东西其他都放抽屉里需要时再拿出来。存储结构的设计决定了检索效率。我推荐用三层键值结构session:{session_id}:facts - 事实性信息用户偏好、领域知识 session:{session_id}:decisions - 决策记录选了什么、为什么 session:{session_id}:artifacts - 产出物代码、文档、配置每一层用不同的存储后端。facts 用向量数据库支持语义检索decisions 用关系型数据库或 JSON 文件支持精确查询artifacts 用对象存储或文件系统支持大内容。为什么不把所有东西都塞向量数据库因为向量检索有误差。用户问“我之前说的那个参数是多少”如果这个参数存在 facts 里语义检索可能召回一堆相关但不精确的内容。但如果存在 decisions 里用decision_typeparameter精确查询一次命中。4.2 写入时机与去重策略Memory Tool 最大的坑是写太多。每轮对话都往里写很快存储就爆了检索质量也下降。我的策略是按事件写入而不是按轮次写入。什么算事件用户明确了一个新约束、达成了一个决策、完成了一个子任务、产生了一个产出物。这些才值得写入。普通的问答、确认、闲聊不写。去重也很关键。同一个事实被反复写入检索时会返回一堆重复结果。我的做法是写入前先做一次相似度检查如果已有相似度超过 0.9 的记录就更新而不是新增。更新时保留版本号方便回溯。def write_memory(session_id, memory_type, content, metadata): existing search_memory(session_id, memory_type, content, top_k1) if existing and existing[0][score] 0.9: update_memory(existing[0][id], content, metadata, versionexisting[0][version]1) else: insert_memory(session_id, memory_type, content, metadata)4.3 检索注入的策略检索注入不是“查到什么塞什么”而是要有选择。我的做法是按需检索、限量注入。按需检索不是每轮都检索而是当模型明确需要历史信息时才检索。怎么判断“明确需要”看用户输入里有没有指代词——“之前那个”“上次说的”“刚才提到的”。或者看当前任务是否依赖历史决策。限量注入每次最多注入 3 条记忆每条不超过 200 token。注入时带上来源标记让模型知道这是从记忆里取出来的不是当前对话的内容。[来自记忆] 用户在 session-abc 中确认空值填充使用中位数而非均值理由是数据存在偏态分布。这种标记很重要否则模型会把记忆内容和当前对话混淆产生“我什么时候说过这个”的困惑。5. 实操过程从零搭建一个抗 50 轮的上下文管理系统5.1 环境准备与基础框架选型我用 Python 做示例框架选 LangChain 做基础编排但上下文管理部分自己实现不依赖 LangChain 的 Memory 模块。原因很简单LangChain 的 Memory 模块抽象层次太高定制化困难而且默认行为就是线性历史改起来别扭。依赖清单pip install langchain openai tiktoken chromadb redislangchain基础编排openai模型调用可替换为其他模型tiktokentoken 计数chromadb向量存储用于 facts 检索redis键值存储用于 decisions 和 session 状态如果你用 Dify 或 Coze 这类平台思路一样只是把代码实现换成节点编排。Dify 的工作流里可以用“代码执行”节点做 Compaction用“知识库”节点做 Memory Tool。5.2 上下文组装器的实现上下文组装器是核心模块负责把即时层、工作层、持久层拼成最终送给模型的 messages。我把它写成一个类import tiktoken from typing import List, Dict class ContextAssembler: def __init__(self, model_namegpt-4, max_tokens8000): self.encoder tiktoken.encoding_for_model(model_name) self.max_tokens max_tokens self.budget { system: 0.15, immediate: 0.50, working: 0.25, persistent: 0.10, } def count_tokens(self, text: str) - int: return len(self.encoder.encode(text)) def assemble(self, system_prompt, recent_turns, working_summary, retrieved_memories): messages [] messages.append({role: system, content: system_prompt}) # 持久层检索到的记忆 if retrieved_memories: memory_text \n.join([f[来自记忆] {m} for m in retrieved_memories]) messages.append({role: system, content: memory_text}) # 工作层结构化摘要 if working_summary: messages.append({role: system, content: f[任务状态]\n{working_summary}}) # 即时层最近几轮原始对话 for turn in recent_turns: messages.append(turn) # 预算检查 total sum(self.count_tokens(m[content]) for m in messages) if total self.max_tokens: # 超出预算从最老的即时层开始裁剪 messages self._trim(messages, total - self.max_tokens) return messages def _trim(self, messages, excess): # 从即时层索引2之后开始删保留 system 和工作层 trimmed 0 result messages[:2] for msg in messages[2:]: tokens self.count_tokens(msg[content]) if trimmed excess: trimmed tokens continue result.append(msg) return result这个组装器的关键设计是预算分配。系统提示词占 15%即时层占 50%工作层占 25%持久层占 10%。这个比例不是拍脑袋定的是根据实际运行数据调的。即时层给 50% 是因为最近几轮对话对当前推理最重要工作层给 25% 是因为结构化摘要信息密度高不需要太多 token持久层给 10% 是因为检索注入要克制。5.3 Compaction 模块的实现Compaction 模块负责把老对话压成摘要。我实现了一个分段压缩器class Compactor: def __init__(self, llm_client, segment_size6): self.llm llm_client self.segment_size segment_size self.segments [] # 存储分段摘要 def should_compact(self, turns) - bool: return len(turns) self.segment_size def compact(self, turns) - str: conversation self._format_turns(turns) prompt COMPACTION_PROMPT.format(conversationconversation) summary self.llm.invoke(prompt) segment { range: f第{self._start_idx}-{self._start_idxlen(turns)-1}轮, summary: summary, tokens: self.count_tokens(summary) } self.segments.append(segment) self._start_idx len(turns) return summary def merge_segments(self, max_segments5): if len(self.segments) max_segments: return # 把最老的几个分段合并成一个中摘要 to_merge self.segments[:max_segments] merged_text \n.join([s[summary] for s in to_merge]) merged_summary self.llm.invoke( f请将以下分段摘要合并为一个更紧凑的摘要保留所有关键信息\n{merged_text} ) new_segment { range: f{to_merge[0][range]}至{to_merge[-1][range]}, summary: merged_summary, tokens: self.count_tokens(merged_summary) } self.segments [new_segment] self.segments[max_segments:]这个实现里有两个细节值得说。第一segment_size6是经验值太小了压缩频繁、延迟高太大了单段信息多、压缩质量下降。第二merge_segments做的是“摘要的摘要”这一步要谨慎合并后一定要人工检查一次确认关键信息没丢。我一般会在合并后跑几个测试用例问 Agent 一些依赖早期信息的问题看它能不能答对。5.4 Memory Tool 的读写实现Memory Tool 我用 Redis ChromaDB 组合实现import redis import chromadb import json class MemoryTool: def __init__(self): self.redis redis.Redis(hostlocalhost, port6379, db0) self.chroma chromadb.Client() self.facts_collection self.chroma.get_or_create_collection(facts) def write_fact(self, session_id, fact, metadataNone): # 去重检查 existing self.facts_collection.query( query_texts[fact], n_results1, where{session_id: session_id} ) if existing[distances] and existing[distances][0][0] 0.1: return # 已存在相似事实跳过 doc_id f{session_id}:fact:{hash(fact)} self.facts_collection.add( documents[fact], metadatas[{session_id: session_id, **(metadata or {})}], ids[doc_id] ) def write_decision(self, session_id, decision_type, content, reason): key fsession:{session_id}:decisions record { type: decision_type, content: content, reason: reason, timestamp: time.time() } self.redis.lpush(key, json.dumps(record)) def retrieve(self, session_id, query, top_k3): results self.facts_collection.query( query_texts[query], n_resultstop_k, where{session_id: session_id} ) facts results[documents][0] if results[documents] else [] # 同时查决策记录 decisions_raw self.redis.lrange(fsession:{session_id}:decisions, 0, 4) decisions [json.loads(d) for d in decisions_raw] return { facts: facts, decisions: decisions }这里的关键是事实和决策分开存。事实用向量检索因为事实的表述可能变化决策用列表存因为决策需要精确回溯和顺序。检索时两者都返回组装器根据当前查询决定注入哪些。5.5 完整流程串联把上面几个模块串起来主循环大概是这样def agent_loop(session_id, user_input): # 1. 加载会话状态 state load_session_state(session_id) # 2. 检索记忆 memories memory_tool.retrieve(session_id, user_input) # 3. 组装上下文 messages assembler.assemble( system_promptSYSTEM_PROMPT, recent_turnsstate[recent_turns][-4:], # 最近4轮 working_summarystate[working_summary], retrieved_memoriesmemories[facts] [d[content] for d in memories[decisions]] ) # 4. 调用模型 response llm.invoke(messages) # 5. 更新状态 state[recent_turns].append({role: user, content: user_input}) state[recent_turns].append({role: assistant, content: response}) # 6. 检查是否需要压缩 if compactor.should_compact(state[recent_turns]): old_turns state[recent_turns][:-4] summary compactor.compact(old_turns) state[working_summary] update_working_summary(state[working_summary], summary) state[recent_turns] state[recent_turns][-4:] # 7. 检查是否需要写入记忆 if is_significant_event(user_input, response): extract_and_write_memory(session_id, user_input, response) # 8. 保存状态 save_session_state(session_id, state) return response这个循环里第 6 步和第 7 步是控制上下文膨胀的关键。第 6 步保证 recent_turns 永远不超过 4 轮老对话被压进 working_summary。第 7 步保证重要信息被持久化不会因为压缩而丢失。6. 常见问题与排查技巧实录6.1 压缩后信息丢失怎么排查这是最高频的问题。Agent 压缩后突然忘了之前确认的参数。排查思路是逐层回溯。先看 working_summary 里有没有这个信息。如果没有说明压缩时丢了。检查压缩提示词是否覆盖了这类信息。我遇到过一次用户确认的“超时时间 30 秒”在压缩后消失了原因是压缩提示词里“关键参数”这一项写得太模糊模型没把它当关键参数。后来我把提示词改成“所有数字型配置必须保留”问题解决。如果 working_summary 里有但 Agent 还是没用到说明注入位置不对。检查组装器是否把 working_summary 放在了 system message 里。有些框架会把摘要放在 user message 里模型注意力权重不同效果差很多。如果 working_summary 和注入都没问题但 Agent 还是答错那可能是模型本身的问题。换一个更强的模型试试或者把摘要展开成更详细的格式。6.2 检索命中率低的优化Memory Tool 检索不准通常是三个原因存储内容太碎、查询表述差异大、top_k 设置不合理。存储内容太碎把一句话拆成多条存检索时召回一堆碎片。解决方法是写入时做聚合把同一主题的信息合并成一条完整的记录。查询表述差异大用户说“那个参数”存储里是“超时时间配置”。语义检索可能匹配不上。解决方法是在写入时生成多个表述变体或者用 LLM 做查询改写把“那个参数”改写成“之前确认的超时时间参数”。top_k 设置不合理top_k1 容易漏top_k10 容易引入噪声。我的经验是 top_k3 起步根据召回质量调整。同时加一个相似度阈值低于阈值的直接丢弃。6.3 常见问题速查表问题现象可能原因排查方法解决方案20轮后失忆线性历史淹没关键信息检查上下文组装逻辑引入分层管理和Compaction压缩后参数丢失压缩提示词覆盖不全对比压缩前后摘要细化提示词强制保留数字和约束检索召回不相关存储碎片化或查询差异查看召回内容和分数聚合存储查询改写调top_k上下文超长报错预算分配失效打印token计数检查组装器裁剪逻辑Agent重复问已答问题记忆未写入或未检索检查Memory写入日志补写入逻辑增加检索触发摘要越来越失真多层压缩累积误差对比原始对话和最终摘要限制压缩层数定期全量重建工具调用结果占满上下文原始返回未处理查看工具返回token数工具结果先摘要再注入多轮后响应变慢上下文过长导致推理慢统计每轮token和延迟压缩检索控制上下文在8K内6.4 几个容易踩的坑坑一压缩太晚。等上下文快满了才压缩模型已经在“失忆”状态压缩出来的摘要质量也差。我的做法是 40% 就触发。坑二摘要不带来源。摘要里写“用户确认了参数”但没写是哪一轮确认的、具体参数值是多少。后来想回溯完全找不到。摘要一定要带轮次范围和具体值。坑三Memory 只写不读。花大力气实现了写入但检索注入没做好等于白写。写入和检索要同步设计写入时就想好“这个信息将来怎么被查到”。坑四忽略工具结果的压缩。工具调用返回的 JSON 动辄几千 token直接塞上下文是灾难。我的做法是工具结果先过一层摘要只保留关键字段和结论。坑五所有会话共用一个记忆库。不同 session 的记忆混在一起检索时召回其他会话的内容。一定要按 session_id 隔离或者至少加过滤条件。7. 进阶优化让上下文管理更智能7.1 基于任务阶段的动态预算固定预算分配15/50/25/10在简单场景够用但复杂任务需要动态调整。比如任务初期用户还在确认需求工作层预算应该加大即时层可以减小任务执行期即时层加大工作层稳定任务收尾期持久层加大把成果写入长期记忆。实现方式是在 session state 里加一个phase字段组装器根据 phase 调整预算比例PHASE_BUDGETS { requirement: {system: 0.15, immediate: 0.35, working: 0.40, persistent: 0.10}, execution: {system: 0.15, immediate: 0.55, working: 0.20, persistent: 0.10}, closing: {system: 0.15, immediate: 0.30, working: 0.25, persistent: 0.30}, }phase 的切换可以由 LLM 判断也可以用规则。比如用户说“开始做吧”就切到 execution用户说“导出结果”就切到 closing。7.2 上下文质量监控上线后要监控上下文质量否则出了问题不知道。我监控三个指标压缩比压缩后 token / 压缩前 token。正常在 0.1-0.3 之间。如果高于 0.5说明压缩没效果如果低于 0.05说明压得太狠可能丢信息。检索命中率检索返回的结果中被实际注入上下文的比例。低于 50% 说明检索噪声大需要优化查询或阈值。失忆率随机抽样对话检查 Agent 是否遗忘了早期确认的信息。这个需要人工标注但每周抽 20 条就够发现趋势。7.3 多 Agent 场景下的上下文隔离如果你做的是多 Agent 系统每个 Agent 有自己的上下文但又要共享部分信息。我的做法是共享持久层隔离即时层和工作层。共享持久层所有 Agent 读写同一个 Memory Tool但按 agent_id 和 session_id 双重隔离。跨 Agent 共享的信息用特殊的共享命名空间。隔离即时层和工作层每个 Agent 维护自己的 recent_turns 和 working_summary。Agent 之间通信时只传递结构化消息不传递原始上下文。这样既保证了信息共享又避免了上下文污染。我试过让多个 Agent 共用一个上下文结果一个 Agent 的工具调用结果被另一个 Agent 误当成用户输入整个流程乱套。8. 我个人的实操体会做 Agent 开发这几年上下文管理是我踩坑最多、也收获最大的领域。最开始我也觉得“窗口不够大”是根本问题后来把窗口从 4K 换到 32K 再换到 128K发现失忆问题只是被推迟没有被解决。真正解决问题是从“管理历史”转向“管理上下文”的那一刻。分层架构、Compaction、Memory Tool 这三件套我现在的项目里是标配。上线后 Agent 的平均对话轮次从 15 轮提升到 60 轮以上用户反馈“聊久了也不糊涂”的比例明显上升。当然代价是系统复杂度上去了需要维护压缩器、检索器、组装器三个模块但相比重新训练模型或者无限加大窗口这个代价是值得的。最后分享一个小技巧每次压缩后让 Agent 自己复述一遍当前任务状态。比如在压缩后插入一轮“请确认你当前理解的任务目标和约束”看它的回答是否和压缩前一致。如果不一致说明压缩丢了信息可以立即回滚重压。这个自检机制帮我抓到了好几次压缩 bug成本几乎为零效果立竿见影。
返回列表