前言
最近在研究一个方向:用大模型做中国传统文化领域的 AI 应用。作为练手,我花了 2 天时间,基于 Golang 做了一个"四柱 AI 分析"的轻量 MVP Demo -- 输入出生信息,自动排盘,再用大模型流式生成命理分析报告。
技术栈:Go (Gin) + Next.js + SSE 流式传输 + Docker 一键部署,AI 兼容 OpenAI / DeepSeek / Ollama 等。
本文完整记录开发过程,包括 3 个真实踩坑。
一、整体架构: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 一键部署
数据流:
最终项目结构:
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字:
-
五行喜忌:一句话总结核心喜用五行
-
事业方向:结合职场/创业给出建议
-
年度运势:结合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 + ReadableStream | fetch + getReader() 实现打字机效果 |
| 部署 | Docker Compose | host.docker.internal 连本机 Ollama |
踩过的 3 个坑:
- 公历近似排盘 vs 农历/节气精确排盘
- Go UTF-8 字节索引 vs 字符索引
- 独立四舍五入导致百分比总和不为 100%
后续可扩展:农历输入、RAG 命理知识库、更精确的格局判断(十神旺衰)、多模型切换等。
项目源码:[github.com/chunyou128/…]
如果觉得有帮助,欢迎点赞收藏,也欢迎在评论区交流 :)