Agent 聊久了就失忆:对话上下文管理避坑复盘

我们的客服 Agent 上线初期口碑很好,能记住用户前面说过的话、多轮对话连贯。可时间一长投诉来了,都集中在一类现象:聊得越久它越不对劲——前几轮还规规矩矩,聊到十几二十轮就开始健忘、答非所问,甚至把最开始设定好的角色和规则都抛到九霄云外,本该是严谨售后客服聊到后面却跟用户东拉西扯。把聊崩的长对话日志拉出来复盘真相渐渐清晰:大模型上下文窗口有限装不下无限长对话,为不超限我写了段很朴素的截断逻辑——历史太长就把最早的消息直接丢掉只保留最近 N 条,这听起来合理却犯了致命错误:最早被丢掉的恰恰包含最重要的东西——开头那条设定角色和规则的系统提示词、以及用户最初几轮交代的核心诉求,它们一被截断 Agent 就既忘了自己是谁该守什么规矩、也忘了用户想解决什么问题。这篇文章从这次 Agent 聊久了就失忆的事故出发,讲透上下文管理:上下文窗口是有限昂贵的工作台、系统提示词永不截断、用滚动摘要压缩历史而非丢弃、长期记忆外置按需检索、关键硬事实用结构化状态维护、警惕中间迷失把关键信息放两端,以及上下文工程同时关乎效果成本与延迟。

我们的客服 Agent 上线初期口碑很好:它能记住用户前面说过的话,多轮对话里有问必答、上下文连贯。可时间一长,用户投诉来了,而且都集中在一类现象上:聊得越久,它越"不对劲"。对话前几轮它还规规矩矩、彬彬有礼,可一旦聊到十几二十轮,它就开始变得健忘、答非所问,甚至把最开始设定好的角色和规则都抛到了九霄云外——本该是个严谨的售后客服,聊到后面却开始跟用户东拉西扯、口风也不对了。

我把那些"聊崩了"的长对话日志拉出来复盘,真相渐渐清晰。问题出在我处理对话历史的方式上:大模型的上下文窗口是有限的,装不下无限长的对话。为了不超限,我写了一段很"朴素"的截断逻辑——当对话历史太长时,就把最早的那些消息直接丢掉,只保留最近的 N 条。这听起来很合理,可它犯了一个致命错误:最早被丢掉的那些消息里,恰恰包含了最重要的东西——开头那条设定 Agent 角色和行为规则的"系统提示词",以及用户在最初几轮里交代的核心诉求。它们一被截断,Agent 就既忘了自己是谁、该守什么规矩,也忘了用户究竟想解决什么问题,可不就越聊越离谱了。

这就是构建对话式 AI Agent 时一个极其普遍、又极易被短对话测试所掩盖的坑:上下文管理(context management)做得太粗暴,导致关键信息在对话变长时被无差别地丢弃。它本质是大模型"上下文窗口有限"这一硬约束,与"对话可以无限长"这一现实需求之间的矛盾,处理不当的结果。这篇文章,就从这次"Agent 聊久了就失忆"的事故出发,把对话上下文管理的坑和正确做法,一次讲透。

先摆几个关于对话上下文的想当然

动手复盘前,先把我自己曾经深信、后来被这个事故教育的几个念头摆出来。

想当然的念头 残酷的真相
"把对话历史一股脑全塞给模型就行" 上下文窗口有限, 超长会报错或丢失早期信息
"太长了就砍掉最早的,留最近的" 最早的往往是系统指令和核心诉求, 砍了就失忆
"短对话测试没问题,上线就稳" 坑只在长对话累积后爆发, 短测根本测不出来
"上下文越长,模型理解得越全面" 过长上下文会稀释注意力, 中间信息容易被忽略
"每轮都把全部历史发过去,反正能装下" token 成本随轮次累加, 又慢又贵, 不可持续

这些念头的共同病根,是没把"对话历史的管理"当成一件需要认真设计的事,以为"全发"或"砍旧的"这种简单粗暴的策略就够用了。可对话式 Agent 的智能,极度依赖于"喂给模型的上下文质量",而上下文管理,正是决定这个质量的核心环节。要看清这次事故,得先理解那个根本约束:上下文窗口。

第一件事:上下文窗口——一个有限且昂贵的"工作台"

大模型有一个绕不开的硬约束:上下文窗口(context window),即它一次能"看到"的文本总量上限(以 token 计)。你和它的整段对话——系统提示词、用户的每一句、它自己的每一句回复——都要塞进这个窗口里,它才能基于这些内容生成下一句。窗口就那么大,一旦对话累积的内容超过上限,就装不下了:要么报错,要么(像我那样)被迫丢弃一部分。

你可以把这个窗口想象成 Agent 的一张有限大小的"工作台":所有它需要"当下看着"才能正确工作的材料,都得摆在这张台子上。台子就那么大,材料一多就摆不下,这时你怎么取舍——把哪些材料留在台上、把哪些收走——就直接决定了 Agent 干活的质量。我那次的错,就是收材料时太随意,把台子最里面、压在最下面的"任务说明书"(系统提示词)和"客户需求单"(初始诉求)给当成废纸清走了。下面这张图,把这个机制画出来:

看懂这张图,事故的根就清楚了:上下文窗口是有限的,这是物理约束、无法回避;但"超限后丢什么、留什么",是你的设计选择——而我做了最差的那个选择,无差别地丢掉了最早的、恰恰最关键的信息。上下文管理的本质,不是"怎么不超限",而是"超限时如何聪明地取舍,确保最重要的信息永远留在台上"。接下来,我们就看怎么做这个聪明的取舍。

第二件事:永远钉住系统提示词,别让它被截断

最直接、也最不该省的一招:系统提示词(System Prompt)必须永远保留,无论对话多长、怎么截断,它都要稳稳地待在上下文的最前面。它定义了 Agent 的角色、能力、行为规则、安全边界——是整个对话的"宪法"。把它截掉,Agent 就成了一个没有人格设定、没有规矩约束的"裸模型",自然会跑偏。所以任何截断逻辑,都必须把系统提示词当成"不可删除"的固定项。

# 反例:简单粗暴地只留最近 N 条, 系统提示词和早期诉求一起被丢
def naive_truncate(messages, keep=10):
    return messages[-keep:]   # 灾难: 把开头的 system 消息也截掉了!

# 正解:系统提示词永远钉在最前, 只对中间的对话历史做截断
def truncate_keep_system(messages, keep=10):
    system_msgs = [m for m in messages if m["role"] == "system"]
    chat_msgs = [m for m in messages if m["role"] != "system"]
    # 系统提示词原样保留 + 最近的若干轮对话
    return system_msgs + chat_msgs[-keep:]
# 这样无论聊多久, Agent 都不会忘记自己是谁、该守什么规矩

这一步是底线中的底线。我那次事故里,只要保住了系统提示词,Agent 至少不会"忘记自己是个严谨客服"而开始跟用户东拉西扯。把"角色与规则的设定"和"可被裁剪的对话内容"在数据结构上就区分开来,让前者拥有'免截断'的特权——这是上下文管理的第一条铁律。

第三件事:用"滚动摘要"压缩历史,而不是直接丢弃

光保住系统提示词还不够,用户在早期对话里交代的核心诉求(比如"我要退的是 3 月 5 号买的那台冰箱,因为有噪音"),也不能因为聊久了就丢失。但全部保留又会超限。这里的关键思路是:不要"丢弃"旧对话,而要"压缩"它——把一长段旧对话,用模型自己总结成一小段摘要,既大幅缩短了长度,又保住了其中的关键信息。这叫"滚动摘要(rolling summary)"。

# 滚动摘要:当历史变长, 把较早的对话压缩成摘要, 保住关键信息
def manage_context(messages, summary, max_tokens=3000):
    system = [m for m in messages if m["role"] == "system"]
    chat = [m for m in messages if m["role"] != "system"]

    # 当对话太长, 把较早的一批对话总结成/并入摘要
    if count_tokens(chat) > max_tokens:
        old, recent = chat[:-8], chat[-8:]     # 保留最近 8 轮原文
        # 用模型把"已有摘要 + 这批旧对话"浓缩成新的摘要
        summary = llm_summarize(
            f"已知摘要:{summary}\n新增对话:{format(old)}\n"
            f"请更新摘要, 保留用户的核心诉求、已确认的关键事实、待办事项。"
        )
        chat = recent

    # 组装:系统提示词 + 摘要(作为一条上下文) + 最近几轮原文
    ctx = system[:]
    if summary:
        ctx.append({"role": "system",
                    "content": f"【对话摘要】{summary}"})
    ctx.extend(chat)
    return ctx, summary

这套机制的精髓,是把对话历史分成三层:"永不丢弃的系统提示词" + "被压缩成摘要的较早历史" + "保留原文的最近几轮"。早期的诉求和关键事实,通过摘要被"提炼"着保留下来,而不是被粗暴地删掉;最近几轮保留原文,保证细节和即时连贯性。这样,即便聊到上百轮,Agent 依然"记得"用户最初要解决的是冰箱噪音问题,也记得中间确认过的那些关键信息。压缩而非丢弃,是长对话不失忆的核心。

第四件事:把长期记忆外置——按需检索,而非全背在身上

摘要解决了"单次长对话"的问题,但还有更长的时间跨度:用户上周聊过的事、他的历史订单、他的偏好——这些"长期记忆",不可能也不该一直背在上下文里(那会瞬间撑爆窗口)。正确的做法,是把它们存到外部(数据库、向量库),用到的时候再检索回来,即"按需召回"。这其实就是我们之前聊过的 RAG 思想,用在了 Agent 的记忆上。

# 长期记忆外置:不全背在上下文里, 而是按当前问题去外部检索相关记忆
def build_context_with_memory(system, summary, recent, user_query, user_id):
    # 1. 根据当前用户问题, 从外部记忆库检索最相关的几条
    memories = memory_store.search(
        query=user_query, user_id=user_id, top_k=3)
    # 2. 只把"和当下问题相关"的记忆放进上下文, 而非全部
    mem_block = {"role": "system",
                 "content": "【相关历史记忆】" + format(memories)} if memories else None

    ctx = system[:]
    if summary:
        ctx.append({"role": "system", "content": f"【对话摘要】{summary}"})
    if mem_block:
        ctx.append(mem_block)
    ctx.extend(recent)
    ctx.append({"role": "user", "content": user_query})
    return ctx
# 关键:上下文里只放"此刻用得上"的记忆, 把整个记忆库留在外部

这套"外部记忆 + 按需检索"的模式,把 Agent 的"记忆容量"从"上下文窗口那么大"扩展到了"几乎无限"——因为真正的存储在外面,上下文里只放当下相关的一小部分。这正是构建有长期记忆、能跨会话记住用户的 Agent 的关键架构。上下文窗口是 Agent 的"短期工作记忆",而外部存储 + 检索,是它的"长期记忆"——两者分工,Agent 才能既专注当下、又记得过去。

第五件事:把关键状态"结构化"地拎出来,别埋在对话流里

还有一类信息,不该靠"摘要"这种自然语言的方式来保管,而该被结构化地、精确地抽取出来——比如对话过程中逐步确认的"订单号、退货原因、用户联系方式、当前处理到哪一步"。这些是任务的关键状态,如果只是混在对话流里靠摘要保留,容易丢失或被总结得不精确。更稳妥的做法,是维护一个独立的、结构化的"任务状态对象"。

# 把任务关键状态结构化地维护, 而不是只靠自然语言摘要
task_state = {
    "intent": "退货",
    "order_id": "20260305-8821",   # 已确认的关键事实, 精确保存
    "reason": "冰箱噪音过大",
    "contact": "138****0000",
    "step": "等待用户确认退货地址",   # 当前进展, 一目了然
    "pending": ["核对退货地址", "生成退货单"],
}
# 每轮对话后, 用模型从最新对话里抽取/更新这个状态对象
# 然后把它作为一段简洁、精确的上下文喂给模型:
state_block = {"role": "system",
               "content": "【当前任务状态】" + json.dumps(task_state, ensure_ascii=False)}
# 好处:关键事实绝不丢失、绝不被总结走样, 且 token 占用极小

这种做法的优势是:关键状态精确、紧凑、不会被自然语言摘要稀释或扭曲。对话可以天马行空,但订单号就是订单号,一个字都不能错。把这类"硬事实"从松散的对话流里拎出来、用结构化的方式专门维护,Agent 在长任务里就不会"记错关键信息"。自然语言摘要负责保留'语境和软信息',结构化状态负责保留'精确的硬事实',二者配合,记忆才既丰富又可靠。

第六件事:上下文不是越长越好——警惕"中间迷失"

最后一个反直觉、但很重要的认知:上下文并不是塞得越满、越长,模型表现就越好。研究和实践都发现一个现象,叫"中间迷失(lost in the middle)"——当上下文很长时,模型对开头和结尾的信息记得最牢,而对夹在中间的大段信息,注意力会显著下降、容易忽略。所以一味地往上下文里堆东西,不仅费 token、变慢,还可能因为关键信息恰好落在"中间"而被模型忽视。

# 利用"开头结尾记得牢"的特性, 把最关键的信息放在两端
def arrange_context(system, summary, memories, recent, query):
    return (
        system                              # 开头:角色与规则(最关键, 放最前)
        + [{"role": "system", "content": summary}]
        + memories                          # 中间:辅助信息(模型注意力较弱)
        + recent                            # 结尾:最近对话
        + [{"role": "user", "content": query}]  # 结尾:当前问题(放最后, 最受关注)
    )
# 原则:把"最不能被忽略"的放在开头或结尾, 别埋进长上下文的中段;
#       同时, 上下文要"精炼"——只放真正需要的, 而非能塞多少塞多少

这带来一个重要的设计转变:上下文管理的目标,不是"尽量多地保留信息",而是"用尽量少、尽量精炼的上下文,装下当下任务真正需要的关键信息,并把它们放在模型最关注的位置"。这和我们做事讲究"抓重点"是一个道理——给模型一份重点突出的简报,远胜过甩给它一本事无巨细的流水账。到这儿,上下文管理的方方面面就齐了。我把它收成一张决策图:

把这套体系建起来,Agent 就能在再长的对话里也保持清醒、连贯、守规矩。最后,拧成几条可直接照做的铁律:

  1. 系统提示词永不截断,把它当成不可删除的固定项, 钉在上下文最前面。
  2. 历史太长要压缩而非丢弃,用滚动摘要把旧对话提炼保留, 别无差别删除。
  3. 分层管理:系统提示 + 摘要 + 最近原文,各司其职, 兼顾规则、语境与即时细节。
  4. 关键硬事实用结构化状态维护,订单号等精确信息别埋在对话流里靠摘要保。
  5. 长期记忆外置 + 按需检索,把记忆库放外面, 上下文只放此刻相关的。
  6. 上下文不是越长越好,警惕中间迷失, 关键信息放开头结尾, 保持精炼。
  7. 一定要用长对话测试,这个坑短测发现不了, 必须模拟几十上百轮的累积。

一张对话上下文管理速查表

把各类信息"该怎么管"汇成一张表,设计对话式 Agent 时对照着用。

信息类型 该怎么管 放在哪
角色与行为规则 系统提示词, 永不截断 上下文最前面
较早的对话历史 滚动摘要压缩 摘要块(靠前)
最近几轮对话 保留原文 上下文结尾
订单号等精确硬事实 结构化状态对象 独立状态块
跨会话长期记忆 外置存储 + 按需检索 外部库, 用时召回
当前用户问题 原样保留 上下文最末尾
无关的闲聊/废话 可丢弃或不纳入摘要 不进上下文

顺带算笔账:上下文管理也是省钱的关键

修好失忆问题后,我意外收获了一个好处:账单也下来了。这背后是一个容易被忽略的事实——大模型 API 是按 token 计费的,而你每轮对话发过去的整个上下文(系统提示 + 全部历史 + 当前问题)都算输入 token。如果像我最初那样"把能塞的历史全塞进去",那么对话每多一轮,这一轮要付费的输入 token 就更多,成本随对话长度近乎平方级地增长——又慢又贵。

而前面那套上下文管理(摘要压缩、外部记忆、精炼上下文),在保住关键信息的同时,把每轮发送的 token 量控制在了一个稳定、可控的范围,无论对话多长。这意味着它不只是"防失忆"的质量手段,更是"控成本、提速度"的效率手段——一举两得。所以上下文管理在 Agent 工程里,是一个同时关乎效果、成本、延迟三方面的核心能力,绝不是可有可无的边角。

# 把上下文 token 量纳入监控, 它直接关系成本、延迟和质量
def log_context_metrics(ctx, response):
    logging.info(json.dumps({
        "input_tokens": count_tokens(ctx),     # 本轮输入(决定成本/延迟)
        "turn_count": len([m for m in ctx if m["role"] != "system"]),
        "had_summary": any("摘要" in m.get("content", "") for m in ctx),
    }, ensure_ascii=False))
# 盯住"输入 token 是否随轮次失控增长"——若是, 说明上下文管理没生效

写在最后

这次"Agent 聊久了就失忆"的事故,给我最深的启发,是它让我重新理解了构建 AI Agent 的重心所在。在做这个客服 Agent 之前,我下意识地以为,Agent 聪不聪明、靠不靠谱,主要取决于背后那个大模型有多强。可这次事故狠狠地纠正了我:那个失忆、跑题、忘记自己是谁的 Agent,用的还是同一个强大的模型——它"变笨"了,不是因为模型退化,而是因为我喂给它的上下文被我自己搞砸了。模型再聪明,它也只能基于你当下递到它眼前的那些信息来思考;你把最关键的信息从它眼前抽走,它就只能在残缺的信息上做出残缺的判断。

这让我领悟到一个 AI 工程的核心心法:构建一个优秀的 Agent,与其说是在'调教模型',不如说是在'为模型精心地组织和管理它每一刻所能看到的上下文'。这门手艺,如今有了一个越来越被重视的名字——"上下文工程(context engineering)"。它要解决的,正是"在有限的上下文窗口里,如何始终把最该被看见的信息,以最合适的形式、放在最合适的位置呈现给模型"。从保住系统提示词,到滚动摘要,到外部记忆,到结构化状态,到防范中间迷失,这一整套,都是围绕"如何给模型一份恰到好处的简报"展开的。这也再次呼应了我做 AI 应用越来越坚定的一个信念:在以大模型为核心的系统里,真正决定上限的,往往不是那个光鲜的模型本身,而是你围绕它构建的、那些'不性感'却至关重要的工程——而上下文管理,正是其中最核心的一环。愿你我做的每一个 Agent,都不仅有一个聪明的大脑,更有一份被精心打理、永远清醒的"记忆"。

如果你也在做对话式 AI 或 Agent,不妨今天就花二十分钟做三件小事自查。第一,翻出你处理对话历史的那段代码,确认系统提示词在任何截断逻辑下都被无条件保留——这是最致命也最容易犯的错。第二,模拟一段几十轮的长对话压一压,看看 Agent 在第三十轮还记不记得你在第一轮提出的核心诉求、守不守最初设定的规则;短对话测试是发现不了这个坑的。第三,把每轮发送的输入 token 量打到日志里,观察它会不会随对话轮次失控增长——如果会,说明你的上下文管理还停留在"全塞"阶段,该上摘要和精炼了。这三步能帮你在用户因为"它越聊越傻"而流失之前,就把这个隐患补上。

说到底,这次失忆事故是一堂关于"有限与取舍"的课。大模型的上下文窗口,就像我们自己有限的注意力——你不可能同时把所有事情都清晰地放在心上,真正的智慧,在于懂得在每一个当下,把注意力聚焦在最重要的事情上,把次要的暂时收纳、需要时再唤起。我们为 Agent 做上下文管理,本质上就是在替它行使这种"取舍的智慧":什么必须时刻牢记(规则与身份),什么可以浓缩保留(过往语境),什么可以存档待查(长期记忆),什么可以放下(无关闲谈)。把这门取舍的功课做好,Agent 才能像一个真正专注、可靠的人那样,在漫长的交流里始终不忘初心、有条不紊。愿你我都能成为善于为智能体"打理记忆"的人——因为在这个大模型的时代,管理好上下文,就是管理好智能本身。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

插一个枚举值搞乱历史数据:TS 数字枚举避坑

2026-5-30 12:12:47

技术教程

本地正常生产时间全错:Python 时区与 datetime 避坑

2026-5-30 12:23:22

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索