From 8eb10049b8f575a9975d2d2f57eadb190ba47d2f Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sun, 9 Aug 2026 01:26:16 +0800 Subject: [PATCH] =?UTF-8?q?P8=EF=BC=9A=E7=AF=80=E9=BB=9E=E8=BC=B8=E5=87=BA?= =?UTF-8?q?=20KV=20=E5=AF=AB=E5=85=A5=E5=8F=AA=E6=9C=8D=E5=8B=99=20PIPE=20?= =?UTF-8?q?=E8=AE=80=E8=80=85=E2=80=94=E2=80=94=E6=8B=86=E6=8E=89=E6=AF=94?= =?UTF-8?q?=20neurons=20=E6=9B=B4=E7=9F=AD=E7=9A=84=E9=9A=B1=E5=BD=A2?= =?UTF-8?q?=E7=9F=AD=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 08-09「不需要換模型,要調整每個 CF 數字搭配」。盤點發現真正最短的板不是 Workers AI neurons(119 檔/日),是 EXEC_CONTEXT KV:每節點(含 FOREACH 每圈) put 一次、rag 工作流一張卡 15 次,但唯一讀點是 PIPE 邊——rag 系全無 PIPE 邊, 15 次全是白燒 ⇒ KV 1,000/日 ÷ 15 ≈ 66 檔/日,兩條路(免金鑰/Gemini)都被卡。 修法=寫入前檢查「節點有 PIPE 出邊」。PIPE 工作流與 resume 路徑行為不變。 實測(youlin stage):修前同構卡留 6 node key(15 put)→ 修後零 key, blocks/triplets 照常寫入。單元測試鎖住兩側行為。 另復原 08-08 重部誤拔的 [ai] binding(extract 501→200,KEEP_AI=true)。 Co-Authored-By: Claude Opus 5 --- cypher-executor/src/graph-executor.ts | 10 ++- cypher-executor/tests/executor.test.ts | 81 ++++++++++++++++++++ system-dev/docs/3-specs/portal-auth/tasks.md | 19 +++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index ea02787..44909e7 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -348,7 +348,15 @@ export class GraphExecutor { // BUILD-006:將節點 output 存入 KV(key = {run_id}:node:{node_id}) // 這讓下游節點可以透過 KV 讀取上游的具名 output,解決同名欄位衝突 - if (kvStore && result !== null && result !== undefined) { + // + // P8 短板齊平(2026-08-09,任務層小改記 portal-auth/tasks.md):只在「下游真的會讀」 + // 時才寫。全 codebase 唯一的讀點是 PIPE 邊處理(本檔下方 kvGetNodeOutput 呼叫處)—— + // 沒有 PIPE 出邊的節點,這筆寫入沒有任何讀者,卻每個節點(含 FOREACH 每一圈) + // 都燒一次 KV write。實測 rag_ingest_card 一張卡燒 15 次(4 固定節點+5 blocks + // +6 triplets),把免費層 KV 1,000 write/日壓成約 66 檔/日的最短板——全是白燒。 + // 有 PIPE 出邊(含「完成後」與未知語意詞的預設)的節點行為完全不變。 + if (kvStore && result !== null && result !== undefined + && graph.edges.some((e) => e.from === node.id && (e.type as EdgeType) === 'PIPE')) { await kvSetNodeOutput(kvStore, node.id, result); } diff --git a/cypher-executor/tests/executor.test.ts b/cypher-executor/tests/executor.test.ts index bb7bbd1..17abc0a 100644 --- a/cypher-executor/tests/executor.test.ts +++ b/cypher-executor/tests/executor.test.ts @@ -250,3 +250,84 @@ describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => expect(result).toBeDefined(); }); }); + +// P8 短板齊平(2026-08-09):節點輸出只在「下游有 PIPE 邊會讀」時才寫 KV。 +// 背景:BUILD-006 原本每個節點(含 FOREACH 每一圈)都 put 一次 EXEC_CONTEXT, +// 但全 codebase 唯一讀點是 PIPE 邊的 kvGetNodeOutput——rag 系工作流 +// (ON_SUCCESS+對每個)一張卡白燒 15 次 KV write,把免費層 1,000/日 +// 壓成比 Workers AI neurons 更短的板。此測試鎖住「無 PIPE 出邊=零 KV put」 +// 與「有 PIPE 出邊=照舊寫、_kv_outputs 照舊可讀」兩個行為。 +describe('P8:節點輸出 KV 寫入只服務 PIPE 讀者', () => { + // 計數型 KV mock:只記 put 次數(kvSetNodeOutput 只用到 put;get 給 PIPE 讀) + function countingKv() { + const store = new Map(); + let puts = 0; + const kv = { + put: async (k: string, v: string) => { puts++; store.set(k, v); }, + get: async (k: string) => store.get(k) ?? null, + } as unknown as KVNamespace; + return { kv, getPuts: () => puts }; + } + + it('ON_SUCCESS+FOREACH 工作流(rag_ingest_card 形狀)→ 零 KV put', async () => { + const loader = async (id: string): Promise => async () => { + if (id === 'parse') { + return { success: true, blocks: [{ n: 1 }, { n: 2 }, { n: 3 }], rels: [{ r: 1 }, { r: 2 }] }; + } + return { success: true, data: { ok: true } }; + }; + const executor = new GraphExecutor(loader); + const graph: ExecutionGraph = { + id: 'p8-no-pipe', + name: 'rag 形狀(無 PIPE 邊)', + nodes: [ + { id: 'input', type: 'Input', data: {} }, + { id: 'list_old', type: 'Component', componentId: 'http_request' }, + { id: 'parse_card', type: 'Component', componentId: 'parse' }, + { id: 'post_block', type: 'Component', componentId: 'http_request' }, + { id: 'post_triplet', type: 'Component', componentId: 'http_request' }, + ], + edges: [ + { from: 'input', to: 'list_old', type: 'ON_SUCCESS' }, + { from: 'list_old', to: 'parse_card', type: 'ON_SUCCESS' }, + { from: 'parse_card', to: 'post_block', type: 'FOREACH', iterator: 'block' }, + { from: 'parse_card', to: 'post_triplet', type: 'FOREACH', iterator: 'rel' }, + ], + }; + const { kv, getPuts } = countingKv(); + const result = await executor.execute(graph, {}, kv); + expect(result).toBeDefined(); + // 修法前這裡是 8(list_old + parse_card + 3×post_block + 2×post_triplet + input 不寫) + expect(getPuts()).toBe(0); + }); + + it('PIPE 工作流 → 照舊寫 KV 且 _kv_outputs 傳遞不變(BUILD-006 語意保留)', async () => { + const seen: Record[] = []; + const loader = async (id: string): Promise => async (ctx) => { + seen.push(ctx as Record); + return { success: true, data: { from: id } }; + }; + const executor = new GraphExecutor(loader); + const graph: ExecutionGraph = { + id: 'p8-pipe', + name: 'PIPE 鏈', + nodes: [ + { id: 'input', type: 'Input', data: { message: 'hi' } }, + { id: 'a', type: 'Component', componentId: 'comp_a' }, + { id: 'b', type: 'Component', componentId: 'comp_b' }, + ], + edges: [ + { from: 'input', to: 'a', type: 'PIPE' }, + { from: 'a', to: 'b', type: 'PIPE' }, + ], + }; + const { kv, getPuts } = countingKv(); + const result = await executor.execute(graph, {}, kv); + expect(result).toBeDefined(); + // a 有 PIPE 出邊 → 寫;b 沒有出邊 → 不寫(原本 a、b 都寫=2) + expect(getPuts()).toBe(1); + // 下游 b 收到的 context 帶 _kv_outputs.a(BUILD-006 讀路徑不變) + const bCtx = seen[seen.length - 1]; + expect((bCtx._kv_outputs as Record)?.a).toBeDefined(); + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 8a68536..824e966 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -325,6 +325,25 @@ 已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。 vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。 +- [x] **P8-資源搭配:節點輸出 KV 寫入只服務 PIPE 讀者(2026-08-09,任務層小改)**: + 來源=arcrun-rag `system-dev/docs/3-specs/pending-changes.md` P8「短板齊平」(leo 08-08 裁決通過) + + leo 08-09「不需要換模型,要調整每個 CF 數字搭配」(模型切換已 revert `894d9ab`,本項不碰模型)。 + 病:BUILD-006 讓**每個節點**(含 FOREACH 每一圈)把輸出 put 進 EXEC_CONTEXT KV, + 但全 codebase 唯一讀點是 PIPE 邊的 `kvGetNodeOutput`——rag 系工作流(ON_SUCCESS+對每個) + 完全沒有 PIPE 邊 ⇒ 一張卡 15 次 put 全是寫了沒人讀的。 + KV 免費層 1,000 write/日 ÷ 15 ≈ **66 檔/日=全系統真正最短的板**(比 neurons 的 119 檔/日更短, + 且免金鑰路與 Gemini 路都吃這條)。§3.8.2 原盤點只算到 execution-logger 那 1 筆,漏了這 15 筆。 + 修:`graph-executor.ts` 節點輸出寫 KV 前檢查「該節點有 PIPE 出邊」才寫; + PIPE 工作流(含「完成後」與未知語意詞預設 PIPE)行為不變,resume 路徑(`resumeFromPaused`)不動。 + 實測(youlin stage,兩張同構測試卡 5 blocks+6 triplets): + 修前 run `rag_ingest_card-1786208572245` 留 6 個 node key(15 次 put); + 修後同形狀執行 EXEC_CONTEXT **零 key**;blocks/triplets 照樣寫入成功(KBDB record id 全數回傳)。 + 單元測試:`tests/executor.test.ts`「P8:節點輸出 KV 寫入只服務 PIPE 讀者」—— + 無 PIPE 圖 put=0/PIPE 圖照舊寫且 `_kv_outputs` 傳遞不變(12 passed;既有 1 failed 為 + 「零件不存在」文案舊測試,修法前後皆失敗,與本項無關)。 + 一併修復:08-08 deploy-all 重部把 youlin 的 `[ai]` binding 洗掉(`/portal/daemon/extract` 501) + ——本次以 `KEEP_AI=true` 重部復原(501→200 實測,模型仍 scout 未動)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)