feat(t152): workers_ai_chat 種子(auth: binding,免金鑰)+ 修 /init/seed 吃掉 3.12 欄位+ 修 D1 LIKE 長查詢 500

SDD: workflow-discovery 3.12/3.13(不是新規格;3.12 已 confirmed 並實作完成)

## 1) workers_ai_chat 種子(新)
Cloudflare Workers AI 走 env.AI binding ⇒ 用戶不必填任何 API 金鑰就能問答。
放種子表而非產品安裝器:「裝好後預設有哪些 recipe」是平台能力(rule 07 薄殼原則)。
換模型/換供應商=改這一筆 recipe,workflow 不動。

選型實測(1.4.4 實例,真實長度 RAG prompt,每個模型連跑 2 次):
  llama-4-scout-17b        2373/2173 ms   答案最完整、引用正確 ← 選它
  llama-3.3-70b-fp8-fast   3261/2147 ms   可用但波動較大
  mistral-small-3.1-24b    3560/3631 ms
  qwen2.5-coder-32b        3572/3353 ms
  gpt-oss-120b             1971/2295 ms   回應形狀不同,response 取不到文字
  gemma-3-12b-it            5018 帳號無權限
對照舊路徑 Gemini gemma-4-31b-it:同型提問 16.87 s,且吐整段英文思考草稿。

## 2) 修 /init/seed 靜默吃掉 3.12 欄位
3.12 給 RecipeDefinition 加了 body_template/response_map/auth/binding_name,
但 /init/seed 是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被丟掉。
最惡劣的地方是「哪裡都不會紅」:recipe 查得到、endpoint 對,只有跑起來像沒設定過。
與 08-02 syncManifest 吃掉 manifest.daemon 欄同型(教訓:東西還在不在也要進機械閘)。
加 tests/init-seed-recipe-fields.test.ts:拿掉修復會紅、補回會綠(已實測會擋)。

## 3) 修 D1 LIKE pattern 50 bytes 上限造成的 500
/entries/search?q=… 只要 q 超過 48 bytes 就回 HTTP 500,沒有錯誤訊息。
逐 byte 二分:48→200/49→500;中文 16 字→200/17 字→500。
判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
⇒ 爆的是 LIKE 的 pattern('%'+q+'%' = 50),不是 statement 長度。
中文問句超過 16 字是常態,而 rag_chat 用整句問題當 q ⇒ 聊天對正常問句等於不能用。
(=InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
修法:q ≤ 48 bytes 走原路(行為逐字不變),超過才拆詞/切 UTF-8 邊界片段。
kbdb 全套 83 測全綠(含新增 8 項)。

## 4) 順手
- 移除被 commit 進 repo 的 node_modules 壞 symlink(指向 leo Mac 的絕對路徑,
  害任何 fresh clone 裝不起來、切分支還會把裝好的蓋掉——本次撞了兩次)。
- pending-changes.md 加 P2 提案(fan-out 並行執行)+等裁決,未動引擎。

驗證:cypher-executor 新增測試 17/17 綠;tsc 與基線逐字相同;
全套測試失敗集合與基線**逐字相同**(基線 14 個失敗,本分支 t173 既有,非本次引入)。
This commit is contained in:
2026-08-03 02:56:53 +00:00
parent 5b983c47b8
commit 47c6aaea03
9 changed files with 369 additions and 5 deletions
-1
View File
@@ -1 +0,0 @@
/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/cypher-executor/node_modules
@@ -22,13 +22,21 @@
* KBDB 改網址後同步更新此處。seed 先照現況進。
*/
import type { ResponseMap } from './recipe-payload';
export interface ApiRecipeSeed {
canonical_id: string;
display_name: string;
description?: string;
/** HTTP recipe=要打的網址;`auth: 'binding'` 型=要呼叫的資源名(如 Workers AI 的模型 id)。 */
endpoint: string;
method: string;
auth_service?: string;
// ── payload/回應/binding 三層(3.12):全選填,既有種子不帶=行為完全不變 ──
body_template?: Record<string, unknown>;
response_map?: ResponseMap;
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
binding_name?: string;
}
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
@@ -120,4 +128,47 @@ export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
method: 'POST',
auth_service: 'line_notify',
},
// ── LLM 對話(binding=免金鑰,3.12 第四型認證的第一個真實案例)──
//
// 為什麼進種子(而非寫在某個產品的安裝器裡):「裝好之後預設有哪些 recipe」是平台能力,
// 與本檔其餘種子同理由(見檔頭)。裝完 /init/seed 就有 ⇒ **用戶不填任何金鑰就能問答**。
//
// 換模型/換供應商=**改這一筆 recipe**endpoint + body_template + response_map),
// workflow 的 ask_llm 節點不動——這正是「換源=換 recipe 不是換引擎」。
//
// 選型實測(2026-08-03,在 1.4.4 實例上跑真實長度的 RAG prompt,每個模型連跑 2 次):
// @cf/meta/llama-4-scout-17b-16e-instruct 23732173 ms ✅ 答案最完整、引用正確
// @cf/meta/llama-3.3-70b-instruct-fp8-fast 32612147 ms ✅ 可用但波動較大
// @cf/mistralai/mistral-small-3.1-24b-instruct 35603631 ms
// @cf/qwen/qwen2.5-coder-32b-instruct 35723353 ms
// @cf/openai/gpt-oss-120b 19712295 ms ❌ 回應形狀不同,response 取不到文字
// @cf/google/gemma-3-12b-it ❌ 5018 This account is not allowed to access this model
// 對照舊路徑(Gemini `gemma-4-31b-it`):同型提問 **16.87 s**,且吐整段英文思考草稿
// ⇒ 選 llama-4-scout:**快 7 倍以上,且不需要淨化思考草稿**。
{
canonical_id: 'workers_ai_chat',
display_name: 'Workers AI 對話(免金鑰)',
description:
'Cloudflare Workers AI 文字生成,走 env.AI binding ⇒ 不需要任何 API 金鑰。'
+ 'ctx 帶 prompt,回應正規化成 text(含【答】標記與前綴淨化)。'
+ '換模型=改本 recipe 的 endpointworkflow 不動。',
endpoint: '@cf/meta/llama-4-scout-17b-16e-instruct',
method: 'POST',
auth: 'binding',
binding_name: 'AI',
body_template: {
messages: [{ role: 'user', content: '{{prompt}}' }],
max_tokens: 1024,
temperature: 0.2,
},
response_map: {
// Workers AI chat 回應:{ response: "…" }(另有 OpenAI 相容的 choices,取 response 最穩)
text_path: 'response',
// 提示詞要求答案以【答】開頭;模型偶爾會在前面多帶一行 ⇒ 取最後一個標記之後
answer_marker: '【答】',
// 前綴組合順序不定,循環剝殼(規則見 recipe-payload.ts sanitize
strip_prefixes: ['*', '-', '•', '>', '#', '"', '「', '【答】', 'Answer:', 'Draft:'],
},
},
];
+7
View File
@@ -50,6 +50,13 @@ initSeedRouter.post('/init/seed', async (c) => {
endpoint: seed.endpoint,
method: (seed.method ?? 'POST').toUpperCase(),
auth_service: seed.auth_service,
// ③ payload/回應/binding 三層(3.12):不列進來的欄位會被**靜默吃掉**——
// 種子帶了 body_template/response_map/auth 卻沒進 KV,症狀是 recipe 存在但跑起來
// 「像沒設定過」,且哪裡都不會紅(08-02 manifest.daemon 欄被列舉式重建吃掉的同型)。
body_template: seed.body_template,
response_map: seed.response_map,
auth: seed.auth,
binding_name: seed.binding_name,
created_at: existing?.created_at ?? now,
updated_at: now,
};
@@ -0,0 +1,67 @@
/**
* /init/seed 必須把種子的 3.12 三層欄位原樣寫進 KV —— SDD: workflow-discovery task 3.12/3.13
*
* 為什麼要有這個測試(別刪):
* 3.12 給 `RecipeDefinition` 加了 body_template / response_map / auth / binding_name
* 但 `/init/seed` 當時是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被靜默吃掉。
* 症狀最惡劣的地方在於「哪裡都不會紅」:recipe 查得到、canonical_id 對、endpoint 對,
* 只有跑起來像沒設定過(auth 掉了 ⇒ 走 HTTP 路徑去 fetch「@cf/…」這種不是網址的字串)。
* 這與 2026-08-02 `syncManifest()` 列舉式重建吃掉 `manifest.daemon` 欄是同一型事故——
* 當時的教訓寫著:「**東西還在不在**也要進機械閘」,本檔就是那道閘。
*
* 範圍:只驗「種子 → KV」這段(純資料搬運)。真的呼叫 Workers AI 由實例端到端驗。
*/
import { describe, it, expect } from 'vitest';
import { env, SELF } from 'cloudflare:test';
import { API_RECIPE_SEEDS } from '../src/lib/api-recipe-seeds';
type StoredRecipe = {
canonical_id: string;
endpoint: string;
auth?: string;
binding_name?: string;
body_template?: Record<string, unknown>;
response_map?: { text_path?: string; answer_marker?: string; strip_prefixes?: string[] };
};
async function seedThenRead(canonicalId: string): Promise<StoredRecipe> {
const res = await SELF.fetch('https://example.com/init/seed', { method: 'POST' });
// 測試環境沒有 KBDB binding ⇒ portal template 那段必然失敗、整體回 207(誠實回報,非本測目標)。
// 本檔只管 API recipe 那半,所以驗它自己的計數,不驗整體 status。
const body = await res.json<{ api_recipes: { seeded: number; failed: number; errors: string[] } }>();
expect(body.api_recipes.errors).toEqual([]);
expect(body.api_recipes.failed).toBe(0);
const uuid = await env.RECIPES.get(`idx:installed:${canonicalId}`);
expect(uuid, `${canonicalId} 沒有被 seed 進 KV`).toBeTruthy();
return JSON.parse((await env.RECIPES.get(`recipe:${uuid}`))!) as StoredRecipe;
}
describe('/init/seed 不得靜默吃掉 recipe 的 3.12 欄位', () => {
it('workers_ai_chat 種子本身宣告齊四個欄位(種子端)', () => {
const seed = API_RECIPE_SEEDS.find(s => s.canonical_id === 'workers_ai_chat');
expect(seed, 'workers_ai_chat 種子不存在=裝完不會有免金鑰問答').toBeDefined();
expect(seed!.auth).toBe('binding');
expect(seed!.binding_name).toBe('AI');
expect(seed!.endpoint.startsWith('@cf/'), 'binding 型的 endpoint=模型 id').toBe(true);
expect(seed!.body_template).toBeDefined();
expect(seed!.response_map?.text_path).toBe('response');
});
it('seed 之後 KV 裡讀回來的仍帶 auth/binding_name/body_template/response_mapKV 端)', async () => {
const stored = await seedThenRead('workers_ai_chat');
expect(stored.auth, 'auth 掉了 ⇒ 會被當成 HTTP recipe 去 fetch 一個不是網址的字串').toBe('binding');
expect(stored.binding_name).toBe('AI');
expect(stored.body_template, 'body_template 掉了 ⇒ 整包 ctx 被當 payload 送給模型').toBeDefined();
expect(stored.response_map?.text_path, 'response_map 掉了 ⇒ 下游拿不到 text').toBe('response');
expect(stored.response_map?.answer_marker).toBe('【答】');
});
it('既有 HTTP 種子不受影響:沒宣告新欄位就是 undefined,不憑空長出來', async () => {
const stored = await seedThenRead('telegram_send');
expect(stored.auth).toBeUndefined();
expect(stored.binding_name).toBeUndefined();
expect(stored.body_template).toBeUndefined();
expect(stored.response_map).toBeUndefined();
expect(stored.endpoint).toContain('api.telegram.org');
});
});
-1
View File
@@ -1 +0,0 @@
/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules
+78 -3
View File
@@ -84,7 +84,10 @@ export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Pr
// no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter).
if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); }
if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); }
if (f.q) { conds.push('content LIKE ?'); params.push(`%${f.q}%`); }
if (f.q) {
const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
conds.push(...m.conds); params.push(...m.params);
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const limit = Math.min(f.limit ?? 100, 1000);
const offset = f.offset ?? 0;
@@ -147,6 +150,77 @@ export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string,
return (result.meta?.changes as number | undefined) ?? 0;
}
// ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ───────────────────
//
// 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes**
// 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。
// q = 48 bytes → 200q = 49 bytes → 500ASCII 逐 byte 二分)
// 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500
// 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/
// library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。
// pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。
// 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。
//
// 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。
// rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文,
// 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。
// (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
//
// 修法(**短查詢行為逐字不變**):
// · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。
// · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。
// 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。
// 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。
//
// 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。
// 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中
// (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。
const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytespattern = '%' + q + '%'
const MAX_LIKE_TERMS = 6;
const utf8Len = (s: string): number => new TextEncoder().encode(s).length;
/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */
function chunkByBytes(s: string, maxBytes: number): string[] {
const out: string[] = [];
let cur = '';
for (const ch of s) {
if (utf8Len(cur + ch) > maxBytes) {
if (cur) out.push(cur);
cur = ch;
} else {
cur += ch;
}
}
if (cur) out.push(cur);
return out;
}
/**
* 把 q 轉成一組 `content LIKE ?` 謂詞與參數(純函式,單測用 export)。
* 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。
*/
export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } {
if (utf8Len(q) <= MAX_LIKE_Q_BYTES) {
return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false };
}
const terms: string[] = [];
for (const word of q.split(/\s+/).filter(Boolean)) {
for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) {
terms.push(piece);
if (terms.length >= MAX_LIKE_TERMS) break;
}
if (terms.length >= MAX_LIKE_TERMS) break;
}
// 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE
if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? '');
return {
conds: terms.map(() => 'content LIKE ?'),
params: terms.map((t) => `%${t}%`),
split: true,
};
}
// 「庫」filter 的 SQL 謂詞(portal-auth P1design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。
// COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))——
// 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。
@@ -209,8 +283,9 @@ export async function searchEntries(
source?: string,
includeDeprecated = false,
): Promise<Entry[]> {
const conds = ['content LIKE ?'];
const params: unknown[] = [`%${q}%`];
const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike
const conds = [...m.conds];
const params: unknown[] = [...m.params];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
+104
View File
@@ -0,0 +1,104 @@
// D1 的 LIKE pattern 有 50 bytes 硬上限 —— 長查詢 500 的修復(2026-08-03t152 途中發現)
//
// 病徵(在 1.4.4 實例上逐 byte 二分實測,非推論):
// `/entries/search?q=…` 只要 q 超過 48 bytes 就回 HTTP 500「Internal Server Error」,
// 沒有錯誤訊息。q=48 → 200q=49 → 500;中文 16 字 → 20017 字 → 500。
// 判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
// ⇒ 爆的是 **LIKE 的 pattern**'%'+q+'%' = 50 bytes),不是 statement 長度。
// 同一個長 q 走 mode=semantic 正常(那條不經過 LIKE)。
//
// 為什麼這是產品級的洞:中文問句超過 16 個字是常態,而 rag_chat 的 kw_search 拿整句問題當 q
// ⇒ 使用者問任何一句正常長度的中文,問答鏈在第二個節點就 500 ⇒ 聊天等於不能用。
//
// 本檔守兩件事:① 短查詢行為**逐字不變**(回歸保護)② 長查詢不再產生超長 pattern。
import { describe, it, expect } from 'vitest';
import { buildContentLike, searchEntries } from '../src/actions/entry-crud';
const bytes = (s: string) => new TextEncoder().encode(s).length;
const MAX_PATTERN = 50; // D1 上限
describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => {
it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => {
const m = buildContentLike('語意檢索');
expect(m.split).toBe(false);
expect(m.conds).toEqual(['content LIKE ?']);
expect(m.params).toEqual(['%語意檢索%']);
});
it('剛好 48 bytes 仍走單一 LIKE(邊界:pattern 正好 50', () => {
const q = 'a'.repeat(48);
const m = buildContentLike(q);
expect(m.split).toBe(false);
expect(bytes(m.params[0])).toBe(MAX_PATTERN);
});
it('49 bytes 起改走拆詞,且每個 pattern 都在上限內', () => {
const q = 'a'.repeat(49);
const m = buildContentLike(q);
expect(m.split).toBe(true);
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
});
it('有空白的長查詢=按詞拆,每詞一個 LIKE(AND 語意由 caller join', () => {
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
expect(m.split).toBe(true);
expect(m.conds.length).toBeGreaterThan(1);
expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true);
expect(m.params).toContain('%語意檢索%');
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
});
it('無空白的長中文句:切在 UTF-8 邊界上,不切出壞字', () => {
const q = '為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢';
expect(bytes(q)).toBeGreaterThan(48);
const m = buildContentLike(q);
expect(m.split).toBe(true);
for (const p of m.params) {
expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
expect(p).not.toContain(''); // 替換字元=切壞了
expect(q).toContain(p.slice(1, -1)); // 每片都是原句的真子字串
}
});
it('永遠不會回空條件(空條件會讓 WHERE 塌掉、把全表撈回來)', () => {
for (const q of ['a'.repeat(200), ' '.repeat(60), '。'.repeat(60)]) {
const m = buildContentLike(q);
expect(m.conds.length).toBeGreaterThan(0);
expect(m.params.length).toBe(m.conds.length);
}
});
});
describe('searchEntries 實際送出的 SQL', () => {
function fakeDb() {
const captured: { sql: string; params: unknown[] }[] = [];
const db = {
prepare(sql: string) {
return {
bind(...params: unknown[]) {
captured.push({ sql, params });
return { all: async () => ({ results: [] }) };
},
};
},
} as unknown as D1Database;
return { db, captured };
}
it('短查詢:SQL 裡只有一個 content LIKE(回歸保護)', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '語意檢索', 'demo');
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
expect(captured[0].params[0]).toBe('%語意檢索%');
});
it('長查詢:拆成多個 content LIKE,且沒有任何 pattern 超過 50 bytes', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '為什麼 rag_chat 不直接用語意檢索排名來選頁而要用字面重疊', 'demo');
const sql = captured[0].sql;
expect((sql.match(/content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
for (const p of captured[0].params) {
if (typeof p === 'string' && p.startsWith('%')) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
});
@@ -8,6 +8,57 @@
## 待裁決
### P2|fan-out 並行執行(一個節點的多條出邊目前是循序跑)— 2026-08-03
**觸發**:leo 08-03 原話——「這是在測試中的計畫,**希望體驗很好**,我發現用 gemma4 的反應非常慢。」
把 rag_chat 的生成端換成 Workers AI 之後(16.87 s → 2.2 s),一量才發現
**慢的大頭根本不是 LLM**
**實測(1.4.4 實例,逐段疊加、每段取兩次的較快值)**
```
① prepcode 208 ms
② +kw_search 1,383 ms (+1,175)
③ +sem_search 3,240 ms (+1,857)
④ +fetch_triplets 5,198 ms (+1,958)
⑤ +fetch_blocks a/b/c 7,793 ms (+2,595)
⑥ +assemblecode 8,421 ms (+628)
+ask_llmWorkers AI ≈10,400 ms (+2,000)
```
**6 個 KBDB 檢索節點合計約 7.6 s,佔全鏈 73%;LLM 只佔 20%。**
而這 6 個節點**彼此完全獨立**kwsemtripletsblocks×3 誰都不吃誰的輸出),
只是因為 flow 被寫成一條鏈才一個接一個跑。
**根因在引擎,不在 workflow**`cypher-executor/src/graph-executor.ts`):
- 起點節點**已經是並行**的(`:85` `Promise.all(startNodes.map(...))`
- fan-in **已經支援**`:78-84` 入度 >1 的節點等所有上游到齊)
- 但**出邊是循序的**`:427` `for (const edge of outEdges)`,迴圈內 `await executeNode`
⇒ 一個節點分岔出 6 條 `ON_SUCCESS`,是一條跑完才跑下一條。
**改 workflow 解不了**:把 6 個節點都掛在 prep 底下,只是把「鏈」變成「6 條循序出邊」,一樣慢。
**提案(擇一,我建議 A**
- **A. 出邊並行 沿用既有 fan-in**:把 `:427` 那個迴圈改成
「同型別、無條件分支的出邊」用 `Promise.all` 併發,其餘(`ON_TRUE``ON_FALSE``ON_BRANCH``ON_FAIL`
維持原樣。rag_chat 的 6 個檢索節點改掛在 prep 底下、匯流進 assemble(入度 6fan-in 現成)
⇒ 預估 **7.6 s → 約 2 s**,全鏈約 **10.4 s → 4.5 s**
⚠️ **這是引擎核心、風險最高**(照本 repo 自己的規矩:先寫測試再改)。
需要先講清楚的語意:多條邊並行時 `result` 怎麼合併(現行是「後一條覆蓋前一條」的隱含語意)、
任一條失敗時的行為、trace 的順序。**既有 workflow 行為必須零變化,且要跑一次證明。**
- **B. 只砍節點數(完全不動引擎)**:`kw_search``sem_search` 在 v2 計分裡只是小加分
kw 命中 +2、sem 前十 +0.4,主導權在 IDF 字面重疊)⇒ 拿掉這兩個節點可省約 3 s。
代價是**檢索品質**(少了兩個佐證訊號),屬產品取捨,不是我能裁的。
- **C. 不做**:接受目前的 10 s。
**影響分析**
- 現行 active SDD `workflow-discovery`A 屬引擎能力,與 3.9`ON_TRUE``ON_FALSE` 走邊)同一塊,
是**新增任務**,不作廢任何既有任務。
- B 只動 `arcrun-rag/workflows/rag-chat.local.yaml`,不碰本 repo。
- 三者都**不影響**已完成的 3.12recipe 三層)與本次 Workers AI 換源。
**⏸ 停在這裡等 leo 裁**:回「A」我就先寫測試再改引擎;回「B」我只改 workflow;回「C」就記著不做。
### P1|host fn 二進位通道(解開「零件無法處理非文字檔」的框架級限制)— 2026-07-27
**觸發**leo 07-27 原話(arcrun-rag t73 收尾時)——
@@ -199,6 +199,17 @@
一次打開 `env.AI``VECTORIZE``BROWSER``QUEUE`)。
**相容硬要求**:既有 recipe(無新欄位)行為完全不變。
- [◐] 3.13 **驗收=CP 步驟 5 考試(features/07**
◐ 08-03t152**3.13「真正的改寫」那半落地**):`rag_chat``ask_llm`
「整包 Gemini 細節寫在 workflow 節點」改成 `component: workers_ai_chat`(一個 recipe 名 一個 prompt),
`finalize`**2786 字元縮到 12 行**(回應解讀搬進 recipe 的 `response_map`)。
本 repo 側配套:`api-recipe-seeds.ts` 新增 `workers_ai_chat` 種子(`auth: binding`
3.12 第四型認證的第一個真實案例)+修掉 `/init/seed` **列舉式重建吃掉 3.12 四個欄位**的洞
(種子帶了 body_template/response_map/auth 卻進不了 KV,且哪裡都不會紅——與 08-02
`syncManifest` 吃掉 `manifest.daemon` 同型)。回歸閘 `tests/init-seed-recipe-fields.test.ts`
3 項,**拿掉修復會紅、補回會綠**(實測過會擋)。
實例端到端(1.4.4 youlin 帳號,**零 API 金鑰**):問答鏈全綠、有答案有出處。
選型實測見種子檔註解(llama-4-scout 2.2s vs Gemini gemma-4-31b **16.87s**)。
⚠️ 仍 ◐ 未 ✅:haiku 場景(缺件時只寫 recipe 就補全、零 JS)=features/09 stage 端到端未驗。
◐ 08-01:本地驗收綠(`tests/step5-acceptance.test.ts` 3 項),實跑輸出=
**舊寫法 1201 字元 / if×8 → 新寫法 350 字元 / if×0(下降 71%**
且分流工作流 `success=true`、零 code 節點。