文章目录
-
- 一、先看现场:一个真实的"失忆"案例
-
- [1.1 先分清:什么才算"关键信息"](#1.1 先分清:什么才算"关键信息")
- [二、原理:Agent 到底是怎么忘的](#二、原理:Agent 到底是怎么忘的)
-
- [2.1 上下文窗口是一笔"硬预算"](#2.1 上下文窗口是一笔"硬预算")
- [2.2 信息"消失"的四种机制](#2.2 信息"消失"的四种机制)
- [2.3 一条因果链](#2.3 一条因果链)
- 三、排查:六步定位法(别猜,看证据)
-
- [第 ① 步:固定复现,把"真正发出去的 prompt"落盘](#第 ① 步:固定复现,把"真正发出去的 prompt"落盘)
- [第 ② 步:统计 token,找到"丢失临界点"](#第 ② 步:统计 token,找到"丢失临界点")
- [第 ③ 步:查裁剪------两个"事故代码"](#第 ③ 步:查裁剪——两个"事故代码")
- [第 ④ 步:查压缩------摘要是"大意",不是"契约"](#第 ④ 步:查压缩——摘要是"大意",不是"契约")
- [第 ⑤ 步:查存储------信息有没有"住在窗口之外"](#第 ⑤ 步:查存储——信息有没有"住在窗口之外")
- [第 ⑥ 步:查注入------信息在,但模型"看不见"](#第 ⑥ 步:查注入——信息在,但模型"看不见")
- 加分项:装一道"校验探针"
- 四、一张表定位根因
- 五、修复:五个方案,按代价从低到高
-
- [方案 ①:关键约束结构化外置(首选,改动小、收益最大)](#方案 ①:关键约束结构化外置(首选,改动小、收益最大))
- [方案 ②:上下文分区------常驻区永不参与截断](#方案 ②:上下文分区——常驻区永不参与截断)
- [方案 ③:裁剪要看价值,不看条数](#方案 ③:裁剪要看价值,不看条数)
- [方案 ④:摘要时给一份"必须保留"清单](#方案 ④:摘要时给一份"必须保留"清单)
- [方案 ⑤:RAG 检索 + 会话分段(兜底,不是主力)](#方案 ⑤:RAG 检索 + 会话分段(兜底,不是主力))
- 六、修复前后对比
- 七、一个可直接抄的最小实现
- [八、上线前 Checklist](#八、上线前 Checklist)
- 九、总结
一句话概括 :Agent 忘事,99% 不是模型变笨,而是你以为它记得的那些话,压根没进它这一轮看到的 prompt。
一个类比:这就像开了一下午会,说到第 20 件事时,你发现会议最开始定下的那条硬性规则没人提了。你以为是大家记性差,其实更可能是------记录本太薄被撕掉了前几页(截断)、记录员写成了摘要(压缩)、或者记录写了但没贴在墙上(注入位置不对)。
一、先看现场:一个真实的"失忆"案例
先看一段典型对话(简化版):
text
[第 3 轮] 用户:我们这个项目用 PostgreSQL,所有 SQL 不要用 MySQL 语法。
[第 5 轮] 用户:接口返回统一用 camelCase。
Agent:好的,我会遵守这两点。✅
...
[第 12 轮] Agent:下面是数据库查询代码......(还是 PostgreSQL,正常)
[第 20 轮] Agent:这是一个 MySQL 的连接示例...... ❌
[第 20 轮] 用户:???我第 3 轮就说过了,用 PostgreSQL!
Agent:抱歉!我马上改用 PostgreSQL。
这个现场有三个非常典型的特征,值得你先记住,后面所有排查都靠它们做判断:
- 悄悄失效,没有任何报错------模型"理所当然"地违反了约定,它甚至不知道自己违约了;
- 一提醒就恢复 ------说明模型能力没问题、知识没问题,纯粹是那段话这一轮没在它眼前;
- 越早的信息越容易丢,中段次之,最近几轮几乎不丢。
1.1 先分清:什么才算"关键信息"
不是所有丢失都值得修。关键是分清"契约"和"闲聊":
| 类型 | 例子 | 丢了会怎样 | 要不要救 |
|---|---|---|---|
| 契约型(贯穿全程的约束/参数) | 技术选型、合同编号、金额上限、安全规则、必用敬语、禁用某函数 | 后面几十轮全部跑偏,且错得毫无察觉 | 必须救 |
| 事实型(一次性结论) | "上次那个报错是权限问题" | 影响局部,追问可补 | 值得救 |
| 闲聊型(过程性内容) | "好的"、"稍等"、寒暄、试错过程 | 基本无影响 | 不用救 |
所以排查前先问自己一句:丢的那条,是契约还是闲聊? 如果整个对话里根本没有"契约型"信息,那你遇到的不是 bug,是正常淘汰。
二、原理:Agent 到底是怎么忘的
2.1 上下文窗口是一笔"硬预算"
模型每轮只能看到有限 token(比如 128K)。所有东西共享同一个窗口,塞不下就必须丢:
text
┌────────────────────────────────────┐
│ ① System Prompt 角色、规则 │ ← 相对固定
│ ② 注入的记忆 / 检索结果 │ ← RAG、长期记忆
│ ③ 历史消息(越来越长......) │ ← 每轮 +2 条起步
│ ④ 工具调用结果(经常巨大) │ ← 一次检索吃掉几千 token
│ ⑤ 本轮用户输入 │
└────────────────────────────────────┘
↑ 只有 1 和 5 是"每轮必需",2/3/4 全是浮动的
问题在于:③ 和 ④ 只增不减。到第 20 轮,早期消息很可能已经被挤出窗口了。
2.2 信息"消失"的四种机制
这是全文的骨架,请务必区分清楚------它们的修法完全不同:
| 机制 | 一句话原理 | 典型表现 | 怎么快速识别 |
|---|---|---|---|
| 滑动窗口截断 | 只保留最近 N 条消息 | 早期几轮整段消失,中段完好 | 固定轮次必丢,不是随机 |
| 摘要压缩 | 旧对话被压成一段总结 | "讨论过数据库选型",但 PostgreSQL 这个词没了 |
信息在,但被泛化了 |
| 工具结果覆写/清理 | 旧工具输出被替换成占位符 | 早期查到的数据引用不了 | tool 消息变成空/[已清理] |
| 注意力淹没(Lost in the Middle) | 信息在,但埋在中部 | 时好时坏,换个位置就好 | 复制一份到 system prompt 就恢复正常 |
前三种是真的丢了 ,第四种是信息在、但模型"看不见"。这是最容易被误判的一类------很多人拼命加大窗口,结果毫无改善,就是因为踩的是第四种。
2.3 一条因果链
把上面的机制串起来,就是完整链路:
历史只增不减 → 逼近窗口上限 → 框架启动裁剪/压缩 → 关键信息被无差别丢弃或泛化 → 模型"失忆"。
注意"无差别"三个字:框架不知道哪句值钱,它只按"新旧"或"长度"取舍。第 3 轮那句一诺千金的选型,和一句"好的"享有完全相同的存活权。
三、排查:六步定位法(别猜,看证据)
排查的核心思想只有一句话:
别猜,去看每一轮实际发给模型的那份完整 prompt。
最容易犯的错,是盯着聊天界面复盘。界面展示的往往不是发给模型的最终内容------界面会显示全部历史,模型只收到了后面 15 条。
text
① 固定复现,把 prompt 原样落盘
↓
② 统计 token,找到"丢失临界点"
↓
③ 查裁剪:滑动窗口 + 消息过滤
↓
④ 查压缩:摘要把事实泛化了吗
↓
⑤ 查存储:有没有外置、入没入库、召不召回
↓
⑥ 查注入:位置上是不是被淹没了
第 ① 步:固定复现,把"真正发出去的 prompt"落盘
做这一件事,就解决了一半问题。 在调用模型的地方加日志,记录的是拼装完成的 messages:
python
import json
def call_model(messages, turn_id):
# 关键:记录"即将发给模型"的内容,而不是用户界面看到的内容
with open(f"prompts/turn_{turn_id:02d}.json", "w", encoding="utf-8") as f:
json.dump(messages, f, ensure_ascii=False, indent=2)
return llm.chat(messages)
⚠️ 两个注意点:① 落盘前做敏感信息脱敏 ;② 复现时固定输入序列,让问题 100% 可重现,否则后面全是玄学。
第 ② 步:统计 token,找到"丢失临界点"
对每轮的 dump 做一次体检,重点不是总 token,而是那条关键信息还在不在:
python
for turn in range(1, 21):
msgs = load_messages(turn)
total = sum(count_tokens(m["content"]) for m in msgs)
has_rule = any("PostgreSQL" in m["content"] for m in msgs)
print(f"turn {turn:2d} | tokens: {total:6d} | 含'PostgreSQL约定': {has_rule}")
跑出来大概长这样:
text
turn 1 | tokens: 3200 | 含'PostgreSQL约定': False
turn 3 | tokens: 6800 | 含'PostgreSQL约定': True ← 约定进入对话
...
turn 18 | tokens: 124000 | 含'PostgreSQL约定': True
turn 19 | tokens: 127800 | 含'PostgreSQL约定': False ← 从这轮起被挤出!
turn 20 | tokens: 126500 | 含'PostgreSQL约定': False
这张表直接把你分流到三条路上:
- 某轮起彻底消失 → 截断,去 ③
- 还在,但变成一句概括 → 摘要失真,去 ④
- 还在且完整,模型仍然不用 → 注意力问题,去 ⑥
💡 如果连第 3 轮就没出现过,说明压根没入库,直接去 ⑤ 查写入逻辑。
第 ③ 步:查裁剪------两个"事故代码"
坑 1:按条数滑动窗口
python
# ❌ 事故代码:只保留最近 20 条
def trim_history(messages, max_turns=20):
return messages[-max_turns:]
到第 20 轮,第 1~5 轮被无声丢掉。特征就是"稳定到固定轮次必丢"------如果你观察到这个规律,基本可以锁定这里。
坑 2:隐形消息过滤(更隐蔽)
很多框架还会顺手做"清理",而无差别清理最致命:
python
# ❌ 只留人类消息和 AI 回复,把所有工具返回扔掉
filtered = [m for m in msgs if m.type in ("human", "ai")]
如果第 3 轮用户说"参数在这个文档里",关键信息其实藏在那一轮的工具返回中,这句过滤就把它连根拔了。
python
# ✅ 修复思路:工具返回先抽取关键字段,再决定丢弃
key_facts = extract_facts(tool_result) # 例如 {"db": "PostgreSQL"}
fact_store.update(key_facts)
filtered = [m for m in msgs if m.type in ("human", "ai")]
验证方法:在 ② 找到的临界点附近,把被裁掉的消息打印出来,看里面有没有选型、参数、约定、禁令。有 → 裁剪就是直接元凶。
第 ④ 步:查压缩------摘要是"大意",不是"契约"
超过阈值时框架会自动摘要。摘要天然有损,而且它不知道哪些细节对你的业务是致命的:
text
原始对话(第 3~8 轮):
用户:用 PostgreSQL;密码用 bcrypt 加密;接口统一 camelCase。
自动摘要(第 15 轮被注入的历史):
"用户讨论了数据库选型和安全要求,并确定了接口规范。"
→ PostgreSQL 没了,bcrypt 没了,camelCase 也没了。模型只能靠猜,而它通常会猜成最常见的那个(MySQL)。
根本原因是:摘要器把"一次性闲聊"和"全局常驻约束"一视同仁地压缩,而约束往往是最先被压掉的那类(因为它只是一句话,没有"过程")。
验证方法 :把摘要版历史 dump 出来,逐条核对早期轮次里的事实性内容------技术选型、数字、参数、配置项、禁止事项。凡是被泛化成"讨论了 XX"的,都是风险点。
第 ⑤ 步:查存储------信息有没有"住在窗口之外"
健康的架构里,重要约定不应该住在对话历史里:
text
对话历史(易失:会被裁剪、会被压缩)
↓ 抽取关键事实
外部记忆 / 状态存储(持久:每轮稳定注入)
- tech_stack: PostgreSQL
- password_hash: bcrypt
- api_style: camelCase
围绕这条链,问自己四个问题:
① 有没有"提取"动作? 是谁在什么时候把约定从对话里抠出来的?
② 提取后每轮都稳定注入吗,还是只在某一轮注入过一次?
③ 约定变了,记忆跟着变吗?(用户第 15 轮改成 MySQL,记忆里还是 PostgreSQL 就是新 bug)
④ 如果用了向量检索,它真的召得回吗?
python
# 直接用第 20 轮的问题去召回第 3 轮的约定
hits = vector_store.similarity_search(query="数据库 选型 约束", k=5)
print([h.score for h in hits]) # 分数极低 → 召回失败
向量召回失败的常见原因:
- Embedding 相似度低(切分太碎、语义没对齐、中英混杂)
topK太小、相似度阈值太高- 早期消息入库时就没写成功(没打标签、没持久化、异步写丢了)
还有一个高频坑:槽位/实体记忆只在首轮提取。
python
# ❌ 只在第一轮抽一次参数,之后既不刷新也不重读
if turn == 1:
slots = extract_slots(user_input)
# 一旦这条消息被滑动窗口清掉,槽位就彻底失联
框架层的坑:局部变量没进全局状态。 如果你用 LangGraph / LangChain AgentExecutor,最常见的 bug 是------关键信息存活在某个节点的局部变量里,节点跑完就销毁:
python
# ❌ 约束只在本函数内存活,函数返回即蒸发
def tool_step(query):
budget = extract_budget(query)
return result
# ✅ 写进全局 State,任何节点都能读到
class AgentState(TypedDict):
global_constraints: dict # 全局常驻区
另外提醒一句:用户眼里的"第 20 轮",不等于 Agent 内部的"第 20 次调用" 。Agent 内部可能已经循环调工具几十次,token 消耗远超预期,于是提前触发了滑动窗口。你按对话轮数去算临界点,会算不对。
第 ⑥ 步:查注入------信息在,但模型"看不见"
如果 ②~⑤ 都正常(信息完整地在窗口内),那问题就是注意力分配,学术上叫 Lost in the Middle :模型对上下文开头和结尾的注意力显著强于中部。
text
❌ 不佳的布局:
[System] [历史 40 轮......约定埋在中部] [巨型工具输出] [用户输入]
↑ 尾部抢走注意力,中部被淹没
✅ 更好的布局:
[System + 关键约定置顶] [历史(可被裁剪)] [用户输入]
还有两个放大器,会让你误以为是"窗口太小":
- 工具返回挤占预算 :后面几轮的大段文档/报表最占 token,而裁剪策略往往优先挤掉用户早期的对话,而不是压缩工具返回;
- 所有消息混在一个列表里一起滑:于是"最早的"永远最先被清------这正是"稳定到 20 轮丢失"最典型的成因。
验证方法(消融实验) :把那句约定复制一份塞进 system prompt,其他一律不动。
- 问题消失了 → 100% 是位置/权重问题,按第五章方案 ①、② 修;
- 问题依然在 → 回去查 ③④⑤。
加分项:装一道"校验探针"
这是最便宜、最好用的观测手段:每一轮让 Agent 先说一遍它记住的约束清单。
text
User: 帮我算下这个方案报价。
Agent 自检: 【全局约束】合同 AX-2026-001、敬语 ✓、预算 ≤ 5 万 → 继续
...
User: (第 20 轮)报个新方案。
Agent 自检: 【全局约束】(空) ← 就是这一轮开始"失忆"的
它能直接告诉你失忆发生在第几轮,比翻日志快得多。上线后也可以留着,但注意别把自检内容拼进正式回复里。
四、一张表定位根因
把前面所有线索收进一张表,对着症状直接找修法:
| 根因 | 现象特征 | 修复方向 |
|---|---|---|
| 滑动窗口 FIFO 截断 | 固定轮次丢;dump 里没有早期消息 | 上下文分区:常驻区 + 滚动区(方案 ①②) |
| 消息过滤误删 | 关键信息原本在 tool 返回里,现在整条没了 | 先抽取字段再丢弃(方案 ①) |
| 摘要压缩失真 | 消息还在,但被泛化成"讨论了 XX" | 摘要保底 + 事实清单(方案 ④) |
| 向量记忆召回失败 | 历史在库里,检索不到 / 分数极低 | 优化切分与 Embedding;但约定别依赖召回(方案 ①) |
| 槽位只抽首轮 | 首轮过后参数就"归零" | 每轮校验并刷新槽位(方案 ①) |
| 局部 State 丢失 | 数据只在节点变量里,没持久化 | 全局 State / 外部存储(方案 ①) |
| 工具返回挤占预算 | 工具输出超长,用户历史先被淘汰 | 工具返回单独截断/摘要(方案 ③) |
| Prompt 权重倾斜 | 信息完整在窗口内,模型仍不用 | 常驻区置顶 + 校验探针(方案 ②) |
五、修复:五个方案,按代价从低到高
先看全局权衡,再决定动哪里:
| 方案 | 改动成本 | 见效速度 | 解决哪类问题 |
|---|---|---|---|
| ① 关键约束结构化外置 | 低 | 立刻 | 截断、压缩、召回失败(治本) |
| ② 上下文分区 | 低 | 立刻 | 位置淹没、常驻信息被清 |
| ③ 重要性感知的裁剪 | 中 | 立刻 | 工具返回挤占、按条数误裁 |
| ④ 摘要保留事实清单 | 低 | 立刻 | 摘要泛化 |
| ⑤ RAG / 会话分段 | 高 | 中期 | 超长对话(50+ 轮)、多天任务 |
方案 ①:关键约束结构化外置(首选,改动小、收益最大)
核心思想:别指望对话历史帮你记住约定。 用规则或轻量 LLM 把契约抽成结构化的键值对,每轮无条件注入 system prompt。
python
# 用户约束的持久化(越简单越好,别上向量库)
fact_store = {
"tech_stack": "PostgreSQL", # 用户于第 3 轮确认
"password_hash": "bcrypt",
"api_style": "camelCase",
}
system_prompt = BASE_PROMPT + "\n\n## 项目约定(长期有效,优先级高于一切历史对话)\n" + \
"\n".join(f"- {k}: {v}" for k, v in fact_store.items())
三个必须做到的细节:
- 注入在 system prompt 里,不是拼在历史尾部------尾部会被当成"可选上下文";
- 每轮都注入,不是只在提取那一轮注入;
- 可更新:第 15 轮用户改成 MySQL,这里要跟着改,否则就是新 bug。改的时候顺手打印一行日志,方便日后追溯。
方案 ②:上下文分区------常驻区永不参与截断
把"一个列表一起滑"改成"两个区各管各的":
text
【全局关键约束】(置顶,永不参与滑动截断)
- 合同号:AX-2026-001
- 必须使用敬语
- 预算上限 5 万元
──────────────────────────────────────
【近期对话】(滑动窗口,允许淘汰旧内容)
- turn-15 ... turn-20
很多"稳定到 20 轮丢失"的案例,改完这一条就消失了。原因是结构的,不是参数的------加大窗口只是把 20 轮推迟到 40 轮,迟早还会犯。
方案 ③:裁剪要看价值,不看条数
python
def smart_trim(messages, token_budget):
pinned = [m for m in messages if m.get("pinned")] # 已标记的关键消息
rest = [m for m in messages if not m.get("pinned")]
# 先压工具返回(最占地方、信息密度最低)
rest = compress_tool_results(rest, max_tokens=2000)
# 还不够,就"从旧到新"渐进摘要,而不是一刀切删除
while total_tokens(pinned + rest) > token_budget:
rest = summarize_oldest_pair(rest)
return pinned + rest
关键在顺序:先压工具返回 → 再摘要旧对话 → 最后才考虑丢。多数实现反过来了,先丢用户历史,把最贵的东西最先扔了。
方案 ④:摘要时给一份"必须保留"清单
压缩历史时,用 prompt 强制约束摘要器:
text
总结以下对话,但必须**逐字保留**:
① 技术选型与工具名(如 PostgreSQL、bcrypt)
② 数字、参数、配置项、金额、编号
③ 用户明确提出的要求与禁止事项
其余过程性讨论可压缩为一句。
如果有条件,让契约根本不进摘要器(方案 ①)------这比调 prompt 更可靠,因为 prompt 约束是概率性的。
方案 ⑤:RAG 检索 + 会话分段(兜底,不是主力)
- RAG :把早期完整对话切片入库,需要细节时按需召回。适合 50+ 轮、无法全量保留的场景。 ⚠️ 关键约定仍应走方案 ①。检索有召回失败的概率,而契约不能有概率。
- 会话分段 + 状态快照:超长任务拆成多个会话,每次开场用"状态快照"恢复上下文,而不是拖着全部历史。这是多天任务型 Agent 的标准做法。
六、修复前后对比
还是开头那个案例,看第 20 轮实际发给模型的内容:
text
修复前:
┌────────────────────────────────────────┐
│ System: 你是一个编程助手 │
│ [第 1~12 轮:已摘要成一段话 │
│ "讨论了数据库选型......" ← PostgreSQL 丢失]│
│ [第 13~19 轮:完整保留] │
│ [巨型工具输出:8000 tokens] │
│ [第 20 轮用户输入] │
└────────────────────────────────────────┘
→ 模型无从知晓约定,输出 MySQL 代码
修复后:
┌────────────────────────────────────────┐
│ System: 你是一个编程助手 │
│ ## 项目约定(长期有效,优先级最高) │
│ - tech_stack: PostgreSQL │
│ - password_hash: bcrypt │
│ - api_style: camelCase │
│ [摘要历史(只占 300 tokens)] │
│ [第 13~19 轮:完整保留] │
│ [第 20 轮用户输入] │
└────────────────────────────────────────┘
→ 约定每轮可见,且在最前面,永不失忆
注意修复后的两个变化:约定到了最前面 (解决注意力问题),摘要只占 300 tokens(解决预算问题)。两者一起做,才叫治本。
七、一个可直接抄的最小实现
把方案 ①②③ 合成一个 ContextBuilder,约 40 行,框架无关:
python
from dataclasses import dataclass, field
RESERVE_FOR_OUTPUT = 2000 # 给模型输出留的余量
@dataclass
class FactStore:
"""关键约束的持久化存储:只存契约,不存闲聊"""
facts: dict = field(default_factory=dict)
def update(self, k, v):
if self.facts.get(k) != v:
print(f"[fact] {k}: {self.facts.get(k)} -> {v}") # 变更留痕
self.facts[k] = v
def render(self) -> str:
if not self.facts:
return ""
body = "\n".join(f"- {k}: {v}" for k, v in self.facts.items())
return f"## 项目约定(长期有效,优先级高于一切历史对话)\n{body}"
def build_prompt(system: str, facts: FactStore, history: list,
user_input: str, budget: int):
pinned_block = facts.render()
# ① 先扣掉"不可压缩"的部分
fixed = (count_tokens(system) + count_tokens(pinned_block)
+ count_tokens(user_input) + RESERVE_FOR_OUTPUT)
room = max(budget - fixed, 512)
# ② 工具返回先压缩(信息密度最低,最该先动)
history = compress_tool_results(history, max_tokens=room // 3)
# ③ 从新到旧贪心装入,装不下的做摘要
rolling, used = [], 0
for msg in reversed(history):
n = count_tokens(msg["content"])
if used + n <= room:
rolling.insert(0, msg)
used += n
else:
break
dropped = history[:len(history) - len(rolling)]
if dropped:
rolling.insert(0, {
"role": "system",
"content": "【更早对话摘要】" + summarize(dropped, must_keep=[
"技术选型", "数字与参数", "用户的要求与禁止事项"
])
})
return [{"role": "system", "content": system + "\n\n" + pinned_block}] \
+ rolling + [{"role": "user", "content": user_input}]
配套两个动作,缺一不可:
python
# 动作 1:每轮提取契约(规则优先,规则抓不到再用 LLM)
new_facts = extract_facts_by_rule(user_input) or extract_facts_by_llm(user_input)
for k, v in new_facts.items():
facts.update(k, v)
# 动作 2:每轮落盘,方便事后追责
json.dump(prompt, open(f"prompts/turn_{i:02d}.json", "w"), ensure_ascii=False)
最后加一条回归测试,堵住复发:
python
def test_remember_turn3_at_turn20():
"""第 20 轮问第 3 轮的事,必须答得出来"""
answer = run_conversation(FIXED_SCRIPT, turns=20, ask="我们用什么数据库?")
assert "PostgreSQL" in answer
assert "MySQL" not in answer
八、上线前 Checklist
每一条都对应一个真实的坑,逐项打勾:
可观测性
- 有每轮完整 prompt 的落盘日志,且与界面展示内容区分开
- 日志做了敏感信息脱敏
- 有"校验探针"能看出失忆发生在第几轮
截断与裁剪
- 裁剪按重要性而非条数
- 关键约束放在不参与截断的常驻区
- 消息过滤没有无差别删除 tool 返回
- 先压工具返回,再摘要历史,最后才丢
压缩
- 摘要 prompt 强制保留事实性内容(选型、参数、数字、禁令)
- 契约本身不经过摘要器
存储与注入
- 关键约定结构化外置,每轮注入 system prompt
- 约定更新时,外部记忆同步更新(有变更日志)
- Agent 框架的全局 State 里有
global_constraints字段,不依赖局部变量 - 长对话有分段管理与状态快照机制
测试
- 有**"第 20 轮问回第 3 轮的事"**的自动化回归测试
- 改过裁剪/摘要策略后,这条测试必须重跑
九、总结
记住这条决策树
排查永远从同一个动作开始------看这一轮实际发给模型的 messages 里,早期那条信息还在不在:
text
关键信息在不在这一轮的 prompt 里?
│
├─ 不在
│ ├─ 固定轮次丢 ──────────→ 滑动窗口截断(第③步)
│ ├─ 变成一句概括 ────────→ 摘要失真(第④步)
│ ├─ 整条 tool 消息没了 ──→ 消息过滤误删(第③步)
│ └─ 从第 1 轮就没有 ─────→ 压根没入库 / 局部变量(第⑤步)
│
└─ 在,但模型不用
├─ 埋在中部被淹没 ──────→ 常驻区置顶(第⑥步 + 方案②)
└─ 从没进过 system ────→ 每轮注入(方案①)
修复优先级
关键约束结构化外置 > 上下文分区 > 重要性感知的裁剪 > 摘要保底 > RAG 兜底
三句话收尾
- 加大窗口不是解法。 它只是把 20 轮推迟到 40 轮------结构错了,参数救不了。
- 摘要不是记忆。 摘要是"大意",而契约需要"原文"。让需要原文的东西绕开摘要器。
- 对话历史是易失的缓存,不是可信的存储。凡是重要的,就该在窗口之外也有一份。
如果你用的是 LangGraph,最省事的做法是把「全局常驻信息」单独放进一个 AgentState 字段,从架构上杜绝它被滑动窗口清掉。