fix(context): 窗口体量估算实测校准——50%压缩/85%折叠/占用环换 token 实测口径(bump 0.58.52)
diag 实测(scripts/diag_context_pressure.py 留仓):折叠机制有效、零撞硬上限, 但静态 CHARS_PER_TOKEN=2.5 对中文密集窗口低估近一倍——名义 85% 折叠线实际 ~155% reliable 才触发(task 9a863424 冲到 40.7 万 tokens);代码密集反向虚高 (fe2d8b73 估 1.2M 实际 616k)。 修法是信号校准、不加新机制: - context.py: estimate_window_tokens(provider 实报 usage 覆盖窗口主体,仅实测 点后尾巴按 2.5 估)+ calibrated_chars_per_token(比值夹 [1.0,4.0] 带宽) - session.py: last_measured_usage() 取窗口内最后一条实报 usage 的 assistant 行 (best-effort 绝不抛;idx→内存 pos 映射校验 role) - context_fold.py: maybe_fold 触发判定换 token 实测口径 - loop.py: 压缩门槛与 context_limit_chars(前端环)用校准比值,逐轮以 sent_chars/prompt_tokens 刷新;校准态挪类属性兜默认,任何异常回退 2.5 已知残余:折叠后 run 在首次 chat 完成前崩掉会多折一次(摘要偏保守,原文全在 DB),不为此加持久化状态。run 中途折叠/超限自愈按 §5 无信号不实施继续搁置。 真实生产 task 只读验证映射与校准(drift 0.95x/0.51x/0.86x),286 测试全绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
92b8aa4575
commit
fb214de960
|
|
@ -304,7 +304,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
|
|||
**心智:边界而非删除**——一条消息都不删,只移动喂给模型的窗口起点;全历史留 DB,web 照旧翻完整记录。
|
||||
|
||||
- **Phase 1(✅):`context_base_idx` 软重置**。`Session.load` 只装 `idx>=base`;自动 gap(默 6h,base=最后一条 user 消息——**不是失忆墙**,留上一轮做续聊锚点)+ 手动「新话题」硬重置(base=总数)。**关键不变量**:append 续号取 DB 真实总条数而非加载条数,否则撞 unique 约束。**不选**"每次 gap 开新 task"(堆文件夹+task 卡片)、"boundary 标记消息"(混进消息流要处理 tool 配对);列是纯元数据零侵入。
|
||||
- **Phase 2(✅ 2026-07-09):阈值结构化摘要**(补 Hermes 阶段③,`core/context_fold.py`):run 起点窗口体量达 `reliable_context×85%` → 中段折叠成固定模板摘要(目标/约束决定/进展/待办 + path/ID/数值**原文保留**,mem0 实测自由摘要会静默丢精确值),存 `tasks.context_summary`(0021)+ 推进 `context_base_idx`,`Session.load` 注入仅内存的「前情摘要」user 消息。双层门槛 50%(压缩)+85%(折叠)正交:分段砍跨话题累积、摘要兜单段超长。关键取舍:① **run 起点触发而非轮间**(轮间要处理 tool 配对切割 + 已加载窗口一致性,回合制下 run 起点是自然缝隙,代价是命中那一回合首 token 慢几秒、每分段一两次);② **摘要存 tasks 列不入消息流**(boundary 消息会混进 tool 配对处理,Phase 1 已拒过;列是纯元数据零侵入);③ **前缀缓存友好**:摘要调用复用会话 prepare 后的消息前缀 + 末尾追加指令 → 与上一轮 chat 缓存字节一致近全程 hit;增量更新 = 旧摘要本在被折前缀里,指令要求合并,不重读全史;折叠后窗口字节稳定至下次折叠,且体量回落 50% 门槛以下、压缩关闭,前缀比折叠前更稳;④ **失败零阻塞**(warn + 跳过,85% 距硬上限有垫);⑤ 切点必落 user 消息(不劈 tool 配对,窗口恒以 user 开头,与 Phase 1 锚点同语义);⑥ 「新话题」硬重置/清空对话清摘要,gap 软重置保留;对全部 task 生效(机制通用,web 短任务不达阈值零影响)。
|
||||
- **Phase 2(✅ 2026-07-09):阈值结构化摘要**(补 Hermes 阶段③,`core/context_fold.py`):run 起点窗口体量达 `reliable_context×85%` → 中段折叠成固定模板摘要(目标/约束决定/进展/待办 + path/ID/数值**原文保留**,mem0 实测自由摘要会静默丢精确值),存 `tasks.context_summary`(0021)+ 推进 `context_base_idx`,`Session.load` 注入仅内存的「前情摘要」user 消息。双层门槛 50%(压缩)+85%(折叠)正交:分段砍跨话题累积、摘要兜单段超长。关键取舍:① **run 起点触发而非轮间**(轮间要处理 tool 配对切割 + 已加载窗口一致性,回合制下 run 起点是自然缝隙,代价是命中那一回合首 token 慢几秒、每分段一两次);② **摘要存 tasks 列不入消息流**(boundary 消息会混进 tool 配对处理,Phase 1 已拒过;列是纯元数据零侵入);③ **前缀缓存友好**:摘要调用复用会话 prepare 后的消息前缀 + 末尾追加指令 → 与上一轮 chat 缓存字节一致近全程 hit;增量更新 = 旧摘要本在被折前缀里,指令要求合并,不重读全史;折叠后窗口字节稳定至下次折叠,且体量回落 50% 门槛以下、压缩关闭,前缀比折叠前更稳;④ **失败零阻塞**(warn + 跳过,85% 距硬上限有垫);⑤ 切点必落 user 消息(不劈 tool 配对,窗口恒以 user 开头,与 Phase 1 锚点同语义);⑥ 「新话题」硬重置/清空对话清摘要,gap 软重置保留;对全部 task 生效(机制通用,web 短任务不达阈值零影响);⑦(0.58.52)触发判定从 chars×2.5 静态估算改为 **token 实测口径**——DB 实测静态折算对中文密集窗口低估近一倍(名义 85% 线实际 ~155% reliable 才触发)、代码密集反向虚高一倍,`estimate_window_tokens` 用 provider 实报 usage(`messages.tokens_in/out`,窗口内最后一条 assistant)覆盖窗口主体、仅实测点后尾巴按 2.5 估,loop 的 50% 压缩门槛与前端占用环同源用校准比值(夹 [1.0,4.0] 带宽,逐轮以 sent_chars/prompt_tokens 刷新);校准是信号不是正确性数据,拿不到实测回退 2.5 旧口径、绝不阻塞 run。已知残余:折叠后 run 在首次 chat 完成前崩掉会多折一次(摘要偏保守,原文全在 DB),不为此加持久化状态。
|
||||
- **Phase 3(design):持久检索**(sqlite-vec/FTS5)解"问很久以前的精确内容";工程最重,待确认真实需求(数据没删随时能补)。
|
||||
|
||||
### 8.9 产物机检门 + 提示层禁令纪律(✅ 2026-07-06)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
### 2026-07
|
||||
|
||||
- **07-21 / 0.58.52 / 窗口体量估算实测校准(50%压缩/85%折叠/前端占用环共用)**:diag 复盘窗口约束现状(`scripts/diag_context_pressure.py` 留仓):机制有效(折叠上线后唯一越线 task 9a863424 下个 run 起点即回落 400k→137k)、零撞硬上限(6 条终态错误无一 context 超限),但量出静态 `CHARS_PER_TOKEN=2.5` 对中文密集窗口**低估近一倍**——名义 85% 折叠线实际 ~155% reliable 才触发(该 task 实测 40.7 万 tokens);代码密集反向虚高(fe2d8b73 估 1.2M 实际 616k)。**修(信号校准,不加新机制)**:`context.py` 加 `estimate_window_tokens`(provider 实报 tokens_in/out 覆盖窗口主体,仅实测点后尾巴按 2.5 估)+ `calibrated_chars_per_token`(比值夹 [1.0,4.0] 带宽);`Session.last_measured_usage()` 从 messages 表取窗口内最后一条实报 usage(best-effort 绝不抛,idx→内存 pos 映射校验 role);`maybe_fold` 触发判定换 token 实测口径;loop 的压缩门槛与 `context_limit_chars`(前端环)用校准比值,每轮成功调用后以 (sent_chars/prompt_tokens) 刷新。已知残余:折叠后 run 若在首次 chat 完成前崩,下个 run 起点读到旧实测会多折一次(后果=摘要偏保守,原文全在 DB,不为此加持久化状态)。run 中途折叠/超限自愈按 §5 无信号不实施继续搁置。真实生产 task 验证映射与校准(9a863424 drift 0.95x、fe2d8b73 0.51x、74696048 0.86x),286 测试全绿。
|
||||
- **07-21 / 0.58.50 / salvage 成功提示降噪(黄→灰)**:用户反馈"工具调用参数损坏但已就地抢救"黄字吓人、问是否影响后续对话——核实**零影响**(salvage 只就地改写 arguments,当轮照常执行,对话历史与正常轮无异;warn 是纯前端 SSE 展示事件,不入 messages 不回灌 LLM,刷新即消失)。处置:不全静默(上游返脏数据的事实要可见、可与 `tool_salvaged` DB 留痕对上),改**分级降噪**——`loop.py` 该 emit 加 `level:"info"` + 措辞软化「已自动修复工具调用参数…继续执行」;`chat.js` warn 渲染按 level 分流,info 走灰色 muted 无 ⚠,黄色 ⚠ 只留给真打断本轮的路径(丢弃重试/熔断等)。
|
||||
- **07-21 / 0.58.51 / 系统提示护栏:大段中文正文别内联进 .py(run_python 语法预检失败治本)**:失败面板 `run_python/error` 语法预检簇(近7天31次/12task)根行为=模型手写 python-docx/pptx 把大段中文正文硬拼进 .py 源码,ASCII 引号/全角标点/缩进崩成 SyntaxError。定位:①现有正规路径充分(所有出 docx 的 skill 都指向 `rendering/render.py` md→docx、且写"别自己手搓"),但 DB 显示 16 个涉事 task 中 **7 个没 load 任何 skill**→够不着 SKILL 指引;②precheck(pysyntax)已 host 侧拦截+回根治 tip+喂 RepeatGuard,浪费已 bound(每任务~2.6次自愈、无失控循环)。故走**最高覆盖、最低改动**:base 系统提示 `prompts/system/general_v1.md`(所有任务加载)的 run_python 段加一句**原则级**护栏——生成/改 docx·pptx 时大段中文正文别内联进 .py、先 write 进 .md/.txt 再 read、有 skill 优先走其渲染路径(守 recipe-ban 不给可 copy 配方)。纯提示词引导、无法端到端 verify,ROI 本就低(precheck 已兜),只求少发。**遗留 Option 4**:改现有 .docx 无平台路径(render.py 只从 md 新生成),模型只能手写 python-docx→内联中文,需另设计(docx→md 拆/模板填充)。`prompts/` 每次 build 实时读、`git pull` 即生效免重启。
|
||||
- **07-21 / 0.58.50 / salvage 成功提示降噪**:抢救成功的畸形 tool_call 对后续对话零影响(warn 纯前端 SSE 不入 messages),黄色⚠警告降级为灰色 info 行(emit 加 level:"info"),黄⚠只留真打断路径。改 web UI + loop emit。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.58.51"
|
||||
__version__ = "0.58.52"
|
||||
|
|
|
|||
|
|
@ -17,9 +17,60 @@ import re
|
|||
# chars↔tokens 粗折算(CJK+代码+json 混合保守按 ~2.5 char/token)与压缩触发比例。
|
||||
# loop(50% 压缩门槛)与 context_fold(85% 折叠门槛)共用,单一事实源;压缩/折叠是
|
||||
# 成本/安全优化、非正确性关键,估算粗糙无妨。
|
||||
# ⚠️ 2.5 只是**无实测时的回退值**:中文密集内容实际 ~1.2-1.5 char/token,静态常数
|
||||
# 会把窗口低估近一倍(2026-07 DB 实测:名义 85% 折叠线在中文重会话实际 ~155%
|
||||
# reliable 才触发)。有 provider 实报 usage 时一律走 estimate_window_tokens /
|
||||
# calibrated_chars_per_token 的实测口径,别再调这个常数。
|
||||
CHARS_PER_TOKEN = 2.5
|
||||
COMPACT_CONTEXT_RATIO = 0.5
|
||||
|
||||
# 校准比值的合理带宽:CJK 密集 ~1.2-1.5,ASCII/代码 ~3-4;越界视为 provider 上报
|
||||
# 异常(网关字段错位等),夹回边界而不是让门槛跟着飞。
|
||||
RATIO_MIN = 1.0
|
||||
RATIO_MAX = 4.0
|
||||
|
||||
|
||||
def clamp_ratio(ratio: float) -> float:
|
||||
"""把 chars/token 比值夹进合理带宽。"""
|
||||
return min(RATIO_MAX, max(RATIO_MIN, ratio))
|
||||
|
||||
|
||||
def estimate_window_tokens(
|
||||
messages: List[dict[str, Any]],
|
||||
measured: "tuple[int, int, int] | None" = None,
|
||||
) -> int:
|
||||
"""估算当前窗口 token 体量,实测优先。
|
||||
|
||||
`measured` = (pos, tokens_in, tokens_out):窗口内最后一条带 provider 实报 usage
|
||||
的 assistant 消息(pos 为 messages 内存 index)。有实测时,该消息的 prompt+输出
|
||||
覆盖窗口绝大部分体量且是 tokenizer 真值,只有其后的小尾巴按 CHARS_PER_TOKEN 估;
|
||||
无实测(新窗口 / usage 缺失)全量按 CHARS_PER_TOKEN 估(旧口径回退)。
|
||||
"""
|
||||
if measured is not None:
|
||||
pos, tokens_in, tokens_out = measured
|
||||
tail_chars = sum(_message_chars(m) for m in messages[pos + 1:])
|
||||
return int(tokens_in + tokens_out + tail_chars / CHARS_PER_TOKEN)
|
||||
total = sum(_message_chars(m) for m in messages)
|
||||
return int(total / CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def calibrated_chars_per_token(
|
||||
messages: List[dict[str, Any]],
|
||||
measured: "tuple[int, int, int] | None" = None,
|
||||
) -> float:
|
||||
"""由实测 usage 反推当前窗口的 chars/token 比值;无实测回退 CHARS_PER_TOKEN。
|
||||
|
||||
loop 用它把 token 口径的门槛(reliable_context × ratio)折回 chars 口径,喂给
|
||||
prepare_messages_with_stats / 前端占用环 —— 内部单位保持 chars 不变,只校准换算系数。
|
||||
"""
|
||||
if measured is None:
|
||||
return CHARS_PER_TOKEN
|
||||
est = estimate_window_tokens(messages, measured)
|
||||
total = sum(_message_chars(m) for m in messages)
|
||||
if est <= 0 or total <= 0:
|
||||
return CHARS_PER_TOKEN
|
||||
return clamp_ratio(total / est)
|
||||
|
||||
|
||||
def _compact_old_tool_content(content: str, max_chars: int) -> str:
|
||||
if len(content) <= max_chars:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from .context import (
|
|||
CHARS_PER_TOKEN,
|
||||
COMPACT_CONTEXT_RATIO,
|
||||
_message_chars,
|
||||
estimate_window_tokens,
|
||||
prepare_messages_with_stats,
|
||||
)
|
||||
from .storage import session_scope
|
||||
|
|
@ -130,12 +131,20 @@ def maybe_fold(
|
|||
"""run 起点调用:体量达阈值则折叠,返回统计 dict;未触发/不可折返回 None。
|
||||
|
||||
session 鸭子类型,需要:messages / task_id / context_head_len / context_base /
|
||||
apply_fold(cutoff, summary)。异常向上抛,由 loop 包 try 兜(失败零阻塞纪律)。
|
||||
apply_fold(cutoff, summary) / last_measured_usage()。异常向上抛,由 loop 包 try 兜
|
||||
(失败零阻塞纪律;last_measured_usage 自身 best-effort 不抛)。
|
||||
|
||||
触发判定用 token 实测口径(estimate_window_tokens):provider 实报 prompt_tokens
|
||||
覆盖窗口主体,只有实测点之后的尾巴按 chars 估 —— 静态 2.5 chars/token 对中文密集
|
||||
内容低估近一倍,曾让名义 85% 线实际 ~155% reliable 才触发(2026-07 DB 实测,
|
||||
task 9a863424 冲到 40.7 万 tokens)。已知残余:折叠后若 run 在首次 chat 完成前
|
||||
崩掉,下次 run 起点读到的实测值仍是折叠前旧窗口的,会多折一次 —— 后果只是摘要
|
||||
偏保守,原文全在 DB,可接受,不为此加持久化状态。
|
||||
"""
|
||||
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:
|
||||
est_tokens = estimate_window_tokens(msgs, session.last_measured_usage())
|
||||
if est_tokens < caps.reliable_context * FOLD_TRIGGER_RATIO:
|
||||
return None
|
||||
head_len = session.context_head_len
|
||||
cutoff = find_cutoff(msgs, head_len)
|
||||
|
|
@ -190,6 +199,7 @@ def maybe_fold(
|
|||
"folded_messages": folded_count,
|
||||
"before_chars": before_chars,
|
||||
"after_chars": after_chars,
|
||||
"est_tokens": est_tokens,
|
||||
"new_base": new_base,
|
||||
}
|
||||
emit(stats)
|
||||
|
|
|
|||
46
core/loop.py
46
core/loop.py
|
|
@ -22,7 +22,13 @@ from uuid import UUID
|
|||
import litellm
|
||||
|
||||
from .capabilities import ModelCapabilities
|
||||
from .context import CHARS_PER_TOKEN, COMPACT_CONTEXT_RATIO, prepare_messages_with_stats
|
||||
from .context import (
|
||||
CHARS_PER_TOKEN,
|
||||
COMPACT_CONTEXT_RATIO,
|
||||
calibrated_chars_per_token,
|
||||
clamp_ratio,
|
||||
prepare_messages_with_stats,
|
||||
)
|
||||
from .context_fold import maybe_fold
|
||||
from .executor import ExecCtx, Executor
|
||||
from .llm import LLM
|
||||
|
|
@ -554,6 +560,9 @@ class AgentLoop:
|
|||
|
||||
usage_details = _extract_usage_details(getattr(response, "usage", None))
|
||||
pt, ct = usage_details["tokens_in"], usage_details["tokens_out"]
|
||||
# 用本轮实报 prompt_tokens 刷新 chars/token 校准比值(下一轮门槛/占用环即用)。
|
||||
if pt > 0 and self._last_sent_chars > 0:
|
||||
self._ctx_chars_per_token = clamp_ratio(self._last_sent_chars / pt)
|
||||
# 记账(0006):一行 usage_event + 回填 messages.tokens_in/out + model_profile。
|
||||
# 任何失败都吞掉(litellm cost map miss / DB 异常),不阻塞主 loop;
|
||||
# message 仍在 session/DB 里,后续重启不影响。
|
||||
|
|
@ -677,6 +686,30 @@ class AgentLoop:
|
|||
# 非正确性关键,估算粗糙无妨。reliable_context(tokens) × ratio × 此值 = 触发的 char 阈值。
|
||||
_CHARS_PER_TOKEN = CHARS_PER_TOKEN
|
||||
|
||||
# chars/token 校准比值(run 内状态,类属性兜默认):None = 尚未校准,首次取用时
|
||||
# 从窗口内最后一次 provider 实报 usage 推算(_context_ratio),之后每轮成功调用用
|
||||
# (sent_chars / prompt_tokens) 实测刷新。静态 2.5 对中文密集窗口低估近一倍,只作
|
||||
# 无实测时的回退(详 context.py 常数注释)。
|
||||
_ctx_chars_per_token: Optional[float] = None
|
||||
_last_sent_chars: int = 0
|
||||
|
||||
def _context_ratio(self) -> float:
|
||||
"""当前窗口的 chars/token 换算比值(校准态)。
|
||||
|
||||
首次调用(run 首轮)从窗口内最后一次实报 usage 推算(与 maybe_fold 的估算
|
||||
同源);之后由 run() 在每轮成功调用后用真实 (sent_chars / prompt_tokens)
|
||||
刷新。校准是信号不是正确性数据 —— 任何异常都回退 CHARS_PER_TOKEN(旧口径,
|
||||
行为不变),绝不弄崩 run。
|
||||
"""
|
||||
if self._ctx_chars_per_token is None:
|
||||
try:
|
||||
self._ctx_chars_per_token = calibrated_chars_per_token(
|
||||
self.session.messages, self.session.last_measured_usage()
|
||||
)
|
||||
except Exception:
|
||||
self._ctx_chars_per_token = CHARS_PER_TOKEN
|
||||
return self._ctx_chars_per_token
|
||||
|
||||
def _maybe_fold_context(self) -> None:
|
||||
"""§8.8 Phase 2:run 起点检查窗口体量,达 reliable_context×85% 则把窗口中段
|
||||
折叠成结构化摘要(core/context_fold.py,SSE 事件 context_fold)。
|
||||
|
|
@ -713,19 +746,22 @@ class AgentLoop:
|
|||
invalid-JSON 分支返错给模型。重试消耗的 token 不单独记账。
|
||||
"""
|
||||
# 上下文压力门槛按当前模型 reliable_context 折算:体量未到阈值前不压缩(缓存全暖 + 不丢信息)。
|
||||
# 换算比值走校准态(实报 usage 优先,回退 2.5)—— 门槛语义是 token 口径,chars 只是载体。
|
||||
ratio = self._context_ratio()
|
||||
compact_threshold = int(
|
||||
self.caps.reliable_context * self._COMPACT_CONTEXT_RATIO * self._CHARS_PER_TOKEN
|
||||
self.caps.reliable_context * self._COMPACT_CONTEXT_RATIO * ratio
|
||||
)
|
||||
llm_messages, context_stats = prepare_messages_with_stats(
|
||||
self.session.messages,
|
||||
compact_threshold_chars=compact_threshold,
|
||||
)
|
||||
self._last_sent_chars = context_stats.get("sent_chars", 0)
|
||||
llm_start_event = {
|
||||
"type": "llm_start",
|
||||
**{f"context_{k}": v for k, v in context_stats.items()},
|
||||
# 窗口总容量(chars 口径,= reliable_context×2.5):前端压缩指示环算占用比;
|
||||
# 50% 进压缩区、85% 触发折叠(context_fold)。
|
||||
"context_limit_chars": int(self.caps.reliable_context * self._CHARS_PER_TOKEN),
|
||||
# 窗口总容量(chars 口径,= reliable_context×校准比值):前端压缩指示环算占用比;
|
||||
# 50% 进压缩区、85% 触发折叠(context_fold)。比值随实测刷新,环读数逐轮趋真。
|
||||
"context_limit_chars": int(self.caps.reliable_context * ratio),
|
||||
}
|
||||
# 各工具必填参数(单一事实源:executor schema),供「必填 key 被吞」畸形检测。
|
||||
required_by_tool = {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,39 @@ class Session:
|
|||
self._db_idx += 1
|
||||
return msg_id
|
||||
|
||||
def last_measured_usage(self) -> Optional[tuple]:
|
||||
"""窗口内最后一条带 provider 实报 usage 的 assistant 消息,返回
|
||||
(内存 pos, tokens_in, tokens_out);没有 / 查询失败一律返 None。
|
||||
|
||||
供上下文体量估算(context.estimate_window_tokens)做实测校准 —— 校准信号
|
||||
而非正确性数据,契约是 **best-effort 绝不抛**:无 DB(测试 / CLI 冷路径)、
|
||||
映射失配(理论不可达)等一切异常都吞掉走 None,调用方自然回退 chars 估算。
|
||||
"""
|
||||
try:
|
||||
with session_scope() as s:
|
||||
row = s.execute(
|
||||
select(Message.idx, Message.tokens_in, Message.tokens_out)
|
||||
.where(
|
||||
Message.task_id == self.task_id,
|
||||
Message.idx >= self._base_idx,
|
||||
Message.tokens_in.isnot(None),
|
||||
Message.tokens_in > 0,
|
||||
)
|
||||
.order_by(Message.idx.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
# DB idx → 内存 pos(映射不变量见 __init__);越界/角色不符视为映射失配,弃用。
|
||||
pos = self._n_head + (row.idx - self._base_idx)
|
||||
if not (self._n_head <= pos < len(self.messages)):
|
||||
return None
|
||||
if self.messages[pos].get("role") != "assistant":
|
||||
return None
|
||||
return pos, int(row.tokens_in), int(row.tokens_out or 0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@property
|
||||
def context_head_len(self) -> int:
|
||||
"""内存头部不落 DB 的消息条数(system + 可选前情摘要),窗口 idx 映射用。"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
"""窗口压力诊断:线上是否真的逼近 / 撞过模型上下文上限(DESIGN §8.8 垫子够不够厚)。
|
||||
|
||||
三个问题,全部按 DB 实测回答:
|
||||
1. usage_events(kind=chat)的 units.tokens_in = provider 实报 prompt tokens,
|
||||
逐 model_profile 对照 config/models/*.yaml 的 reliable_context / max_context:
|
||||
有多少调用越过 50% 压缩线 / 85% 折叠线 / 100% reliable / 硬上限 max_context。
|
||||
2. tasks.run_error 里有没有 context/length 类的 provider 超限报错(终态错误)。
|
||||
3. kind=context_fold 的事件量 = 折叠实际触发了多少次(85% 线被踩的次数)。
|
||||
|
||||
结论口径:若 (1) 无越过 100% reliable 的调用且 (2) 无超限终态错误 → 现有
|
||||
50%/85% 双层门槛 + reliable 与 max 之间的余量足够,run 中途折叠 / 超限自愈
|
||||
继续搁置(§5 无信号不实施)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
for line in (ROOT / ".env").read_text(encoding="utf-8").splitlines():
|
||||
if line.strip().startswith("ZCBOT_DB_URL="):
|
||||
os.environ["ZCBOT_DB_URL"] = line.split("=", 1)[1].strip()
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
|
||||
engine = create_engine(os.environ["ZCBOT_DB_URL"])
|
||||
|
||||
# model_profile("family.variant")→ (reliable_context, max_context),直读 yaml
|
||||
caps: dict[str, tuple[int, int]] = {}
|
||||
for f in (ROOT / "config" / "models").glob("*.yaml"):
|
||||
data = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
||||
family = data.get("family", f.stem)
|
||||
for variant, var in (data.get("variants") or {}).items():
|
||||
caps[f"{family}.{variant}"] = (
|
||||
int(var.get("reliable_context", 64_000)),
|
||||
int(var.get("max_context", 128_000)),
|
||||
)
|
||||
|
||||
with engine.connect() as conn:
|
||||
rows = conn.execute(text(
|
||||
"select model_profile, task_id, units, created_at::date"
|
||||
" from usage_events where kind='chat'"
|
||||
)).fetchall()
|
||||
folds = conn.execute(text(
|
||||
"select model_profile, count(*), min(created_at)::date, max(created_at)::date"
|
||||
" from usage_events where kind='context_fold' group by 1"
|
||||
)).fetchall()
|
||||
errs = conn.execute(text(
|
||||
"select task_id, model_profile, run_error, updated_at::date from tasks"
|
||||
" where run_error is not null and run_error <> ''"
|
||||
)).fetchall()
|
||||
|
||||
# ---- 1. prompt tokens vs 各模型门槛 ----
|
||||
stat = defaultdict(lambda: {"n": 0, "max": 0, "max_task": "", "over50": 0,
|
||||
"over85": 0, "over100": 0, "overmax": 0, "top": []})
|
||||
for profile, task_id, units, day in rows:
|
||||
tin = int((units or {}).get("tokens_in") or 0)
|
||||
s = stat[profile]
|
||||
s["n"] += 1
|
||||
if tin > s["max"]:
|
||||
s["max"], s["max_task"] = tin, str(task_id)[:8]
|
||||
rel, mx = caps.get(profile, (64_000, 128_000))
|
||||
if tin >= rel:
|
||||
s["over100"] += 1
|
||||
s["top"].append((tin, str(task_id)[:8], str(day)))
|
||||
elif tin >= rel * 0.85:
|
||||
s["over85"] += 1
|
||||
elif tin >= rel * 0.5:
|
||||
s["over50"] += 1
|
||||
if tin >= mx:
|
||||
s["overmax"] += 1
|
||||
|
||||
print("=== 1. chat 调用 prompt tokens vs 窗口门槛(usage_events 实报)===")
|
||||
print(f"{'model_profile':<28}{'calls':>7}{'max_in':>9}{'(task)':>10}"
|
||||
f"{'>=50%':>7}{'>=85%':>7}{'>=100%rel':>10}{'>=max':>7}")
|
||||
for profile in sorted(stat, key=lambda p: -stat[p]["max"]):
|
||||
s = stat[profile]
|
||||
rel, mx = caps.get(profile, (0, 0))
|
||||
known = "" if profile in caps else " [!] 无 yaml 档案,按默认 64k/128k 算"
|
||||
print(f"{profile:<28}{s['n']:>7}{s['max']:>9}{s['max_task']:>10}"
|
||||
f"{s['over50']:>7}{s['over85']:>7}{s['over100']:>10}{s['overmax']:>7}"
|
||||
f" (rel={rel} max={mx}){known}")
|
||||
|
||||
hits = [(t, p) for p, s in stat.items() for t in s["top"]]
|
||||
if hits:
|
||||
print("\n[!] 越过 100% reliable_context 的调用样本(最多 10 条):")
|
||||
for (tin, tid, day), profile in sorted(hits, key=lambda x: -x[0][0])[:10]:
|
||||
print(f" {tin:>8} tokens task {tid} {day} {profile}")
|
||||
else:
|
||||
print("\n[ok] 没有任何调用越过 100% reliable_context。")
|
||||
|
||||
# ---- 2. 终态错误里的超限报错 ----
|
||||
pat = re.compile(
|
||||
r"context|length|token|exceed|too long|maximum|上下文|超(出|过).{0,6}(长度|上限)",
|
||||
re.I,
|
||||
)
|
||||
ctx_errs = [(t, p, e, d) for t, p, e, d in errs if pat.search(e or "")]
|
||||
print(f"\n=== 2. tasks.run_error 终态错误:总 {len(errs)} 条,疑似上下文超限 {len(ctx_errs)} 条 ===")
|
||||
for t, p, e, d in ctx_errs[:10]:
|
||||
print(f" task {str(t)[:8]} {d} [{p}] {(e or '')[:160]}")
|
||||
if not ctx_errs and errs:
|
||||
print(" [ok] 无一条匹配 context/length/超限类关键词。")
|
||||
|
||||
# ---- 3. 折叠实际触发量 ----
|
||||
print("\n=== 3. context_fold 触发次数(85% 线被踩的实况)===")
|
||||
if folds:
|
||||
for profile, n, d1, d2 in folds:
|
||||
print(f" {profile}: {n} 次({d1} ~ {d2})")
|
||||
else:
|
||||
print(" 0 次 —— 85% 折叠线从未被踩到。")
|
||||
|
|
@ -1,7 +1,17 @@
|
|||
import unittest
|
||||
import json
|
||||
|
||||
from core.context import prepare_messages_for_llm, prepare_messages_with_stats
|
||||
from core.context import (
|
||||
CHARS_PER_TOKEN,
|
||||
RATIO_MAX,
|
||||
RATIO_MIN,
|
||||
_message_chars,
|
||||
calibrated_chars_per_token,
|
||||
clamp_ratio,
|
||||
estimate_window_tokens,
|
||||
prepare_messages_for_llm,
|
||||
prepare_messages_with_stats,
|
||||
)
|
||||
|
||||
|
||||
class ContextCompactionTests(unittest.TestCase):
|
||||
|
|
@ -300,5 +310,50 @@ class ContextCompactionTests(unittest.TestCase):
|
|||
self.assertEqual(prepared[2]["tool_call_id"], "call_x")
|
||||
|
||||
|
||||
class WindowTokenEstimateTests(unittest.TestCase):
|
||||
"""token 体量估算与 chars/token 比值校准(实测优先,回退 2.5)。"""
|
||||
|
||||
_MSGS = [
|
||||
{"role": "system", "content": "s" * 100},
|
||||
{"role": "user", "content": "中" * 100},
|
||||
{"role": "assistant", "content": "答" * 100},
|
||||
{"role": "user", "content": "尾巴" * 10},
|
||||
]
|
||||
|
||||
def test_no_measurement_falls_back_to_chars_estimate(self) -> None:
|
||||
total = sum(_message_chars(m) for m in self._MSGS)
|
||||
self.assertEqual(
|
||||
estimate_window_tokens(self._MSGS), int(total / CHARS_PER_TOKEN)
|
||||
)
|
||||
|
||||
def test_measured_prefix_dominates_estimate(self) -> None:
|
||||
# 实测点 = assistant(pos 2, tin=5000, tout=300):前缀+输出用真值,
|
||||
# 只有 pos 之后的尾巴按 CHARS_PER_TOKEN 估。
|
||||
tail = _message_chars(self._MSGS[3])
|
||||
est = estimate_window_tokens(self._MSGS, (2, 5000, 300))
|
||||
self.assertEqual(est, int(5000 + 300 + tail / CHARS_PER_TOKEN))
|
||||
|
||||
def test_measured_at_last_message_has_no_tail(self) -> None:
|
||||
est = estimate_window_tokens(self._MSGS[:3], (2, 5000, 300))
|
||||
self.assertEqual(est, 5300)
|
||||
|
||||
def test_calibrated_ratio_reflects_measurement_and_clamps(self) -> None:
|
||||
# 无实测 → 恒回退 2.5
|
||||
self.assertEqual(calibrated_chars_per_token(self._MSGS), CHARS_PER_TOKEN)
|
||||
# 中文密集 + 实测偏大 → 比值低于 2.5(校准生效)
|
||||
ratio = calibrated_chars_per_token(self._MSGS[:3], (2, 200, 30))
|
||||
total = sum(_message_chars(m) for m in self._MSGS[:3])
|
||||
self.assertAlmostEqual(ratio, total / 230, places=6)
|
||||
self.assertLess(ratio, CHARS_PER_TOKEN)
|
||||
# 实测异常大/小 → 夹回带宽,不让门槛跟着飞
|
||||
self.assertEqual(calibrated_chars_per_token(self._MSGS[:3], (2, 10**9, 0)), RATIO_MIN)
|
||||
self.assertEqual(calibrated_chars_per_token(self._MSGS[:3], (2, 1, 0)), RATIO_MAX)
|
||||
|
||||
def test_clamp_ratio_bounds(self) -> None:
|
||||
self.assertEqual(clamp_ratio(0.2), RATIO_MIN)
|
||||
self.assertEqual(clamp_ratio(9.9), RATIO_MAX)
|
||||
self.assertEqual(clamp_ratio(1.8), 1.8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -186,6 +186,33 @@ class MaybeFoldTests(unittest.TestCase):
|
|||
persist.assert_not_called()
|
||||
self.assertIsNone(llm.last_messages)
|
||||
|
||||
def test_measured_tokens_trigger_fold_despite_small_chars_estimate(self) -> None:
|
||||
"""chars/2.5 估算不到阈值,但实测 tokens 超了 → 折叠触发(校准修的正是这个:
|
||||
中文密集内容静态折算低估近一倍,名义 85% 线拖到 ~155% 才触发)。"""
|
||||
sess = self._session()
|
||||
caps = SimpleNamespace(**{**_FAKE_CAPS.__dict__, "reliable_context": 10_000})
|
||||
# 窗口 ~13k chars → chars 口径估 ~5.2k tokens < 阈值 8.5k;实测说 20k → 必须折
|
||||
measured = (len(sess.messages) - 1, 20_000, 100)
|
||||
llm = _FakeLLM("## 目标\n...")
|
||||
with patch.object(sess, "last_measured_usage", return_value=measured), \
|
||||
patch.object(cf, "persist_fold"), \
|
||||
patch.object(cf, "record_chat_usage"):
|
||||
stats = cf.maybe_fold(sess, llm, caps, user_id=uuid4(), emit=lambda e: None)
|
||||
self.assertIsNotNone(stats)
|
||||
self.assertGreaterEqual(stats["est_tokens"], 20_000)
|
||||
|
||||
def test_measured_tokens_block_fold_when_window_actually_small(self) -> None:
|
||||
"""chars 估算超阈值但实测证明窗口实际很小(ASCII/代码密集)→ 不折。"""
|
||||
sess = self._session() # chars 口径估 ~5.2k tokens,远超 reliable=100 的阈值
|
||||
measured = (len(sess.messages) - 1, 10, 1)
|
||||
llm = _FakeLLM("unused")
|
||||
with patch.object(sess, "last_measured_usage", return_value=measured), \
|
||||
patch.object(cf, "persist_fold") as persist:
|
||||
result = cf.maybe_fold(sess, llm, _FAKE_CAPS, user_id=uuid4(), emit=lambda e: None)
|
||||
self.assertIsNone(result)
|
||||
persist.assert_not_called()
|
||||
self.assertIsNone(llm.last_messages)
|
||||
|
||||
def test_empty_summary_skips_fold(self) -> None:
|
||||
sess = self._session()
|
||||
before = list(sess.messages)
|
||||
|
|
|
|||
Loading…
Reference in New Issue