1. 从“金鱼记忆”到“长期搭档”:会话记忆到底在解决什么问题
做过对话式AI应用的人都有一个共同的痛:你跟模型聊了十轮,它到第十一轮突然问你“你刚才说的那个文件叫什么来着”。这不是模型笨,而是它的“工作台”上压根没留位置放之前的东西。大模型本身是无状态的,每次调用对它来说都是第一次见面。所谓“会话记忆”,本质上就是我们在模型外面给它搭了一个“小本本”,每次对话前把之前的关键信息抄一遍塞给它,让它看起来像是记得。
Lang-Agent这类框架里,会话记忆要解决的核心问题有三个层次。第一层是短期记忆,也就是当前这轮对话的上下文,通常用消息列表来维护,用户说了什么、助手回了什么、工具返回了什么,按顺序排好。第二层是长期记忆,跨会话甚至跨天的信息,比如用户偏好、历史任务结论,这层通常要落到向量库或结构化存储里。第三层是记忆的压缩与筛选,因为模型的上下文窗口是有限的,你不能把一百轮对话原封不动全塞进去,得做摘要、做关键信息抽取、做滑动窗口。
我见过不少项目在这块翻车,最常见的就是把记忆做成了“只增不减”的列表,跑了几十轮之后token直接爆掉,要么报错要么响应慢得离谱。还有一种是把所有历史都做摘要,结果摘要丢掉了关键参数,工具调用的时候参数对不上,排查半天以为是模型问题,其实是记忆层把信息吃掉了。
所以这一块的设计思路,我的经验是:分层管理、按需注入、定期压缩。短期记忆用消息列表保真,长期记忆用键值对或向量检索保准,中间加一层“记忆管理器”来决定每次请求到底带哪些历史进去。这个管理器就是整个会话记忆模块的大脑,它的策略好坏直接决定了你的Agent是“金鱼”还是“老搭档”。
2. 会话记忆的工程实现:从消息列表到记忆管理器
2.1 消息列表的基本结构与角色划分
在Lang-Agent体系里,会话记忆最基础的载体就是消息列表。每条消息至少包含两个字段:角色和内容。角色通常有四种:system、user、assistant、tool。system放系统提示词和全局指令,user放用户输入,assistant放模型回复,tool放工具调用的返回结果。
这里有个容易踩的坑:很多人把工具调用的请求和结果混在一起放。正确的做法是,assistant发起工具调用时,消息里带的是tool_calls字段,而工具执行完的结果作为一条独立的tool角色消息追加进去,并且要带上对应的tool_call_id。这样模型在下一轮才能把“我请求了什么”和“我拿到了什么”对应起来。我实测过,如果不带tool_call_id,在多工具并行调用的场景下,模型经常张冠李戴,把A工具的结果当成B工具的。
消息列表的维护策略一般有两种:全量保留和滑动窗口。全量保留适合短对话,比如客服场景一轮就结束的。滑动窗口适合长对话,比如代码助手这种可能聊几十轮的。滑动窗口的实现细节后面会展开讲,这里先记住一个原则:system消息永远保留,最近的N轮对话优先保留,中间的历史按重要性筛选。
2.2 记忆管理器的核心职责与策略选择
记忆管理器要干三件事:存、取、压。存的时候要决定哪些信息值得记,取的时候要决定这次请求带哪些历史,压的时候要决定怎么把长历史变短。
存这块,短期记忆直接追加到消息列表就行,长期记忆需要做抽取。比如用户说“我习惯用Python写后端”,这句话可以抽成一个键值对:{“语言偏好”: “Python”, “领域”: “后端”},存到外部存储。抽取的时机可以在每轮对话结束后异步做,避免阻塞主流程。
取这块,策略就多了。最简单的是“最近N轮”,但这样会丢掉早期的重要信息。进阶一点的是“最近N轮 + 关键词匹配”,比如用户当前问题里出现了“数据库”,就把历史里所有跟数据库相关的消息捞出来一起带上。再复杂一点的是“向量检索”,把历史消息都做embedding,当前问题也做embedding,算相似度取Top-K。我个人的经验是,对于大多数中小型应用,“最近N轮 + 关键词匹配”的性价比最高,向量检索虽然效果好,但引入的延迟和存储成本要权衡。
压这块,常见手段是摘要。把早期的对话让模型自己总结成一段话,替换掉原始消息。这里有个细节:摘要的时候要保留实体和参数,不能只保留语义。比如“用户要查订单12345的状态”摘要成“用户查了订单状态”,那订单号就丢了,后面工具调用直接抓瞎。我的做法是摘要提示词里明确要求“保留所有数字、ID、文件名、参数名”。
2.3 上下文窗口的预算分配与压缩时机
上下文窗口是有限资源,得当成预算来管。假设模型窗口是8K token,我的分配习惯是:system提示词占10%,长期记忆注入占15%,短期记忆占50%,当前用户输入占5%,留给模型回复的空间占20%。这个比例不是固定的,得根据场景调。工具调用密集的场景,短期记忆比例要更高,因为工具返回的结果往往很长。
压缩的时机很关键。我试过两种:阈值触发和轮次触发。阈值触发是当消息列表的token数超过某个值时启动压缩,比如超过窗口的60%就压。轮次触发是每N轮压一次,比如每10轮把前5轮摘要掉。实测下来,阈值触发更稳,因为它能适应不同长度的消息。轮次触发在遇到超长工具返回时会失效,可能第3轮就爆了。
压缩的时候要注意,正在进行的工具调用链不能被压掉。比如模型刚发起一个工具调用,结果还没回来,这时候如果把请求消息压没了,结果回来就找不到对应的请求了。我的做法是压缩前先检查消息列表尾部是否有未完成的tool_calls,有就跳过这轮压缩。
3. 工具调用机制拆解:从声明到执行再到回填
3.1 工具声明的格式与参数Schema设计
工具调用的第一步是让模型知道有哪些工具可用。在Lang-Agent里,工具通常以JSON Schema的形式声明,包含工具名、描述、参数列表和每个参数的类型、是否必填、描述。
这里的设计要点是:描述要写给模型看,不是写给人看。很多人写工具描述的时候很随意,比如“查询数据”,模型根本不知道查什么数据、怎么查。好的描述应该是“根据订单号查询订单的当前状态,返回状态码和预计送达时间”。参数描述也一样,“订单号”不如“订单号,字符串格式,通常是10位数字”。
参数Schema的类型要尽量精确。能用enum就用enum,比如状态参数限定为[“待支付”, “已支付”, “已发货”],这样模型不会瞎编一个“正在处理中”出来。能用integer就别用string,类型模糊会导致模型传参时格式不对,工具执行直接报错。
还有一个坑是必填参数的默认值。有些参数模型经常忘传,如果你在Schema里标了required,模型不传就会报错。我的做法是,对于有合理默认值的参数,不标required,而是在描述里写“不传则默认为XX”。这样模型不传的时候,工具内部用默认值兜底,流程不会断。
3.2 模型如何决定调用哪个工具:决策逻辑与提示词配合
模型决定调用工具,靠的是两样东西:用户输入和工具描述。用户说“帮我查一下北京明天的天气”,模型看到有个叫get_weather的工具,描述是“查询指定城市指定日期的天气”,参数是city和date,它就会发起调用。
但实际场景往往更复杂。用户可能说“我明天要去北京,该穿什么”,这句话里没有“天气”两个字,但模型得推理出需要查天气。这时候工具描述里最好带上使用场景,比如“当用户询问出行建议、穿衣建议、是否需要带伞时,使用此工具查询天气”。这种“触发条件”写在描述里,能显著提升模型的调用准确率。
另一个关键是多工具的选择。当有十几个工具可用时,模型容易选错。我的经验是给工具分组,比如“订单相关工具”、“用户相关工具”、“物流相关工具”,然后在system提示词里写清楚“根据用户意图先判断属于哪个组,再从组内选具体工具”。这相当于给模型一个决策树,比让它从平铺的列表里硬选要准得多。
还有一点,模型有时候会“过度调用”。用户只是闲聊一句“今天天气不错”,它也要去调天气工具。这时候可以在提示词里加一句“只有当用户明确需要查询实时信息或执行操作时才调用工具,闲聊场景直接回复”。这个约束能省掉不少无谓的调用。
3.3 工具执行结果的回填与错误处理
工具执行完,结果要回填到消息列表里,角色是tool,内容就是执行结果。这里有个格式问题:结果是JSON还是纯文本?我的建议是统一转成字符串,如果是JSON就序列化一下。因为模型对纯文本的解析更稳定,嵌套太深的JSON它容易看漏。
错误处理是工具调用里最容易被忽视的环节。工具执行失败有几种情况:参数不对、网络超时、返回空结果、返回格式不符合预期。每种情况的处理方式不一样。
参数不对的话,直接把错误信息回填给模型,让它重新生成参数。比如“订单号格式错误,应为10位数字”,模型看到这个会自己修正。网络超时的话,可以重试一次,重试还失败就把“服务暂时不可用”回填,让模型告诉用户稍后再试。返回空结果的话,要区分是“真的没有数据”还是“查询条件不对”,前者直接告诉用户没查到,后者把错误回填让模型调整参数。
我踩过的一个坑是:工具返回了一个超长的错误堆栈,直接回填给模型,模型被这一大段吓到了,回复了一堆无关的话。后来我在回填前做了截断,只保留错误摘要,比如“数据库连接失败”,模型就能正常处理了。
4. 记忆与工具的协同:让Agent真正“记得住、调得动”
4.1 工具调用历史如何影响后续决策
工具调用的历史本身就是重要的记忆。比如用户第一轮让Agent查了订单A的状态,第二轮说“再查一下另一个”,这个“另一个”指什么?如果记忆里没有上一轮的订单A,模型就不知道“另一个”是跟A同类的订单。所以工具调用的参数和结果都要进记忆,而且不能被轻易压缩掉。
我的做法是给工具调用消息打标记,压缩的时候优先保留最近3次工具调用的完整信息,更早的只保留摘要。摘要里至少要有工具名和关键参数,比如“查了订单A,状态已发货”。
还有一个场景是多轮工具调用链。用户说“帮我订一张明天去上海的机票”,Agent先调航班查询,拿到结果后调座位选择,再调支付。这一串调用如果中间断了,用户说“换个时间”,Agent得知道之前查的是哪条航线。这就要求记忆里保留调用链的上下文,不能只保留最后一次调用的结果。
4.2 记忆注入时的工具上下文保留策略
每次请求模型时,注入的记忆里要包含工具相关的上下文。具体来说,如果当前用户输入可能触发工具调用,那么历史里最近一次相关工具调用的参数和结果要带上。
举个例子,用户第一轮问“北京天气”,Agent调了天气工具返回“晴,25度”。第二轮用户问“那上海呢”,这时候注入的记忆里必须包含“北京天气”那次调用的信息,模型才能理解“那上海呢”是在问上海的天气。如果记忆管理器把这次工具调用压掉了,模型就会一脸懵。
实现上,我通常会在记忆管理器里加一个“工具上下文保留”规则:最近5轮内所有工具调用的消息不参与压缩,原样保留。5轮之前的工具调用只保留摘要,摘要格式是“工具名(关键参数) -> 关键结果”。
4.3 避免记忆污染与工具误调用的实战技巧
记忆污染是指历史里的错误信息被反复带入,导致模型持续犯错。比如某次工具调用因为参数错误失败了,错误信息留在记忆里,后面几轮模型看到这个错误,可能会误以为当前也有问题。
避免的方法是:错误信息只保留一轮。工具调用失败后,下一轮如果成功了,就把之前的错误消息标记为可压缩,优先压掉。或者在错误消息回填时加一个“已解决”标记,记忆管理器看到这个标记就知道不用再带了。
工具误调用是另一个常见问题。模型看到历史里有工具调用,就倾向于继续调用,哪怕当前用户只是闲聊。我的对策是在system提示词里加一句“工具调用必须基于当前用户输入的实际需求,不要因为历史调用过就惯性调用”。这句话能压掉不少无意义的调用。
还有一个技巧是工具调用的冷却期。同一个工具在短时间内被连续调用多次,可能是模型陷入了循环。我设了一个规则:同一个工具在3轮内被调用超过2次,就暂时从可用工具列表里移除,并在system提示里说明“该工具暂时不可用,请用其他方式回复”。这能有效打断死循环。
5. 常见问题排查与性能优化实录
5.1 记忆丢失与上下文超限的排查思路
记忆丢失的典型表现是:模型突然问“你刚才说的XX是什么”。排查的时候先看消息列表,确认那条信息是否还在。如果不在,看是被压缩了还是压根没存进去。压缩导致的丢失,调整压缩策略,把关键信息加入保留白名单。没存进去的话,检查消息追加的逻辑,是不是在某个分支里漏了。
上下文超限的报错通常是“maximum context length exceeded”。排查的时候算一下当前消息列表的总token数,跟模型窗口对比。如果超了,看是短期记忆太长还是工具返回太长。工具返回太长的话,可以在回填前做截断,只保留前500个字符加“...”省略。短期记忆太长的话,调压缩阈值,提前触发压缩。
我遇到过一次诡异的情况:消息列表看起来不长,但就是超限。后来发现是某条工具返回里带了一个超长的base64字符串,占了大量token。这种就是回填前没做清洗,把二进制数据也塞进去了。后来我加了一个规则:工具返回超过1000字符的,只保留摘要和关键字段。
5.2 工具调用失败的典型场景与修复方法
工具调用失败最常见的原因是参数格式不对。比如Schema里定义的是integer,模型传了个字符串“123”。修复方法是在工具执行前加一层参数校验和转换,能转就转,不能转就返回错误让模型重试。
第二个原因是工具名拼写错误。模型有时候会自己造一个工具名,比如把get_weather写成getWeather。修复方法是在工具注册时同时注册别名,或者在提示词里强调“必须使用工具列表中列出的确切名称”。
第三个原因是工具依赖的外部服务不可用。这种情况工具本身没问题,但执行会超时。修复方法是加超时和重试机制,重试失败后返回友好的错误信息,让模型告诉用户“服务暂时不可用”。
第四个原因是模型一次发起了多个工具调用,但其中某个的参数依赖另一个的结果。比如先查订单再查物流,但模型同时发起了两个调用,物流查询拿不到订单号。修复方法是在提示词里说明“有依赖关系的工具调用必须分步进行,不要并行”。
5.3 性能优化:减少无效调用与降低延迟
无效调用是指模型调了工具但结果没用上,或者调了不该调的工具。减少无效调用的关键是优化工具描述和提示词。工具描述里写清楚“什么时候用”和“什么时候不用”,提示词里加约束“非必要不调用”。
降低延迟有几个方向。一是并行调用,没有依赖关系的工具让模型并行发起,减少轮次。二是缓存,同样的查询参数在短时间内重复出现,直接返回缓存结果,不走工具执行。三是预取,根据用户输入预测可能要调的工具,提前执行。比如用户说“帮我看看”,大概率要查东西,可以预取一些常用数据。
还有一个容易被忽视的点是消息列表的序列化开销。消息列表很长的时候,每次请求都要序列化一遍,这个开销不小。我的做法是维护一个序列化后的缓存,只在消息列表变更时重新序列化。这个优化在大对话量下能省不少CPU。
6. 几个我踩过的坑和对应的解法
第一个坑是工具返回的中文编码问题。某次工具返回了乱码,模型解析不了,回复了一堆问号。排查发现是工具内部用了默认编码,跟框架的编码不一致。解法是统一用UTF-8,并且在回填前做一次编码检查。
第二个坑是记忆压缩把system提示词也压了。有次压缩逻辑写错了,把system消息也纳入了压缩范围,结果模型的角色设定丢了,回复风格完全变了。解法是在压缩逻辑里硬编码排除system角色。
第三个坑是工具调用的超时设置太短。默认超时是5秒,但有些查询要10秒,结果工具还没返回就被判定超时了。解法是根据工具类型设置不同的超时,查询类15秒,操作类30秒。
第四个坑是多轮对话里工具调用的ID冲突。并发场景下,两个请求的工具调用ID撞了,结果A的结果回填到了B的对话里。解法是用UUID做调用ID,确保全局唯一。
第五个坑是记忆管理器把用户的最新输入也压了。压缩逻辑是在请求前执行的,如果没排除当前轮的用户输入,就会把刚说的话压掉。解法是压缩时排除最后一条user消息。
这些坑说到底都是细节问题,但每一个都能让整个Agent的表现从“能用”掉到“不能用”。我的体会是,会话记忆和工具调用这两块,框架能帮你搭架子,但真正决定体验的是这些边角逻辑。多写测试用例,把各种边界情况都覆盖到,比事后排查省事得多。