全用一种语言,两头都不舒服
alchemy-furnace 立项时,我面对一个很典型的矛盾:我是 Go 主力,后端那一套(Gin、GORM、Wire、并发模型)写得最顺;但炼丹的核心,提示词变异算子、多供应商 LLM 适配、后面可能要接的 agent 编排,Python 生态明显更快。前端我又想用 Next.js 做一个交互流畅的控制台,不想套模板。
全用一种语言呢?要么牺牲迭代速度,让 Go 手搓 prompt 实验;要么牺牲工程稳定性,让 Python 扛业务网关和并发。两条路都不痛快,所以一开始我就定了三段式:Go 网关、Python 合成引擎、Next.js 前端。这篇讲它们怎么切、怎么连,踩了哪些坑。
稳定的归 Go,易变的归 Python
职责切分就一条原则:稳定的、要强类型和高并发的归 Go;易变的、AI 实验性强的归 Python;交互和渲染归 Next.js。
Go 网关(gateway):用户认证、API Key 管理、金丹/任务/产物的 CRUD、计费配额、请求签名、SSE 进度推送、对 Python 引擎的调用与降级。Gin + GORM + Wire,MySQL 存元数据,Redis 做任务队列和缓存。
Python 合成引擎(furnace-engine):融合算子(crossover/mutate/ensemble)、多供应商 LLM 适配层、提示词缓存、血统计算。FastAPI + httpx,无状态,水平扩展靠 K8s 加副本。
Next.js 前端(console):金丹编辑器、炼丹任务向导、分身对话沙箱、血统图谱可视化。App Router + SSE 消费网关进度。
三段之间的调用链:
1
2
3
| Browser ──HTTP/SSE──> Go Gateway ──HTTP+签名──> Python Engine
│
└──> MySQL / Redis / S3
|
Python 引擎不直接暴露给浏览器,也不直连业务库。它只接收网关签名过的任务请求,必要时从 S3 读写大对象。这样安全边界是清楚的:引擎挂了,用户登录和数据查询照常,网关还能返回降级结果。
网关到引擎:签名、超时、降级
Go 调 Python 这一层我封了一个 client,带超时、重试和签名。签名用 HMAC-SHA256,防的是引擎被内网其他服务误调:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| type FurnaceClient struct {
cli *http.Client
baseURL string
signSecret string
}
func (c *FurnaceClient) Submit(ctx context.Context, job FurnaceJob) (*FurnaceAck, error) {
body, _ := json.Marshal(job)
req, _ := http.NewRequestWithContext(ctx, "POST",
c.baseURL+"/v1/furnace/fuse", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
ts := strconv.FormatInt(time.Now().Unix(), 10)
req.Header.Set("X-Furnace-Ts", ts)
req.Header.Set("X-Furnace-Sign",
c.sign(body, ts)) // HMAC-SHA256(secret, ts+body)
resp, err := c.cli.Do(req)
if err != nil {
return nil, fmt.Errorf("furnace call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return nil, ErrFurnaceUnavailable // 网关层触发降级
}
var ack FurnaceAck
json.NewDecoder(resp.Body).Decode(&ack)
return &ack, nil
}
|
Python 侧用一个轻量依赖校验签名:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| from fastapi import Request, HTTPException
import hmac, hashlib, time
async def verify_signature(request: Request):
secret = settings.FURNACE_SIGN_SECRET
ts = request.headers.get("X-Furnace-Ts", "")
sign = request.headers.get("X-Furnace-Sign", "")
body = await request.body()
expected = hmac.new(
secret.encode(),
(ts.encode() + body),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sign):
raise HTTPException(status_code=401, detail="bad signature")
if abs(time.time() - int(ts)) > 300:
raise HTTPException(status_code=401, detail="stale timestamp")
|
前端的进度条走 SSE,网关把引擎的阶段事件透传成统一格式:
1
2
3
4
5
6
7
| // Next.js 客户端
const evt = new EventSource(`/api/fusion/${jobId}/stream`);
evt.onmessage = (e) => {
const { stage, message } = JSON.parse(e.data);
setStages((s) => [...s, { stage, message }]);
if (stage === "done" || stage === "failed") evt.close();
};
|
四个抉择
第一个抉择是同步还是异步。融合任务要调 LLM,耗时几秒到几十秒。我没让网关同步阻塞着等引擎,而是引擎返回 job_id,网关写任务表,引擎通过回调 + SSE 推进度。好处是网关不会被长连接拖垮,坏处是多了一套任务状态机。中间态放 Redis,终态进 MySQL,状态流转集中在 Go 侧,Python 只发事件、不直接改库。
第二个是缓存放哪。引擎要保持无状态,滚动更新和扩缩容才安全,所以合成提示词缓存(后面单篇讲)放在 Redis 里,实例本身不持有本地状态。代价是每次多一次网络往返,但跟一次 LLM 调用比,这点开销可以忽略。
第三个是 CORS 和 Cookie,这块坑过我。Next.js 开发时直连 Go 网关会跨域,我没有放开 CORS 让浏览器直连,而是在 Next.js 里用 Route Handler 做反向代理:同源访问,Cookie 和 SSE 都干净。生产上由 KubeSphere 的网关统一路由。
第四个是要不要上 gRPC。我考虑过,最后没上。引擎接口变化快、字段常加,HTTP+JSON 在这个阶段调试成本最低,pydantic 做校验也够用。等接口稳定、QPS 真上来了,再把内部高频调用换成 gRPC 不迟。我在某 SaaS 公司做过 fasthttp 到 go-zero 的迁移,过早引入复杂 RPC 的代价,我是见识过的。
后来
三段式不是为了炫技,就是让每种语言干它最擅长的事:Go 守住稳定和并发的底线,Python 承接 AI 的快速迭代,Next.js 提供交互体验。边界靠三条约束维持:Python 不直连业务库,所有内部调用带签名,任务状态归 Go 管。这套结构让我能一个人把全栈交付下来,任何一层也没有长成难维护的大泥球。
封面图:Me in ME / Flickr · CC BY 2.0