feat(workflow): 增加工作流计费与执行生命周期管理

- 新增计费模块:执行开始建单、终态结算/取消/失败处理,支持按条/按秒/按token计费
- 新增执行生命周期跟踪:优雅关停时取消运行中执行并等待落库
- 新增异步任务等待/通知机制(Wait/Notify)
- 重构执行记录落库与进度上报,统一失败分类与重试语义
- 重命名文件:async_task.go→async.go、flow_checkpoint_store.go→exec_checkpoint.go、flow_graph_util.go→exec_record.go
- 更新 .gitignore 与数据库密码配置
This commit is contained in:
2026-09-03 13:22:22 +08:00
parent 67d049e586
commit d699f7ce14
46 changed files with 2690 additions and 2450 deletions
+252
View File
@@ -0,0 +1,252 @@
package flow
import (
"ai-agent/gateway"
flowDao "ai-agent/workflow/dao/flow"
nodeDao "ai-agent/workflow/dao/node"
flowDto "ai-agent/workflow/model/dto/flow"
nodeDto "ai-agent/workflow/model/dto/node"
"ai-agent/workflow/model/entity"
"ai-agent/workflow/service/flow/processor/builtin/media"
"ai-agent/workflow/service/flow/values"
"context"
"fmt"
"sync"
"github.com/gogf/gf/v2/util/gconv"
)
// StartLambda 启动节点
func StartLambda(ctx context.Context, input any) (any, error) {
return input, nil
}
// FormLambda 表单调用节点
func FormLambda(ctx context.Context, input any) (any, error) {
nodeInput, ok := input.(*flowDto.NodeExecutionInput)
if !ok {
return nil, fmt.Errorf("入参类型错误")
}
// 解析 valueSource 引用,填充表单节点输出配置(供下游引用)
for _, output := range nodeInput.Config.OutputConfig {
values.ProcessValueSourceRecursive(output, nodeInput.Global)
}
return nodeInput, nil
}
// ModelLambda 模型调用节点
func ModelLambda(ctx context.Context, input any) (any, error) {
nodeInput, ok := input.(*flowDto.NodeExecutionInput)
if !ok {
return nil, fmt.Errorf("入参类型错误")
}
modelParams, err := values.BuildModelRequestBody(nodeInput.Config.ModelConfig.ModelRequestParamsPath, nodeInput.Global)
if err != nil {
return nil, err
}
// 2. 前置工具:决定模型调用入参(单次/多次)
// 入参统一为扁平模型请求体(BuildModelRequestBody 输出,key 为点分路径)。
// 分批处理器按默认上限拆分集合字段,其余前置工具(如 split_shots_pipeline)读取扁平参数。
preToolParams := modelParams
paramsList, err := invokePreTool(ctx, nodeInput.Config.PreTool, preToolParams)
if err != nil {
return nil, err
}
// 3. 逐批调用模型,汇总输出(保持请求顺序),累计 token/费用供节点记录落库
var outputRes []map[string]any
var totalTokens int64
var totalPrompt int64
var totalCompletion int64
var totalCost float64
var totalDuration int64
// 计价用生效模型 id:引用行由 model-gateway 解析为系统模型 idModelCallRes.ModelId,计价按系统模型);
// 未返回(model-gateway 旧版本)时回落节点配置的模型 id。media_type 供 per_token 命中媒体价。
effModelID := nodeInput.Config.ModelConfig.ModelId
var effMediaType string
if len(paramsList) > 1 {
// 段级续跑仅在"多段 + 视频模型"启用;非视频分段(批量文本等)走原逻辑零影响。
// 段身份 = 列表位置(0-based):paramsList 顺序即段序,concat 按列表顺序拼接;
// 位置互不重复且跨 reExecute 稳定(参数一致 → 段数/顺序不变)。不依赖 params 里的
// segment_index——真实链路(上游 split_shots_pipeline 转写 → 下游 split_segment 按
// __segment_fields 拆分,invokePreTool 剥离 __ 内部键)下 paramsList 只有模型参数。
// segVideo=false 时走既有非段级合并路径(全量生成、不落库、不复用),全新执行行为不变,
// 后续自动 concat 判断(独立的 isVideoModel 调用)仍正常执行。
segVideo := isVideoModel(ctx, nodeInput.Config.ModelConfig.ModelId)
// 续跑(!ForceNewRun)时读取该节点已成功段;全新执行不查(BuildExecution 已清旧段),saved 为 nil → 全量重生成
var saved map[int]entity.SegmentRef
if !nodeInput.Global.ForceNewRun && segVideo {
saved, err = flowDao.FlowSegmentResultDao.ListByNode(ctx, nodeInput.Global.ExecutionId, nodeInput.Config.Id)
if err != nil {
return nil, err
}
}
idxList, needGen := planSegmentResume(paramsList, saved)
results := make([][]map[string]any, len(paramsList))
tokenRes := make([]*gateway.ModelCallRes, len(paramsList))
errs := make([]error, len(paramsList))
saveErrs := make([]error, len(paramsList))
isInference := make([]bool, len(paramsList))
var wg sync.WaitGroup
for i, params := range paramsList {
if !needGen[i] {
continue
}
wg.Add(1)
go func(i int, params map[string]any) {
defer wg.Done()
// 每段单次调用,不原地重试:段失败即走节点失败收口(HandleFailedNodeExecution → Interrupt),
// 下次 reExecute 由 planSegmentResume 复用已成功段、仅重生成失败段
results[i], tokenRes[i], isInference[i], errs[i] = ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt, nodeInput.Global.ExecutionId, nodeInput.Config.Id, idxList[i])
// 每段成功立即落库:该段刚成功即持久化,其他段仍在跑时已成功段也不丢;
// 后续段失败或进程崩溃(panic/OOM/kill)时,已完成段已在库中,reExecute 可直接复用
if segVideo && errs[i] == nil {
for _, rec := range results[i] {
key := media.FindVideoKey(rec)
url := media.FindVideoURL(ctx, rec)
if key == "" || url == "" {
continue
}
if err := flowDao.FlowSegmentResultDao.Save(ctx, nodeInput.Global.ExecutionId, nodeInput.Config.Id, idxList[i], key, url); err != nil {
saveErrs[i] = err
}
}
}
}(i, params)
}
wg.Wait()
// 仍有失败段或落库失败 → 节点失败(成功段已立即落库,供下次 reExecute 复用)
for i := range results {
if saveErrs[i] != nil {
return nil, saveErrs[i]
}
if needGen[i] && errs[i] != nil {
return nil, errs[i]
}
if needGen[i] && tokenRes[i] != nil {
totalTokens += tokenRes[i].TotalTokens
totalPrompt += tokenRes[i].PromptTokens
totalCompletion += tokenRes[i].CompletionTokens
totalCost += tokenRes[i].Cost
if isVideoModel(ctx, nodeInput.Config.ModelConfig.ModelId) {
totalDuration += tokenRes[i].Duration
}
if tokenRes[i].ModelId > 0 {
effModelID = tokenRes[i].ModelId
}
if effMediaType == "" {
effMediaType = tokenRes[i].MediaType
}
}
}
if segVideo {
// 复用段 + 新生段按段序号升序合并,concat 按列表顺序拼接 → 顺序保证
outputRes = mergeSegmentOutputs(idxList, needGen, results, saved)
} else {
if isInference[0] {
outputRes = mergeInferenceBatchResults(results)
} else {
for _, res := range results {
outputRes = append(outputRes, res...)
}
}
}
} else {
for _, params := range paramsList {
res, modelRes, _, err := ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt, nodeInput.Global.ExecutionId, nodeInput.Config.Id, flowDao.FlowAsyncSegSentinel)
if err != nil {
return nil, err
}
if modelRes != nil {
totalTokens += modelRes.TotalTokens
totalPrompt += modelRes.PromptTokens
totalCompletion += modelRes.CompletionTokens
totalCost += modelRes.Cost
if isVideoModel(ctx, nodeInput.Config.ModelConfig.ModelId) {
totalDuration += modelRes.Duration
}
if modelRes.ModelId > 0 {
effModelID = modelRes.ModelId
}
if effMediaType == "" {
effMediaType = modelRes.MediaType
}
}
outputRes = append(outputRes, res...)
}
}
// 3.5 把本次节点消耗的 token/费用/生成视频时长写入节点执行记录,供汇总节点聚合到 exec_workflow
// model_id 供 per_token 结算按模型聚合 tokentotal_duration 供 per_item/per_second 按生成视频总时长计费;
// per_char 模型把输出字数映射到 completion_tokens 传输,随 token 拆分一并落库
if nodeInput.NodeExecutionId > 0 && (totalTokens > 0 || totalCost > 0 || totalDuration > 0) {
if _, err = nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
Id: nodeInput.NodeExecutionId,
TokenInfo: []map[string]any{{
// model_id 写字符串:token_info 为 JSONBint64 落库成 JSON 数字,读回是 float64
// billing 侧按 model 聚合时 (string) 断言会失败导致 per_token 永远记 0。
// 与 ModelItem.ModelId 的 json:"modelId,string" 约定一致,字符串精确回环(雪花id>2^53 无损)。
// effModelID 为解析后的系统模型 id(引用行),per_token 结算按此查价。
"model_id": gconv.String(effModelID),
"prompt_tokens": totalPrompt,
"completion_tokens": totalCompletion,
"media_type": effMediaType,
"total_tokens": totalTokens,
"total_fee": totalCost,
"total_duration": totalDuration,
}},
}); err != nil {
return nil, fmt.Errorf("节点:%v 写入token信息失败: %v", nodeInput.Config.Name, err)
}
}
// 4.5 视频模型节点返回多个视频时,自动调用视频合成工具(concat_videos)合并为单条;
// 已显式配置 concat_videos 后置工具时跳过,避免重复合并
if nodeInput.Config.PostTool != media.ProcessorName && len(outputRes) > 1 && isVideoModel(ctx, nodeInput.Config.ModelConfig.ModelId) {
outputRes, err = invokePostTool(ctx, media.ProcessorName, outputRes, map[string]any{"callback_url": "callback_url", "upload": true})
if err != nil {
return nil, err
}
} else {
// 4. 后置工具:加工模型输出(透传原始请求参数,供后置工具读取合并配置等)
outputRes, err = invokePostTool(ctx, nodeInput.Config.PostTool, outputRes, modelParams)
if err != nil {
return nil, err
}
}
nodeInput.Config.OutputResult = outputRes
return nodeInput, nil
}
// mergeInferenceBatchResults 推理模型分批结果拼接为单条输出记录:
// 各批结果按批序对同名 key 的值做字符串拼接("拼到一个字段"),最终返回单条 {key:值} 记录。
// 非字符串值(如结构/数组字段)取最后一份,避免误拼接。
func mergeInferenceBatchResults(results [][]map[string]any) []map[string]any {
merged := make(map[string]any)
for _, res := range results {
for _, record := range res {
for key, val := range record {
prev, has := merged[key]
if !has {
merged[key] = val
continue
}
sPrev, pOK := prev.(string)
sVal, vOK := val.(string)
if pOK && vOK {
merged[key] = sPrev + "\n" + sVal
continue
}
merged[key] = val
}
}
}
return []map[string]any{merged}
}