在 LLM 不能信賴的世界裡,工程的職責不是強迫 LLM 完美,而是為它每一種可能的失敗形態都備好出路。
Andrej Karpathy 在 2024 年公開分享過一個概念:傳統 RAG 直接把 chunk 餵給 LLM 太「碎」,沒有結構;不如先用 LLM 把所有源文件編譯成 wiki,每篇 wiki 有清楚的 slug / page_type / TLDR / tags,query 時先看 wiki(快、便宜、可解釋),wiki 沒答案才 fallback 到 chunk-level RAG。
我們把這個概念真實實作到 cs-frontend(企業 RAG 知識庫產品)+ GEO Platform 共用的 rag-backend-v2 微服務,不只當宣傳概念用,實際跑生產:
tenant_documents 表wiki_pages 表這個 L1+L2 hybrid 跑出來的效益:同樣 query「百原 GEO 怎麼幫品牌提升 AI 引用率」:
但 L1 的成立有一個前提:Wiki 編譯必須穩定。實際上,我們 4 月初到 5 月初一個月內,踩了 6 種 LLM hallucination 形態的雷,每一種都讓 wiki 編譯部分或全部失敗。本章記錄這六層加固。
GEO 主後端跟 rag-backend-v2 雖然在同一個 PostgreSQL instance 內,但兩個 DB 完全分離:
geo_db |
cs_rag_db |
|
|---|---|---|
| 服務 | GEO Platform 主 backend | rag-backend-v2 微服務 |
| 用途 | brands / users / axp_pages / ground_truths | tenant_documents / chunks / wiki_pages |
| 角色 | 業務邏輯 + 多租戶資料 | 向量檢索 + LLM Wiki 編譯 |
| 跨庫查詢 | csRagQuery(text, params) 獨立 pool |
— |
切分的理由有三:
chunks 表(向量 embedding)會隨客戶量爆量,單表 100M row 不少見,跟業務表混在一起會傷主庫效能backend/src/config/database.js 開兩個獨立 PG pool:
const geoPool = new Pool({ connectionString: DATABASE_URL });
const csRagPool = new Pool({
connectionString: DATABASE_URL.replace(/\/geo_db$/, '/cs_rag_db'),
});
async function csRagQuery(text, params) {
try {
return await csRagPool.query(text, params);
} catch (err) {
console.error('[csRagQuery] failed:', err.message);
return { rows: [] }; // fail-soft 不擋主流程
}
}
UAT 沒 cs_rag_db(DATABASE_URL 對應 single-DB instance)→ csRagQuery 直接 fail,但 try/catch 接住回空陣列,主流程不擋。PROD 雙 DB 都在,csRagQuery 正常工作。
代價:GEO 主後端要跨庫拿 wiki_link_count 顯示「N 個 Wiki 頁」必須跑 csRagPool query,延遲 +30ms vs 同庫 JOIN。但相比把兩 DB 合在一起的維運成本,這 30ms 可接受。
V1 的 RAG 只有 chunks(L2 向量檢索)+ pgvector + BM25 + RRF。L1 layer 是 2026 年 4 月加的「Karpathy LLM Wiki」概念實作。V1→V2 的決策驅動因素:
V2 加 wiki 後,L1 hit 時 1.5 秒回 + 可指 wiki page_id 給「來源」,客戶滿意度提升。但 L1 必須穩定,任何編譯失敗都讓客戶看到「沒有相關資料」假象。
當客戶在前端按「刪除文件」,我們需要連帶清掉:
brand_documents (geo_db)
│ via central_doc_id UUID
▼
tenant_documents (cs_rag_db) ← LLM Wiki 編譯來源
│ via wiki_page_sources junction
▼
wiki_page_sources ← 多對多關聯表
│ via SSOT 同步 trigger
▼
wiki_pages.source_doc_ids ← 被引用的 wiki page
四層全自動 cascade 透過:
brand_documents.central_doc_id 欄位(migration 170)— 上傳時 capture 中央回傳的 doc id 寫進此欄ragDeleteDocument(tenantId, central_doc_id) → 中央 trg_source_soft_delete cascade trigger 自動清 junctionmigration 201:sync_wiki_page_sources trigger — 強制 wiki_pages.source_doc_ids array 與 wiki_page_sources junction 同步(SSOT)設計關鍵:junction 是 SSOT,array 是冗餘 derivation。原本兩者各自維護,常 desync(觀察到 36 stale junction entries),migration 201 後 trigger 強制兩者一致,客戶端「N 個 Wiki 頁」UI 顯示就永遠正確。
migration 201 的 trigger 強制兩者同步:
CREATE OR REPLACE FUNCTION sync_wiki_page_sources()
RETURNS TRIGGER AS $$
BEGIN
-- INSERT/UPDATE wiki_pages.source_doc_ids → 同步 junction
IF TG_TABLE_NAME = 'wiki_pages' AND (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
-- 刪除 junction 中不在 source_doc_ids 的 row
DELETE FROM wiki_page_sources
WHERE wiki_page_id = NEW.id
AND NOT (source_doc_id = ANY(NEW.source_doc_ids));
-- 插入 source_doc_ids 中不在 junction 的 row
INSERT INTO wiki_page_sources(wiki_page_id, source_doc_id)
SELECT NEW.id, doc_id
FROM unnest(NEW.source_doc_ids) AS doc_id
WHERE NOT EXISTS (
SELECT 1 FROM wiki_page_sources
WHERE wiki_page_id = NEW.id AND source_doc_id = doc_id
);
END IF;
-- DELETE wiki_page_sources 後,若 wiki_page 失去最後一個 source → 軟刪
IF TG_TABLE_NAME = 'wiki_page_sources' AND TG_OP = 'DELETE' THEN
UPDATE wiki_pages
SET deleted_at = NOW(), source_doc_ids = '{}'
WHERE id = OLD.wiki_page_id
AND NOT EXISTS (
SELECT 1 FROM wiki_page_sources WHERE wiki_page_id = OLD.wiki_page_id
);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
部署這個 trigger 之前,我們的清資料邏輯散在 7 個地方(application code 4 處 + migration script 3 處),常 desync。trigger 上線後改 1 處不需動 7 處,1 萬租戶 scale 必要。
trigger 上線時做了一次性 reconciliation:
-- 偵測 stale junction(junction 指向不存在的 wiki_pages 或 tenant_documents)
SELECT wps.*
FROM wiki_page_sources wps
LEFT JOIN wiki_pages wp ON wp.id = wps.wiki_page_id
LEFT JOIN tenant_documents td ON td.id = wps.source_doc_id
WHERE wp.id IS NULL OR td.id IS NULL;
-- → 36 row
一次性 DELETE 清掉,trigger 之後保證新增不再產生。這 36 個 entry 怎麼來的?Track 下去:
trigger 之後三類 case 都死路一條,以下保證:junction 永遠 reflect array 內容。
UAT 沒中央 RAG(CENTRAL_RAG_URL unset)→ Phase 1 path 自動 skip,本地 brand_documents 純走 pgvector adapter,不影響主流程(有 try/catch 不擋)。PROD 有中央 RAG → 完整四層 cascade。這條對齊「降級邊界清晰」的設計。
每一種失敗都來自真實 PROD 觀察,以下用 Patch N 編號順序記錄。這六個 patch 是我們在 2026 年 4 月底 5 月初一個禮拜內連環踩雷修出來的,每一個都對應一個血淋淋的故障時刻。
失敗形態:某 KB 84 個 docs 只有 33 個編進 wiki,51 個永久 orphan。
發現過程:客戶反應「我上傳了 84 個 FAQ 文件,wiki 顯示 wiki 頁面數只有 33 頁」。一開始懷疑是 LLM 編譯失敗,看 log 全綠;再懷疑 doc 沒 ready,DB 檢查全部 status='ready'。最後 grep wikiCompiler.js 發現 cutoff 邏輯。
根因:compileWiki() function 開頭有一段 cutoff 邏輯,從 compileIncremental 借過來但邏輯不適用:
// Buggy 原版
let totalChars = 0;
const selectedDocs = [];
for (const doc of docs) {
if (totalChars + (doc.content?.length || 0) > 150000) break; // 砍超過 150K 後的全部 doc
selectedDocs.push(doc);
totalChars += doc.content?.length || 0;
}
問題在於 main compileWiki 對每份 doc 跑獨立 LLM call,沒有 aggregate token 限制理由。但 compileIncremental 是把多 doc 拼在一個 prompt 裡,有 prompt token 限制。code 借過來時沒看清楚使用情境。
且 ORDER BY created_at ASC 讓早期 doc 先進 selectedDocs,新 doc 永遠被砍。1 萬租戶 scale:84 docs × 4.5K avg = 378K → 只編 33 docs → 51 docs 永遠 orphan。
修法:刪掉 cutoff,全部 ready wiki source 都納入(per-doc LLM call 為 sequential,不會 burst):
// Patched
const selectedDocs = docs;
const totalChars = docs.reduce((s, d) => s + (d.content?.length || 0), 0);
失敗形態:整個 KB 的 compile 中斷,後續 docs 全沒編。PROD log:
[WIKI] Compile error: invalid input syntax for type uuid: "b7bced27"
發現過程:Patch 1 deploy 後重跑 compile,跑到第 13 個 doc 整批 abort。log 顯示「invalid uuid」但完整 UUID 是 b7bced27-3e4f-4abc-...,看起來只截到前 8 字元。
根因:LLM(DeepSeek)在 SOURCE_IDS: header 行偶爾輸出截斷的 UUID(8 字元 prefix 而非完整 36 字元)。原 code 沒驗證,直接傳給 PG INSERT INTO wiki_pages(source_doc_ids UUID[]) → PG RAISE → catch 不住 → 整批 compile transaction rollback。
關鍵:單一 LLM 一次小錯誤,炸掉整個 batch。1 萬租戶 scale 下,只要 LLM 偶發 hallucinate 一次,就整 KB 重跑。不可接受。
修法:正規式驗證 LLM 輸出:
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const sourceDocIds = sourceIdsRaw
? sourceIdsRaw.split(',').map(s => s.trim()).filter(s => UUID_RE.test(s))
: [];
filter 後若空陣列,後段「保底」邏輯仍 prepend doc.id 真實 UUID,LLM 給 garbage 不影響歸屬正確性。
失敗形態:LLM 偶爾忽略 prompt 規定的 ---ARTICLE---/---CONTENT--- marker 直接吐純文字。parseCompiledArticles 找不到 marker → 回 0 article → 該 doc 永久 orphan。PROD 觀察 02_醫美業_FAQ_與_Query集.md 編譯 LLM 輸出 5670 字無 marker。
發現過程:Patch 2 deploy 後再跑,84 docs 全部 compile 完成,但 wiki_pages 只新增 51 個。grep 對應 doc id,確認某些 doc 沒對應 wiki 頁。檢查 LLM 輸出 log,發現某些 doc 的 LLM 直接吐純文字,連 ---ARTICLE--- 都沒。
根因:LLM 輸出 schema 不可信,任何依賴特定 token / marker / JSON shape 的 parser 都會偶發失敗。Anthropic / OpenAI / Gemini 都會偶爾「忘記指令」,DeepSeek 比例偏高(約 5-8%)。
修法:寫 fallback wrap helper,marker 缺失但有 ≥50 字內容時保底包成 single article:
function fallbackWrapArticle(text, doc) {
const trimmed = (text || '').trim();
if (trimmed.length < 50) return null;
return {
title: doc.title.replace(/\.md$/i, '').slice(0, 100),
slug: deriveSlug(doc.title) || `fallback-${doc.id.slice(0, 8)}`,
pageType: heuristicPageType(doc.title), // faq/product/policy/concept
confidence: 'low', // 標 low 提示 admin 人工檢視
tags: ['wiki_fallback'], // admin 用此 tag 過濾
content: trimmed,
sourceDocIds: [doc.id], // 與原 doc 雙向綁定
// ...
};
}
關鍵設計:寧可保底用低信心包裝(tags=wiki_fallback)+ admin 警示,也不能讓客戶上傳的源文件變孤兒。compileWiki per-doc loop 與 compileIncremental 批次 hook 雙路徑都套用。
admin UI 用 WHERE 'wiki_fallback' = ANY(tags) 過濾人工檢視,實測 84 docs KB 有 14 個進 fallback bucket,人工看內容大多 OK 只是格式不規整,signal 還是夠用。
失敗形態:rag 容器無法解析 postgres 內部 hostname → pg.Pool.connect("postgres") SERVFAIL silent hang(不 throw)→ wikiCompiler 第一個 await q(SELECT...) 永遠等不到 connection → 整個 wiki compile 表面正常但 0 進度。
發現過程:Patch 3 deploy 後 PROD 觀察 wiki 編譯「卡住」— admin UI 顯示 progress: 0/84,容器 CPU 0.01%,DB 0 active query,沒有任何 error log。30 分鐘沒進度。SSH 進容器 nslookup postgres → SERVFAIL。
根因:容器 /etc/resolv.conf 指向 host systemd-resolved(127.0.0.53)而不是 Docker 內部 DNS(127.0.0.11),Lightsail Ubuntu daemon 的 DNS 配置邊角案例。SERVFAIL 不是 NXDOMAIN,pg client 把它解讀為「暫時失敗,持續重試」而不是「域名不存在,放棄」,於是無限等待。
修法(三版演進,前兩版都失敗):
| 版本 | 配置 | 結果 |
|---|---|---|
| 初版 | extra_hosts: ["postgres:172.18.0.10"] 硬編 IP |
失敗:postgres IP 在 docker recreate 時 shift |
| 第二版 | dns: ["127.0.0.11"] only |
災難:解不到 www.googleapis.com → Google OAuth 全平台壞 8 小時 |
| 最終版 | dns: ["127.0.0.11", "1.1.1.1", "8.8.8.8"] |
通過:內外 hostname 都通 |
第二版的災難尤其慘——把 backend / worker / frontend / rag 全改成 dns: [127.0.0.11] 後,容器內 Node.js 解 www.googleapis.com(Google OAuth 用)失敗,Google 登入返回 getaddrinfo EAI_AGAIN,客戶整整 8 小時無法登入。緊急回滾後追根本因:Docker embedded DNS(127.0.0.11)只管內部 hostname(postgres / redis / backend),外部 hostname 它會 forward 到 host 的 resolv.conf,但 Lightsail Ubuntu 的 systemd-resolved 配錯導致 forward 失敗。
最終版加 1.1.1.1(Cloudflare)+ 8.8.8.8(Google)當外部 fallback,內部 hostname 走 127.0.0.11,外部 hostname 走 fallback,兩者都通。
教訓:單純設 Docker embedded DNS 不夠,還必須保留外部 fallback。docker-compose.prod.yml 對 backend / worker / frontend / rag 全部都要這樣寫。任何新加容器「dns: 必須包含內部 + 外部 fallback」已寫進平台憲法。
失敗形態:wikiCompiler progress 卡在「AI 編譯 (13/84):官網-FAQ」10+ 分鐘,CPU 0.01%,DB 0 active query。aiCall 在等 DeepSeek 永不返回的 stream。
發現過程:Patch 4 deploy 後 DNS 通了,但客戶反應某次編譯「卡在第 13 個」。SSH 進容器 top 看 node process 0% CPU,netstat 看到一個 ESTABLISHED 連線到 DeepSeek API 但 0 byte traffic 5 分鐘。
根因:OpenAI SDK 預設 timeout: 600_000ms(10 分鐘)+ maxRetries: 2,理論上 20 分鐘卡死;Gemini SDK(@google/generative-ai)沒提供原生 timeout / abort API。DeepSeek 偶有 ECONNRESET / 半開連線 / 低速回應,預設 timeout 撐不住。
更糟的是,streaming response 中斷不會丟錯——SDK 認為「stream 還沒結束,繼續等」,於是永遠等不到 [DONE] event。
修法(三層 timeout):
// 1) OpenAI client constructor 60s + maxRetries=1
new OpenAI({ apiKey, timeout: 60_000, maxRetries: 1 });
// 2) per-request abort signal(防 streaming 卡 chunk)
client.chat.completions.create(params, { signal: AbortSignal.timeout(60_000) });
// 3) Gemini Promise.race + 60s timeout(SDK 無 abort)
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`${label} timeout ${ms}ms`)), ms)
),
]);
}
withTimeout(genModel.generateContent(...), 60_000, `gemini/${model}`);
修後:單一 doc 失敗最多卡 60s,wikiCompiler per-doc try/catch 接住 → 走 Patch 3 fallback 或跳下一 doc,整批 84 docs 不再被單一 LLM hang 拖垮。1 萬租戶 scale 安全。
這個 patch 後來變成平台憲法「LLM call 必有 ≤60s timeout」——適用 rag-backend-v2 + geo-saas backend + frontend 所有 LLM call site。違反此鐵律的後果:單一 LLM 半開連線就能拖垮整批工作流,1 萬 brand 同時失敗。
失敗形態:Wiki L1 全平台 miss,大多查詢 fallback L2,回「沒有相關資料」。
發現過程:2026-04-30 把 PROD primary LLM 從 deepseek-v3-direct 換到 deepseek-v4-flash(reasoning model 比較聰明,理論上 wiki query 路由更準),但隔天客戶反應「問什麼都答不出來」。grep wiki query log 全空。
根因:reasoning model 的 message.content 之外另吃 reasoning_tokens(可佔 token budget 70%+)。wikiCompiler.js 兩處 wiki_query_route call 用 maxTokens: 200 對 deepseek-v3 夠,但 V4 reasoning 把 200 token 全部吃進 reasoning,content 永遠空字串 → JSON parse fail → wiki query 路由失敗 → fallback L2 也回「沒命中」。
最詭異的是:沒有 error,LLM API 200 OK 回 { content: "", reasoning_tokens: 198 },只是 content 空。原本的 try/catch 抓不到。
修法:兩處 maxTokens 200 → 2000。實測 2000 token 給 reasoning + content 都夠,wiki query routing 恢復正常。
1 萬租戶影響:任何啟用 wiki + 用 V4-style reasoning model(DeepSeek V4 / OpenAI o1-o4 / Anthropic 含 thinking)的租戶,Wiki L1 全失靈,fallback L2 雖能勉強回答但成本高且品質差。這條教訓推廣為平台憲法:換 reasoning model 後必查所有 maxTokens 配置,任何 maxTokens < 1500 在 reasoning model 上都要驗證 content 不為空。
部署完整 6 patch 後 PROD 觀察:
| 指標 | Before | After |
|---|---|---|
| 平台 wiki orphan rate | 45%(156/348) | ≤25%(預期) |
| 5ce78a51 KB(84 docs)compile 完成率 | 39%(33/84) | 100%(84/84) |
| 單 doc LLM hang 卡死整批機率 | ~20% | <1% |
| Wiki L1 hit rate | 12%(reasoning model 後) | 65% |
orphan rate 從 45% 降到 ≤25% 但還沒降到 0% 的原因:Patch 3 的 fallback 算「保底解決」但不算「完美編譯」,進 fallback bucket 的 doc 仍標 confidence='low',在某些 admin 計算口徑下算 partial orphan。完全消除 orphan 需要 LLM 永遠不漏 marker(不可能)或全部 doc 都用結構化 schema(JSON output mode,部分 LLM 支援),這是 V2 工作。
回頭看,六個 patch 的發現順序非常巧合——前一個 patch 修好之前,看不到後一個 patch 的 bug:
Patch 1 (cutoff) → 修了才看到所有 doc 都進編譯
↓
Patch 2 (partial UUID) → 修了才看到後續 doc 都編完
↓
Patch 3 (no marker) → 修了才看到 wiki page 真實生成
↓
Patch 4 (DNS) → 修了才看到生成過程不卡死
↓
Patch 5 (LLM timeout) → 修了才看到單 doc 失敗不拖整批
↓
Patch 6 (maxTokens) → 修了才看到 wiki query 真實命中
每一個 patch 都解開「下游錯覺正常其實不正常」的層,這是 LLM-driven 系統 debug 的典型形態:錯誤被 silent swallow 多層,要剝洋蔥一層一層才看見。
WikiCompiler 失敗只是冰山一角。整個 BullMQ worker 體系也踩了同類陷阱。
每個 cron worker 業務邏輯前必先檢查 entity 存在:
async (job) => {
const { brandId } = job.data;
// 預檢 — brand 被刪後 cron 還在跑會 throw → BullMQ 累積 failed jobs → admin UI 永遠紅
const { rows: [b] } = await query('SELECT id FROM brands WHERE id = $1', [brandId]);
if (!b) {
console.warn(`[Worker] Brand ${brandId} not found — removing orphan scheduler`);
const q = new Queue(QUEUE_NAME, { connection });
await q.removeJobScheduler(`monthly-${brandId}`); // 自動清孤兒 scheduler
await q.close();
return { skipped: true, reason: 'brand_not_found', brandId };
}
// ... 業務邏輯
}
關鍵點:檢查不過 → return skipped 不是 throw。throw 算 failed,return 算 completed,UI 永遠綠。同時順手 removeJobScheduler 把 orphan scheduler 也清掉。
PROD 觀察套用範圍:monthly.worker.js(brand 被刪)、abTesting.worker.js(experiment 被刪 → FK violation)、closedLoop sentinel(table 被改名 brand_prompts → multilingual_prompts)。
backend/src/workers/queueRetention.worker.js 每日 02:30 跑:
// 24 個 known queue,daily cleanup
for (const name of KNOWN_QUEUES) {
const q = new Queue(name, { connection });
await q.clean(7 * 24 * 3600 * 1000, 500, 'failed'); // 7 天 failed
await q.clean(30 * 24 * 3600 * 1000, 500, 'completed'); // 30 天 completed
await q.close();
}
不清的後果:per-brand cron 每天產生大量 jobs,失敗的若不清會撐到 Redis OOM,且 admin UI(/admin/scan-frequencies)永遠紅燈累積看不出新狀態。
實測一次性 cleanup 結果:
[QueueRetention] Cleaned 2026-05-02 02:30
monthly-report-queue: 7 failed, 312 completed
ab-testing-queue: 30 failed, 845 completed
geo-closed-loop-queue: 4 failed, 198 completed
geo-rlhf-queue: 0 failed, 67 completed
geo-axp-queue: 2 failed, 630 completed
...
Total: 43 failed + 2052 completed cleaned
清乾淨後 admin UI 恢復實時狀態,任何新失敗立即可見。1 萬租戶 scale 預估 daily cleanup ≥ 1 萬筆,retention worker 必跑。
retention worker 的 KNOWN_QUEUES 陣列必須含全平台所有 queue 名,否則新 queue 會永遠不被清:
const KNOWN_QUEUES = [
'geo-axp-queue',
'geo-axp-immediate',
'geo-axp-bootstrap',
'monthly-report-queue',
'tier-a-visual-monthly',
'billing-cycle-queue',
'payment-dunning-queue',
'reverse-scan-queue',
'ab-testing-queue',
'geo-closed-loop-queue',
'geo-rlhf-queue',
'geo-f12-optimize-queue',
'queue-retention-queue', // self
// ... 其他 11 個
];
漏寫某 queue → 失敗永久累積。為避免漏掉,我們加了 admin UI 的 /admin/scan-frequencies 頁面 — 從 BullMQ 列出所有 queue 名 vs KNOWN_QUEUES array,差集顯示「⚠ 未納入 retention」紅標。新 queue 加完忘記更新 KNOWN_QUEUES → admin 立即看到。
除了 wikiCompiler,geo-saas backend 同步加上 LLM timeout(防同類 silent hang):
services/modelRouter.service.js Anthropic SDK timeout: 60000, maxRetries: 0(原預設 600s)services/modelRouter.service.js Gemini call 加 Promise.race + 60s timeoutservices/tier-a/visualEnrichment.service.js Anthropic Vision API fetch 加 signal: AbortSignal.timeout(60_000)驗證指令(commit 前自查):
grep -rn "fetch(.*api\.\(deepseek\|openai\|anthropic\|googleapis\|xiaomimimo\)" backend/src/services/ \
| grep -v "AbortSignal\|signal:"
# 應為空,有 = 違反 LLM timeout 鐵律
我們把這個 grep 加進 pre-commit hook(規畫中),違反 → block commit。1 萬租戶 scale 不能容忍任何漏網。
rag-backend-v2 不在 geo-saas git repo(獨立微服務),patch 紀錄保留於 docs/rag-backend-v2-patches/:
docs/rag-backend-v2-patches/
├── README.md ← 部署順序 + verification block
├── wikiCompiler.js ← Patch 1, 2, 3, 6 都改這個
├── modelRouter.js ← Patch 5 改這個
├── docker-compose.rag.yml ← Patch 4 DNS 設定
└── verification-block.sh ← 一鍵驗證 6 patch live
每次 RAG 容器重建必須三個檔一起 scp(漏一個 patch 就會回退)。
# 1. 從 patches/ 取最新版
scp wikiCompiler.js ubuntu@PROD:/home/ubuntu/rag-backend-v2/src/services/
scp modelRouter.js ubuntu@PROD:/home/ubuntu/rag-backend-v2/src/services/
scp docker-compose.rag.yml ubuntu@PROD:/home/ubuntu/geo-saas/
# 2. 重 build + recreate
ssh ubuntu@PROD
cd /home/ubuntu/rag-backend-v2
sudo docker build -t geo-saas-prod-rag:latest .
cd /home/ubuntu/geo-saas
sudo docker compose -f docker-compose.rag.yml --project-name geo-saas-prod up -d --force-recreate rag
# 3. 驗證 6 個 patch 全 live
sudo docker exec geo-saas-prod-rag-1 sh -c '
grep -c "selectedDocs = docs" /app/src/services/wikiCompiler.js # patch 1: 1
grep -c UUID_RE /app/src/services/wikiCompiler.js # patch 2: 2
grep -c fallbackWrapArticle /app/src/services/wikiCompiler.js # patch 3: 3
grep -c postgres /etc/hosts # patch 4 prep: 1+
grep -c LLM_CALL_TIMEOUT_MS /app/src/services/modelRouter.js # patch 5: 8
grep -c "maxTokens: 2000" /app/src/services/wikiCompiler.js # patch 6: ≥3
'
期望輸出 1 / 2 / 3 / 1 / 8 / ≥3。任一不符即 patch 回退,立即重 scp + rebuild。每次 PROD 容器重建必須帶這個 verification block,否則 patch 會默默丟失重蹈覆轍。
部署 patches 之外,1 萬租戶 readiness 還需:
f12_cache_metrics 每小時聚合,目標 ≥65%。低於 50% → admin alertf12_billing_records 月度聚合,starter 超 $1 / pro 超 $50 → email warnSELECT COUNT(*) FROM wiki_pages WHERE 'wiki_fallback' = ANY(tags) > 100 / brand → admin reviewSELECT COUNT(*) FROM tenant_documents td WHERE NOT EXISTS (SELECT 1 FROM wiki_page_sources WHERE source_doc_id=td.id) > 10% → 重新 trigger compile這 4 個指標都進 /admin/rag-health admin dashboard,daily cron 算出後寫 alert。
從 2026-04-23 到 2026-05-02 短短 9 天內,rag 容器 recreate 了 11 次:
| 日期 | 原因 | Patch |
|---|---|---|
| 04-23 | 升級 DeepSeek SDK | — |
| 04-25 | 加 Wiki Cascade trigger | migration 170 |
| 04-27 | 加 sync_wiki_page_sources trigger | migration 201 |
| 04-29 | Patch 1 (cutoff) | wikiCompiler.js |
| 04-30 | Patch 2 (UUID) | wikiCompiler.js |
| 04-30 | 切 V4 reasoning(Patch 6 之前) | platform_llm_config |
| 05-01 | Patch 3 (no marker) | wikiCompiler.js |
| 05-01 | Patch 4 v1 (extra_hosts IP) | docker-compose.rag.yml |
| 05-01 | Patch 4 v2 (dns 127.0.0.11 only) | docker-compose.rag.yml |
| 05-01 | Patch 4 v3 (dns + fallback) | docker-compose.rag.yml + .prod.yml |
| 05-02 | Patch 5 + 6 | modelRouter.js + wikiCompiler.js |
每次重建都是 ~3 分鐘 downtime(during build & recreate),11 次累計 ~33 分鐘。下次再大改建議走 blue-green deployment(雙容器交替)減 downtime,目前 quota 不夠開兩容器同時跑。
六層 patch 看似已涵蓋常見失敗模式,但 LLM 演進速度快過 patch 速度:
maxTokens 對 reasoning 不夠)這意味著 LLM-driven 系統的 robustness 設計必須永遠假設 LLM 會以新方式失敗,每加一個新 model 都要重跑一輪 hallucination test。
Patch 3 的低信心 fallback 雖然救起了客戶 doc,但 confidence='low' + tags=['wiki_fallback'] 的 wiki page 在 L1 retrieval 時實際品質如何,目前沒做端到端 evaluation。理論上應該:
第三點還沒做。
進一步地,fallback 內容的「來源綁定」雖然有(sourceDocIds),但「LLM 重組品質」沒驗證——可能 LLM 把客戶兩個不相關 doc 內容混在一起。這需要人工或 LLM-as-judge 抽樣審視,V2 工作。
理論上,如果同一 query 同時跑兩個不同 LLM(DeepSeek + Gemini),交叉驗證 marker / UUID / 內容,可以解掉大部分 hallucination 問題。但成本翻倍且延遲倍增,是否值得?目前數據不夠決策,留 V2 評估。
具體量化:單一 wiki compile call 約 $0.0003(DeepSeek V4 flash),雙 LLM 變 $0.0006 + max latency 1.5s → 3s。84 docs KB compile $0.025 → $0.05。1 萬租戶月 100 KB recompile = $50/月,可接受。但需要先做 A/B test 驗證雙 LLM 真能降 hallucination(可能兩 LLM 偶爾犯同樣錯)。
刪除是 cascade 自動處理,但還原(undo soft-delete)沒做。客戶誤刪文件 → wiki_pages 軟刪 → 30 天後自動 hard delete。如果客戶 7 天後想要回來,目前要客服手工 SQL UPDATE。這是常見需求但工程沒排上。
完整實作需要:
deleted_at IS NOT NULL 檢查 + restore 函式工程量約 1-2 週,V2 排上。
V1 編譯失敗 → DB 寫 last_compile_error,UI 顯示「編譯失敗」。但沒重試機制 — admin 必須手動點按鈕。1 萬租戶 readiness 應該:
也是 V2 工作。
ragDeleteDocument 失敗後 brand_documents 已軟刪但 tenant_documents 沒清,造成 orphan tenant_document(無 brand_document 對應)。目前靠 daily cron 抓 orphan 重新清一次,但這條 cron 可能漏跑或失敗。
更乾淨的設計是 outbox pattern:
-- brand_documents.delete 時寫 outbox row
INSERT INTO outbox_events(event_type, payload, status)
VALUES ('rag_delete_document', '{"central_doc_id": "..."}', 'pending');
-- 獨立 worker 讀 outbox,call ragDeleteDocument,成功才標 'done'
-- 失敗自動 retry,長期失敗 alert
但要重構整個 cascade flow 才能套 outbox,V2 工作量大,目前 daily cron 暫時夠用。
從 6 個 patch + Worker robustness 演進(2 週,5 次 PROD 故障),整理出 5 條教訓:
六個 patch 沒一個是 LLM 直接 throw error。全部都是「看似成功但實際空 / 截斷 / 卡住」:
LLM-driven 系統 debug 的第一條規則:不能信任「沒看到 error」,必須驗證「output 真的是預期 shape」。每個 LLM call 後加 schema validate(UUID_RE / marker / minLength)是基本動作。
DNS SERVFAIL / streaming 中斷 / reasoning content 空——三種情況都讓系統「看起來正常運作」但 0 進度。傳統的「失敗會 throw」假設破功。
防禦這類失敗的工程模式:
Patch 3 的 fallback 設計是「LLM 失敗→ 寬鬆解析 → 仍失敗 → 用 doc title 保底包裝」三層,任一層成功就保底。對比一層 retry(失敗 retry 3 次,還失敗就 throw),三層 fallback 永遠有出路:
代價:fallback bucket 的 wiki page 品質低,需 admin 看。但相比「LLM 失敗 → doc 變孤兒」,值得。
1 萬租戶 scale 不能容忍「永久孤兒」,任何客戶上傳的內容都必須有出口。
回顧六個 patch 發現順序——每修好一個都會「揭露」下一個:
看到 33/84 完成 → 修 cutoff → 看到 全部 doc 進編譯
看到 全部 doc 進編譯 → 看到第 13 個就斷 → 修 UUID → 看到 全部 doc 完成
看到 全部 doc 完成 → 看到 wiki page 數不對 → 修 marker → 看到 wiki 真實生成
看到 wiki 真實生成 → 看到 編譯卡住 → 修 DNS → 看到 編譯運行
看到 編譯運行 → 看到 卡在第 13 個 → 修 LLM timeout → 看到 編譯穩定
看到 編譯穩定 → 看到 wiki query 全 miss → 修 maxTokens → 看到 wiki 真實命中
每一層都是「上一層 silent failure 把下一層遮起來了」。debug 必須一層一層剝開,急著一次解全部會錯估根因。
rag-backend-v2 不在 geo-saas git repo,但 patch 進去後容易丟失。我們設計 docs/rag-backend-v2-patches/ 當 SSOT:
如果不寫 verification block,下次重建忘記帶就回退。我們踩過一次:某次升級 RAG 重 build,忘了 scp wikiCompiler.js,Patch 6 的 maxTokens=2000 回到 200,客戶反應「Wiki 又不命中」。grep 'maxTokens: 2000' count=0 直接知道 patch 沒帶。從此每次 RAG container recreate 必跑 verification,不會再踩。
| 日期 | 版本 | 說明 |
|---|---|---|
| 2026-05-03 | v1.1 | 新章 — 六層 LLM hallucination patch + Wiki Cascade + Worker robustness |
| 2026-05-03 | v1.1.1 | 章節擴充至 ~7100 字 — 加 15.1.1/3 Karpathy 概念與 V1→V2 演進、15.2.2/3 SSOT trigger SQL 與 36 entry 偵錯、15.3 每 patch 加發現過程、15.3.8 Patch 順序因果鏈、15.4.3 KNOWN_QUEUES 維護、15.5.4 RAG 11 次重建史、15.6.6 outbox pattern、15.7 工程教訓 5 條;修正章節編號 14.x → 15.x typo |