# TASK · 普通聊天每一轮都在同步等外网,本地计算只占 3% - 日期:2026-09-15 - 基线 commit:`origin/staging` @ `6b3248bf` - 执行分支:`codex/consultation-external-evidence-cache-20260915` - 主要落点:`scripts/jyotish_api_server.py`、`scripts/vedastro_service_adapter.py`、`scripts/vedastro_user_entrypoint.py` - **依赖已于 2026-09-15 反转:本单排在 API server 拆解单之前。** 原文写的是「串行在 `TASK-api-server-decomposition-20260916` 之后」,产品拍板改为**本单先做**——理由见 §3.7,拆解单已同步改为串行在本单之后 - 与 context-memory 单、session-capacity 单无文件重叠,可并行 --- ## 1. 事故实证 普通聊天每发一条消息,每个问题域调一次 `POST /api/consultation_workflow`。本机(Python 3.13 + swisseph)热身后实测: | 问题域 | 第 1 次 | 第 2 次(同参数) | 响应体积 | | --- | ---: | ---: | ---: | | career | 928 ms | 865 ms | 523 k 字符 | | wealth | 493 ms | 490 ms | 523 k 字符 | | marriage | 493 ms | 492 ms | 518 k 字符 | | general | 491 ms | 493 ms | 542 k 字符 | | timing | 523 ms | 507 ms | 550 k 字符 | cProfile 按 `tottime` 排前五名: | | 耗时 | | --- | ---: | | `select.poll`(等外网 socket) | 0.801 s | | `_ssl._SSLSocket.read` | 0.786 s | | `_ssl._SSLSocket.do_handshake` | 0.206 s | | `swisseph.calc_ut`(本地排盘) | **0.022 s** | | `copy.deepcopy` | 0.048 s | 前三名全部来自 `_join_foreground_vedastro` → `_run_foreground_vedastro_gateway` → `vedastro_service_adapter` 的 `urlopen`,目标是 `api.vedastro.org`,而且每次重新 TLS 握手。**本地占星计算连 3% 都不到。** 三个护栏数字凑不到一起,符号定位: | 位置 | 值 | 含义 | | --- | ---: | --- | | `jyotish_api_server.py` `_foreground_vedastro_join_seconds` | 1.5 s(上限 3 s) | 前台最多等多久 | | `jyotish_api_server.py` `_foreground_vedastro_budget_seconds` | 8 s(2–12 s) | 后台任务自己跑多久 | | `jyotish_api_server.py` `_FOREGROUND_VEDASTRO_WORKERS` | **2** | 整个进程的前台线程池 | `_join_foreground_vedastro` 超时走 `FuturesTimeoutError` 分支返回 `official_blocked`,**但不 `cancel()` 那个 future**,任务继续跑满 8 秒预算。于是:只要平均每 4 秒有一轮聊天,2 个 worker 就永远被占着,之后每一轮都白等 1.5 秒再拿到 `official_blocked`——用户付出了等待,拿不到证据,技法表上那一层还是 blocked。 另有一条纯浪费:响应体 52 万字符里 `western_evidence_packet` 占 **122 KB**,全仓检索前端**零读取点**(`grep -rn "western_evidence_packet" frontend/src` 无命中)。算出来、序列化、走 HTTP、在 Node 里 `JSON.parse` 一遍,然后丢掉。三个域就是 366 KB 的无效解析,全压在 2 vCPU 上。其余键体积:`chart` 238 KB、`consumer_context` 43 KB、`rectification` 23 KB、`vedastro_gateway` 21 KB。 ## 2. 根因 这份外网证据**本来就是按「盘 + 日期」组织的**,只是没有按这个键缓存过: - `vedastro_service_adapter._official_snapshot_reference_date()` 在 case 里找不到 `reference_date` / `today` / `transit_date` / `current_date` 时,缺省取 **UTC 当天**。 - `consultationInputSchema`(`frontend/src/mastra/consultation-workflow.ts`)**没有任何日期字段**,所以普通聊天永远走缺省。 - `_official_dasha_range_body()` 把大运区间取成该日期所在**自然年的 1 月 1 日到 12 月 31 日**。 也就是说:本命部分永不变,随时间变的部分粒度是天/年。同一个人一天里问十轮,十次拿到的是同一份东西,却打了十次外网。 ## 3. 决策记录 产品 2026-09-15 授权本单,并明确以下口径: 1. **不许「干脆不调 VedAstro」。** 那正是 BUG-301 修掉的问题(前台跳过后技法表里 VedAstro 云状态永远 blocked)。本单的目标是**留住证据、去掉等待**,不是二选一。 2. **缓存键沿用引擎内部已有的那一个**:出生数据 + 岁差 + 交点 + **UTC 日期**。产品明确否决了「自己定一个 N 小时 TTL」——那会和 `_official_snapshot_reference_date` 的日期键错位,跨 UTC 零点时以为还新鲜、内容其实已经该换。 3. **新鲜度分档**(产品定): - 同一天命中 → 直接用,**0 等待**。 - 跨天了 → **先用旧的**(上限 7 天)立刻返回,同时后台刷新,下一轮就是新的。 - `entrypoint = daily_starlanguage`(「深入看今日」)→ **要求当天**,没有当天的不吃旧的,走下一档。 - 完全没有缓存 → 才走现在那套 1.5 秒有界等待。 4. **后台任务必须可取消、线程池必须可排队。** 现在「超时不 cancel + 只有 2 个 worker」的组合是本单必须解决的部分,不是顺带。 5. **`western_evidence_packet` 按需返回,不删计算。** 西洋盘本身仍是 must-use 层之一(被读的是别的字段),本单只是不再把这个整包塞进每一轮的响应。 6. **不动并发闸门(默认 2)、不动 `AGENT_TIMEOUT_MS`、不动 `maxDuration`。** 7. **本单排在 API server 拆解之前**(2026-09-15 产品拍板,推翻本任务书首版的排序)。依据:本单要改的 `execute_consultation_workflow`、`_join_foreground_vedastro`、那三个常量**全是模块级函数**,而拆解单的核心是把业务逻辑搬出 `JyotishAPIHandler` **类**,两边动的是文件的不同部分;且本单的用户价值(每一轮省掉一次外网等待)远高于一次纯搬运。代价是拆解单将来要吸收本单的 diff,已在那一单里写明。 ## 4. 硬红线 1. 缓存键必须与 `_api_chart_cache` 的键**分开**(BUG-161 防复发原文:「快速排盘与完整证据排盘必须使用不同缓存键」)。不得复用同一个目录或同一个 key 函数。 2. 赶不上外网时,不得把未交付的官方层标成 `executed`(BUG-301 防复发)。用旧缓存时,证据里必须能看出它是哪一天的。 3. 缓存内容里不得落盘姓名、邮箱、用户 ID;出生资料派生值只能以哈希进键,不得明文写进缓存文件名(AGENTS §8)。 4. 前台不得再同步串联 overview + snapshot + range scan(BUG-301 防复发,现状已满足,不得回退)。 5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。**具体到数字**:开工时实测 11,334 行、上限 11,363,**余量只有 29 行**;`JyotishAPIHandler` 有 225 个方法,**本单不得新增任何类方法**。 若 `TASK-freeze-metric-change-20260915` 已经合入,主门改为「类方法数不得增长」,行数粗护栏放宽——**以开工当时生效的合同测试为准**,但「不新增类方法、新逻辑进独立模块」这条无论口径怎么换都成立。 6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 ## 5. 任务分解 ### 5.1 外网证据按「盘 + UTC 日期」缓存 新增一个独立模块(例如 `scripts/vedastro_snapshot_cache.py`),提供读/写/新鲜度判定: - 键:`sha256(出生年月日时分秒 + lat + lon + tz + ayanamsa_policy + node_policy + reference_date)`。 - 落盘位置与 `_api_chart_cache_dir()` **不同目录**,TTL 由 `reference_date` 与「最多 7 天」共同决定,不再引入第二个 TTL 环境变量。 - `execute_consultation_workflow` 里:命中当天 → 直接用,**完全不 submit 后台任务**;命中 1–7 天前 → 直接用旧的并 submit 一次后台刷新(刷新结果只写缓存,不参与本轮);`entrypoint == "daily_starlanguage"` 时只接受当天;全未命中 → 现有 1.5 秒有界路径,拿到结果后写缓存。 - 验收:新增 pytest——同一份 body 连调两次,第二次**零外网调用**(用 stub/monkeypatch 计数 `urlopen` 或适配器入口),且两次返回的 `vedastro_gateway` 逐字相等。 - 验收:把 `reference_date` 推到第二天,断言缓存不再命中当天档、走「先用旧的 + 后台刷新」,且**本轮不等待**。 - 验收:`daily_starlanguage` 入口在只有昨天缓存时不得使用它。 - 验收:缓存目录与 `_api_chart_cache_dir()` 不同,且有一条断言钉死这一点。 ### 5.2 后台任务可取消 + 线程池可排队 `_join_foreground_vedastro` 超时时必须 `future.cancel()`;已经在跑的任务要能被 `temporary_timeout_seconds` 之外的显式取消标记打断,或者把预算从 8 秒收到不超过 join 的两倍并说明理由。线程池大小与 join/budget 三个数必须在同一处成组声明,并写明它们的关系(照 `consultation-tools.ts` 里那段预算注释的写法)。 - 验收:新增测试——连续发起超过 `_FOREGROUND_VEDASTRO_WORKERS` 个前台请求,断言第 N+1 个的等待时间仍不超过 join 上限,且不会因为前面的任务没结束而排队变长。 - 验收:源码合同断言三个数在同一个声明块里,且 `budget ≤ k × join`(k 取实现选定的常量)。 ### 5.3 `western_evidence_packet` 按需返回 默认不放进 `/api/consultation_workflow` 的响应;需要它的调用方(个人报告、高严谨工作流,若确有读取点)显式请求。开工第一步先把真实读取点查清楚,查到的写进进度记录,没查到的按「无人读」处理。 - 验收:`grep -rn "western_evidence_packet" frontend/src scripts/ tests/` 的结果写进进度记录,逐个说明保留或去掉。 - 验收:改后响应体积实测下降幅度写进进度记录(改前 523 k 字符是本单的基线数)。 - 验收:`consultationWorkflowResponseSchema` 仍能解析(它是 `.passthrough()`,去掉一个键不应报错——但必须有测试证明,不能靠推断)。 ### 5.4 在 staging 量一次真实的单域耗时 `frontend/src/mastra/consultation-tools.ts` 的预算注释写明:域上限 3 是按「staging 实测三域共 62.9 秒」反推的,即一域约 21 秒。本机同样的调用只要 0.5 秒,**差 40 倍**。这单交付后必须在 staging 上重新量一次: - 交付一份可照做的清单进 `docs/testing/`,内容是:在 staging 上对同一张盘连发两轮同域提问,记录两轮的 `consultationToolDurationMs`(第一轮冷、第二轮应命中缓存)。 - 结论写进进度记录:如果单域耗时已显著低于 21 秒,**明确写出「三域上限可以放宽到 N」的依据**,但**本单不改那个上限**——放宽是产品决策,另开单。 - 验收:`docs/testing/` 下有这份清单;进度记录里有改前/改后的实测两组数字,或明确写成环境缺口(无 staging 访问权时)。 ### 5.5 两条 Bug 历史 同一变更内写进 `docs/BUG_HISTORY.md`: - **BUG-727**:普通聊天每轮每域同步等外网,本地计算只占 3%;超时不取消 + 2 个 worker 让线程池长期饱和。**关联 BUG-161、BUG-301、BUG-718**,并写清楚:BUG-301 是**故意**把 VedAstro 放回前台的(否则技法表永远 blocked),所以本单不是推翻它,而是用缓存同时满足 161 和 301。防复发写成:**前台外部证据必须有「盘 + 日期」级缓存;任何有界等待都必须同时取消它等待的后台任务。** - **BUG-728**:`western_evidence_packet` 无人读却每轮每域传 122 KB。防复发写成:**工作流响应新增大字段前必须有读取点;没有读取点的字段不得进入前台响应。** ## 6. 让步顺序 1. 5.1(缓存)必须做,它是本单的全部意义。 2. 5.2(可取消 + 可排队)必须做——只加缓存不修线程池,冷启动和跨天刷新仍会把池子占死。 3. 5.3 可以砍到下一轮,砍了在进度记录里写明。 4. 5.4 **不得砍**,但允许写成环境缺口(没有 staging 访问权时,把清单交出来即可)。 5. 5.5 不得砍。 ## 7. 开工前置命令 ```bash git fetch origin --prune # 本单不再等 api-server-decomposition;直接以 origin/staging 为基线 git log --oneline origin/staging | head -5 git worktree add -b codex/consultation-external-evidence-cache-20260915 \ .worktrees/consultation-external-evidence-cache-20260915 origin/staging cd .worktrees/consultation-external-evidence-cache-20260915 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/testing/vedastro-runtime-20260915.md`(BUG-719/720 的运行期真相,与本单同一外部服务)。 验收命令: ```bash .venv/bin/python -m pytest tests/test_api_server_security.py \ tests/test_consultation_consumer_context.py \ tests/test_vedastro_runtime_ops.py .venv/bin/python scripts/run_quality_gate.py --profile quick cd frontend && npx tsx --test tests/consultation-*.test.ts ``` ## 8. BUG 编号起点 基线 `6b3248bf` 上最大号 **BUG-720**;2026-09-15 校正四单已预占 **721–726**。本单预占 **BUG-727 / 728**。开工时核对当时的实际最大号,冲突顺延并在进度记录写明。 ## 9. 不在本单范围 - `JyotishAPIHandler` 的拆解本身(本单只做薄注册,拆解排在本单之后) - 三域上限 3 → N 的放宽(5.4 只负责量数据,放宽是产品决策) - 并发闸门、`AGENT_TIMEOUT_MS`、`maxDuration` - `reference_date` 缺省用 UTC 当天、而用户的「今天」是 UTC+8,早上八点前后会错开一天——**记为观察项,本单不修** - 对话记忆与上限(见另外两单)