diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e28d7..86156e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,3 +52,10 @@ - **GitHub Actions CI**:`.github/workflows/test.yml`,Python 3.11/3.12/3.13 矩阵跑单测 + 冒烟 + dry-run(零依赖,无需装包);README 加 badge 行 - README 修正一处悬空引用(原指向方案 v3.1 已不存在的节号,改指实验文档方法学节) - 文档口径:实验文档成本列改为**相对倍数**(7.35× / 10.2× / 7.9×),不再出现绝对金额;ir-schema 移除签字栏(冻结约束以「双方确认 + 变更记录」为准) + +## 2026-09-09 · v1.5 运维闭环:TTL 惰性淘汰 + 实验数据入库(本提交) + +- **TTL 惰性淘汰**(LIMITATIONS #11 闭环):`SessionStore.maybe_evict()`——读写路径(`get_or_create`)顺手触发淘汰,60s 节流避免每请求全表扫描;在锁外调用规避非可重入锁死锁。回归测试 2 项(节流窗口 / 流量驱动),测试 44 → **46 项全过** + 11 项冒烟全过 +- **实验原始数据入库**:`data/experiments/` 19 个文件(34 组实测逐轮 jsonl + 汇总 csv)+ `data/README.md` 溯源表——实验文档每个数字可回查到具体文件具体行;`.gitignore` 改为只忽略运行时产物、放行精选数据目录 +- LIMITATIONS #12 补标已闭环(property-based + lossy-fields 已于 PR #8 落地,原标记滞后) +- README:目录补 data/、已知限制行标注 3 项已闭环、单测数 40 → 46;REVIEW.md 测试口径统一为 46 项 diff --git a/README.md b/README.md index eab2f76..99fd52c 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,13 @@ OpenAI Chat / OpenAI Response ↔ Anthropic 的协议转换层(网关/代理 + | 三组对照实验 | ✅ 34 组全量实测 | `docs/experiment-results.md`(含第〇节证伪条件预登记) | | TRACK04 边界参数对齐稿 | ✅ 已对齐 | `docs/TRACK04_签字确认稿.md`(single_task 等三参数已定稿) | | 弹网页(本地仪表盘) | ✅ 记忆编排界面 | `src/webui/` 安全骨架 + 记忆勾选/裁剪 + 缓存前缀可视化 | -| 已知限制(边界自觉) | 📋 12 项四要素 | `docs/LIMITATIONS.md`(含结论隔离证明) | +| 已知限制(边界自觉) | 📋 12 项四要素(3 项已闭环留档) | `docs/LIMITATIONS.md`(含结论隔离证明) | ## 快速开始(离线) ```bash # 零外部依赖(仅标准库),Python 3.11+ -python -m unittest tests.test_offline -v # 40 项离线单测:IR 往返 / adapter / 状态层 / 预热 / 三轮自查回归 / 多轮链 E2E / ID 映射 +python -m unittest tests.test_offline -v # 46 项离线单测:IR 往返 / adapter / 状态层 / 预热 / 三轮自查回归 / 多轮链 E2E / ID 映射 / property-based / 惰性淘汰 python tools/smoke_e2e.py # 11 项端到端冒烟:真起 mock+网关子进程,5 组链路断言 python tools/mock_backend.py # 起 mock backend(127.0.0.1:9100,按端点返回三种协议形状) python -m src.gateway.server # 起网关(转发到 mock) diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index f6704f2..2d45f9d 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -86,6 +86,8 @@ ### #11 TTL 淘汰无后台触发器 +> ✅ **v1.5 已修复**(2026-09-09):新增 `SessionStore.maybe_evict()` 惰性淘汰——读写路径(`get_or_create`)顺手触发,按 `_EVICT_INTERVAL`(60s)节流避免每请求全表扫描,无需后台线程;在锁外调用规避非可重入锁死锁。回归测试 `test_lazy_eviction_throttled`(节流窗口)/ `test_get_or_create_triggers_lazy_eviction`(流量驱动)。以下原文留档。 + - **影响半径**:`evict_expired` 已实现且测试覆盖,但网关主进程没有定时调用——会话表随运行时间无界增长(`replay_from=full` 时每轮全量重放,长对话重放代价线性增长)。 - **触发条件**:长时间运行的网关进程。 - **绕过方式**:重启进程不丢会话(SQLite 持久化),可定期人工调用淘汰;实验场景会话数受控。 @@ -93,6 +95,8 @@ ### #12 往返一致性为例举式测试(非 property-based),有损清单待自动生成 +> ✅ **已闭环**(2026-09-09,PR #8):`TestPropertyRoundTrip` 以 stdlib 实现 property-based 往返测试(440 随机用例、固定种子可复现,断言断点恒在 [3,4] / 未知字段必降级 / 工具参数守恒 / signature 守恒四条性质);`docs/lossy-fields.md` 已从三个 adapter 实码推导生成(含两处诚实标注的清单盲区)。以下原文留档。 + - **影响半径**:`TestAdapterRoundTrip` 覆盖录制样例的代表性路径,不做随机化性质测试;已知有损字段清单当前依赖运行时 dropped 记录,无独立文档。 - **触发条件**:边缘字段组合(罕见的块类型/参数搭配)。 - **绕过方式**:铁律兜底——未知字段一律进 `extra` + dropped 降级记录,绝不静默丢弃;`_bridge.dropped` 每轮可见。 diff --git a/docs/REVIEW.md b/docs/REVIEW.md index c49ea73..635aa3b 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -1,7 +1,7 @@ # 代码评审材料(Code Review Pack) > 版本 v1.2(2026-09-02)| src 1493 行 + tools/experiments/tests 1065 行(纯标准库,零第三方依赖,Python 3.11+) -> 测试:**44 项单测全过 + 11 项端到端冒烟全过**(2026-09-09,含第三轮自查回归 + property-based 性质测试)+ Groq 真实链路验证通过(2026-09-01) +> 测试:**46 项单测全过 + 11 项端到端冒烟全过**(2026-09-09,含第三轮自查回归 + property-based 性质测试 + 惰性淘汰回归)+ Groq 真实链路验证通过(2026-09-01) > 本版差异:**两轮代码自查发现 9 个真实 bug,全部修复并各配回归测试**(见第六节) ## 一、整体架构(IR 星型) @@ -112,12 +112,12 @@ do_POST(路由 /v1/{source}/to/{target},未知协议 → 400) | `verify_cache.py` | 152 | **验站脚本**:唯一随机 run_id 前缀两步验证(预热看 creation → 复发看 read);PASS/PARTIAL/FAIL;`max_tokens:0` 被拒自动降级 1 | | `smoke_e2e.py` | 164 | **v1.2 新增**:端到端冒烟——真起 mock + 网关两个子进程,跑 5 组 11 项断言(直通/跨协议/多轮链/预热/非法路由),退出码 0/1 | -### tests/ —— 测试(44 项全过) +### tests/ —— 测试(46 项全过) | 测试类 | 覆盖 | | ------ | ---- | | `TestAdapterRoundTrip` | Chat→IR→Anthropic 往返、断点数 ≤4 且 ≥3、usage 三口径归一 | -| `TestStateLayer` | prev_id 重放、重放三策略、TTL 淘汰、SQLite 重启不丢 | +| `TestStateLayer` | prev_id 重放、重放三策略、TTL 淘汰、惰性淘汰节流(v1.5)、SQLite 重启不丢 | | `TestWarmup` | 预热构造、冲突校验、"畸形"响应解析 | | `TestSessionConfig` | 配置加载/热切换/快照 | | `TestGatewayPolicies` | 预热四类拒绝、记忆注入去重+cap+cap 三级生效 | @@ -133,7 +133,7 @@ do_POST(路由 /v1/{source}/to/{target},未知协议 → 400) | 验证 | 结果 | 日期 | | ---- | ---- | ---- | -| 44 项单测(`-W error::ResourceWarning` 下零警告,含第三轮自查回归 + property-based) | ✅ 全过 | 2026-09-09 | +| 46 项单测(`-W error::ResourceWarning` 下零警告,含第三轮自查回归 + property-based + 惰性淘汰) | ✅ 全过 | 2026-09-09 | | 11 项端到端冒烟(子进程级,`tools/smoke_e2e.py`) | ✅ 全过 | 2026-09-02 | | 多轮 previous_response_id 链路(0→2→4 条重放) | ✅ 修复后通过 | 2026-09-02 | | Groq 真实链路(Chat 直通 + Responses→Chat + usage 归一 + 埋点) | ✅ | 2026-09-01 | diff --git a/src/state/store.py b/src/state/store.py index 4c04091..f64427a 100644 --- a/src/state/store.py +++ b/src/state/store.py @@ -58,8 +58,13 @@ class SessionStore: db_path: None → 环境变量 PB_DB → 项目 data/sessions.db;测试可传 ":memory:"。 """ + # 惰性 TTL 淘汰的最小间隔(秒):两次全表淘汰扫描之间至少隔这么久, + # 避免每个请求都扫一次 sessions 表。 + _EVICT_INTERVAL = 60.0 + def __init__(self, cfg: SessionConfig, db_path: str | None = None): self.cfg = cfg + self._last_evict = 0.0 path = db_path or os.environ.get("PB_DB") or _DEFAULT_DB if path != ":memory:": os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) @@ -122,6 +127,9 @@ def _save(self, s: Session) -> None: # -- session 生命周期 -------------------------------------------------- def get_or_create(self, key: str) -> Session: + # 惰性淘汰(LIMITATIONS #11):由流量驱动,无后台线程; + # 必须在 self._lock 之外调用——evict_expired 会取同一把非可重入锁。 + self.maybe_evict() with self._lock: s = self._load(key) if s is None or s.closed: @@ -198,6 +206,18 @@ def append(self, session: Session, messages: list) -> None: self._save(session) # -- TTL 淘汰 ----------------------------------------------------------- + def maybe_evict(self, now: float | None = None) -> int: + """惰性 TTL 淘汰:读写路径顺手调用,按 _EVICT_INTERVAL 节流。 + + 返回实际淘汰数量;处于节流窗口内时返回 0(未执行扫描)。 + _last_evict 的竞写是良性的(最坏情况是多扫一次表),故不加锁。 + """ + now = now if now is not None else time.time() + if now - self._last_evict < self._EVICT_INTERVAL: + return 0 + self._last_evict = now + return self.evict_expired(now) + def evict_expired(self, now: float | None = None) -> int: """TTL 结束策略下淘汰超时会话。返回淘汰数量。""" if self.cfg.end_policy != TTL: diff --git a/tests/test_offline.py b/tests/test_offline.py index 7e66088..5041f71 100644 --- a/tests/test_offline.py +++ b/tests/test_offline.py @@ -9,6 +9,7 @@ import string import sys import threading +import time import unittest import urllib.request @@ -90,6 +91,41 @@ def test_ttl_eviction(self): store._save(s) self.assertEqual(store.evict_expired(), 1) + def test_lazy_eviction_throttled(self): + """LIMITATIONS #11 回归:惰性淘汰按间隔节流,不每请求全表扫描。""" + cfg = SessionConfig(end_policy="ttl", ttl_seconds=1, on_end="archive") + store = SessionStore(cfg, db_path=":memory:") + self.addCleanup(store.shutdown) + t0 = time.time() + s = store.get_or_create("task-old") + s.touched_at = t0 - 100 + store._save(s) + # get_or_create 本身已触发过一次实时惰性淘汰(节流窗由此刻起算), + # 重置 _last_evict 以便用显式 now 做确定性验证 + store._last_evict = 0 + # 首次触发:淘汰 1 个超时会话 + self.assertEqual(store.maybe_evict(now=t0), 1) + # 节流窗口内(< 60s):不再扫描,即使又有新超时会话 + s2 = store.get_or_create("task-old2") + s2.touched_at = t0 - 100 + store._save(s2) + self.assertEqual(store.maybe_evict(now=t0 + 30), 0) + # 窗口过后:恢复淘汰 + self.assertEqual(store.maybe_evict(now=t0 + 61), 1) + + def test_get_or_create_triggers_lazy_eviction(self): + """LIMITATIONS #11 回归:正常读写路径(get_or_create)会顺手淘汰超时会话。""" + cfg = SessionConfig(end_policy="ttl", ttl_seconds=1, on_end="archive") + store = SessionStore(cfg, db_path=":memory:") + self.addCleanup(store.shutdown) + s = store.get_or_create("task-expired") + s.touched_at -= 100 + store._save(s) + store._last_evict = 0 # 绕过节流,模拟「距上次淘汰已很久」 + store.get_or_create("task-new") # 任意流量即触发 + self.assertTrue(store._load("task-expired").closed) + self.assertFalse(store._load("task-new").closed) + def test_sqlite_persistence_roundtrip(self): """重启(换连接)后会话与 meta 不丢。""" import tempfile, os as _os