Skip to content

feat: 上下文管理增加收敛机制——防止 LLM 在探索性对话中固守已抛弃方案 #548

@HamsteRider-m

Description

@HamsteRider-m

title: "feat: 上下文管理增加收敛机制——防止 LLM 在探索性对话中固守已抛弃方案"
labels: ["enhancement", "core"]

问题描述

在长对话的探索性场景中(方案设计、架构讨论、问题诊断),GA 经常会在 10+ 轮之后仍然"念念不忘"已被用户明确否决的早期方案,表现为:

  • Turn 1-5 探讨方案A,Turn 6 用户否决并转向方案B
  • Turn 15+ GA 突然又提出方案A的变体,仿佛忘记了它已被否决

用户需要反复纠正,严重影响探索效率和使用体验。

根因分析

经过对 ga.pyllmcore.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_callbackrsumm = 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_checkpointkey_info 是单一字符串,混杂了稳定事实和当前路径。拆成两个字段:facts(环境/配置等,只增不改)和 trajectory(当前探索路径 + 已否决列表,方向变就覆盖)。

改动文件ga.py + update_working_checkpoint 工具定义(ga.py:438-448),~50 行

注意:当前 update_working_checkpoint 工具只支持 key_inforelated_sop 两个参数,需要扩展为支持 factstrajectory

伪代码

# 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 只需覆盖 trajectoryfacts 保持不变。清晰的"当前路径 + 否决列表"结构让 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 自己梳理和标记。系统只负责在合适的时机提出要求,并让梳理结果能被后续轮次有效消费。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions