feat(context): 压缩加"上下文压力门槛",短任务不压缩以护缓存+保信息
prepare_messages_with_stats 加 compact_threshold_chars:总 chars 未超阈值 则完全跳过压缩、原样发 —— 短任务 prompt 前缀逐轮字节一致、DeepSeek 前缀 缓存全程命中,且不白丢旧 tool 细节(context 预算还很空时无谓压缩=纯损失)。 超阈值才走原压缩逻辑。 - loop 按 caps.reliable_context × 0.5 × 2.5(char/token 粗折算)算阈值 (flash ≈ 33 万 chars),_COMPACT_CONTEXT_RATIO/_CHARS_PER_TOKEN 可调 - compaction_skipped 进 stats / llm_start 事件可观测 - 默认 compact_threshold_chars=0 = 永远压缩(向后兼容) 背景:实测 task b27466a0 DeepSeek 缓存命中已 92-94%、滑动边界损失有限 (压缩函数确定性、旧消息压缩态稳定),故只补门槛、暂不改边界为阶梯式。 新增 2 测试(below/above 门槛),全量 105 过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0df9e5fe3f
commit
8b6e66b006
|
|
@ -592,6 +592,7 @@ zcbot-sandbox image 已 ~1.5G(python deps + chromium + nodejs + mermaid-cli),后
|
||||||
**Stage 3:上下文预算与自动压缩(中风险,需测试)**:
|
**Stage 3:上下文预算与自动压缩(中风险,需测试)**:
|
||||||
1. 新增 `core/context.py` 负责构造 LLM messages,输入为 `Session.messages` + budget,输出为裁剪后的 messages。
|
1. 新增 `core/context.py` 负责构造 LLM messages,输入为 `Session.messages` + budget,输出为裁剪后的 messages。
|
||||||
2. 第一步只做**旧 tool / tool_call 参数压缩**:保留 system、最近约 12 条原文,对较旧且过长的 `role=tool` 内容做头尾摘要;旧 `load_skill` 结果压成"已加载 skill: name/dir"标记;旧 assistant `tool_calls[].function.arguments` 超过约 800 chars 时压成合法 JSON 标记(保留 path/script_path/name/original_chars),避免 `write(content=...)` 源码参数反复进 prompt。`role` / `tool_call_id` / `name` 不变,保证 OpenAI/LiteLLM tool_call 协议完整。这个阶段不调用额外 LLM,不生成全局摘要。
|
2. 第一步只做**旧 tool / tool_call 参数压缩**:保留 system、最近约 12 条原文,对较旧且过长的 `role=tool` 内容做头尾摘要;旧 `load_skill` 结果压成"已加载 skill: name/dir"标记;旧 assistant `tool_calls[].function.arguments` 超过约 800 chars 时压成合法 JSON 标记(保留 path/script_path/name/original_chars),避免 `write(content=...)` 源码参数反复进 prompt。`role` / `tool_call_id` / `name` 不变,保证 OpenAI/LiteLLM tool_call 协议完整。这个阶段不调用额外 LLM,不生成全局摘要。
|
||||||
|
- **上下文压力门槛(2026-06-10)**:压缩只在历史体量逼近上限时才做 —— `prepare_messages_with_stats(compact_threshold_chars=...)`,总 chars 未超阈值则**完全跳过压缩、原样发**。loop 按当前模型 `reliable_context × 0.5 × ~2.5 char/token` 折算阈值。**取舍**:① 短/中任务没有上下文压力却被压缩 = 白丢旧工具细节,门槛后零损失;② 压缩函数确定性但边界 `len-keep_recent` 逐轮滑动,每轮重压 1-2 条 → 破 DeepSeek 前缀缓存;门槛让短任务**前缀逐轮字节一致、缓存全程命中**。实测高轮 task 缓存命中已 92-94%(滑动边界损失有限,旧消息压缩态稳定),故只补门槛、暂不改滑动边界为阶梯式(收益仅再抬几个点,不值复杂度)。`compaction_skipped` 进 `llm_start` 事件可观测。
|
||||||
3. 第二步再做 task summary:保留 system、最近 6-10 轮原文、未闭合 tool_call 协议相关消息、最新用户消息;旧消息压成一条 summary。
|
3. 第二步再做 task summary:保留 system、最近 6-10 轮原文、未闭合 tool_call 协议相关消息、最新用户消息;旧消息压成一条 summary。
|
||||||
4. summary 必须区分:用户确认的硬约束、当前计划、已生成文件路径、关键事实、待办/风险、可丢弃日志。旧 tool 原文不直接塞回,只保留路径和摘要。
|
4. summary 必须区分:用户确认的硬约束、当前计划、已生成文件路径、关键事实、待办/风险、可丢弃日志。旧 tool 原文不直接塞回,只保留路径和摘要。
|
||||||
5. 阈值建议先按字符粗估触发(如 200k chars),后续接 tokenizer 精确预算;触发后目标压到 reliable_context 的 25%-40%,避免刚压完又涨满。
|
5. 阈值建议先按字符粗估触发(如 200k chars),后续接 tokenizer 精确预算;触发后目标压到 reliable_context 的 25%-40%,避免刚压完又涨满。
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-06-10
|
### 2026-06-10
|
||||||
|
|
||||||
|
- **上下文压缩加"压力门槛":体量未逼近上限前不压缩(护缓存 + 不丢旧细节)**:此前 `loop` 每轮无条件压缩,短任务也把旧 tool 结果砍掉——既白丢信息(context 预算还很空),又因压缩边界逐轮滑动破 DeepSeek 前缀缓存。落地 `prepare_messages_with_stats(compact_threshold_chars=...)`:总 chars 未超阈值则**完全跳过压缩、原样发**(短任务前缀逐轮字节一致、缓存全程命中);超阈值才走原压缩逻辑。loop 按 `caps.reliable_context × 0.5 × 2.5(char/token 粗折算)` 算阈值(flash ≈ 33 万 chars),`_COMPACT_CONTEXT_RATIO/_CHARS_PER_TOKEN` 两常量可调。`compaction_skipped` 进 stats/`llm_start` 事件可观测。默认阈值 0 = 永远压缩(向后兼容)。背景:实测 task `b27466a0` DeepSeek 缓存命中已 92-94%、滑动边界损失有限(压缩函数确定性、旧消息压缩态稳定),故只补门槛、不改边界为阶梯式(收益仅再抬几个点不值复杂度)。新增 2 测试(below/above 门槛),全量 105 过。
|
||||||
- **单轮停机判据从"步数"解耦为"是否在推进":`max_iterations` 升为纯 backstop + 新增全局「无进展」熔断 + 撞顶明确提示**:DB 诊断 task `b27466a0`(智能体介绍 PPT)所谓"中途断了"——查实=该 run 跑满 `max_iterations`(flash 旧值 50)后 `return "[reached max iterations]"` 干净停下、留一条悬空 tool 结果,用户离开 25min 回来打"继续"才续完(`run_status=idle/run_error=None`,非崩溃);"步骤太长"=少数轮 DeepSeek API 延迟 126-185s,工具本身全 <13s;顺带实测该 task DeepSeek 前缀缓存命中 92-94%,**上下文压缩对缓存几乎无害**(压缩函数确定性→旧消息压缩态稳定,只滑动边界这一处断,每轮 miss 几十~几百 token)。**洞察**:`max_iterations` 把"用户感知的轮(来回对话)"和"一轮内自主工作步数"混在一个旋钮上——自主 tool 链概念上是 1 轮,该松;真正要掐的是"空转"。落地:① yaml `max_iterations` flash 50→120 / pro 100→150,dataclass 默认 50→120,定位为安全兜底非"轮"预算;② `_RepeatGuard.record` 多返一个 `productive`(净产出=非 `[Error]` 且非一字不差重复);③ `_execute_tool_call` 三个返回点都带 `productive`(invalid-JSON/被拦=False);④ run loop 累计 `self._stall`——整步所有 tool 都无净产出则 +1、任一净产出清零,连续 `_STALL_LIMIT=8` 步空转主动停(`[stopped: no progress]`),比撞 120 早得多掐死循环,配 `_RepeatGuard` 逐指纹 HARD=4 双保险;⑤ 撞 backstop / 熔断都 emit 明确"回复『继续』可接着跑"提示,不再静默停。`tests/test_loop_repeat_guard.py` 更新 record 解包调用 + 加 `productive` 信号用例(17 例过,全量 103 过)。
|
- **单轮停机判据从"步数"解耦为"是否在推进":`max_iterations` 升为纯 backstop + 新增全局「无进展」熔断 + 撞顶明确提示**:DB 诊断 task `b27466a0`(智能体介绍 PPT)所谓"中途断了"——查实=该 run 跑满 `max_iterations`(flash 旧值 50)后 `return "[reached max iterations]"` 干净停下、留一条悬空 tool 结果,用户离开 25min 回来打"继续"才续完(`run_status=idle/run_error=None`,非崩溃);"步骤太长"=少数轮 DeepSeek API 延迟 126-185s,工具本身全 <13s;顺带实测该 task DeepSeek 前缀缓存命中 92-94%,**上下文压缩对缓存几乎无害**(压缩函数确定性→旧消息压缩态稳定,只滑动边界这一处断,每轮 miss 几十~几百 token)。**洞察**:`max_iterations` 把"用户感知的轮(来回对话)"和"一轮内自主工作步数"混在一个旋钮上——自主 tool 链概念上是 1 轮,该松;真正要掐的是"空转"。落地:① yaml `max_iterations` flash 50→120 / pro 100→150,dataclass 默认 50→120,定位为安全兜底非"轮"预算;② `_RepeatGuard.record` 多返一个 `productive`(净产出=非 `[Error]` 且非一字不差重复);③ `_execute_tool_call` 三个返回点都带 `productive`(invalid-JSON/被拦=False);④ run loop 累计 `self._stall`——整步所有 tool 都无净产出则 +1、任一净产出清零,连续 `_STALL_LIMIT=8` 步空转主动停(`[stopped: no progress]`),比撞 120 早得多掐死循环,配 `_RepeatGuard` 逐指纹 HARD=4 双保险;⑤ 撞 backstop / 熔断都 emit 明确"回复『继续』可接着跑"提示,不再静默停。`tests/test_loop_repeat_guard.py` 更新 record 解包调用 + 加 `productive` 信号用例(17 例过,全量 103 过)。
|
||||||
- **`systemctl restart` 优雅 drain in-flight run(单实例止血,不再误标 error)**:此前 restart 硬杀 BG run 线程,下次启动 reaper 把所有 `running/cancelling` 标 `error: server restarted before run finished` —— 用户一多就不能随便重启。落地纯进程内、**零 DB 改动**:① lifespan 加 `app.state.draining`(asyncio.Event)+ `app.state.inflight`(`{asyncio.Task: task_id}`,顺手修 `create_task` 不留引用可能被 GC 的旧坑);② POST `/messages` 起 run 时登记+done 回调自摘除,draining 置位时返 503+`Retry-After`;③ lifespan `finally` 先置 draining 拒新 run,`asyncio.wait(inflight, drain_timeout)` 等收尾,超时的 `broker.request_cancel` 转协作式 cancel(下个 chunk 间隙退、标 idle 不报 error),再过 `cancel_grace` 仍没退的留给 SIGKILL(最坏退化=改前)。④ `main.py` uvicorn 加 `timeout_graceful_shutdown=5`(否则长连 SSE 无限挡在 drain 前);⑤ `config/agent.yaml` 加 `shutdown` 段(drain_timeout 30s / cancel_grace 15s,超时转 cancel = 用户按停止可重发,故偏短);⑥ dev SPA `chat.js` 发送包退避重试(503 背压 + 交接拒连 TypeError 都重试 ~26s,显"服务更新中",耗尽贴友好提示)。**部署强耦合**:unit `TimeoutStopSec` 从 10 提到 90(必须 > drain+grace+sandbox 清扫余量,否则 SIGKILL 砍掉 drain),已写进 RUN.md unit + 故障兜底。B 蓝绿(零 503 窗口)留作触发信号后再做,前置是 instance-aware reaper(§7.8)。
|
- **`systemctl restart` 优雅 drain in-flight run(单实例止血,不再误标 error)**:此前 restart 硬杀 BG run 线程,下次启动 reaper 把所有 `running/cancelling` 标 `error: server restarted before run finished` —— 用户一多就不能随便重启。落地纯进程内、**零 DB 改动**:① lifespan 加 `app.state.draining`(asyncio.Event)+ `app.state.inflight`(`{asyncio.Task: task_id}`,顺手修 `create_task` 不留引用可能被 GC 的旧坑);② POST `/messages` 起 run 时登记+done 回调自摘除,draining 置位时返 503+`Retry-After`;③ lifespan `finally` 先置 draining 拒新 run,`asyncio.wait(inflight, drain_timeout)` 等收尾,超时的 `broker.request_cancel` 转协作式 cancel(下个 chunk 间隙退、标 idle 不报 error),再过 `cancel_grace` 仍没退的留给 SIGKILL(最坏退化=改前)。④ `main.py` uvicorn 加 `timeout_graceful_shutdown=5`(否则长连 SSE 无限挡在 drain 前);⑤ `config/agent.yaml` 加 `shutdown` 段(drain_timeout 30s / cancel_grace 15s,超时转 cancel = 用户按停止可重发,故偏短);⑥ dev SPA `chat.js` 发送包退避重试(503 背压 + 交接拒连 TypeError 都重试 ~26s,显"服务更新中",耗尽贴友好提示)。**部署强耦合**:unit `TimeoutStopSec` 从 10 提到 90(必须 > drain+grace+sandbox 清扫余量,否则 SIGKILL 砍掉 drain),已写进 RUN.md unit + 故障兜底。B 蓝绿(零 503 窗口)留作触发信号后再做,前置是 instance-aware reaper(§7.8)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ def prepare_messages_for_llm(
|
||||||
keep_recent: int = 12,
|
keep_recent: int = 12,
|
||||||
old_tool_chars: int = 2_000,
|
old_tool_chars: int = 2_000,
|
||||||
old_tool_arg_chars: int = 800,
|
old_tool_arg_chars: int = 800,
|
||||||
|
compact_threshold_chars: int = 0,
|
||||||
) -> List[dict[str, Any]]:
|
) -> List[dict[str, Any]]:
|
||||||
"""返回发给 LLM 的 messages 副本。
|
"""返回发给 LLM 的 messages 副本。
|
||||||
|
|
||||||
|
|
@ -122,6 +123,7 @@ def prepare_messages_for_llm(
|
||||||
keep_recent=keep_recent,
|
keep_recent=keep_recent,
|
||||||
old_tool_chars=old_tool_chars,
|
old_tool_chars=old_tool_chars,
|
||||||
old_tool_arg_chars=old_tool_arg_chars,
|
old_tool_arg_chars=old_tool_arg_chars,
|
||||||
|
compact_threshold_chars=compact_threshold_chars,
|
||||||
)
|
)
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
|
|
@ -132,11 +134,32 @@ def prepare_messages_with_stats(
|
||||||
keep_recent: int = 12,
|
keep_recent: int = 12,
|
||||||
old_tool_chars: int = 2_000,
|
old_tool_chars: int = 2_000,
|
||||||
old_tool_arg_chars: int = 800,
|
old_tool_arg_chars: int = 800,
|
||||||
|
compact_threshold_chars: int = 0,
|
||||||
) -> tuple[List[dict[str, Any]], dict[str, int]]:
|
) -> tuple[List[dict[str, Any]], dict[str, int]]:
|
||||||
"""返回发给 LLM 的 messages 副本和压缩统计。"""
|
"""返回发给 LLM 的 messages 副本和压缩统计。
|
||||||
|
|
||||||
|
`compact_threshold_chars`:上下文压力门槛。总体量(原始 chars)未超过它时**完全不压缩**
|
||||||
|
—— 短任务不丢旧工具细节,且 prompt 前缀逐轮字节一致、DeepSeek 等前缀缓存全程命中。
|
||||||
|
默认 0 = 永远压缩(向后兼容)。caller(loop)按模型 reliable_context 折算传入。
|
||||||
|
"""
|
||||||
if keep_recent < 0:
|
if keep_recent < 0:
|
||||||
keep_recent = 0
|
keep_recent = 0
|
||||||
original_chars = sum(_message_chars(m) for m in messages)
|
original_chars = sum(_message_chars(m) for m in messages)
|
||||||
|
|
||||||
|
# 未到上下文压力门槛 → 原样发,零压缩(缓存全暖 + 不丢信息)。压缩是"放不下"才做的事。
|
||||||
|
if original_chars < compact_threshold_chars:
|
||||||
|
prepared = [deepcopy(m) for m in messages]
|
||||||
|
stats = {
|
||||||
|
"original_chars": original_chars,
|
||||||
|
"sent_chars": original_chars,
|
||||||
|
"saved_chars": 0,
|
||||||
|
"compacted_tool_messages": 0,
|
||||||
|
"compacted_skill_messages": 0,
|
||||||
|
"compacted_tool_call_arguments": 0,
|
||||||
|
"compaction_skipped": 1,
|
||||||
|
}
|
||||||
|
return prepared, stats
|
||||||
|
|
||||||
recent_start = max(0, len(messages) - keep_recent)
|
recent_start = max(0, len(messages) - keep_recent)
|
||||||
prepared: List[dict[str, Any]] = []
|
prepared: List[dict[str, Any]] = []
|
||||||
compacted_tool_messages = 0
|
compacted_tool_messages = 0
|
||||||
|
|
@ -177,5 +200,6 @@ def prepare_messages_with_stats(
|
||||||
"compacted_tool_messages": compacted_tool_messages,
|
"compacted_tool_messages": compacted_tool_messages,
|
||||||
"compacted_skill_messages": compacted_skill_messages,
|
"compacted_skill_messages": compacted_skill_messages,
|
||||||
"compacted_tool_call_arguments": compacted_tool_call_arguments,
|
"compacted_tool_call_arguments": compacted_tool_call_arguments,
|
||||||
|
"compaction_skipped": 0,
|
||||||
}
|
}
|
||||||
return prepared, stats
|
return prepared, stats
|
||||||
|
|
|
||||||
16
core/loop.py
16
core/loop.py
|
|
@ -361,6 +361,13 @@ class AgentLoop:
|
||||||
# 保守取 8:几乎不误伤"连踩几个错再纠正"的正常波动,配 _RepeatGuard 逐指纹 HARD=4 双保险。
|
# 保守取 8:几乎不误伤"连踩几个错再纠正"的正常波动,配 _RepeatGuard 逐指纹 HARD=4 双保险。
|
||||||
_STALL_LIMIT = 8
|
_STALL_LIMIT = 8
|
||||||
|
|
||||||
|
# 上下文压缩门槛:历史体量未到 reliable_context 的此比例前不压缩 —— 短任务不丢旧工具细节,
|
||||||
|
# 且 prompt 前缀逐轮字节一致、DeepSeek 前缀缓存全程命中。50% 留足上下文安全垫。
|
||||||
|
_COMPACT_CONTEXT_RATIO = 0.5
|
||||||
|
# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token);压缩是成本/安全优化、
|
||||||
|
# 非正确性关键,估算粗糙无妨。reliable_context(tokens) × ratio × 此值 = 触发的 char 阈值。
|
||||||
|
_CHARS_PER_TOKEN = 2.5
|
||||||
|
|
||||||
def _stream_llm(self) -> Tuple[Optional[Any], bool]:
|
def _stream_llm(self) -> Tuple[Optional[Any], bool]:
|
||||||
"""拉一轮 LLM 并保证返回的 tool_call arguments 可解析。
|
"""拉一轮 LLM 并保证返回的 tool_call arguments 可解析。
|
||||||
|
|
||||||
|
|
@ -374,7 +381,14 @@ class AgentLoop:
|
||||||
故拼回后先校验 tool_call arguments 能否解析:不能 → 丢弃整轮(不 append/不记账)重
|
故拼回后先校验 tool_call arguments 能否解析:不能 → 丢弃整轮(不 append/不记账)重
|
||||||
roll;连续失败到最后一次降级非流式兜底。重试消耗的 token 不单独记账(罕见路径)。
|
roll;连续失败到最后一次降级非流式兜底。重试消耗的 token 不单独记账(罕见路径)。
|
||||||
"""
|
"""
|
||||||
llm_messages, context_stats = prepare_messages_with_stats(self.session.messages)
|
# 上下文压力门槛按当前模型 reliable_context 折算:体量未到阈值前不压缩(缓存全暖 + 不丢信息)。
|
||||||
|
compact_threshold = int(
|
||||||
|
self.caps.reliable_context * self._COMPACT_CONTEXT_RATIO * self._CHARS_PER_TOKEN
|
||||||
|
)
|
||||||
|
llm_messages, context_stats = prepare_messages_with_stats(
|
||||||
|
self.session.messages,
|
||||||
|
compact_threshold_chars=compact_threshold,
|
||||||
|
)
|
||||||
self._emit({
|
self._emit({
|
||||||
"type": "llm_start",
|
"type": "llm_start",
|
||||||
**{f"context_{k}": v for k, v in context_stats.items()},
|
**{f"context_{k}": v for k, v in context_stats.items()},
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,43 @@ class ContextCompactionTests(unittest.TestCase):
|
||||||
self.assertEqual(prepared[1]["content"], "[task_progress updated; UI-only details omitted from context]")
|
self.assertEqual(prepared[1]["content"], "[task_progress updated; UI-only details omitted from context]")
|
||||||
self.assertEqual(stats["compacted_tool_messages"], 1)
|
self.assertEqual(stats["compacted_tool_messages"], 1)
|
||||||
|
|
||||||
|
def test_below_threshold_skips_compaction_entirely(self) -> None:
|
||||||
|
"""总体量未到 compact_threshold_chars → 原样发,旧 tool 不被压、缓存可全暖。"""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "rules"},
|
||||||
|
{"role": "tool", "tool_call_id": "tc1", "name": "shell", "content": "A" * 2000},
|
||||||
|
] + [{"role": "user", "content": f"recent {i}"} for i in range(12)]
|
||||||
|
|
||||||
|
prepared, stats = prepare_messages_with_stats(
|
||||||
|
messages,
|
||||||
|
keep_recent=1,
|
||||||
|
old_tool_chars=40,
|
||||||
|
compact_threshold_chars=10_000_000, # 远大于本例体量 → 跳过
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(prepared[1]["content"], "A" * 2000) # 旧 tool 原样保留
|
||||||
|
self.assertEqual(stats["compaction_skipped"], 1)
|
||||||
|
self.assertEqual(stats["saved_chars"], 0)
|
||||||
|
self.assertEqual(stats["sent_chars"], stats["original_chars"])
|
||||||
|
|
||||||
|
def test_above_threshold_still_compacts(self) -> None:
|
||||||
|
"""体量超过门槛 → 照常压缩,compaction_skipped=0。"""
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": "rules"},
|
||||||
|
{"role": "tool", "tool_call_id": "tc1", "name": "shell", "content": "A" * 2000},
|
||||||
|
] + [{"role": "user", "content": f"recent {i}"} for i in range(12)]
|
||||||
|
|
||||||
|
prepared, stats = prepare_messages_with_stats(
|
||||||
|
messages,
|
||||||
|
keep_recent=1,
|
||||||
|
old_tool_chars=40,
|
||||||
|
compact_threshold_chars=100, # 远小于本例体量 → 触发
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("compacted old tool result", prepared[1]["content"])
|
||||||
|
self.assertEqual(stats["compaction_skipped"], 0)
|
||||||
|
self.assertGreater(stats["saved_chars"], 0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue