构建零幻觉的确定性数术引擎: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 内核里。