zcbot/core/context_fold.py

197 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§8.8 Phase 2:阈值结构化摘要 —— 长会话窗口中段折叠。
Phase 1(context_base_idx 软重置)解决"跨话题累积";单个分段内持续对话,窗口仍只增
不减 —— 50% 门槛的旧 tool 压缩(core/context.py)只摘工具正文,user/assistant 对话
本体一字不少,最终仍会撞 reliable_context。本模块在 run 起点检测窗口体量,达
reliable_context × FOLD_TRIGGER_RATIO 时把窗口中段(保留尾部约 FOLD_TAIL_RATIO)
折叠为一份固定模板的结构化摘要。
设计要点(取舍详 DESIGN §8.8):
- **边界而非删除**:原文全量留 messages 表,只写 tasks.context_summary + 推进
context_base_idx;web 端照旧翻完整历史。
- **增量更新**:旧摘要作为注入消息本就在被折叠的前缀里,摘要指令要求合并 ——
新摘要 = f(旧摘要, 新折叠段),不重读全史。
- **前缀缓存友好**:摘要调用复用会话自身的消息前缀(prepare 同款压缩后取 [:切点]),
末尾追加一条摘要指令 user 消息 —— 前缀与上一轮 chat 的缓存字节一致,几乎全程
cache hit;折叠后的窗口(system + 摘要 + 尾段)在下次折叠前字节稳定,缓存恢复全暖。
- **精确值原文保留**:模板硬性要求路径/ID/数值不得改写(mem0 实测自由摘要会静默丢
精确值),守 §8.2 质量边界(确认过的需求/结论不删)。
- **失败零阻塞**:摘要调用失败只 warn + 跳过本次折叠,run 照常跑(85% 距硬上限有垫)。
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional
from uuid import UUID
from sqlalchemy import update
from .context import (
CHARS_PER_TOKEN,
COMPACT_CONTEXT_RATIO,
_message_chars,
prepare_messages_with_stats,
)
from .storage import session_scope
from .storage.models import Task
from .storage.usage import record_chat_usage
# 窗口体量达 reliable_context 的此比例 → 触发折叠(50% 压缩、85% 折叠双层门槛)。
FOLD_TRIGGER_RATIO = 0.85
# 折叠后保留的尾部体量占比(近期对话原文,续聊手感;其余折进摘要)。
FOLD_TAIL_RATIO = 0.30
# 可折叠段少于这么多条消息就不折 —— 摘要调用本身有成本,段太短折了省不了几个字。
MIN_FOLD_MESSAGES = 8
SUMMARY_NOTE_HEADER = "[前情摘要]"
# 摘要指令:作为最后一条 user 消息追加在会话前缀之后(前缀缓存复用,见模块注释)。
FOLD_INSTRUCTION = (
"请暂停手头任务,现在只做一件事:把以上全部对话内容压缩成一份结构化摘要,"
"它将替代这些消息作为后续对话的上下文。若开头已有「[前情摘要]」,把它合并进来。\n"
"严格按以下模板输出,除模板内容外不要输出任何别的文字:\n"
"## 目标\n## 已确认的约束与决定\n## 进展\n## 待办\n## 关键路径 / ID / 数值\n"
"硬性要求:文件路径、URL、ID、数字、代码符号、命令等精确值必须原文保留,"
"不得改写、四舍五入或省略;用户明确确认过的需求与结论必须保留;"
"不确定的内容标注[待确认]。"
)
def build_summary_note(summary: str) -> dict:
"""把摘要包成注入消息(user 角色,仅内存不落库;Session.load 与折叠现场共用)。"""
return {
"role": "user",
"content": (
f"{SUMMARY_NOTE_HEADER} 以下是本对话更早内容的结构化摘要"
"(原始消息已归档,不在当前上下文中);请把它当作已发生的事实,"
"直接继续对话,不必复述:\n\n" + summary
),
}
def find_cutoff(messages: List[dict], head_len: int) -> Optional[int]:
"""选折叠切点:返回内存 index(切点本身保留在窗口里),不可折返回 None。
从尾部向前累计体量,第一条使尾部 ≥ FOLD_TAIL_RATIO 的 **user 消息**即切点 ——
切在 user 消息上天然不劈开 assistant tool_calls / tool 配对,与 Phase 1 软重置
的锚点语义一致(窗口永远以 user 消息开头)。user 消息稀疏时尾部占比可能超过
FOLD_TAIL_RATIO,保守方向,可接受。
"""
n = len(messages)
if n <= head_len:
return None
total = sum(_message_chars(m) for m in messages[head_len:])
tail_target = total * FOLD_TAIL_RATIO
acc = 0
cutoff: Optional[int] = None
for i in range(n - 1, head_len - 1, -1):
acc += _message_chars(messages[i])
if acc >= tail_target and messages[i].get("role") == "user":
cutoff = i
break
if cutoff is None or cutoff - head_len < MIN_FOLD_MESSAGES:
return None
return cutoff
def _user_ordinal(messages: List[dict], idx: int) -> int:
"""messages[idx] 是从头数第几条 user 消息(1-based)。"""
return sum(1 for m in messages[: idx + 1] if m.get("role") == "user")
def _kth_user_index(messages: List[dict], k: int) -> Optional[int]:
"""从头数第 k 条(1-based)user 消息的 index;不存在返回 None。"""
seen = 0
for i, m in enumerate(messages):
if m.get("role") == "user":
seen += 1
if seen == k:
return i
return None
def persist_fold(task_id: UUID, new_base: int, summary: str) -> None:
"""折叠结果落库:摘要 + 窗口起点,一个事务。先于内存变更执行 —— DB 写失败时
内存未动,本次折叠白跑一次摘要调用但状态零污染,下次 run 重试。"""
with session_scope() as s:
s.execute(
update(Task)
.where(Task.task_id == task_id)
.values(context_base_idx=new_base, context_summary=summary)
)
def maybe_fold(
session: Any,
llm: Any,
caps: Any,
*,
user_id: UUID,
emit: Callable[[dict], None],
) -> Optional[dict]:
"""run 起点调用:体量达阈值则折叠,返回统计 dict;未触发/不可折返回 None。
session 鸭子类型,需要:messages / task_id / context_head_len / context_base /
apply_fold(cutoff, summary)。异常向上抛,由 loop 包 try 兜(失败零阻塞纪律)。
"""
msgs = session.messages
before_chars = sum(_message_chars(m) for m in msgs)
threshold = int(caps.reliable_context * FOLD_TRIGGER_RATIO * CHARS_PER_TOKEN)
if before_chars < threshold:
return None
head_len = session.context_head_len
cutoff = find_cutoff(msgs, head_len)
if cutoff is None:
return None
folded_count = cutoff - head_len
emit({"type": "context_fold", "phase": "start", "folding_messages": folded_count})
# 复用 loop 同款 prepare(50% 门槛旧 tool 压缩)再取前缀 —— 字节与上一轮 chat 的
# 缓存前缀一致,摘要调用吃到 cache hit。切点在两个列表间用「第 k 条 user 消息」对齐:
# prepare 只改写 tool 内容 / 补占位 tool 消息,user 消息原样且相对顺序不变。
compact_threshold = int(caps.reliable_context * COMPACT_CONTEXT_RATIO * CHARS_PER_TOKEN)
prepared, _ = prepare_messages_with_stats(msgs, compact_threshold_chars=compact_threshold)
p_cut = _kth_user_index(prepared, _user_ordinal(msgs, cutoff))
if p_cut is None: # 理论不可达,防御
return None
fold_input = prepared[:p_cut] + [{"role": "user", "content": FOLD_INSTRUCTION}]
response = llm.chat(messages=fold_input)
choices = getattr(response, "choices", None) or []
summary = ((choices[0].message.content if choices else "") or "").strip()
if not summary:
emit({"type": "warn", "msg": "context fold: 摘要调用返回空,本轮跳过折叠"})
return None
usage = getattr(response, "usage", None)
try:
record_chat_usage(
task_id=session.task_id,
user_id=user_id,
message_id=None, # 摘要不入 messages,event 挂 task 维度
model_profile=f"{caps.family}.{caps.variant}",
prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0,
completion_tokens=getattr(usage, "completion_tokens", 0) or 0,
input_cny_per_mtoken=caps.input_cny_per_mtoken,
output_cny_per_mtoken=caps.output_cny_per_mtoken,
response=response,
# 独立 kind:按 kind 对账"折叠烧多少钱",且折叠次数 = count(该 kind)
# (任务详情 context_folds 字段的数据源,免加计数列)
kind="context_fold",
)
except Exception:
pass # 记账失败不阻塞折叠(与 loop 记账同纪律)
new_base = session.context_base + folded_count
persist_fold(session.task_id, new_base, summary)
session.apply_fold(cutoff, summary)
after_chars = sum(_message_chars(m) for m in session.messages)
stats = {
"type": "context_fold",
"phase": "done",
"folded_messages": folded_count,
"before_chars": before_chars,
"after_chars": after_chars,
"new_base": new_base,
}
emit(stats)
return stats