我真正开始重视上下文工程是在一次线上事故之后。一个跟客户对话的系统单轮测试怎么问怎么对上线第二天就被投诉说“它把我上一个问题忘了”。查了一圈发现根本不是模型不行而是 Session 和 Memory 的设计全都不对——这是很多做 Agent 的团队都会经过的坎。后来我花了一个迭代周期把业界关于上下文工程的那套方法尤其是 Sessions 与 Memory 的实现路径搬进自己的项目里才真正解决“长期对话失忆”和“跨会话切换失智”这两个老大难。这篇文章不是概念科普而是实践笔记。我会从一个后端开发者的视角把 Session 怎么组织、Memory 怎么分层、哪些信息进模型窗口、哪些信息留在外部存储、以及生产环境里会踩的坑一条条拆开讲。适合正在用大模型 API 做客服助手、数据分析 Agent、自动化工作流 Agent 的团队特别是被“多轮对话丢上下文”和“重启会话就忘了老客户”折磨过的人。读完你可以直接照着搭一套最小可用的上下文工程框架而不是拿着一堆提示词技巧在文档里打转。1. 为什么说上下文工程是 Agent 能不能落地的分水岭先聊一个现象。很多人觉得 Agent 就是“多轮 Prompt 工具调用”只要模型够强把用户的连续问题丢进去就能得出好结果。但真实业务里用户的输入往往是不完整的上一句说“还是按上次的方案”下一句问“那价格呢”客服场景里客户可能隔了三天继续追问同一个工单数据分析场景里用户会要求“给我看刚才那个区域的数据对比”。如果模型窗口里只有当前这一句话再强的模型也只能猜。上下文工程要解决的就是这个问题在有限的 Token 预算内决定把哪些历史信息、用户事实、业务规则和工具返回结果以什么顺序放进模型窗口以及剩下的内容如何在外部保留。它不是提示词工程的下位替代而是更靠后的一层——提示词定的是“怎么说”上下文工程定的是“让模型看什么”。这里的两个基础构件就是 Sessions 和 Memory。Sessions 负责管理当前对话的活状态这轮聊到哪了、工具调用了哪些、用户确认过什么。Memory 负责管理跨会话的长期信息客户的付款习惯、项目的背景约束、上一次沟通的结论。两者配合Agent 才有条件做到“像人一样记得住、接得上”。我见过不少团队在上下文上偷懒要么直接把所有历史对话一股脑塞进窗口结果长会话里几十轮之后 Token 直接爆炸要么每次请求都只带最后两轮消息短对话看起来没问题一旦用户绕几个弯就彻底迷糊。这两条路都不是工程方案是撞运气。上下文工程的意义就在于把“运气”变成“可控的系统行为”。这套方法论从原理到工程实现都不复杂难的是把每一层想清楚、把边界划分好。2. Session 层单个 Agent 如何组织、回放和压缩上下文2.1 从“一篮子消息”到有状态的会话事件早期的做法很简单把用户消息和助理回复按顺序存成一个列表每次请求就把列表塞给模型。这个做法在小 demo 里完全够用但一旦 Agent 开始调用工具问题就出现了——工具调用本身也是对话的一部分而且往往有多个步骤、多个结果。如果只存“用户说什么、助手答什么”中间的工具输出就会丢失模型经常因为看不到某个中间结果而断言错误。我的做法是把 Session 内部的所有活动都建模成事件而不是单纯的消息。事件不仅包括 user 和 assistant 的文本还包括 tool_call 的请求参数以及 tool_result 的执行结果。每个事件有独立的 event_id并且在业务数据上只追加、不修改。这样做的最大好处是任何时刻要恢复一个会话都可以通过重放事件流把 Agent 的状态完整还原。这跟后端开发里事件溯源的思路是一致的。举个例子一个会话可能包含这样的事件序列user 说“帮我查上周销售额”tool_call 发起查询参数是date_range2025-01-01~2025-01-07tool_result 返回一个 JSON 数据块assistant 总结出“上周销售额为 520 万元环比 6%”user 追问“那华东区呢”tool_call 再次发起查询参数是region华东tool_result 返回华东区数据assistant 回答最终结论如果只存消息列表第 5 步之后模型能看到的上下文是残缺的它知道用户问了华东区却看不到第一个查询的完整纬度和上一次的总结口径。事件模型就不会出现这种问题因为它保留了每一步的输入、处理、输出。存储上我用一张会话事件表就够结构大致是这样字段说明session_id会话唯一标识event_id递增事件编号用于排序和回放event_typeuser / assistant / tool_call / tool_resultcontent文本内容或工具调用参数tool_name非文本事件时记录工具名created_at时间戳用于过期策略2.2 会活持久化与恢复中断之后继续干前面讲的都是单人单进程的临时状态但生产环境里会话一定会在不同的服务实例间流转用户也可能隔几分钟甚至几天再次回来。所以 Session 的持久化就变得非常重要。我现在的方案是Redis 存“热会话”数据库存“冷归档”。热会话指最近 30 分钟内还在活跃的会话用 Redis 存主要是为了低延迟读写一旦会话超过一定时间没有新事件就把完整事件流异步转储到 PostgreSQL并在 Redis 里只留一个 session_id 的索引。当用户再次发起请求时先从 Redis 拉最近事件如果 Redis 没有就从数据库恢复。恢复的过程其实就是一个回放过程把事件流按顺序读出来在内存里重建最近状态。这里有一个容易忽略的细节会话恢复不只是把文本消息拼回来工具执行的中间状态也要重建。比如用户在 Agent 还没拿到工具结果时就断线了恢复后应该能接着继续等待结果而不是让用户重新描述一遍需求。工具轮次的状态我习惯单独存一个pending_tool_calls字段标记当前有哪些工具调用尚未完成。恢复会话时如果发现存在 pending 事件就自动触发补一次执行或用缓存结果回填避免对话出现“断层”。持久化层还有一个策略要提前定好Session 存多久。我的经验是业务上给一个明确的 TTL比如 90 天超过后只保留 Memory 层提取出的结构性摘要原始事件流清理掉。否则事件表会无限膨胀恢复一次会话的成本会越来越高。2.3 窗口有限摘要来凑滚动截断策略模型窗口是有限的所以 Session 层必须解决“历史太长放不下”的问题。我试过两种粗暴的办法一是简单截断只保留最近 N 条消息二是把所有历史都压缩成摘要每次全带上。单独用一种都不好。简单截断的问题在于突然性。用户聊了 40 轮你只留最近 10 轮第 5 轮里用户说过的“目标客户是中小商户预算不超过 5 万”这个关键信息就凭空消失了。摘要全带的问题是摘要会越滚越大而且生成摘要本身要消耗模型调用每次全带会影响单次请求的有效输出空间。现在我的方案是“截断 滚动摘要”双轨制。具体来说会话最近 20 轮事件完整保留原样进入模型窗口超过 20 轮的部分每增加 5 轮就触发一次摘要生成把窗口之外的内容压缩成一个滚动摘要滚动摘要放在上下文的最前端并且每次都带着“已确认信息”和“待办事项”两个小标题方便模型快速定位。比如摘要会写成“用户已确认使用 A 方案顾虑是预算不能超过 5 万待确认的是交付时间。上一轮承诺了 3 天内给报价单”。这比把原始对话扔进去高效得多——模型读摘要只需要几十个 Token而原始对话可能要几千个。摘要的生成频率要控制好不能每轮都做否则成本会失控。我的经验是设定一个阈值比如事件数量超过 25 时生成一次较详细的摘要之后每再增加 5 个事件基于旧摘要增量更新。增量更新比每次全量重写要便宜得多而且上下文连贯性更好。3. Memory 层从“对话历史”到“长期存储”的关键跃迁Session 解决的是“这一场对话怎么记得住”但真正让 Agent 变智能的是它能跨会话记住用户。这就是 Memory 层要做的事。这里的难点不是存储而是如何从原始对话里提炼出值得长期保留的信息并在合适的时机把它注入模型窗口。我把 Memory 分为三层工作记忆、情景记忆和语义记忆。工作记忆就是 Session 里的实时上下文模型本次推理时直接用情景记忆是某一次完整会话的语义级摘要比如“3 月 15 日用户曾讨论过一个供应链优化项目聊了一个小时最后结论是采用分步实施”语义记忆则是从多条会话里沉淀出来的稳定事实和规则比个如“该用户偏好月结付款联系人姓王主要业务在华东”。这三层的转化关系是Session 事件流 → 单次会话结束后生成情景记忆 → 跨会话提炼出语义记忆。两者存储方式和用途完全不同。我见过一个经典误区把 Memory 当作“聊天记录的备份”把整个对话塞进向量库查询时按相似度找回原始文本。这有两个问题一是检索噪音大返回一堆不相关内容二是无法提炼出稳定的结构化事实。真正的记忆系统要做的是从信息里抽特征而不是存原始物。3.1 情景记忆一次会话结束后留下浓缩的“胶片”情景记忆我习惯在会话结束或超时无新事件时异步生成。生成的方式是拿这次会话的事件流调用一次模型产出三部分内容用户的核心目标、主要结论、遗留待办。这三部分会被存成一条记录可以是一段文本也可以转成向量用于后续检索。它和滚动摘要的区别要分清楚滚动摘要是给“当前会话内的后续轮次”用的所以是动态的、前置在上下文里的情景记忆是给“下一次会话”用的所以是静态的、按需检索的。滚动摘要要求时效性高情景记忆要求概括性强。很多项目把这两个混在一起导致场景错位——一个需求复杂的长会话里既没有及时压缩又没能在下次开会时检索到上次结论双向踩坑。存情景记忆时我会额外打上业务标签比如“订单讨论”“合同变更”“入职流程”这样检索时可以先用标签过滤再走语义相似度既省成本又准。如果项目体量小标签过滤加关键词检索其实就够用不一定要上向量库。3.2 语义记忆把“事实”抽出来单独成表语义记忆是最容易出价值、也最容易踩坑的一层。价值在于它能跨场景复用用户不需要重新说一遍“我们公司不用电子发票”Agent 也能记得。踩坑在于“事实抽取”如果做得不干净会把一些偶发表述当成长期事实——比如客户在某次谈判里说“价格能接受一万”这不代表他的长期预算就是一万。我现在的做法是让模型做结构化抽取而不是开放总结。每次对话结束后我会从会话里抽出一组(subject, predicate, object, confidence, timestamp, source_event_id)的元组。例如“客户公司 / 结算方式 / 月结 30 天”置信度 0.92来源是某月某日某事件。这样后续做冲突处理时有据可查。存储上小项目用一张 JSONL 表就够了字段就是上面提到的元组。中等规模项目可以上 KV 存储或关系表把subjectpredicate作为联合索引值直接放 object。只有当你需要做语义模糊检索时才需要向量库——比如用户问“我们付款习惯怎么样”而记忆里存的是“结算周期 30 天内”这时关键词匹配不上向量检索才有优势。关键点是注入策略。语义记忆不能每次全量注入否则给每个用户都带上几十条事实窗口立刻被撑爆。我设定的策略是每轮请求前先按用户身份拉取最近 30 天内使用频率最高的 5 条事实再结合当前请求的意图标签主动拉取与该意图相关的 3 条。总共控制在 8 条以内。这样模型窗口里始终有稳定的“用户画像摘要”又不会喧宾夺主。再补充一点冲突处理当新事实与旧事实冲突时我的策略不是覆盖而是保留双版本并带上时间戳。系统会在上下文里这样写“用户 6 月之前采用 A 方案6 月之后改为 B 方案”。模型看到时间演进会在回答时主动询问是否沿用新方案。这个细节看似小但对高价值客户场景很关键——直接覆盖旧记忆等于挽弓断弦会让回答失真。3.3 记忆淘汰与遗忘Agent 也得会“翻篇”工程上还有一个很容易被忽略的问题记忆没有生命周期管理就会腐烂。时间长了用户换了一家供应商Agent 却还带着一年前的采购偏好回答说事不但不智能反而显得愚蠢。我的淘汰规则有三层时间衰减超过 180 天未激活的语义记忆默认不参与注入只保留在归档中用户显式修改用户明确说“以后不要 XX 方案了”把这条理解成对旧记忆的显式作废立刻降低旧记忆的权重手动删除用户要求删除个人数据时不仅是删原始聊天记录还要删除从记录中抽取的所有记忆元组。这个动作必须在系统里留痕。这一层做不好后面做隐私合规也会很被动。记忆是推断出来的信息不能因为“我们没有存原始对话”就假装没有存用户画像。4. 手写一个 Sessions Memory 的 Agent 骨架顺便说清楚取舍4.1 核心数据结构我在这里给一个可复制的 Python 骨架不会依赖某个特定框架你可以把它接进 FastAPI、Flask 或者任何服务里。from dataclasses import dataclass, field from typing import List, Dict, Optional, Any import json dataclass class SessionEvent: session_id: str event_id: int event_type: str # user / assistant / tool_call / tool_result content: str tool_name: Optional[str] None created_at: float 0.0 def to_dict(self): return { session_id: self.session_id, event_id: self.event_id, event_type: self.event_type, content: self.content, tool_name: self.tool_name, created_at: self.created_at, } dataclass class MemoryTuple: subject: str # 主体例如“客户公司” predicate: str # 属性例如“结算方式” object: str # 值例如“月结 30 天” confidence: float # 置信度 0-1 timestamp: float # 发布时间 source_event_id: int # 来源会话事件用于回查 class SessionStore: def __init__(self, max_recent_events20): self.max_recent_events max_recent_events self.events: List[SessionEvent] [] self.pending_tool_calls: Dict[str, Any] {} def append(self, evt: SessionEvent) - None: self.events.append(evt) # 事件数量超过阈值时触发滚动摘要这里只留占位方法 if len(self.events) self.max_recent_events: self.trigger_rolling_summary() def recent_for_prompt(self, max_tokens: int) - List[Dict]: # 从尾部取尽量多的最近事件但受 token 上限限制 events [] budget 0 for evt in reversed(self.events): event_size len(evt.content) // 2 # 粗略估算 token if budget event_size max_tokens: break events.insert(0, evt.to_dict()) budget event_size return events def trigger_rolling_summary(self) - str: # 这里是交给 LLM 来做滚动摘要的入口 # 返回最新摘要文本并存入 session 摘要字段 pass这里我把最近事件数定为 20主要基于经验值20 轮以内的原始信息足够模型理解当前语境超过 20 轮后边际收益下降明显而 token 成本线性上涨。你根据自己的模型窗口大小可以调整这个值比如 GPT 级别的窗口 32K可以放到 25-30 轮如果是小参数模型窗口只有 8K我建议压到 10-12 轮。Memory 侧的核心是两层class MemoryStore: def __init__(self): # 生产环境这里替换成数据库或向量库 self.tuples: List[MemoryTuple] [] self.episodic_memories: List[Dict] [] def extract_facts(self, session_events: List[SessionEvent]) - List[MemoryTuple]: # 调用 LLM从事件流中抽取 (subject, predicate, object) # 返回结构化事实列表 pass def recall_relevant(self, query: str, k: int 8) - List[MemoryTuple]: # 先按意图标签过滤再按语义/关键词相似度召回 top-k pass def inject_into_prompt( self, query: str, memory_tokens: int 400 ) - List[Dict]: # 找到最相关的记忆并转换成 prompt 片段 facts self.recall_relevant(query, k8) fact_lines [ f{t.subject} - {t.predicate}: {t.object}置信度{t.confidence:.2f} for t in facts ] return [ { role: system, content: 以下是从长期记忆库中检索出的用户稳定事实\n \n.join(fact_lines) \n注若与用户当前表述冲突优先当前表述。, } ]值得注意的地方是inject_into_prompt里我特意加了一句话“若与用户当前表述冲突优先当前表述。”这一行字看着不起眼却能大幅减少记忆误导。因为语义记忆毕竟是推断出来的事实有可能在更早的时间点是对的现在变了。模型看到这句话时会让“当前轮回话”的技术事实优先级高于历史记忆减少张冠李戴。4.2 一次完整请求的上下文装配流程现在我们把这些组件串起来看一次请求从进入到返回之间发生了什么。流程分为三步召回用户记忆、装载近期会话、组装最终 Prompt。def build_context(query: str, session: SessionStore, memory: MemoryStore, prompt): # 1. 加载长期记忆 memory_unit memory.inject_into_prompt(query, memory_tokens400) # 2. 加载最近会话事件 recent_events session.recent_for_prompt(max_tokens2000) history_unit [] for evt in recent_events: history_unit.append({role: evt.event_type, content: evt.content}) # 3. 如果有滚动摘要放在历史事件之前 if session.rolling_summary: summary_unit { role: system, content: 以下是本次会话早期内容的滚动摘要\n session.rolling_summary, } else: summary_unit None # 4. 组装最终 messages按固定顺序 messages [] messages.append({role: system, content: prompt}) if summary_unit: messages.append(summary_unit) messages.extend(memory_unit) if history_unit: messages.extend(history_unit) messages.append({role: user, content: query}) return messages这是一个非常朴实但可用的骨架关键就在 messages 的顺序。顺序不同效果天差地别。我把系统 Prompt 放在最前然后是滚动摘要、长期记忆、最近会话最后才是当前用户问题。理由有三模型对最前面的内容注意力权重通常更高系统 Prompt 和摘要放前面能让用户意图在早期就被框定长期记忆属于辅助信息放在摘要之后、历史会话之前可以减少与直接对话内容混淆当前问题放最后模型会在完整的上下文中看到“最终问题”而不是被历史淹没。有个团队也试过把记忆放在用户问题之后效果立刻打折因为这等于让模型先看问题再看证据容易产生先入为主的推理。所以在上下文顺序这种细节上值得形态敏感。4.3 成本、延迟、一致性三者怎么平衡骨架搭好之后真正难的是调节数据量。我整理了一张经常用来取舍的表格指标调大调小风险最近会话事件数上下文更连续单次 token 成本下降调过大浪费窗口延迟上升调过小上下文断裂滚动摘要更新频率压缩更及时调用成本下降调过大生成摘要成本高产出重复调过小窗口被历史堵死记忆召回条数事实更丰富噪声降低调过大无关记忆干扰判断调过小关键事实缺失记忆抽取频率记忆更新及时成本降低调过大系统开销大调过小记忆滞后会误导我目前的默认值是最近会话 20 轮、摘要每 5 个新事件迭代一次、记忆召回 8 条、记忆抽取在会话结束后异步执行。这样在成本可控的前提下一致性最高。如果你的场景对延迟要求特别高比如客服机器人必须在 800ms 内响应用户那建议把最近会话降到 12 轮摘要改为每 10 个新事件更新一次记忆召回降到 4 条。一致性方面有一个很容易踩的坑异步抽取记忆和实时会话之间会有延迟窗口。用户刚说完“我改了支付方式以后用月结”如果记忆抽取还没完成下一次查询可能还是旧的记忆。解决方式很简单实时请求里把当前会话最近一轮的显式事实变化直接挂在 memory_unit 字段里优先级高于历史记忆。我管这叫“热事实注入”是为了弥补异步存储的天然延迟。5. 生产环境才会暴露的坑以及我现在的处理方式5.1 记忆重复注入导致的“复读机”第一次上线带记忆的 Agent 时我遇到了一个诡异问题模型会反复确认“您是不是要选择刚刚提到的 A 方案”用户明明已经说得很确定了。排查后发现根源是长期记忆和最近会话里都出现了同一事实系统 Prompt 里带了“用户偏好 A 方案”历史会话里也有用户说“我要 A 方案”的原话。模型看到重复信息时会误以为犹豫还需要再次确认于是一次次地问。现在的解法是在组装上下文前做一次冗余检测若发现最近会话里已经包含某条记忆的关键词就把长期记忆里重复的那条降低权重或直接剔除。这个检测可以用非常轻量级的关键词碰撞不用每次走向量语义。我写了个简单的函数提取记忆里的核心实体词拿 session 里最后 5 轮内容做精确匹配命中就不再重复注入。5.2 超长 Session 的摘要抖动如果一个 Session 持续了几百轮滚动摘要会一层套一层地迭代最终结果会逐渐“漂移”——早期对话的关键细节在多次压缩后丢失。这个问题我是在一个真实的长售后工单里发现的客户在第 3 轮说过“设备编号是 SN2023-188”到第 80 轮模型竟然报成了别的编号因为中间摘要迭代时把这串编号泛化掉了。这是一个本质性问题每一轮压缩都会丢失信息丢失的部分很难无中生有地恢复。对策有两个方向。第一尽量扩大“完整保留事件”的数量比如把 max_recent_events 从 20 提到 40让关键细节在更长时间内保持原文形态。第二对“关键高价值实体”做锁定比如设备编号、金额、日期这类信息不放进自由摘要里而是单独建立一个 session_fact 表全程原样保留。这比让模型反复压缩安全得多。5.3 多 Agent 协作时的 Session 隔离现在很多系统不是一个 Agent 包打天下而是主 Agent 调度多个子 Agent各自负责查数据、写报告、发消息等功能。这时候 Session 的隔离边界必须清晰。我会给每个子 Agent 建独立 session_id 前缀比如main_xxx、search_xxx。主 Agent 的 Session 里只保留子 Agent 返回的摘要不塞入子 Agent 的中间事件。这样既控制了上下文规模又避免了不同 Agent 之间的状态互相污染。这个设计同时解决了工具调用的时效性问题子 Agent 的工作记忆只对子 Agent 有效子 Agent 执行完毕输出结论后主 Agent 拿到的是干净结论而不是一串中间计算过程。如果主 Agent 看到太多中间步骤反而会被过程干扰。这个边界一开始就要定好等到多 Agent 链路复杂后再改拆分逻辑会非常痛苦。5.4 可观测性上下文是怎么拼出来的必须能回放所有上下文工程做多了以后最痛苦的问题就是 Debug。用户说“你答错了我上次明明说要 XX”可你根本不知道模型窗口里实际看到了什么因为你手上的日志只存了最终 Prompt没存当时系统里读出的记忆和摘要。我后来专门加了一个 Debug 工具每个请求的 messages 组装完成后快照存一份 JSON 文件同时记录每个片段的来源标记比如summary_source: rolling_summary_v3、memory_retrieval: [3, 7, 1]。当线上有问题时我直接打开最近几个请求的 JSON 快照看模型看到的上下文到底是什么。有时候一查发现某条记忆的置信度已经很低了但召回逻辑还是把它捞出来了有时候发现滚动摘要没有触发更新——这些大概率问题都能靠回放快速定位。上下文工程的代码不复杂复杂的是你怎么确认上下文工程本身的运行是否符合预期。可观测性不是可选项是上线前必须做的一个模块。5.5 记忆不是“没上锁的抽屉”最后说一条上线前就要想明白的边界只要系统存了记忆就要考虑这些记忆怎么被合理使用、怎么被合理删除。我的原则是记忆抽取出来的信息分为三类完成当前任务所必需的类型、可用于提升体验的类型、以及完全不应该保留的类型如密码、卡号等敏感信息。第三类在抽取阶段就要过滤掉不是存储时才处理。实用的小技巧是在 MemoryTuple 里加一个usage_scope字段标记这条记忆可以用于哪些场景比如仅当前项目或者可跨项目使用。这样后续接入新业务场景时不容易把不该带出的信息带出去。隐私这条线虽然不是纯技术问题但没做好会让整个上线前功尽弃值得提前多花半天时间补上这个字段。说句实在话上下文工程做到最后拼的已经不是某个一鸣惊人的技巧而是一堆细节的叠加。Session 的截断节奏、Memory 的分层与淘汰、上下文拼装时的顺序、乃至每一处冗余的过滤每一项单独看都很简单组合起来的效果却是质的飞跃。我自己的体会是先跑通 Session 层让会话不要丢上下文再叠加 Memory 层让跨会话有记忆最后才谈得上更复杂的自适应检索和多 Agent 协同。等项目稳定以后你再回头会发现当初那个“模型老是失忆”的问题其实从来都不是模型的问题。