用 Golang + AI 做了一个四柱排盘分析 Demo:从排盘算法到 SSE 流式输出全记录

1 阅读7分钟

前言

最近在研究一个方向:用大模型做中国传统文化领域的 AI 应用。作为练手,我花了 2 天时间,基于 Golang 做了一个"四柱 AI 分析"的轻量 MVP Demo -- 输入出生信息,自动排盘,再用大模型流式生成命理分析报告。

技术栈:Go (Gin) + Next.js + SSE 流式传输 + Docker 一键部署,AI 兼容 OpenAI / DeepSeek / Ollama 等。

本文完整记录开发过程,包括 3 个真实踩坑。

e8f6013c3ac7891151c874e66284db44.png


一、整体架构:5 步增量实现

我把整个 Demo 拆成 5 步,每步都能独立验证:

Step 1: 四柱排盘接口 (POST /api/bazi)

Step 2: AI 流式接入 + SSE 接口 (POST /api/analyze/stream)

Step 3: 串联接口 (POST /api/bazi/analyze) -- 排盘 + AI 一条龙

Step 4: Next.js 前端单页 + 打字机效果

Step 5: Docker Compose 一键部署

数据流:

image.png

最终项目结构:

fulu/

├── docker-compose.yml

├── backend/

│ ├── main.go

│ ├── bazi/bazi.go # 排盘(lunar-go)

│ └── service/

│ ├── ai_provider.go # AI 流式调用

│ ├── handlers.go # SSE 接口

│ └── config.go # 环境变量配置

└── frontend/

└── app/page.tsx # Next.js 单页


二、四柱排盘:从自写公式翻车到引入 lunar-go

第一版:公历近似公式(翻车了)

一开始想"排盘不就是查表嘛",于是自己写了一套基于公历的近似公式:年柱按"2月15日前算上年",月柱按公历月映射,日柱用高氏日柱公式。

测试用例:农历 1981年10月15日 17:19,八字应为 辛酉 己亥 癸巳 辛酉。

实际输出:壬戌 辛亥 丁卯 癸酉 -- 四柱全错。

原因:八字的年柱按立春换年(不是春节更不是元旦),月柱按节气换月(不是公历月),公历近似根本对不上。

第二版:引入 lunar-go,一劳永逸

lunar-go 是一个零依赖的 Go 日历库,内置了农历、节气、八字计算。核心代码只需 3 行:

solar := calendar.NewSolar(year, month, day, hour, 0, 0)
lunar := solar.GetLunar()
eightChar := calendar.NewEightChar(lunar)

完整的排盘函数:

func CalculateBazi(year, month, day, hour int, gender string) (*BaziResult, error) {
    solar := calendar.NewSolar(year, month, day, hour, 0, 0)
    lunar := solar.GetLunar()
    eightChar := calendar.NewEightChar(lunar)
    pillars := [4]string{
    eightChar.GetYear(), // 年柱(按立春换年)
    eightChar.GetMonth(), // 月柱(按节气换月)
    eightChar.GetDay(), // 日柱
    eightChar.GetTime(), // 时柱
}

wuxing := calcWuxing(pillars)
    // ...
}

写个单元测试验证:

func TestCalculateBazi_1981(t *testing.T) {
// 公历 1981-11-11 17:00 = 农历十月十五 酉时
    r, _ := CalculateBazi(1981, 11, 11, 17, "男")
    expect := [4]string{"辛酉", "己亥", "癸巳", "辛酉"}
    for i := 0; i < 4; i++ {
        if r.Pillars[i] != expect[i] {
        t.Errorf("pillar[%d] got %s, want %s", i, r.Pillars[i], expect[i])
    }
}

--- PASS: TestCalculateBazi_1981 (0.00s)

PASS

教训:涉及农历/节气的计算,不要自己造轮子,直接用成熟库。


三、踩坑:UTF-8 字节 vs 字符,五行全变 20%

排盘对了之后,又发现五行占比不对 -- 辛酉 己亥 癸巳 辛酉 明明没有"木"属性,前端却显示金木水火土各 20%。

问题出在五行统计函数。Go 的字符串是 UTF-8 编码,一个中文占 3 个字节:

// 错误写法:p[0] 取的是字节,不是字符

for gi, g := range tianGan {
    if string(p[0]) == g { // "辛"[0] = 0xe8,永远不等于 "辛"
        count[ganWuXing[gi]]++
    }
}

string(p[0]) 取到的是第一个字节(0xe8),不是字符"辛",所以天干地支都没被识别,count 全为 0,最后走了兜底的"各 20%"。

修复:用 []rune 按字符拆分:

runes := []rune(p)
gan, zhi := string(runes[0]), string(runes[1])
for gi, g := range tianGan {
    if gan == g {
        count[ganWuXing[gi]]++
        break
    }
}

// 正确写法:先转 rune 再取字符

修复后,辛酉 己亥 癸巳 辛酉 的五行变成了 金 50%、水 25%、火 13%、土 12%、木 0%,符合预期。

教训:Go 处理中文字符串,永远用 []rune 而不是按字节索引。


四、AI 流式接入:SSE 实现打字机效果

这是项目的核心技术亮点。整个流式链路分 3 层:

第 1 层:请求大模型时开启流式(stream: true

body := map[string]interface{}{

"model": p.config.Model,

"messages": messages,

"stream": true, // 关键:开启流式返回

}

大模型会以 SSE 格式返回,每个 chunk 是一行 data: {"choices":[{"delta":{"content":"一段文字"}}]}

后端用 bufio.Scanner 逐行读取并解析:

scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
    line := strings.TrimSpace(scanner.Text())
    if !strings.HasPrefix(line, "data: ") {
    continue
}

data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
    break
}

// 解析 JSON,提取 choices[0].delta.content

content := extractStreamDeltaContent(obj)
if content != "" {
    writeChunk(content)

    }
}

第 2 层:后端用 SSE 推给前端

先设置 SSE 必需的响应头:

c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // 防止 Nginx 缓冲

然后先发排盘数据,再持续发 AI 分析,通过 type 字段区分:

// 第一条事件:排盘结果

firstEvent, _ := json.Marshal(map[string]interface{}{
"type": "bazi",
"payload": baziResult,
})
c.Writer.Write([]byte("data: " + string(firstEvent) + "\n\n"))
c.Writer.Flush()

// 后续事件:AI 逐段输出

provider.GenerateResponseStream(prompt, func(chunk string) error {
payload, _ := json.Marshal(map[string]string{
    "type": "content", "payload": chunk,
})

c.Writer.Write([]byte("data: " + string(payload) + "\n\n"))
c.Writer.Flush()
return nil
})

这样一条 HTTP 连接里,有序传输了两种数据:结构化的排盘 JSON + 流式的 AI 文本。

第 3 层:前端用 ReadableStream 消费

前端用 fetch + getReader() 读取 SSE 流,按 type 分别处理:

const reader = res.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
const { done, value } = await reader.read();

if (done) break;

buffer += decoder.decode(value, { stream: true });

const lines = buffer.split("\n");

buffer = lines.pop() || "";

    for (const line of lines) {

        if (line.startsWith("data: ")) {
            const data = JSON.parse(line.slice(6));
            if (data.type === "bazi") setBazi(data.payload); // 左侧显示四柱
            if (data.type === "content") setAnalysis(prev => prev + data.payload); // 右侧追加
            }
        }

}

setAnalysis(prev => prev + data.payload) 就是打字机效果的核心 -- 每收到一段就追加到已有文本后面。


五、Prompt 工程:让大模型输出结构化命理分析

Prompt 模板用 Go 的 text/template 渲染,把排盘结果填进去:

var promptTpl = `请基于以下四柱信息,生成结构化分析报告,分3部分,每部分不超过50字:

  1. 五行喜忌:一句话总结核心喜用五行

  2. 事业方向:结合职场/创业给出建议

  3. 年度运势:结合2026丙午年分析

四柱:{{.Pillars}}

五行分布:{{.Wuxing}}

格局:{{.Pattern}}

`

实测在 Ollama 的 gemma3:4b 上效果就不错,换 GPT-3.5 或 DeepSeek 更好。


六、Docker 一键部署(又踩了一坑)

docker-compose.yml

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    container_name: fulu-backend
    environment:
      PORT: "8080"
      AI_API_URL: ${AI_API_URL:-}
      AI_API_KEY: ${AI_API_KEY:-}
      AI_MODEL: ${AI_MODEL:-gpt-3.5-turbo}
    ports:
      - "${BACKEND_PORT:-8080}:8080"
    restart: unless-stopped

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
    container_name: fulu-frontend
    environment:
      NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
    ports:
      - "${FRONTEND_PORT:-3000}:3000"
    depends_on:
      - backend
    restart: unless-stopped

用 .env 管理配置,本地 Ollama 示例:

AI_API_URL=http://host.docker.internal:11434/v1/chat/completions
AI_API_KEY=
AI_MODEL=gemma3:4b
NEXT_PUBLIC_API_URL=http://localhost:8080

注意 host.docker.internal -- 容器里的 localhost 是容器自己,要访问宿主机的 Ollama 必须用这个地址。

踩坑:改了代码但排盘还是错的

部署后发现排盘结果和本地 go run . 不一样 -- 还是旧的错误结果。原因:Docker 用了缓存的旧镜像,二进制没更新。

解决:

docker compose down
docker compose up -d --build # 必须带 --build

或者彻底清缓存:

docker compose build --no-cache
docker compose up -d

教训:改了 Go 代码后,Docker 部署必须 --build,否则跑的还是旧二进制。


七、还有一个小坑:五行百分比加起来 101%

修完五行统计后,发现前端显示 金 50% 水 25% 火 13% 土 13% = 101%。

原因:每个五行用 (value * 100).toFixed(0) 独立四舍五入,12.5% 会变成 13%,两个 13% 就多了 1%。

解决:前端用最大余数法分配整数百分比,保证总和严格为 100%:

function wuxingToPct(wuxing: Record<string, number>): Record<string, number> {
    const keys = ["金", "木", "水", "火", "土"].filter(k => wuxing[k] > 0);
    const items = keys.map(k => ({
        k,
        pct: Math.floor(wuxing[k] * 100),
        remainder: wuxing[k] * 100 - Math.floor(wuxing[k] * 100),
    }));

    let sum = items.reduce((s, x) => s + x.pct, 0);
    items.sort((a, b) => b.remainder - a.remainder);

    for (let i = 0; i < 100 - sum && i < items.length; i++) {
        items[i].pct += 1; // 余数大的优先 +1
    }

    // ...

}

这个方法在选举计票、统计图表中很常见,也叫"汉密尔顿方法"。


总结

2 天完成了一个可运行的 MVP,核心技术点:

模块技术关键点
排盘Go + lunar-go农历/节气精确计算,不能用公历近似
AI 接入OpenAI 兼容接口stream: true + bufio.Scanner 解析 SSE
流式传输SSE (Server-Sent Events)一条连接传两种数据:type=bazi + type=content
前端Next.js + ReadableStreamfetch + getReader() 实现打字机效果
部署Docker Composehost.docker.internal 连本机 Ollama

踩过的 3 个坑:

  1. 公历近似排盘 vs 农历/节气精确排盘
  2. Go UTF-8 字节索引 vs 字符索引
  3. 独立四舍五入导致百分比总和不为 100%

后续可扩展:农历输入、RAG 命理知识库、更精确的格局判断(十神旺衰)、多模型切换等。

项目源码:[github.com/chunyou128/…]


如果觉得有帮助,欢迎点赞收藏,也欢迎在评论区交流 :)