title: "feat: 上下文管理增加收敛机制——防止 LLM 在探索性对话中固守已抛弃方案"
labels: ["enhancement", "core"]
问题描述
在长对话的探索性场景中(方案设计、架构讨论、问题诊断),GA 经常会在 10+ 轮之后仍然"念念不忘"已被用户明确否决的早期方案,表现为:
- Turn 1-5 探讨方案A,Turn 6 用户否决并转向方案B
- Turn 15+ GA 突然又提出方案A的变体,仿佛忘记了它已被否决
用户需要反复纠正,严重影响探索效率和使用体验。
根因分析
经过对 ga.py 和 llmcore.py 的源码级分析,问题出在所有上下文管理机制都是"累加式"(additive)的,没有一个"收敛式"(convergent)的机制——系统能加信息,但不能标记"旧信息已过时"。
根因 1:history_info 只增不删,且无否定语义
ga.py:562 turn_end_callback:
self.history_info.append(f'[Agent] {summary}')
每轮追加,永不删除。"方案A很好(5轮)"和"用户否决方案A(1轮)"在 history_info 里是平级的摘要条目,LLM 回头看时容易被早期那 5 条"方案A很好"压过那 1 条"用户否决"。
根因 2:_fold_earlier 做格式压缩,但不做语义筛选
ga.py:522-534 _fold_earlier:旧对话被折叠成 [Agent](5 turns) 格式,但内容本身不区分"这是已否决的探索"还是"这是有效的积累"。LLM 看到"我在方案A上投入了5轮",自然会倾向于回归。
根因 3:<summary> 标签是物理快照,不是语义收敛
ga.py:550-561 turn_end_callback 中 rsumm = re.search(r"<summary>(.*?)</summary>", _c, re.DOTALL) 回答的是"这一轮发生了什么",而不是"经过这一轮,我们的认知更新到了什么状态"。缺乏一个机制来回答"当前应该相信什么、什么已经排除了"。
根因 4:compress_history_tags 不碰 user/assistant 消息正文
llmcore.py:38-69 compress_history_tags 只截断 <thinking>、<tool_use>、<tool_result> 等标签内的内容。user 消息和 assistant 消息的正文完全不处理。所以旧消息里关于方案A的原始讨论,只要没被 trim_messages_history 丢弃,就完整保留在上下文中。
结构性问题总结
当前数据流:
每轮 → summary 追加到 history_info → _fold_earlier 折叠旧轮 → _get_anchor_prompt 注入
问题:
history_info 只 append,不 prune
_fold_earlier 只压缩格式,不过滤语义
_get_anchor_prompt 无"已否决"标记层
compress_history_tags 不处理消息正文
一句话:系统不知道"什么是被否过的",所以无法阻止 LLM 回头捡起来。
建议方案
以下按实现复杂度和收益排序。建议①+③作为第一步,②+④作为后续迭代。
方案①:里程碑自动收敛提示(最简单,最治本)
思路:在 turn_end_callback 中已有的 turn 检测逻辑处,增加一个收敛触发——每 N 轮强制 LLM 做一次状态收敛。
改动文件:ga.py,~15 行
伪代码:
# ga.py:565-569 已有 turn % 75 和 turn % 7 的 DANGER 检测
# 收敛机制应插入在 L569 之后、L571 之前
CONVERGE_INTERVAL = 12 # 可配
if turn > 3 and turn % CONVERGE_INTERVAL == 0 and (not _plan): # 与 L565 的 plan 检测对齐
next_prompt += "\n\n[SYSTEM] 已进行多轮对话。请在本次回复开头(<summary>之前)先输出以下收敛块:\n\n### [STATUS]\n- **当前方案/结论**:(一句话)\n- **已否决**:列出已被明确否决的方案或方向,并说明否决原因\n- **待决策**:列出仍开放的问题\n\n然后继续正常回复。此块仅在本次输出,后续轮次无需重复。\n\n注意:在后续回复中不要重新主张"已否决"中的方案。除非用户明确要求重新考虑。"
效果:LLM 每 12 轮被迫梳理一次"当前相信什么、什么废了"。这条收敛结果自己就会进入 history_info,后续 LLM 看到它就知道边界在哪。
方案②:/solidify 命令——用户手动触发收敛
思路:增加一个显式命令,用户在任何时候输入 /solidify(或 /lock、/conclude),触发 LLM 生成收敛块并存入 working 存储,后续注入时优先级高于 history_info。
改动文件:ga.py + 命令路由,~40 行
伪代码:
# ga.py - GenericAgentHandler 新增
class GenericAgentHandler:
def _get_anchor_prompt(self):
prompt = super()._get_anchor_prompt()
if self.working.get('solidified'):
# 收敛块插入到 history_info 之前,优先级最高
prompt = f"\n### [SOLIDIFIED]\n{self.working['solidified']}\n" + prompt
return prompt
def handle_solidify(self, messages):
"""处理 /solidify 命令"""
return {
"next_prompt": """[SYSTEM] 请分析当前对话的探索过程,输出一个收敛块:
### [SOLIDIFIED]
当前结论:
- (当前采用什么方案、什么理由)
已否决(标明确否决原因):
- 方案X — 用户Turn N否决,原因:...
- 方案Y — Turn M放弃,原因:...
仍待决策:
- (开放问题)
输出后,后续对话以此为新的认知起点。旧方案除非用户重新提出,否则不再回溯。"""
}
效果:用户在感觉 GA "跑偏"时手动纠正。收敛块存储在 self.working['solidified'],会话重启后仍然保留。
方案③:增强 <summary> 的收敛语义
思路:在系统 prompt 的 <summary> 协议中增加可选标记,让 LLM 在方向转变时自觉标记"之前的东西被否了"。turn_end_callback 检测到标记后,在 history_info 中插入一条高亮分隔线。
改动文件:ga.py ~40 行 + prompt 模板 ~3 行
伪代码:
# ga.py - turn_end_callback 中,summary 提取后
REJECTED_PATTERN = r'\[REJECTED:(.*?)\]' # LLM 在 summary 中标记已否决项
PIVOT_PATTERN = r'\[PIVOT\]'
summary = extract_summary(response)
if re.search(PIVOT_PATTERN, summary):
self.history_info.append('[SYSTEM] ⚠️ 方向转变 — 此前的方案/结论已被用户否决或放弃,勿回溯。')
elif re.search(REJECTED_PATTERN, summary):
rejected_items = re.findall(REJECTED_PATTERN, summary)
for item in rejected_items:
self.history_info.append(f'[SYSTEM] ❌ 已否决: {item}')
self.history_info.append(f'[Agent] {summary}')
方案④:key_info 拆分为双轨(facts + trajectory)
思路:当前 working_checkpoint 的 key_info 是单一字符串,混杂了稳定事实和当前路径。拆成两个字段:facts(环境/配置等,只增不改)和 trajectory(当前探索路径 + 已否决列表,方向变就覆盖)。
改动文件:ga.py + update_working_checkpoint 工具定义(ga.py:438-448),~50 行
注意:当前 update_working_checkpoint 工具只支持 key_info 和 related_sop 两个参数,需要扩展为支持 facts 和 trajectory。
伪代码:
# ga.py - _get_anchor_prompt 中
prompt = "### [WORKING MEMORY]\n"
if self.working.get('facts'):
prompt += f"<facts>{self.working['facts']}</facts>\n"
if self.working.get('trajectory'):
prompt += f"<trajectory>{self.working['trajectory']}</trajectory>\n"
其中 trajectory 的典型内容:
当前方案:单体+模块化(方案B)。已否决:方案A(微服务,Turn 6用户认为过重)、gRPC(Turn 8换成REST)。
效果:方向转变时 LLM 只需覆盖 trajectory,facts 保持不变。清晰的"当前路径 + 否决列表"结构让 LLM 很难自己跳回老路。
实验验证(方案① A/B 对照实验)
为验证方案①的实际效果,在沙盒中进行了严格 A/B 对照实验。
实验设计
- 对照组(Control):converge_interval=0,正常 GA 行为
- 实验组(Experimental):converge_interval=12,第12轮注入收敛 prompt
- 测试场景:S2 数据库选型(MongoDB → PostgreSQL),16 轮对话,Turn 7 否决 MongoDB
- 测试模型:gpt-5.5
结构测试结果:5/5 通过 ✅
| 测试 |
结果 |
| 对照组不注入收敛 prompt |
✅ converge_turns=[] |
| 实验组在第12轮注入 |
✅ converge_turns=[12],prompt 含 [STATUS]/已否决 |
| 多轮触发 (interval=6) |
✅ [6, 12, 18, 24] 全部正确 |
| _fold_earlier 格式 |
✅ 正确折叠 |
| _get_anchor_prompt 结构 |
✅ 含完整结构 |
LLM 行为测试结果
对照组回复(7229字)
直接进入 PostgreSQL 迁移方案,全文未出现 "MongoDB" 或 "NoSQL"。LLM 仅凭 16 轮上下文就保持了方向,未回归。
实验组回复(8874字)
开头正确输出了 STATUS 块:
### [STATUS]
- **当前方案/结论**:采用 PostgreSQL 作为核心交易数据库...
- **已否决**:MongoDB 方案;原因是金融核心数据对强一致性、事务完整性要求较高...
- **待决策**:老系统数据库类型与数据规模、是否允许停机窗口...
随后正文与对照组同质量的 PostgreSQL 迁移方案。正文中 未主张采用已否决方案。
关键发现:关键词匹配有假阳性
若用简单关键词命中衡量回归:实验组命中"MongoDB""NoSQL"→ 2 回归。但实际上这 2 个命中全部来自 STATUS 块的"已否决"字段——这是正确行为,不是回归。
修正指标(区分"标记已否决" vs "主张重新采用")后:两组均无真正回归。
实验价值
| 对话长度 |
无收敛 |
有收敛 |
价值 |
| <20 轮 |
够用 |
额外开销 |
低 |
| 20-50 轮 |
可能模糊 |
STATUS 作为 checkpoint |
中 |
| 50+ 轮 |
早期否决被折叠 |
STATUS 持续可读 |
高 |
| 多次 pivot |
极易混乱 |
STATUS 记录变更史 |
极高 |
完整实验报告和 LLM 回复原文见沙盒产物(test_harness.py 可复现)。
建议实施路径
| 阶段 |
内容 |
改动量 |
理由 |
| Phase 1 |
方案① 里程碑收敛 + 方案② /solidify 命令 |
~55 行 |
最小改动,最大体验提升。不涉及 llmcore.py,只在 ga.py 的 prompt 注入层操作。实验已验证 STATUS 块能被正确输出。 |
| Phase 2 |
方案③ 增强 summary 语义 + 方案④ 双轨 checkpoint |
~90 行 |
需要改 prompt 模板和工具定义,需要让 LLM 学习新的 summary 协议,测试成本略高。 |
| Phase 3(可选) |
在 _fold_earlier 中识别 [PIVOT]/[REJECTED] 标记并给已否决内容加视觉衰减 |
~30 行 |
锦上添花——让折叠后的旧历史也能体现否决信息。 |
对 STATUS prompt 的小优化建议
基于实验结果,建议在收敛 prompt 末尾加一句约束:
注意:在后续回复中不要重新主张"已否决"中的方案。除非用户明确要求重新考虑。
进一步防止 LLM 在 STATUS 块之外顺手提"当然我们也可以考虑 MongoDB..."。
补充说明
- 所有改动不涉及
llmcore.py。收敛机制是 prompt 注入层的事,压缩/截断保持不变。这符合"上下文大小管理"和"上下文质量管理"的分层职责。
- 方案① 利用了
turn_end_callback 中已有的 turn 检测逻辑,改动极为克制。
- 这些方案都遵循 GA 的设计哲学:不预装规则,而是让 LLM 自己梳理和标记。系统只负责在合适的时机提出要求,并让梳理结果能被后续轮次有效消费。
title: "feat: 上下文管理增加收敛机制——防止 LLM 在探索性对话中固守已抛弃方案"
labels: ["enhancement", "core"]
问题描述
在长对话的探索性场景中(方案设计、架构讨论、问题诊断),GA 经常会在 10+ 轮之后仍然"念念不忘"已被用户明确否决的早期方案,表现为:
用户需要反复纠正,严重影响探索效率和使用体验。
根因分析
经过对
ga.py和llmcore.py的源码级分析,问题出在所有上下文管理机制都是"累加式"(additive)的,没有一个"收敛式"(convergent)的机制——系统能加信息,但不能标记"旧信息已过时"。根因 1:
history_info只增不删,且无否定语义ga.py:562turn_end_callback:每轮追加,永不删除。"方案A很好(5轮)"和"用户否决方案A(1轮)"在
history_info里是平级的摘要条目,LLM 回头看时容易被早期那 5 条"方案A很好"压过那 1 条"用户否决"。根因 2:
_fold_earlier做格式压缩,但不做语义筛选ga.py:522-534_fold_earlier:旧对话被折叠成[Agent](5 turns)格式,但内容本身不区分"这是已否决的探索"还是"这是有效的积累"。LLM 看到"我在方案A上投入了5轮",自然会倾向于回归。根因 3:
<summary>标签是物理快照,不是语义收敛ga.py:550-561turn_end_callback中rsumm = re.search(r"<summary>(.*?)</summary>", _c, re.DOTALL)回答的是"这一轮发生了什么",而不是"经过这一轮,我们的认知更新到了什么状态"。缺乏一个机制来回答"当前应该相信什么、什么已经排除了"。根因 4:
compress_history_tags不碰 user/assistant 消息正文llmcore.py:38-69compress_history_tags只截断<thinking>、<tool_use>、<tool_result>等标签内的内容。user 消息和 assistant 消息的正文完全不处理。所以旧消息里关于方案A的原始讨论,只要没被trim_messages_history丢弃,就完整保留在上下文中。结构性问题总结
一句话:系统不知道"什么是被否过的",所以无法阻止 LLM 回头捡起来。
建议方案
以下按实现复杂度和收益排序。建议①+③作为第一步,②+④作为后续迭代。
方案①:里程碑自动收敛提示(最简单,最治本)
思路:在
turn_end_callback中已有的 turn 检测逻辑处,增加一个收敛触发——每 N 轮强制 LLM 做一次状态收敛。改动文件:
ga.py,~15 行伪代码:
效果:LLM 每 12 轮被迫梳理一次"当前相信什么、什么废了"。这条收敛结果自己就会进入
history_info,后续 LLM 看到它就知道边界在哪。方案②:
/solidify命令——用户手动触发收敛思路:增加一个显式命令,用户在任何时候输入
/solidify(或/lock、/conclude),触发 LLM 生成收敛块并存入working存储,后续注入时优先级高于history_info。改动文件:
ga.py+ 命令路由,~40 行伪代码:
效果:用户在感觉 GA "跑偏"时手动纠正。收敛块存储在
self.working['solidified'],会话重启后仍然保留。方案③:增强
<summary>的收敛语义思路:在系统 prompt 的
<summary>协议中增加可选标记,让 LLM 在方向转变时自觉标记"之前的东西被否了"。turn_end_callback检测到标记后,在history_info中插入一条高亮分隔线。改动文件:
ga.py~40 行 + prompt 模板 ~3 行伪代码:
方案④:
key_info拆分为双轨(facts+trajectory)思路:当前
working_checkpoint的key_info是单一字符串,混杂了稳定事实和当前路径。拆成两个字段:facts(环境/配置等,只增不改)和trajectory(当前探索路径 + 已否决列表,方向变就覆盖)。改动文件:
ga.py+update_working_checkpoint工具定义(ga.py:438-448),~50 行注意:当前
update_working_checkpoint工具只支持key_info和related_sop两个参数,需要扩展为支持facts和trajectory。伪代码:
其中
trajectory的典型内容:效果:方向转变时 LLM 只需覆盖
trajectory,facts保持不变。清晰的"当前路径 + 否决列表"结构让 LLM 很难自己跳回老路。实验验证(方案① A/B 对照实验)
为验证方案①的实际效果,在沙盒中进行了严格 A/B 对照实验。
实验设计
结构测试结果:5/5 通过 ✅
LLM 行为测试结果
对照组回复(7229字)
直接进入 PostgreSQL 迁移方案,全文未出现 "MongoDB" 或 "NoSQL"。LLM 仅凭 16 轮上下文就保持了方向,未回归。
实验组回复(8874字)
开头正确输出了 STATUS 块:
随后正文与对照组同质量的 PostgreSQL 迁移方案。正文中 未主张采用已否决方案。
关键发现:关键词匹配有假阳性
若用简单关键词命中衡量回归:实验组命中"MongoDB""NoSQL"→ 2 回归。但实际上这 2 个命中全部来自 STATUS 块的"已否决"字段——这是正确行为,不是回归。
修正指标(区分"标记已否决" vs "主张重新采用")后:两组均无真正回归。
实验价值
建议实施路径
/solidify命令_fold_earlier中识别[PIVOT]/[REJECTED]标记并给已否决内容加视觉衰减对 STATUS prompt 的小优化建议
基于实验结果,建议在收敛 prompt 末尾加一句约束:
进一步防止 LLM 在 STATUS 块之外顺手提"当然我们也可以考虑 MongoDB..."。
补充说明
llmcore.py。收敛机制是 prompt 注入层的事,压缩/截断保持不变。这符合"上下文大小管理"和"上下文质量管理"的分层职责。turn_end_callback中已有的 turn 检测逻辑,改动极为克制。