Go 调用Coze工作流实现 AI 游戏生成
背景
最近在做一个项目,核心功能之一是根据用户输入的年龄和游戏类型,自动生成一个适龄的互动小游戏方案。
游戏内容的生成不是靠规则引擎,而是调用字节跳动的扣子(Coze)平台上的工作流。扣子平台提供了可视化的 AI 工作流编排能力,我们把"理解需求 → 生成游戏方案 → 推荐配乐"这条链路在扣子上编排好,后端只需要调一个 HTTP 接口就能拿到完整结果。
本文记录整个技术方案的落地过程,包括扣子 API 的对接、SSE 流式响应的解析、以及踩过的一些坑。
整体架构
┌──────────┐ POST /game/generate ┌──────────────┐
│ 小程序/ │ ───────────────────────────► │ Go 后端 │
│ App 前端 │ ◄─────────────────────────── │ (Gin) │
└──────────┘ JSON Response └──────┬───────┘
│
│ POST /v1/workflow/stream_run
│ Authorization: Bearer <API_KEY>
▼
┌──────────────┐
│ 扣子 Coze │
│ 工作流平台 │
│ (SSE 流式) │
└──────────────┘
核心流程:
- 前端传入
age(年龄)和game_type(游戏类型) - 后端组装 prompt,调用扣子工作流
- 扣子工作流内部调用大模型生成游戏方案 + 推荐配乐
- 后端解析 SSE 流式响应,提取最终结果
- 存入数据库,返回给前端
1. 扣子平台侧的配置
1.1 创建工作流
在扣子平台的工作流编排页面,我们创建了一个游戏生成工作流,核心节点包括:
- 开始节点 :接收一个
input参数(即 prompt) - 大模型节点:根据 prompt 生成游戏方案,输出包含游戏名称、玩法说明、适龄建议等
- 结束节点 :输出
output(游戏方案文本)和music(推荐的配乐标识)
发布后拿到 workflow_id,这就是我们调用时需要传的标识。
1.2 获取 API Key
在扣子平台的个人设置 → API Key 页面生成一个 Personal Access Token,后续所有请求都通过 Authorization: Bearer <token> 方式鉴权。
2. 后端代码实现
2.1 配置定义
go
// pkg/config/config.go
type CozeConfig struct {
BaseURL string `mapstructure:"base_url"` // https://api.coze.cn
APIKey string `mapstructure:"api_key"` // Personal Access Token
WorkflowID string `mapstructure:"workflow_id"` // 默认工作流ID
BotID string `mapstructure:"cozeBotID"` // 关联的Bot ID(可选)
TimeoutSec int `mapstructure:"timeout_sec"` // 超时时间,默认60s
}
配置文件(yaml):
yaml
coze:
base_url: https://api.coze.cn
api_key: pat_xxxxxxxx
workflow_id: "73664689170551xxxxx"
timeout_sec: 120
2.2 定义请求和响应结构
go
// internal/intergration/coze/types.go
// 请求体
type WorkflowRunRequest struct {
WorkflowID string `json:"workflow_id"`
Parameters WorkflowRunRequestParams `json:"parameters"`
}
type WorkflowRunRequestParams struct {
Input string `json:"input"`
}
// SSE 流中的单条消息
type WorkflowRunResponse struct {
NodeExecuteUUID string `json:"node_execute_uuid"`
Usage Usage `json:"usage"`
NodeIsFinish bool `json:"node_is_finish"`
NodeSeqID string `json:"node_seq_id"`
NodeTitle string `json:"node_title"`
Content string `json:"content"`
ContentType string `json:"content_type"`
NodeType string `json:"node_type"`
NodeID string `json:"node_id"`
}
// 最终从 End 节点解析出的内容
type WorkflowRunContent struct {
Output string `json:"output"` // 游戏方案文本
Music string `json:"music"` // 推荐配乐
}
2.3 核心:调用扣子流式接口
这是最关键的部分。扣子的工作流流式接口地址是 /v1/workflow/stream_run,返回的是 SSE(Server-Sent Events)格式。
go
// internal/intergration/coze/client.go
type Client struct {
baseURL string
apiKey string
workflowID string
httpClient *http.Client
}
func (c *Client) GenerateGame(workflowID string, prompt string) (*service.GameAIResult, error) {
// 构建请求体
reqBody := WorkflowRunRequest{
WorkflowID: workflowID,
Parameters: WorkflowRunRequestParams{
Input: prompt,
},
}
bodyBytes, _ := json.Marshal(reqBody)
url := fmt.Sprintf("%s/v1/workflow/stream_run", c.baseURL)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(bodyBytes))
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream") // 关键:声明接受SSE
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("调用扣子工作流失败:%w", err)
}
defer resp.Body.Close()
// 解析 SSE 流,取最终 End 节点的输出
finalEvent, err := parseStreamResponse(resp.Body)
if err != nil {
return nil, err
}
// 解析 JSON 内容
var workflowResp WorkflowRunContent
json.Unmarshal([]byte(finalEvent.Content), &workflowResp)
return &service.GameAIResult{
GameName: extractGameName(workflowResp.Output),
GeneratedContent: workflowResp.Output,
Music: workflowResp.Music,
}, nil
}
2.4 SSE 流解析
扣子返回的 SSE 格式大致如下:
event:Message
data:{"node_title":"大模型","content":"{\"output\":\"...\",\"music\":\"...\"}","node_is_finish":false,...}
event:Message
data:{"node_title":"End","content":"{\"output\":\"完整游戏方案\",\"music\":\"bgm_01\"}","node_is_finish":true,...}
event:Done
data:{}
我们需要的是 node_title == "End" 且 node_is_finish == true 的那条 Message:
go
func parseStreamResponse(r io.Reader) (*WorkflowRunResponse, error) {
scanner := bufio.NewScanner(r)
// 扩大 buffer,游戏方案文本可能很长
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 4*1024*1024) // 最大 4MB
var currentEventName string
var currentData strings.Builder
var finalEvent *WorkflowRunResponse
flushEvent := func() error {
if currentEventName == "" || currentData.Len() == 0 {
currentEventName = ""
currentData.Reset()
return nil
}
dataStr := strings.TrimSpace(currentData.String())
switch strings.TrimSpace(currentEventName) {
case "Message":
var evt WorkflowRunResponse
if err := json.Unmarshal([]byte(dataStr), &evt); err != nil {
return fmt.Errorf("解析 Message 事件失败: %w", err)
}
// 只关心 End 节点的最终输出
if evt.NodeIsFinish && evt.NodeTitle == "End" && evt.Content != "" {
copyEvt := evt
finalEvent = ©Evt
}
case "PING":
// 保活事件,忽略
case "Done":
// 流结束,忽略
}
currentEventName = ""
currentData.Reset()
return nil
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
flushEvent() // 空行 = 事件边界
continue
}
if strings.HasPrefix(line, "event:") {
currentEventName = strings.TrimPrefix(line, "event:")
}
if strings.HasPrefix(line, "data:") {
if currentData.Len() > 0 {
currentData.WriteByte('\n')
}
currentData.WriteString(strings.TrimPrefix(line, "data:"))
}
}
if finalEvent == nil {
return nil, fmt.Errorf("未找到最终完成的 End 节点")
}
return finalEvent, nil
}
2.5 Service 层:组装 prompt 并调用
go
// internal/service/game_service.go
func (s *gameService) GenerateGame(userID uint, req *request.GenerateGameRequest) (*dto.GenerateGameResponse, error) {
// 1. 从数据库获取当前启用的游戏生成工作流
flow, _ := s.flowService.GetCreationGameFlow()
// 2. 组装 prompt
prompt := fmt.Sprintf("给我生成一个适合%d岁孩子的%s的小游戏", req.Age, req.GameType)
// 3. 调用扣子工作流
result, err := s.aiClient.GenerateGame(flow.FlowID, prompt)
if err != nil {
return nil, fmt.Errorf("游戏生成失败: %w", err)
}
// 4. 保存生成记录
record := &entity.GameRecord{
UserID: int64(userID),
Age: req.Age,
GameType: req.GameType,
GameName: result.GameName,
GeneratedContent: result.GeneratedContent,
Music: result.Music,
PromptText: prompt,
WorkflowCode: flow.FlowID,
}
s.gameRepo.Create(record)
// 5. 返回结果
return &dto.GenerateGameResponse{
RecordID: record.ID,
GameName: record.GameName,
GeneratedContent: record.GeneratedContent,
Music: result.Music,
}, nil
}
2.6 工作流的动态管理
工作流 ID 不是写死在配置里的。我们在数据库维护了一张 flows 表:
go
type Flow struct {
FlowID string // 扣子工作流 ID
Description string // 功能描述,如"游戏生成"
Type int8 // 0=游戏, 1=问答技能
Status int8 // 0=禁用, 1=启用
ImageNeed int8 // 是否需要传图片
}
通过 flowService.GetCreationGameFlow() 从数据库查 type=0, description="游戏生成" 的记录,拿到当前启用的工作流 ID。这样换工作流只需要改数据库,不用重启服务。
3. 踩过的坑
坑 1:SSE 解析的 buffer 溢出
现象 :长文本游戏方案返回时,bufio.Scanner 默认 buffer 只有 64KB,超长行会被截断,导致 JSON 解析失败。
解决:
go
scanner.Buffer(buf, 4*1024*1024) // 扩大到 4MB
坑 2:扣子返回的 content 是双重 JSON 编码
现象 :finalEvent.Content 拿到的不是直接的对象,而是一个 JSON 字符串里面嵌套了另一层 JSON 字符串。
解决 :先 json.Unmarshal 到 WorkflowRunContent,里面的 Output 和 Music 字段再做 TrimSpace 和清理。
go
output := strings.TrimSpace(workflowResp.Output)
music := strings.TrimSpace(workflowResp.Music)
music = strings.Trim(music, "`") // 有时会带反引号
坑 3:超时设置
现象 :游戏生成工作流内部要调大模型,整个链路可能需要 30~90 秒。默认的 http.Client 超时如果设太短,会经常 timeout。
解决:流式接口超时设为 120 秒,非流式设为 60 秒。配置化管理:
yaml
coze:
timeout_sec: 120 # 流式场景给够时间
坑 4:工作流需要关联 Bot
扣子部分工作流(涉及数据库节点、变量节点的)在调用时需要传 bot_id。我们的游戏生成工作流比较简单,不依赖 Bot,但如果后续加了复杂节点,需要注意这个参数。
总结
扣子平台的工作流能力让我们不需要自己搭建复杂的 AI 编排链路。后端只需要做好三件事:
- 组装 prompt --- 把用户输入转成自然语言描述
- 调用接口 --- POST 到
/v1/workflow/stream_run,带好 API Key - 解析响应 --- 从 SSE 流中找到 End 节点,提取 JSON 内容