构建零幻觉的确定性数术引擎:Rust 状态机与 LLM 语义图谱的混合架构实践
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 内核里。