docs(tasks): 缓存生效后剩下的两块——外网健康探测与运行时重复编译

f8e607c2 上重测:BUG-727 的快照缓存确实每轮命中、响应体 52 万→40 万
字符,但耗时几乎没变。6 次调用累计 5.30 s,网络仍占 70%(poll 2.42 +
ssl read 0.76 + 握手 0.56),本地 swisseph 只剩 2.4%。

- BUG-734:剩下那一次外网是 gateway_status 的健康探测。它不是 ping——
  probe_calculate_health 把一份虚构排盘 POST 给 api.vedastro.org,并且
  consume_rate_token() 会烧掉对方一个业务限流令牌;opener 默认 None,
  每次新建 TLS(6 次调用 3 次握手,单次 0.186 s);结果零 TTL。
  修法:成功 60 s / 失败 10 s 的 TTL,配置变即失效,诊断端点走
  force_refresh 保持实时;共享 opener 摊掉握手。
- BUG-735:yoga_engine._eval_custom 对 references/yoga_rules.json 的
  192 条静态表达式每请求重新编译 102 次,多语句的还要先抛一次
  SyntaxError 再 parse + 改写 AST + compile,占 9.4%。
  修法:lru_cache 存 (mode, code object)。硬红线:绝不缓存求值结果,
  那会跨用户串盘。

等价证明沿用 BUG-733 的同进程差分,不再写跨机 golden。

纯文档推送,不触发门禁、不发布镜像、不部署。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-16 00:55:05 +00:00
co-authored by Claude Opus 5
parent f8e607c29a
commit 4f643aa095
2 changed files with 200 additions and 0 deletions
+1
View File
@@ -244,6 +244,7 @@
| `TASK-freeze-metric-change-20260915.md` | — | **规则单(后面两单的前置,无 BUG 号)**:两条增长冻结余量都用完(`page.tsx` 1,951/1,951 余 **0**`jyotish_api_server.py` 11,334/11,363 余 **29**),冻结从「逼新代码往外走」退化成「拦路」。实证:`page.tsx` 行数砍 59% 但 `Home()` 的 `useState` 从 56 涨到 **66**(拆的是代码不是状态);api server **225 个类方法只有 12 处真碰 HTTP 上下文**,4 处 `__new__` 伪造空壳就是这么来的。**产品拍板换口径**:主门改成「`Home()` 的 useState/useRef 不得增长」与「类方法数 + `__new__` 计数不得增长」,行数降级为粗护栏;**同时推翻 §6「参数式 hook 内部保持 0 个 React hook」**(那正是状态搬不走的原因)。改 `AGENTS.md` §6 + 两个合同测试,不碰业务代码 | 待领取 | — |
| `TASK-home-state-lowering-20260915.md` | — | **page.tsx 状态下沉第一簇(串行在 freeze-metric-change + C2 + R3 之后)**66 个 state 里 `rectification*` 占 **15** 个,而它们服务的 `<ConversationalBirthTimeRectification>` 本来就是 `dynamic()` 懒加载子树、挂着 24 个 props;`useRectificationSurface` 要解构约 56 个参数。把这簇搬进子树,`Home()` 的 useState 从 66 降到 ≤ 53。**零行为变化**;第一步必须先把 15 个逐个分类(只服务子树 / 外壳也要读)。产品否决了 Context Provider 与外部 store 两条路。不占 BUG 号 | 待领取 | — |
| `TASK-rectification-engine-memoization-fix-20260915.md` | — | **验收修复单(只改测试,一行实现不许动)**:BUG-721 的实现**等价性成立**(我在改前 `6b3248bf` / 改后 `e4788dfc` 同机跑同一 payload`candidate_scores` 逐字相同),9 条计数断言全过;但等价 golden 在本机复现不出来——4 处浮点尾数差(score 1.0e-4 ×2、`margin_percent` 1.1e-3 ×2)。**复发自 BUG-712**(「不得对全精度浮点做整体 `==`」,那一单只落在 ephemeris 一处)。而 `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,**staging 门禁靠机器舍入碰巧一致才是绿的**。修法:主证据换成**同进程差分**(把 static context 的四个缓存键置 `None` 即可回退旧路径,A/B 严格相等),golden 降为离散字段严格相等 + 浮点带容差(容差按实测 1.1e-3 推);**禁止重建 golden 来「修」**。另含六份 golden 的仓库级排查。BUG-733 | 待领取 | — |
| `TASK-consultation-residual-hotspots-20260916.md` | — | **性能单(与拆解单文件不重叠,可并行)**:BUG-727 的快照缓存已验收生效(逐轮 trace 确认每轮 hit、响应体 52 万→40 万字符),但耗时没降——重测 6 次调用 5.30 s 里**网络仍占 70 %**poll 2.42 + ssl read 0.76 + 握手 0.56),本地占星计算只剩 **2.4 %**。剩下那一次外网是 `gateway_status → probe_official_rest_health → probe_calculate_health`:**它不是 ping,是把一份虚构排盘 POST 给 `api.vedastro.org`**,而且 `consume_rate_token()` 会**烧掉对方一个业务限流令牌**,还不复用连接(6 次调用 3 次 TLS 握手,单次 0.186 s),结果零 TTLBUG-734)。另 `yoga_engine._eval_custom` 对静态规则表的 192 条表达式**每请求重新编译 102 次**(多语句的还要先抛一次 SyntaxError 再 parse+改写 AST+compile),占 9.4 %BUG-735)。修法:探测加 TTL(成功 60 s / 失败 10 s,配置变即失效,**诊断端点必须 force_refresh 保持实时**+ 共享 opener;表达式缓存 code object。**硬红线:绝不缓存 yoga 求值结果**(会跨用户串盘)。等价证明用 BUG-733 的同进程差分,不再写跨机 golden。BUG 段 734735 | 待领取 | — |
## 命名与归档
@@ -0,0 +1,199 @@
# TASK · 缓存生效后剩下的两块:外网健康探测与运行时重复编译
- 日期:2026-09-16
- 基线 commit`origin/staging` @ `f8e607c2`
- 执行分支:`codex/consultation-residual-hotspots-20260916`
- 落点:`scripts/vedastro_gateway.py``scripts/vedastro_rest_bridge.py``scripts/yoga_engine.py`(以及各自的新增测试)
- 与 API server 拆解单**文件不重叠**(本单一行都不碰 `scripts/jyotish_api_server.py`),可并行
- 规模:一处探测缓存 + 一处连接复用 + 一处编译缓存。**零行为变化、零输出变化。**
---
## 1. 为什么现在做:上一轮的缓存生效了,瓶颈换了位置
`TASK-consultation-external-evidence-cache-20260915`BUG-727/728)已合入并由我验收通过。我在 `f8e607c2` 上重测,**快照缓存每一轮都命中**(用 trace 逐轮确认),响应体也从 52 万字符降到 40 万。但耗时几乎没变:
| | 改前 | 改后(快照缓存已命中) |
| --- | ---: | ---: |
| career | 928 ms | 1,007 / 918 ms |
| wealth | 493 ms | 951 / 497 ms |
| general | 491 ms | 492 ms |
连续 6 次调用累计 5.30 秒,按 `tottime` 排:
| | 耗时 | 占比 |
| --- | ---: | ---: |
| `select.poll`(等外网 socket | 2.422 s | 46 % |
| `_ssl._SSLSocket.read` | 0.762 s | 14 % |
| `_ssl._SSLSocket.do_handshake` | 0.556 s | 10 % |
| **网络合计** | **3.740 s** | **70 %** |
| 运行时 AST 编译(`compile` + `ast._fix` + `iter_child_nodes` + `iter_fields` | 0.497 s | 9.4 % |
| `swisseph.calc_ut`(真正的占星计算) | 0.129 s | **2.4 %** |
逐轮 trace 的结果最能说明问题:
```
第 1 轮: 1109 ms 快照缓存=hit 外网 urlopen=1 次
第 2 轮: 992 ms 快照缓存=hit 外网 urlopen=1 次
第 3 轮: 1018 ms 快照缓存=hit 外网 urlopen=1 次
第 4 轮: 529 ms 快照缓存=hit 外网 urlopen=0 次
```
**快照命中了,但每轮还是多打一次外网;那一次值 ~0.5 秒。**
## 2. 事故实证
### 2.1 BUG-734 · 每轮拿一次「服务还活着吗」,代价是一次完整业务请求
调用链(符号定位):
```
vedastro_gateway.run_gateway_packet
└─ vedastro_gateway.gateway_status()
└─ probe_official_rest_health()
└─ vedastro_rest_bridge.probe_calculate_health()
└─ call("HoroscopePredictions", 虚构 smoke payload)
└─ urllib.request.urlopen(...)
```
三件事叠在一起:
1. **这不是 ping,是一次完整的业务调用。** `probe_calculate_health` 的 docstring 写着「POST a fictional smoke horoscope and report whether Status is Pass」——它把一份虚构的排盘请求发给 `api.vedastro.org/api/HoroscopePredictions`,用返回的 Status 当健康信号。
2. **它会消耗对方的限流额度。** `call()` 开头 `consume_rate_token()`(除非 `skip_rate_limit=True`,而探测没传)。也就是说:**每一轮普通聊天都在拿一个外部限流令牌去做健康检查**,真正的业务调用反而要和它抢。
3. **没有连接复用。** `call()``opener` 默认 `None` → 走 `urllib.request.urlopen(req)`,每次新建 TCP + TLS。实测 6 次调用握手 3 次,单次握手 **0.186 s**
`probe_official_rest_health()` 的结果**没有任何 TTL**:它检查的是「这个外部服务此刻可用吗」,这种状态不会每 500 毫秒变一次。
### 2.2 BUG-735 · 每个请求把同一批规则表达式重新编译 102 次
`scripts/yoga_engine.py``_eval_custom``cond.get("expr")`)对每条规则做:
```python
result = eval(expr, exec_globals, exec_globals) # expr 是源码字符串 → 每次调用都重新编译
except SyntaxError:
tree = ast.parse(expr.strip(), mode="exec") # 多语句表达式走这条
tree.body = _capture_tail_expr(tree.body)
exec(compile(tree, "<yoga_custom>", "exec"), ...)
```
cProfile 的调用方视图(单请求):
| 被调用 | 次数 | 调用方 |
| --- | ---: | --- |
| `builtins.compile` | 102 | `ast.py:30(parse)` |
| `builtins.compile` | 102 | `yoga_engine.py:1235(_eval_custom)` |
`ast.parse` 也被调了 102 次,说明**相当一部分表达式是多语句的**——它们每次都要先抛一次 `SyntaxError`、被捕获、再 parse、再改写 AST、再 compile。每个请求重来一遍。
表达式的来源是仓库里的静态规则表 `references/yoga_rules.json`**198 条 `expr`,去重后 192 条,最长 2,614 字符**。它们在进程生命周期内不会变。
## 3. 根因
两处同一个形状:**把「只依赖静态输入的昂贵结果」放在了每请求都走的路径上**。
- 外部服务的可用性只依赖配置与对方状态,却每轮重新探测一次,还顺手烧掉一个限流令牌;
- 规则表达式的字节码只依赖表达式文本,却每轮重新编译 102 次。
上一轮的引擎记忆化(BUG-721)修的是同一类问题的第三个实例(候选分钟不变量被放在事件循环里)。
## 4. 决策记录
产品 2026-09-16 授权本单:
1. **健康探测要缓存,但诊断端点必须仍然是实时的。** `gateway_status()` 现在有两类调用方——`jyotish_api_server._compute_vedastro_gateway_status`(诊断端点,人在看「现在到底通不通」)和 `run_gateway_packet`(每轮聊天)。**缓存只能加在聊天这条路上,或者给诊断端点一个强制刷新的入口**;不得让运维看到一个 60 秒前的假象。
2. **缓存的是「探测结果」,不是「业务答案」。** 本单不碰快照缓存(BUG-727 已做),也不改任何业务数据的新鲜度口径。
3. **yoga 表达式只缓存编译产物,不缓存求值结果。** 求值依赖每次不同的盘上下文(`exec_globals`),**绝不能缓存 `result`**——那会让所有人拿到同一张盘的 yoga 判定。
4. **零输出变化。** 两处改完,同一份请求的响应必须与改前逐字相同(计时字段除外)。
## 5. 硬红线
1. **不得缓存 yoga 的求值结果,只缓存 code object。** 这条如果做错,后果是跨用户串盘,比性能问题严重得多。
2. **`eval` 的语义不得放宽**:今天的行为是「先按表达式求值,`SyntaxError` 时退到改写过的多语句 exec」。缓存版本必须在**第一次**确定该表达式走哪条路,之后固定走那条,结果与改前逐条相同。不得把 `exec` 换成 `eval` 或反之。
3. **编译缓存必须有界**,并且要在测试里断言 `expr` 只来自仓库内的静态规则表、不含任何用户输入派生的字符串。若发现有用户输入能进入 `expr`**立刻停手**写进 `BLOCKED.md`——那是一个远比性能严重的问题。
4. **失败不得被长时间缓存。** 探测失败(服务挂了、限流、超时)的 TTL 必须显著短于成功的 TTL,否则对方恢复了我们还要再瞎等一分钟。
5. **不得改 `scripts/jyotish_api_server.py`**(AGENTS §6,且它是拆解单的独占文件)。诊断端点若需要强制刷新,用参数从 `vedastro_gateway` 侧暴露,主文件那一行调用保持不变。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 6. 任务分解
### 6.1 健康探测加 TTLBUG-734 主体)
`vedastro_gateway` 里给 `probe_official_rest_health()` 的结果加进程级缓存:
- 键:影响探测结论的**有效配置**endpoint、network enabled、是否配了 `VEDASTRO_API_KEY`、mode)。配置一变立刻失效。
- 成功 TTL 建议 **60 秒**,失败 TTL 建议 **10 秒**(数值写成具名常量并在注释里说明依据)。
- `gateway_status()` 增加一个显式的 `force_refresh` 形参(默认 `False`)。诊断端点走 `force_refresh=True`
- 验收:新增 pytest——同一配置下连调 3 次,底层 `probe_calculate_health` 只被调 **1** 次(monkeypatch 计数);把时钟推过 TTL 后再调,计数变 2。
- 验收:探测失败后,在成功 TTL 之内、失败 TTL 之外再调一次,必须重新探测。
- 验收:改变配置(例如注入 `VEDASTRO_API_KEY`)必须让缓存失效。
- 验收:`force_refresh=True` 永远打真探测;`tests/test_vedastro_gateway.py``tests/test_vedastro_runtime_ops.py` 既有断言一条不改仍绿。
### 6.2 连接复用(BUG-734 的另一半)
`call()` 已经接受 `opener` 形参,但默认 `None` 每次新建连接。给 `vedastro_rest_bridge` 一个模块级共享 opener(线程安全),让探测与业务调用共用,摊掉 TLS 握手。
- 验收:新增测试断言默认路径使用的是同一个 opener 实例(不是每次新建)。
- 验收:`consume_rate_token()` 的调用次数不变——**复用连接不等于绕过限流**。
- 验收:进度记录里贴出改前/改后的握手次数实测(改前:6 次调用 3 次握手)。
### 6.3 yoga 表达式编译缓存(BUG-735
抽一个 `_compiled_expr(expr: str)` 帮助函数,用 `functools.lru_cache``maxsize` 取 ≥ 256,规则表去重后 192 条)返回 `(mode, code_object)`:第一次按现有逻辑决定这条表达式走 `eval` 还是走「改写 AST 后 exec」,之后直接用缓存的 code object。`exec_globals` 仍然每次新建,**只有 code object 被复用**。
- 验收:新增 pytest 计数断言——同一个盘跑两次 yoga 判定,`builtins.compile` 的调用次数第二次为 **0**(或至少不随请求数线性增长)。
- 验收:**等价断言**——对 `references/yoga_rules.json` 里全部 192 条去重表达式,改前/改后在同一张公开示例盘上的判定结果逐条相同(用同进程差分,参照 `tests/test_rectification_engine_memoization.py` 里 BUG-733 建立的做法,不要再写跨机 golden)。
- 验收:源码合同断言 `expr` 的来源只有规则表,且缓存里存的是 code object 不是求值结果。
### 6.4 两条 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`
- **BUG-734**:每轮聊天用一次完整业务请求做健康探测、消耗对方限流令牌、且不复用连接。**关联 BUG-727**(快照缓存那一单——它修的是快照,没覆盖探测)、**BUG-161 / BUG-301**(前台外部证据的两条既有红线)。防复发:**前台路径上的外部服务探测必须有 TTL,且不得消耗业务限流额度;探测与业务调用必须共用连接。**
- **BUG-735**:静态规则表达式每请求重新编译 102 次。**关联 BUG-721**(同一形状的第三个实例:只依赖静态输入的昂贵结果被放在每请求路径上)。防复发:**`eval` / `exec` 的入参若来自静态规则表,必须缓存编译产物;缓存 code object,永远不缓存求值结果。**
## 7. 让步顺序
1. 6.3(yoga 编译缓存)最先做——最独立、风险最低、9.4 % 立刻到手。
2. 6.1(探测 TTL)次之,是 70 % 里最大的一块。
3. 6.2(连接复用)可以砍到下一轮,砍了在进度记录里写明还剩多少握手开销。
4. 6.4 不得砍。
## 8. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/consultation-residual-hotspots-20260916 \
.worktrees/consultation-residual-hotspots-20260916 origin/staging
cd .worktrees/consultation-residual-hotspots-20260916
git status -sb | head -1
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 # AGENTS §9:涉及外部 oracle
```
开工前必读:`docs/research/pre_work_error_ledger.md`AGENTS §9);`docs/BUG_HISTORY.md`**BUG-161 / BUG-301 / BUG-727**(前台外部证据的三条既有边界)与 **BUG-733**(同进程差分怎么写)。
验收命令:
```bash
.venv/bin/python -m pytest tests/test_vedastro_gateway.py tests/test_vedastro_runtime_ops.py \
tests/test_vedastro_snapshot_cache.py tests/test_api_server_security.py \
tests/test_yoga_engine*.py
.venv/bin/python scripts/run_quality_gate.py --profile quick
```
## 9. 预期收益(本机实测基线)
改前(快照缓存已生效的稳态):6 次调用 5.30 s,网络 70 %、运行时编译 9.4 %、本地占星计算 2.4 %。
本单做完后,同一组 6 次调用里应当只剩 **1 次**真实探测(第一次)而不是 3–6 次,编译次数应当只剩 **192 次**(进程首次)而不是每请求 204 次。**进度记录必须贴出改前/改后的同口径实测**,不得只写「变快了」。
## 10. BUG 编号起点
基线 `f8e607c2``docs/BUG_HISTORY.md` 最大号为 **BUG-733**。本单预占 **BUG-734 / 735**。开工时核对当时实际最大号。
## 11. 不在本单范围
- 快照缓存本身(BUG-727 已做并验收)
- `scripts/jyotish_api_server.py` 的任何改动(拆解单独占)
- 三域上限 3 → N 的放宽(仍待 staging 实测)
- yoga 规则内容、判定口径、置信度边界