構建零幻覺的確定性數術引擎:Rust 狀態機與 LLM 語義圖譜的混合架構實踐

Published · AI Daily — AI-assisted deep research, methodology & disclosure

LLM 直接排盤會算錯變爻、重複主星,根因是自迴歸取樣守不住離散不變數。本文拆解 InsightApp 的線上方案:Rust 純函式核心生成命盤與卦象的 JSON 事實圖譜,LLM 只做語言投射。文中給出真實資料結構、三錢法機率推導、化忌鏈的有界圖遍歷,也列出三個尚未守住的邊界。

利益披露:易數洞察 InsightApp 與 sns.style 由同一運營方維護。本文引用的程式碼全部來自 InsightApp 後端倉庫 commit `22cccf4`,每處都標出檔案路徑,便於溯源;該倉庫目前不公開。本文不是第三方評測,下文的案例分析也不構成獨立背書。

把一個周易卦或一張紫微斗數命盤直接交給大語言模型,讓它“排盤並解讀”,結果常常讀起來很像樣,細看卻經不起核對:變爻算錯了,某顆主星出現在兩個宮位,化忌落在一顆根本不在盤上的星曜上。問題不在模型不夠聰明,而在任務的形狀。排盤是離散組合數學,解讀是自然語言。把兩件事交給同一個機率取樣器,等於讓一個擅長寫散文的人兼職做會計。

這篇特稿拆解一種已經上線執行的解法:用 Rust 寫一個確定性的排盤核心,輸出結構化的 JSON 事實圖譜;再讓 LLM 只做它擅長的事,也就是在這張事實圖譜上做語言投射和追問。案例系統是 InsightApp 的周易與紫微斗數引擎。我們會讀它的真實資料結構,算它的真實機率,也會指出它目前沒有守住的三個邊界。標題裡的“零幻覺”有明確的適用範圍:它只對盤面成立,不對解讀成立。這一點文章最後會專門討論。

一、根因:自迴歸取樣守不住離散不變數

紫微斗數排盤是一串嚴格的離散運算。以 InsightApp 的實現為例,命宮位置是 `mod12(lunar_month + 1 - hour)`,身宮是 `(lunar_month + 1 + hour) % 12`,五行局要查一張 5×6 的納音表 `NA_YIN_JU[gan / 2][ming_gong_idx / 2]`,紫微星位置再由五行局和農曆日按“起紫微星訣”推出(`backend/src/ziwei/calc.rs`)。之後天府、六吉、六煞、雜曜依次落宮,最後用年幹查四化表 `SI_HUA_TABLE`。整條鏈有十幾步,每一步都依賴上一步的精確結果。

這類計算有一組必須守恆的不變數:

  • 14 顆主星在 12 個宮位裡各出現且只出現一次;
  • 每個天干恰好對應祿、權、科、忌四化,各指向一顆確定的星;
  • 天府的位置由紫微唯一決定,`tian_fu = mod12(4 - zi_wei)`;
  • 周易一卦恰好六爻,每爻由三枚銅錢決定,變卦由變爻唯一決定。

自迴歸語言模型的工作方式是逐個 token 取樣。它沒有內部的“宮位陣列”,也沒有機制保證第 9 步引用的值等於第 3 步算出的值。中間結果只以文字形式存在於上下文裡,每一步都有出錯的機率,而且這些錯誤會向後傳播。更關鍵的是,模型輸出時不會檢查不變數:它可以寫出“紫微在命宮”,幾段之後又寫“紫微在遷移宮”,兩句話各自都很通順。

需要說明,本文沒有測量“LLM 直接排盤”的錯誤率。這是一個值得做的實驗:用同一批出生資料,對比模型輸出和確定性引擎的結果,逐宮統計差異。在拿到這樣的配對資料之前,上面的分析只是結構性論證,不是實測結論。

二、原則:核心歸核心,語義歸語義

解法的第一性原理很簡單:凡是有唯一正確答案的部分,交給程式碼;凡是需要語言、語氣和情境的部分,交給模型。兩者之間用一份結構化資料隔開,這份資料就是“事實圖譜”。

出生資料 / 起卦請求
|
v
+-----------------------------+
|  Rust 確定性核心 (axum)      |
|  ziwei::full_chart()        |   純函式,無 I/O
|  ziwei::feixing::compute()  |   同一輸入,同一輸出
|  ziwei::patterns::detect()  |
|  agent::ZhouyiEngine        |
+-----------------------------+
|  ChartResult / Reading  (JSON 事實圖譜)
v
+-----------------------------+
|  知識庫檢索                  |
|  kb::search + 古籍標籤查詢    |   骨髓賦 / 全書 / 全集
+-----------------------------+
|  事實圖譜 + 典籍片段
v
+-----------------------------+
|  LLM (OpenRouter)           |
|  預設 gemini-2.0-flash-001   |   只負責語言投射
|  SSE 流式輸出                |
+-----------------------------+
|
v
使用者介面 / 追問

這張圖裡,LLM 位於最下游。它看得到事實,但不負責產生事實。InsightApp 的預設模型是 OpenRouter 上的 `google/gemini-2.0-flash-001`,模型名由配置注入,可以換成別的模型;取樣溫度是 0.7(`backend/src/llm/client.rs`)。溫度不為零是有意的:解讀需要措辭的變化,盤面不需要。

三、周易核心:隨機只發生在一處

周易起卦本來就包含隨機性,所以“確定性”在這裡的含義要說準確:隨機只發生在擲銅錢那一刻,之後的一切推導都是確定的。

InsightApp 的三錢法實現在 `backend/src/agent/hexagram.rs`:

pub struct CoinThrow {
pub coins: [u8; 3],   // 每枚 2(陰) 或 3(陽)
pub sum: u8,          // 6 老陰, 7 少陽, 8 少陰, 9 老陽
pub line: bool,       // 當前爻是陽爻
pub changing: bool,   // 老陰或老陽,即變爻
}

pub struct Cast {
pub throws: [CoinThrow; 6],   // 自下而上,index 0 = 初爻
}

每枚銅錢以 1/2 的機率取 2 或 3(`rand::random::<bool>()`)。所以三錢之和等於 6 加上“3 的個數”,服從二項分佈:和為 6 的機率是 1/8,7 是 3/8,8 是 3/8,9 是 1/8。由此可以直接算出幾個常被誤傳的數字:每一爻為陽的機率恰好 1/2;每一爻成為變爻的機率是 1/8 + 1/8 = 1/4;一卦六爻裡變爻個數的期望是 1.5;完全沒有變爻的機率是 (3/4)^6,約 17.8%。這些數字是從程式碼推出來的,不需要模型去“記得”。

擲完之後,本卦由六個 `line` 值唯一確定,變卦由“把變爻翻轉後的六爻”唯一確定,兩者都透過 `find_hexagram_by_lines` 在 64 卦表裡查出來。`ZhouyiEngine::build_reading`(`backend/src/agent/zhouyi_engine.rs`)把結果組裝成 `Reading`:

pub struct Reading {
pub primary: HexagramView,          // 本卦:卦辭、大象、六爻爻辭
pub derived: Option<HexagramView>,  // 變卦,無變爻時為 None
pub changing_indices: Vec<usize>,   // 變爻位置
pub changing_yao_ci: Vec<ChangingYao>,
pub kb_query_hint: String,          // 交給知識庫檢索的提示
}

這裡有一個值得借鑑的細節:查不到卦時,引擎返回 `EngineError`,錯誤資訊寫明“hexagrams.json corrupted?”,而不是返回一個預設卦。資料表壞了,系統就停下來報錯,不會用一個看起來合理的卦掩蓋問題。這就是 fail-closed。

`build_reading` 接受一個預先給定的 `Cast`,所以測試可以固定六次投擲,斷言本卦、變卦和變爻完全符合預期。隨機性被隔離在 `Cast::cast()` 一個入口裡,其餘部分都是可測試的純函式。想直觀看到 64 卦和變爻的關係,可以對照 InsightApp 的六十四卦查詢頁 和部落格裡的 六十四卦完整指南,以及專門講變爻規則的 《周易變爻詳解》。

四、紫微核心:一條純函式流水線

紫微斗數的入口是 `ziwei::full_chart(&ChartRequest) -> ChartResult`(`backend/src/ziwei/chart.rs`)。它沒有 I/O,沒有隨機數,同樣的輸入永遠得到同樣的命盤。流水線的順序是:

1. 處理閏月:閏 N 月的初一到十五按 N 月布星,十六以後按 N+1 月布星;

2. 由有效月份和時辰算命宮、身宮;

3. 由年幹算十二宮宮幹,再查納音表得五行局;

4. 起紫微,推天府,安十四主星;

5. 安文昌文曲、左輔右弼、天魁天鉞等吉星;

6. 安祿存、擎羊陀羅、火鈴、地空地劫等煞星;

7. 安天馬、紅鸞天喜等雜曜,以及長生十二神、博士十二神;

8. 查年幹四化,排大限;

9. 識別格局,計算飛星四化。

輸出的核心型別在 `backend/src/ziwei/models.rs`,這就是“事實圖譜”的骨架:

pub struct Palace {
pub pos: usize,              // 地支位置 0..12
pub branch: String,
pub stem: String,            // 宮幹,飛星四化的起點
pub name: String,            // 命宮、兄弟宮……
pub is_shen_gong: bool,
pub main_stars: Vec<StarEntry>,
pub lucky_stars: Vec<StarEntry>,
pub sha_stars: Vec<StarEntry>,
pub misc_stars: Vec<StarEntry>,
pub chang_sheng: String,
pub bo_shi: String,
pub da_xian_range: String,
}

pub struct ChartResult {
pub chart: Vec<Palace>,                 // 12 宮
pub ming_gong_idx: usize,
pub shen_gong_idx: usize,
pub ju: u8,
pub si_hua: Vec<String>,
pub da_xian: Vec<DaXianPeriod>,
pub patterns: Vec<DetectedPattern>,     // 服務端生成
pub feixing: Option<FeixingDetail>,     // 服務端生成
// ……其餘欄位從略
}

注意 `patterns` 和 `feixing` 兩個欄位帶有 `skip_deserializing`。客戶端把命盤迴傳給服務端時,這兩個欄位會被丟棄,只能由服務端重新生成。這是一個小而正確的決定:派生事實不接受外部輸入。

閏月規則的來歷也值得一提。程式碼註釋記錄,這條規則是在與開源排盤庫 iztro 交叉比對 505 個隨機樣本和 5 個固定樣本後加入的,加入前唯一的分歧就出在閏月,加入後差異為零。確定性核心的價值正在這裡:它可以和另一個獨立實現逐樣本對拍,分歧可以定位到具體的規則。一段 LLM 生成的文字做不到這一點。

關於真太陽時,需要準確說明:InsightApp 的真太陽時校正目前在前端完成(`frontend/src/lib/ziwei/trueSolarTime.ts`),按城市經度和日序修正鐘錶時間,再換算成時辰序號。Rust 核心接收的是已經換算好的時辰。也就是說,真太陽時這一步是確定的,但它不在 Rust 裡。

五、飛星四化與化忌鏈:有界的圖遍歷

三合派只看本命四化。飛星派要追蹤宮幹引動的四化如何在宮位之間流動,這本質上是一個圖遍歷問題。InsightApp 把它放在 `backend/src/ziwei/feixing.rs`:

pub struct Sihua4 { pub lu: String, pub quan: String, pub ke: String, pub ji: String }

pub struct HuajiNode {
pub palace_pos: usize,
pub palace_name: String,
pub depth: usize,
pub trigger_star: String,
}

pub struct HuajiChain { pub nodes: Vec<HuajiNode>, pub is_loop: bool }

pub struct FeixingDetail {
pub daxian_sihua: Vec<DaxianSihua>,          // 12 個大限各自的四化
pub current_liunian: Option<LiunianSihua>,   // 指定年份才計算
pub zi_hua: Vec<ZihuaEntry>,                 // 自化
pub huaji_chain: HuajiChain,                 // 化忌入宮鏈
}

化忌鏈的演算法很短,卻正好說明為什麼這件事不該交給模型。從本命年幹查出化忌星,找到它所在的宮;再用這個宮的宮幹查出新的化忌星,找到下一個宮;如此遞推。遍歷有三個終止條件:深度達到 `HUAJI_MAX_DEPTH = 4`;回到已經訪問過的宮,此時標記 `is_loop = true`;或者新宮干與當前天干相同,鏈不再變化。每一個節點都記錄深度和觸發星曜。

這是一個有界、可判定、可復現的過程。讓模型“推一下化忌鏈”,它需要在文字裡連續做四次查表加定位,並且自己判斷是否成環。程式碼只要幾十行。想看這一層在產品裡如何呈現,可以開啟 InsightApp 的飛星四化頁面;規則背景可讀 《大限四化詳解》 和 《流年四化詳解》。

六、格局識別:宣告式規則加通用匹配器

格局(如紫府同宮、機月同梁、火貪格)是經典文獻裡的模式。`backend/src/ziwei/patterns.rs` 沒有為每個格局寫一段 if-else,而是把規則寫成資料:條件列舉 `Cond` 描述“某星在某宮”“幾顆星同宮”等原子條件,一條規則的所有條件取“與”,通用匹配器 `detect(&[Palace])` 掃描全盤,輸出 `DetectedPattern`:

pub struct DetectedPattern {
pub id: &'static str,
pub name: PatternName,          // zh / zh-TW / ja / en 四語名稱
pub category: PatternCategory,  // Auspicious / Inauspicious / Neutral
pub palaces: Vec<usize>,        // 提供證據的宮位
}

`palaces` 欄位很關鍵。它記錄了哪些宮位構成了這個格局的證據,所以下游的解讀可以指回具體位置,而不是憑印象說“你的命盤有某某格”。程式碼註釋說明,V1 實現了 23 個最高頻格局,其餘經典格局留了擴充套件位,新增一條規則只需在 `PATTERNS` 裡追加資料。

七、事實圖譜如何進入 LLM

紫微解讀介面把命盤 JSON 整體放進 prompt,並附上兩類檢索結果:一是按命盤內容在知識庫裡檢索的條目;二是按命宮主星和已識別格局的 id 去古籍庫(骨髓賦、全書、全集)查出的原文片段,最多三條、八百字(`backend/src/handlers/ziwei.rs`)。prompt 明確告訴模型:命盤 JSON 的 `patterns` 欄位是已檢測到的格局,請把它們編進解讀。周易解讀的 prompt 結構相同:本卦、變爻、變卦的原文和白話含義逐項列出,再給出固定的輸出結構,並寫明不做醫療診斷、不給投資建議、不做生死預測(`backend/src/llm/prompt_zh.rs`)。

這樣分工後,模型的任務從“算出來再說出來”變成“讀懂再說出來”。它仍然可能說錯,但說錯的方式變了:它不再需要憑記憶生成一顆星的位置,因為位置就寫在它眼前。

八、誠實的邊界:“零幻覺”只對盤面成立

一套架構值不值得學,要看它在哪裡還沒做到。讀完程式碼,我們看到三個明確的缺口。

第一,解讀介面信任客戶端提交的盤面。周易的 `/interpret` 接收客戶端發來的本卦、變卦和變爻內容,紫微的解讀介面接收前端傳來的 `chart_summary` 字串,兩者都直接拼進 prompt,服務端沒有用出生資料重算一遍再比對(`backend/src/handlers/interpret.rs`、`backend/src/handlers/ziwei.rs`)。正常使用時這些資料來自同一套確定性程式碼,問題不大;但從架構上講,事實圖譜的“權威性”止步於客戶端。改法很直接:解讀介面只接收出生資料或投擲結果,由服務端呼叫 `full_chart` 或 `build_reading` 重新生成;或者對服務端生成的圖譜做簽名,解讀時驗籤。

第二,流年四化用的是公曆年近似。`compute_liunian_sihua` 用 `(year - 4) mod 10` 求流年天干,程式碼註釋自己寫明,在立春前後一兩天會差一位。這是一個已知且有記錄的近似,比隱藏起來好得多,但它確實意味著這幾天的流年四化不可靠。

第三,沒有對模型輸出做事後核對。prompt 約束了模型,卻沒有在生成後檢查:解讀裡提到的“某星在某宮”,是否真的在盤上?這一步可以做成一個確定性校驗器:從解讀文字里抽取星曜和宮位的組合,與 `ChartResult` 比對,不一致就標註或重新生成。

所以準確的說法是:盤面層是確定的,可以和獨立實現對拍,可以寫單元測試;語言層是受約束的,但沒有被證明無誤。“零幻覺”是盤面層的性質,不是整套系統的性質。把這句話說清楚,比把標題喊響更重要。

九、長尾內容也從同一張表生成

確定性核心還有一個副產品:內容結構可以從資料表直接展開。InsightApp 在 `backend/src/seo.rs` 裡定義了 `STARS_14` 和 `PALACES_12` 兩張表,組合出 14×12 = 168 個“星曜入宮”頁面,路由形如 `/ziwei/star/<星曜>/<宮位>`。前端的 `lib/ziwei/star-palace-graph.ts` 共享同一套 slug 對映,服務端的 sitemap 也從同一張表生成,所以頁面、連結和站點地圖不會互相漂移。

幾個例子:紫微星入命宮、貪狼星入夫妻宮、武曲星入財帛宮。想先建立讀盤的整體概念,可以讀 《如何看懂紫微斗數命盤》;更多專題文章在 InsightApp 部落格。

十、可以帶走的工程清單

如果你要為任何“有規則的領域”搭一個 LLM 應用,例如稅務計算、排班、化學計量或遊戲規則裁判,這套分層都適用:

  • 先列出領域裡必須守恆的不變數,再決定哪些步驟進核心;
  • 核心寫成純函式,把隨機性隔離在一個入口,讓測試可以固定它;
  • 派生事實只由服務端生成,反序列化時丟棄客戶端傳來的同名欄位;
  • 找一個獨立實現做逐樣本對拍,把分歧落到具體規則;
  • 查表失敗時報錯停機,不要返回一個看起來合理的預設值;
  • 已知的近似寫進註釋和文件,不要藏起來;
  • 讓模型讀事實,不讓模型算事實;生成之後再做一次確定性核對。

InsightApp 已經做到了其中大部分,也留下了第四節和第八節裡寫明的幾個缺口。正因為缺口清楚、位置可查,它才適合作為這種混合架構的參考樣本。產品本身可以在 insightapp.life 直接試用,起卦和排盤免費。

Sources

FAQ

為什麼不能讓 LLM 直接排紫微命盤?

排盤是十幾步環環相扣的模運算和查表,必須守住“14 主星各出現一次”等不變數。自迴歸模型逐 token 取樣,沒有機制保證後一步引用前一步的精確值,也不檢查不變數。本文沒有實測錯誤率,這是結構性論證。

“零幻覺”在這套架構裡指什麼?

只指盤面層:Rust 核心是純函式,同一輸入永遠得到同一命盤,可以和 iztro 等獨立實現逐樣本對拍。LLM 寫的解讀仍可能出錯,系統目前沒有在生成後核對解讀與命盤是否一致。

InsightApp 的實現還有哪些已知缺口?

三處:解讀介面直接使用客戶端提交的盤面,服務端不重算;流年天干用公曆年近似,立春前後一兩天會差一位;沒有生成後的確定性校驗。真太陽時校正在前端完成,不在 Rust 核心裡。