将 checkpoint、异步任务与段结果缓存的唯一键从 execution_id 改为 node_group_id,区分换参重跑与续跑语义;恢复/续跑复用执行记录的组标识,换参重跑则换新组并软删旧组残留,消除软删墓碑导致同键重存失效的问题。
266 lines
11 KiB
Go
266 lines
11 KiB
Go
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
|
||
}
|
||
|
||
// cacheNodeId 缓存键节点 id:scope 非空(子流程批量子执行)时拼上作用域后缀,使各份子执行的
|
||
// async/segment 缓存行互相隔离(同一 exec 下多份内层节点 id 相同,不隔离会互相命中/覆盖 done 结果,
|
||
// 见《工作流子流程批量缓存隔离设计.md》);顶层 scope 为空 → 原样返回,行为不变。
|
||
// 仅用于缓存读写键,节点的 Config.Id / node_execution 记录 / 输出引用一律不受影响。
|
||
func cacheNodeId(global *flowDto.FlowExecutionInput, nodeId string) string {
|
||
if global == nil || global.SubFlowScope == "" {
|
||
return nodeId
|
||
}
|
||
return nodeId + global.SubFlowScope
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// async/segment 缓存键节点 id(子流程批量子执行带 scope,顶层即 Config.Id)
|
||
cNodeId := cacheNodeId(nodeInput.Global, nodeInput.Config.Id)
|
||
|
||
// 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 解析为系统模型 id(ModelCallRes.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.NodeGroupId, cNodeId)
|
||
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.Global.NodeGroupId, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt, nodeInput.Global.ExecutionId, cNodeId, 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.NodeGroupId, nodeInput.Global.ExecutionId, cNodeId, 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.Global.NodeGroupId, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt, nodeInput.Global.ExecutionId, cNodeId, 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 结算按模型聚合 token;total_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 为 JSONB,int64 落库成 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}
|
||
}
|