diff --git a/DESIGN.md b/DESIGN.md index 43f8278..5643bfe 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) diff --git a/PROGRESS.md b/PROGRESS.md index 7e5a2f5..aafbc6e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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。 diff --git a/core/__init__.py b/core/__init__.py index 44e304f..a1e4f9f 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.51" +__version__ = "0.58.52" diff --git a/core/context.py b/core/context.py index 5abd9f7..038ec26 100644 --- a/core/context.py +++ b/core/context.py @@ -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: diff --git a/core/context_fold.py b/core/context_fold.py index a7979ad..b9e9e7e 100644 --- a/core/context_fold.py +++ b/core/context_fold.py @@ -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) diff --git a/core/loop.py b/core/loop.py index a3729e5..ec50835 100644 --- a/core/loop.py +++ b/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 = { diff --git a/core/session.py b/core/session.py index f8410e8..456988a 100644 --- a/core/session.py +++ b/core/session.py @@ -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 映射用。""" diff --git a/scripts/diag_context_pressure.py b/scripts/diag_context_pressure.py new file mode 100644 index 0000000..71ddc7b --- /dev/null +++ b/scripts/diag_context_pressure.py @@ -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% 折叠线从未被踩到。") diff --git a/tests/test_context_compaction.py b/tests/test_context_compaction.py index 7efb6d0..8bbce2a 100644 --- a/tests/test_context_compaction.py +++ b/tests/test_context_compaction.py @@ -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() diff --git a/tests/test_context_fold.py b/tests/test_context_fold.py index 9e62f6b..71a3766 100644 --- a/tests/test_context_fold.py +++ b/tests/test_context_fold.py @@ -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)