Skip to content

11_03_Context_Compaction

Wyckoff edited this page Jun 21, 2026 · 1 revision

(三) 上下文双向切分与可恢复压缩

上下文压缩逻辑实现在 compaction.py,由终端 TUI 和无界面运行时(Headless Runtime)共用。其主要任务是在对话不断延长、Token 即将溢出时,自动压缩历史对话,并在不破坏最新工具调用上下文的同时释放上下文空间。

4.1 触发判定与阈值计算 (Trigger & Threshold)

为了防止多轮 ReAct 循环或大型工具数据爆发(Burst)时瞬间撑爆上下文,系统采用了保守的 动态预留安全垫(Safety Reserve) 策略:

  1. 上下文窗口推断 (Context Window):优先读取用户通过 wyckoff model cost 显式配置的窗口值。若未配置,则通过 infer_context_window() 按模型名称映射默认窗口大小(如 Claude 为 200K、Gemini 为 1M、DeepSeek 为 64K)。

Note

安全垫的设立是为了给大模型的 回复内容、系统提示词、工具定义以及下一轮工具调用的临时返回数据 预留出充足的空白空间。如果把上下文塞到 100% 满才压缩,大模型将直接因为没有空间说话而报错中断。

安全垫的估算公式

使用 0.25 代替 % 符号以避免 GitHub 渲染解析报错:

$$\text{Reserve} = \min\left(\max(16384, \min(\text{ContextWindow} \times 0.25, 32768)), \frac{\text{ContextWindow}}{2}\right)$$

通俗理解这个公式

  • 我们期望在上下文窗口中,预留 25% 的空间不放历史记录。
  • 为了防止小窗口模型的 25% 空间太少,系统规定了安全垫的 硬性最低保底值为 16,384
  • 为了防止极大型窗口模型(如 Gemini 的 1M 窗口)预留的 25% 空间过大造成浪费(250K),安全垫的 最高上限硬性限制在 32,768
  • 同时,为了防止在极小窗口模型中安全垫过大,安全垫的 最高上限绝不能超过窗口大小的一半
  1. 压缩触发条件 (Compaction Trigger): 系统使用 estimate_tokens() 实时估算当前整个对话队列占用的空间。一旦当前对话体积超过了 “触发阈值(ContextWindow - Reserve)”,系统就会判定“空间不够了”,立刻启动上下文压缩管线。
模型类别 窗口容量 (Context Window) 预留缓冲 (Reserve) 压缩触发阈值 (Threshold)
DeepSeek 64K 16.4K 47.6K
GPT / 兼容端点常见模型 128K 32K 96K
Claude 200K 32.8K 167.2K
Gemini 2 1M 32.8K 967.2K

4.2 双向切分与工具链依赖保护 (Split & Tail Alignment)

压缩发生时,系统会将 messages 划分为陈旧头历史 head 与最新保留尾历史 tail

  1. Token 倒序扫描定位: 系统调用 find_tail_start_by_token_budget 从最近的一条消息倒序向前扫描,累加 Token 长度,直到其大小满足保留预算 keep_recent_tokens(默认保留最近的 20K,至少保留 TAIL_KEEP = 4 轮)。
  2. 工具返回消息修正: 如果切分出来的首条消息其 role == "tool",系统会自动向前滑动移动指针,避免 tail 队列从中间截断工具结果。
  3. 前置工具调用追溯对齐 (_expand_tail_for_tool_refs): 对于所有被划分在 tail 内部的 tool 返回消息,系统会检查其关联的 tool_call_id。如果发起该调用的 assistant 消息被分到了 head,系统会 强行向左拓宽 tail 的边界 ,将该 assistant 消息拉入 tail 范围中。

Important

前置工具追溯对齐彻底避免了“孤儿工具返回结果”导致的 API 语法协议报错(例如:tool response must follow assistant message with tool calls)。


4.3 动态保留与可恢复压缩管线 (Dynamic & Recoverable Compaction)

flowchart TD
    A["1. 检测到估算 Token 超出 Threshold"] --> B["2. 按 Token 预算倒序截出 tail"]
    B --> C["3. 运行 _expand_tail_for_tool_refs 对齐工具链"]
    C --> D["4. 对 head 做动态重要性评分"]
    D --> E["5. 选出动态保留片段 anchors"]
    E --> F["6. 复制 head 后执行 shrink_stale_tool_results"]
    F --> G["7. flush_memory_before_compaction 提取长期偏好"]
    G --> H["8. LLM 生成 500 字摘要"]
    H --> I["9. create_context_archive 写入原文归档"]
    I --> J["10. 重组 messages:摘要 + anchors + archive 引用 + tail"]
Loading

旧版本的压缩主要依赖“保留最近一段 tail”。当前实现改成两层:tail 保留最近工作现场,anchors 保留旧历史中的高价值片段。高价值片段由 score_message_importance()select_anchor_messages() 计算,评分依据包括:

评分信号 目的
用户消息 用户约束和目标通常比助手复述更重要
股票代码 单股问题必须保留明确实体,例如 603373301348
文件路径 代码修改、review、bug 定位需要保留具体文件
报错/失败/提交/回测/持仓/建仓等关键词 这些通常代表未完成任务、风险点或可执行决策
工具调用和工具结果 保留证据链,避免摘要只剩结论

这不是把所有旧消息继续塞回上下文,而是把最可能影响后续执行的几条原文短片段放进压缩摘要中的 [动态保留片段]。因此,后续简短追问仍能看到关键股票、关键文件和错误现场。

阶段一:陈旧工具结果预裁剪 (Compaction Input Pruning)

工具 observation(观察结果)一旦写入 messages,常规 agent loop(智能体循环)不会回头改写它;这样可以保证历史消息前缀稳定,提升 prompt cache(提示词缓存)命中率,也避免模型前后看到的证据链不一致。

只有在上下文确实超过阈值、即将执行整体 compaction(压缩)时,系统才会复制 head,并在这份压缩输入副本上针对长度超过 SHRINK_THRESHOLD (800字符) 的旧轮次工具结果运行 shrink_stale_tool_results()

  • analyze_stock 这种海量实时 JSON,调用 _summarize_tool_result() 提取核心字段(代码、简称、健康状态分类、最新的 5 根日线数据),过滤其他详细调试字段。
  • 将大型 JSON 在压缩输入副本中转化为 400-600 字符的简易摘要体,在送给 LLM 前先对 head 自身的 Token 长度进行清洗,大幅降低摘要 LLM 的调用开销。
  • 如果 compaction(压缩)失败,原始 messages 不会被这一步污染。

阶段二:压缩前记忆刷写 (Memory Flush)

为避免在 LLM 生成大段摘要的过程中遗失用户的强个性化特征,在 head 消息彻底被截断前,调用 flush_memory_before_compaction()

  • 提取 head 中属于 user / assistant 角色的对话片段。
  • 调用 LLM 并使用专属的 _FLUSH_PROMPT,专门提取用户的 稳定投资偏好、仓位/止损风险偏好、重点标的长期结论
  • 提取出的记录被当场写入本地 SQLite 的 agent_memory 数据库(归类为 preference),使其转化为长期记忆,脱离会话上下文的生命周期。

阶段三:LLM 摘要生成与重组 (LLM Summarization & Assembly)

  • 序列化:通过 serialize_messages_for_compaction()head 转化为纯文本,并将工具交互转换为结构化标记(如 [tool:analyze_stock] ... )。
  • LLM 总结:使用专属的 COMPACTION_PROMPT 总结,控制在 500 字以内,着重提取用户的意图、已完成的动作和股票关键结论。
  • 原文归档:摘要生成成功后,调用 create_context_archive() 将被压缩的 head 原文写入 ~/.wyckoff/context_archive/<session_id>/<compaction_id>.jsonl,同时写入一个 JSON metadata 文件,记录摘要、股票代码、文件路径、关键词、anchors 和 archive://... 引用。
  • 重组:生成完毕后,原 messages 被替换为一条带 [对话摘要][动态保留片段][可恢复归档] 的首发 user 消息与一条接续的 assistant 消息,再无缝衔接上完全没有受损的 tail 队列。
# 压缩后重组的 messages 结构实例(拼接:[对话摘要] + [动态保留片段] + [可恢复归档] + [未受损的 Tail])
messages = [
    {
        "role": "user",
        "content": (
            "[对话摘要]\n"
            "用户指示分析平安银行(000001)的量价形态。Agent调用了analyze_stock工具并获得了最新分析结果。\n\n"
            "[动态保留片段]\n"
            "- #14 user: 平安银行(000001)的入场位应该设在哪里?\n"
            "- #18 tool: execute_backtest 失败,报错 No data found...\n\n"
            "[可恢复归档]\n"
            "- archive://session_id/ctx_20260620173727_abcd1234\n"
            "- 涉及标的:000001, 603373"
        )
    },
    {
        "role": "assistant",
        "content": "好的,我已了解之前的对话上下文,请继续。"
    }
] + tail  # 无缝衔接上完全没有受损的最近对话现场

4.4 信息保真与可回溯边界

上下文压缩摘要本身仍然是有损的,但系统不再把“摘要”当成唯一历史来源。现在采用 摘要继续执行 + 原文归档恢复 的双轨策略:

风险 保护机制
最新任务、最新约束被压掉 tail 按 Token 预算保留最近原文,且至少保留最近 4 条消息
工具调用链被截断 _expand_tail_for_tool_refs() 会把 tail 中工具结果对应的 assistant 工具调用一起拉回
旧历史中的关键股票/文件被摘要淡化 select_anchor_messages() 按重要性评分选出动态保留片段
大型工具 JSON 淹没摘要 shrink_stale_tool_results() 只在压缩输入副本上做结构化裁剪,保留股票代码、最新价格、健康状态、信号和关键结论
用户长期偏好被摘要吞掉 flush_memory_before_compaction() 在截断 head 前提取稳定偏好和长期结论,写入 agent_memory
LLM 摘要失败或质量过低 摘要为空或过短时直接放弃压缩,继续使用原始 messages
事后需要复盘原始证据 context_archive 保存被压缩原文,scratchpad 记录 archive_ref 和压缩事件

所以压缩后的上下文由三部分共同保证连续性:

  1. 当前工作状态:最近 tail 原文继续留在模型上下文里。
  2. 动态关键片段:被压缩 head 中最重要的股票、文件、报错和用户约束以 anchors 形式保留。
  3. 长期稳定信息:偏好、风控习惯、长期标的结论进入记忆系统。
  4. 原始运行证据:context archive、scratchpad 和 chat log 负责审计与回溯。

后续用户提到相关股票、文件、关键词或 archive://... 引用时,build_memory_context() 会通过 archive_recall_lines() 把命中的压缩归档索引注入当前轮。需要完整原文时,可以通过 restore_context_archive()archive_ref 找回被压缩消息。这样模型平时只携带轻量摘要,需要证据时再恢复原文。

4.5 历史归档主动召回与恢复 (Active Recall & Recovery)

为了解决摘要丢失细节的问题,系统引入了历史归档主动召回与恢复机制,使得大模型可以通过特定工具主动调阅并还原已被压缩的原始历史证据。

  • 工具统一封装:无需引入额外工具,将归档查询与恢复能力统一集成在已有的历史查询工具 query_history 中,指定 source="archive" 并支持两个特定参数:
    • query:支持检索关键词或 6 位股票代码,匹配后返回归档引用标识(archive_ref)和简易摘要。
    • archive_ref:接收引用链接(如 archive://session_id/compaction_id),从本地磁盘加载原始的 .jsonl 消息还原对话。
  • 会话 ID 自动流转:AgentRuntime 会在 run_stream() 起始时自动将当前的 session_id 注入到 tool_context.state 中,使得工具底层执行时自动绑定当前会话目录。
sequenceDiagram
    participant Agent as Agent (LLM)
    participant Tool as query_history (archive)
    participant DB as 本地归档 (JSON/JSONL)
    
    Note over Agent: 发现压缩摘要丢失细节 (如旧诊结果或命令报错)
    Agent->>Tool: 调用 query_history(source="archive", query="平安银行")
    Tool->>DB: 扫描当前 session_id 目录下的 *.json 索引文件
    DB-->>Tool: 返回匹配的 archive_ref 与 summary 列表
    Tool-->>Agent: 呈报匹配的历史归档引用与要点
    Agent->>Tool: 调用 query_history(source="archive", archive_ref="archive://...")
    Tool->>DB: 根据 compaction_id 读取 *.jsonl 原始历史消息
    DB-->>Tool: 返回当时被压缩的高保真消息记录
    Tool-->>Agent: 灌入原始对话片段,完全恢复高保真现场
Loading

时序图所展示的逻辑使得大模型拥有了自主检索权,可以按需恢复被压缩的历史。


4.6 前端/网页端轻量化摘要实现

由于 Web 端(前端面板)没有常驻的 Python 运行时,它使用 TypeScript 实现了一套确定性的轻量化压缩方案:

  • prepareChatMessagesForModel() 中,计算同样的安全预留缓冲并评估 Token。
  • 触发时,不再调用 LLM,而是直接由前端代码对历史消息中的股票代码、用户近期提问、Assistant 主要结论进行静态拼接,提取出一段确定性的 [读盘室对话摘要] 文本替换头部,同样实现了对 Token 窗口的保底控制。

4.7 核心算法与计算策略细节

1. 动态预留安全垫 (Reserve) 估算逻辑

安全垫的设立不仅是为了应对单次推理的输出,更是为了容纳工具定义(Tool Schemas)和系统提示词。公式如下:

$$\text{Reserve} = \min\left(\max(16384, \min(\text{ContextWindow} \times 0.25, 32768)), \frac{\text{ContextWindow}}{2}\right)$$

  • 下限保底 (16,384):主要针对小窗口模型(如 64K 窗口的 DeepSeek-V3)。防止 25% 的比例(16K)被系统 Prompt 与工具定义直接占满,导致模型无可用输出空间。
  • 上限封顶 (32,768):针对巨型窗口模型(如 1M 窗口的 Gemini 2.0)。如果继续保留 25%(即 250K Tokens)会造成严重的缓存冷启动时延和 Token 浪费,因此硬性封顶在 32.8K。
  • 对半截断 (ContextWindow / 2):针对极小上下文窗口的特殊测试环境,确保安全垫不会超过模型可用容量的一半。

2. 双向切分 (Double-Ended Split) 边界判定

当检测到 estimate_tokens(messages) > ContextWindow - Reserve 时,触发双向压缩:

  • Tail 端 Token 预算匹配: 系统从最后一条消息倒序向前扫描,累加每条消息的估算 Token 数。直到累加值 $\ge \text{keep_recent_tokens}$(默认 20,000 Tokens)。此时的索引记为 tail_start
  • 前置依赖对齐 (_expand_tail_for_tool_refs): 为防止工具消息与其调用的 Assistant 消息被硬性切开,系统会追溯依赖关系:
    # 检查 tail 中是否存在 role == "tool" 的消息
    # 若其引用的 tool_call_id 发起自 head 中的 assistant
    # 则强行将 tail_start 向左移动,把该 assistant 消息也拉入 tail 中
    这保证了尾部保留的 messages 序列在被送回 LLM 时,符合 assistant.tool_callstool_result 必须一一对应且连续的严格 API 校验协议。

3. 陈旧工具结果预裁剪 (Input Pruning)

为了避免 head 历史中含有上万行的原始股票行情行情(如 analyze_stock 返回的庞大日线序列),在送交压缩总结大模型前,系统会调用 shrink_stale_tool_results()

  • 阈值限制:仅针对 content 长度超过 800 字符的工具结果。
  • 结构化提取:只提取股票代码、股票简称、Wyckoff 诊断健康状态标签及最新的 5 根 K 线实体数据,过滤掉冗余的历史 Tick 级成交量和调试性日志。
  • 压缩率:将原本高达几万 Token 的原始工具输出,无损压缩至 400 ~ 600 字符,大幅降低了总结模型的处理开销,避免了总结失败或超出 Token 预算。

返回 系列索引

Clone this wiki locally