在 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
13 KiB
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(...)
三件事叠在一起:
- 这不是 ping,是一次完整的业务调用。
probe_calculate_health的 docstring 写着「POST a fictional smoke horoscope and report whether Status is Pass」——它把一份虚构的排盘请求发给api.vedastro.org/api/HoroscopePredictions,用返回的 Status 当健康信号。 - 它会消耗对方的限流额度。
call()开头consume_rate_token()(除非skip_rate_limit=True,而探测没传)。也就是说:每一轮普通聊天都在拿一个外部限流令牌去做健康检查,真正的业务调用反而要和它抢。 - 没有连接复用。
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"))对每条规则做:
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 授权本单:
- 健康探测要缓存,但诊断端点必须仍然是实时的。
gateway_status()现在有两类调用方——jyotish_api_server._compute_vedastro_gateway_status(诊断端点,人在看「现在到底通不通」)和run_gateway_packet(每轮聊天)。缓存只能加在聊天这条路上,或者给诊断端点一个强制刷新的入口;不得让运维看到一个 60 秒前的假象。 - 缓存的是「探测结果」,不是「业务答案」。 本单不碰快照缓存(BUG-727 已做),也不改任何业务数据的新鲜度口径。
- yoga 表达式只缓存编译产物,不缓存求值结果。 求值依赖每次不同的盘上下文(
exec_globals),绝不能缓存result——那会让所有人拿到同一张盘的 yoga 判定。 - 零输出变化。 两处改完,同一份请求的响应必须与改前逐字相同(计时字段除外)。
5. 硬红线
- 不得缓存 yoga 的求值结果,只缓存 code object。 这条如果做错,后果是跨用户串盘,比性能问题严重得多。
eval的语义不得放宽:今天的行为是「先按表达式求值,SyntaxError时退到改写过的多语句 exec」。缓存版本必须在第一次确定该表达式走哪条路,之后固定走那条,结果与改前逐条相同。不得把exec换成eval或反之。- 编译缓存必须有界,并且要在测试里断言
expr只来自仓库内的静态规则表、不含任何用户输入派生的字符串。若发现有用户输入能进入expr,立刻停手写进BLOCKED.md——那是一个远比性能严重的问题。 - 失败不得被长时间缓存。 探测失败(服务挂了、限流、超时)的 TTL 必须显著短于成功的 TTL,否则对方恢复了我们还要再瞎等一分钟。
- 不得改
scripts/jyotish_api_server.py(AGENTS §6,且它是拆解单的独占文件)。诊断端点若需要强制刷新,用参数从vedastro_gateway侧暴露,主文件那一行调用保持不变。 - 不得顺手升级依赖、不得顺手修不在本单里的 warning。
6. 任务分解
6.1 健康探测加 TTL(BUG-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. 让步顺序
- 6.3(yoga 编译缓存)最先做——最独立、风险最低、9.4 % 立刻到手。
- 6.1(探测 TTL)次之,是 70 % 里最大的一块。
- 6.2(连接复用)可以砍到下一轮,砍了在进度记录里写明还剩多少握手开销。
- 6.4 不得砍。
8. 开工前置命令
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(同进程差分怎么写)。
验收命令:
.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 规则内容、判定口径、置信度边界