Go 调用Coze工作流实现 AI 游戏生成

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 流式)   │
                                           └──────────────┘

核心流程:

  1. 前端传入 age(年龄)和 game_type(游戏类型)
  2. 后端组装 prompt,调用扣子工作流
  3. 扣子工作流内部调用大模型生成游戏方案 + 推荐配乐
  4. 后端解析 SSE 流式响应,提取最终结果
  5. 存入数据库,返回给前端

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 = &copyEvt
            }
        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.UnmarshalWorkflowRunContent,里面的 OutputMusic 字段再做 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 编排链路。后端只需要做好三件事:

  1. 组装 prompt --- 把用户输入转成自然语言描述
  2. 调用接口 --- POST 到 /v1/workflow/stream_run,带好 API Key
  3. 解析响应 --- 从 SSE 流中找到 End 节点,提取 JSON 内容
相关推荐
夕除10 小时前
spring boot 12
java·开发语言·python
Brilliantwxx10 小时前
【C++】 认识STL set与map(基础接口+题目OJ运用)
开发语言·数据结构·c++·笔记·算法
Huangjin007_10 小时前
【C++ STL篇(十一)】深入浅出红黑树:从原理到实现,一篇搞定
开发语言·c++
fqbqrr10 小时前
2605C++,C++继承类实现调试器
开发语言·c++
阿里嘎多学长10 小时前
2026-05-21 GitHub 热点项目精选
开发语言·程序员·github·代码托管
wjs202410 小时前
PHP 面向对象编程(OOP)深入解析
开发语言
Deep-w10 小时前
【MATLAB】基于遗传算法的直流电机 PI 控制器参数优化研究
开发语言·算法·matlab
wb0430720110 小时前
从 Java 1 到 Java 26 的HTTP Client发展历程
java·开发语言·http
fu159357456811 小时前
【使用python代码制作数学逻辑动画】 ——【教程】
开发语言·python