Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 项
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,17 @@

### #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 持久化),可定期人工调用淘汰;实验场景会话数受控。
- **后续路线**:网关加定时清扫(或惰性淘汰:读写时顺手淘汰);生产形态需配合 `sliding_window` 重放策略 cap 历史。

### #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` 每轮可见。
Expand Down
8 changes: 4 additions & 4 deletions docs/REVIEW.md
Original file line number Diff line number Diff line change
@@ -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 星型)
Expand Down Expand Up @@ -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 三级生效 |
Expand All @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions src/state/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions tests/test_offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import string
import sys
import threading
import time
import unittest
import urllib.request

Expand Down Expand Up @@ -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
Expand Down
Loading