feat: 支持工作流断点续跑并拆分错误信息存储
- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑 - exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示 - 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法 - 新增 GetLatestBySessionAndFlow 查询最近执行记录 - 修正 ListDates 分组与排序 SQL 表达式 - 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
@@ -3,6 +3,7 @@ package flow
|
||||
import (
|
||||
"ai-agent/gateway"
|
||||
"ai-agent/workflow/consts/flow"
|
||||
"ai-agent/workflow/consts/model"
|
||||
"ai-agent/workflow/consts/node"
|
||||
"ai-agent/workflow/consts/public"
|
||||
nodeDao "ai-agent/workflow/dao/node"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
sessionDto "ai-agent/workflow/model/dto/session"
|
||||
"ai-agent/workflow/model/entity"
|
||||
"ai-agent/workflow/service/flow/processor"
|
||||
"ai-agent/workflow/service/flow/processor/builtin/media"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -20,6 +22,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
|
||||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||||
"github.com/cloudwego/eino-examples/compose/batch/batch"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
@@ -53,14 +56,16 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
|
||||
return nil, fmt.Errorf("入参类型错误")
|
||||
}
|
||||
|
||||
// 1. 解析 valueSource 引用,填充模型请求参数
|
||||
ProcessValueSourceRecursive(nodeInput.Config.ModelConfig.ModelRequestParams, nodeInput.Global)
|
||||
|
||||
// 1.5 剔除 value 为空的字段;数组/枚举元素整体为空时移除整个元素(0/false 视为有效值)
|
||||
CleanEmptyModelParams(nodeInput.Config.ModelConfig.ModelRequestParams)
|
||||
modelParams, err := BuildModelRequestBody(nodeInput.Config.ModelConfig.ModelRequestParamsPath, nodeInput.Global)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 前置工具:决定模型调用入参(单次/多次)
|
||||
paramsList, err := invokePreTool(ctx, nodeInput.Config.PreTool, nodeInput.Config.ModelConfig.ModelRequestParams)
|
||||
// 入参统一为扁平模型请求体(BuildModelRequestBody 输出,key 为点分路径)。
|
||||
// 分批处理器按默认上限拆分集合字段,其余前置工具(如 split_shots_pipeline)读取扁平参数。
|
||||
preToolParams := modelParams
|
||||
paramsList, err := invokePreTool(ctx, nodeInput.Config.PreTool, preToolParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -69,21 +74,22 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
|
||||
var outputRes []map[string]any
|
||||
var totalTokens int64
|
||||
var totalCost float64
|
||||
if nodeInput.Config.IsBatchExec && len(paramsList) > 1 {
|
||||
if len(paramsList) > 1 {
|
||||
// 异步批量执行:并发请求模型,等待全部返回后再继续,避免下游读到空结果
|
||||
results := make([][]map[string]any, len(paramsList))
|
||||
tokenRes := make([]*gateway.ModelCallRes, len(paramsList))
|
||||
errs := make([]error, len(paramsList))
|
||||
isInference := make([]bool, len(paramsList))
|
||||
var wg sync.WaitGroup
|
||||
for i, params := range paramsList {
|
||||
wg.Add(1)
|
||||
go func(i int, params map[string]any) {
|
||||
defer wg.Done()
|
||||
results[i], tokenRes[i], errs[i] = ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params)
|
||||
results[i], tokenRes[i], isInference[i], errs[i] = ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt)
|
||||
}(i, params)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, res := range results {
|
||||
for i := range results {
|
||||
if errs[i] != nil {
|
||||
return nil, errs[i]
|
||||
}
|
||||
@@ -91,11 +97,19 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
|
||||
totalTokens += tokenRes[i].TotalTokens
|
||||
totalCost += tokenRes[i].Cost
|
||||
}
|
||||
outputRes = append(outputRes, res...)
|
||||
}
|
||||
// 推理模型(分批同一模型,isInference 各批一致):分批结果拼到单个字段(单条输出记录);
|
||||
// 非推理模型保持逐条展平
|
||||
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)
|
||||
res, modelRes, _, err := ModelCallResultLambda(ctx, nodeInput.Config.ModelConfig.ModelId, nodeInput.Global.SessionId, params, nodeInput.Config.Prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,7 +123,7 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
|
||||
|
||||
// 3.5 把本次节点消耗的 token/费用写入节点执行记录,供汇总节点聚合到 exec_workflow
|
||||
if nodeInput.NodeExecutionId > 0 && (totalTokens > 0 || totalCost > 0) {
|
||||
if _, err := nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
|
||||
if _, err = nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
|
||||
Id: nodeInput.NodeExecutionId,
|
||||
TokenInfo: []map[string]any{{
|
||||
"total_tokens": totalTokens,
|
||||
@@ -120,16 +134,61 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 后置工具:加工模型输出(透传原始请求参数,供后置工具读取合并配置等)
|
||||
outputRes, err = invokePostTool(ctx, nodeInput.Config.PostTool, outputRes, nodeInput.Config.ModelConfig.ModelRequestParams)
|
||||
if err != nil {
|
||||
return nil, 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
|
||||
}
|
||||
|
||||
// isVideoModel 判断模型是否为视频模型(模型类型 TypeVideo=600),用于视频节点多视频自动合成判断
|
||||
func isVideoModel(ctx context.Context, modelId int64) bool {
|
||||
modelInfo, err := gateway.GetModelInfoById(ctx, &gateway.GetModelInfoByIdReq{ModelId: modelId})
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "查询模型配置失败,跳过自动视频合成 modelId=%d err=%v", modelId, err)
|
||||
return false
|
||||
}
|
||||
return modelInfo.ModelManage.ModelType != nil && *modelInfo.ModelManage.ModelType == model.TypeVideo
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// invokePreTool 执行前置处理器,把模型请求参数转换为模型调用入参列表。
|
||||
// 前置处理器契约:入参即模型请求参数本体;返回值:
|
||||
// - map[string]any 一次模型调用,入参为返回值
|
||||
@@ -137,7 +196,7 @@ func ModelLambda(ctx context.Context, input any) (any, error) {
|
||||
// - nil 视为异常,节点失败(不允许静默跳过模型调用)
|
||||
func invokePreTool(ctx context.Context, processorName string, modelParams map[string]any) (paramsList []map[string]any, err error) {
|
||||
if processorName == "" {
|
||||
return []map[string]any{modelParams}, nil
|
||||
return []map[string]any{stripInternalKeys(modelParams)}, nil
|
||||
}
|
||||
data, err := processor.Call(ctx, processorName, modelParams)
|
||||
if err != nil {
|
||||
@@ -147,14 +206,32 @@ func invokePreTool(ctx context.Context, processorName string, modelParams map[st
|
||||
case nil:
|
||||
return nil, fmt.Errorf("前置处理器[%s]返回空", processorName)
|
||||
case map[string]any:
|
||||
return []map[string]any{v}, nil
|
||||
return []map[string]any{stripInternalKeys(v)}, nil
|
||||
case []map[string]any:
|
||||
return v, nil
|
||||
list := make([]map[string]any, 0, len(v))
|
||||
for _, m := range v {
|
||||
list = append(list, stripInternalKeys(m))
|
||||
}
|
||||
return list, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("前置处理器[%s]返回类型不支持: %T", processorName, data)
|
||||
}
|
||||
}
|
||||
|
||||
// stripInternalKeys 剥离 __ 前缀的内部键(如 __segment_fields/__produced),
|
||||
// 模型网关做参数严格校验(CheckParams strictUnknown)会拒绝未知字段,内部标记不得随请求体下发。
|
||||
func stripInternalKeys(params map[string]any) map[string]any {
|
||||
if params == nil {
|
||||
return params
|
||||
}
|
||||
for k := range params {
|
||||
if strings.HasPrefix(k, "__") {
|
||||
delete(params, k)
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// invokePostTool 执行后置处理器,加工模型调用结果。
|
||||
// 后置处理器契约:入参 {"output": 模型输出结果列表, "request": 原始模型请求参数}(列表须包成对象传入);返回值:
|
||||
// - []map[string]any 替换模型输出
|
||||
@@ -444,9 +521,10 @@ func SummaryLambda(ctx context.Context, input any) (any, error) {
|
||||
|
||||
// collectSaveFileResults 按两层规则收集需入库的文件结果:
|
||||
// 第一层:节点须开启"保存文件"(IsSaveFile);
|
||||
// 第二层:key 取自模型响应内容 respBody(即节点 OutputResult 的各字段),命中
|
||||
// ModelResponseBodyMapping 才入库;原始响应体 key(respBody)恒入库(不要求映射声明)。
|
||||
// 结果值为 http(s) URL 直接使用;非路径值(base64 图片/文本)先上传 OSS 换取 URL,
|
||||
// 第二层:key 取自节点 OutputResult 的各字段,命中 ModelResponseBodyMapping 才入库;
|
||||
// 原始响应体 key(respBody)恒入库(不要求映射声明);HTTP 节点产出以 http_file_url:{key}
|
||||
// 标记的字段(IsSaveFile 时由 HttpCallResultLambda 生成)恒入库(无模型响应映射可查)。
|
||||
// 结果值为 http(s) URL 或 MinIO 对象裸路径直接使用;非路径值(base64 图片/文本)先上传 OSS 换取 URL,
|
||||
// 文本内容以 .inc 扩展名存储。
|
||||
func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutionInput) []*sessionDto.CreateWorkflowResultReq {
|
||||
if execInput == nil {
|
||||
@@ -458,13 +536,14 @@ func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutio
|
||||
if nodeConfig == nil || len(nodeConfig.OutputResult) == 0 || !nodeConfig.IsSaveFile {
|
||||
continue
|
||||
}
|
||||
// 第二层:key 取自模型响应内容 respBody(即节点 OutputResult 的各字段),
|
||||
// 命中 ModelResponseBodyMapping 才入库;原始响应体 key(respBody)恒入库
|
||||
// 第二层:key 取自节点 OutputResult 的各字段,
|
||||
// 命中 ModelResponseBodyMapping 才入库;respBody 与 HTTP 节点 http_file_url:{key} 标记恒入库
|
||||
saveKeys := nodeConfig.ModelConfig.ModelResponseBodyMapping
|
||||
for _, respBody := range nodeConfig.OutputResult {
|
||||
for key, val := range gconv.Map(respBody) {
|
||||
if _, ok := saveKeys[key]; !ok {
|
||||
if key != "respBody" {
|
||||
isHTTPFile := strings.HasPrefix(key, "http_file_url:")
|
||||
if !isHTTPFile {
|
||||
if _, ok := saveKeys[key]; !ok && key != "respBody" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -486,7 +565,7 @@ func collectSaveFileResults(ctx context.Context, execInput *flowDto.FlowExecutio
|
||||
}
|
||||
|
||||
// resolveSaveFileResult 解析结果值为可入库的 URL:
|
||||
// - 已是 http(s) URL → 直接返回
|
||||
// - 已是 http(s) URL 或 MinIO 对象裸路径 → 直接返回
|
||||
// - 非路径(base64 图片/文本)→ 上传 OSS 换取 URL
|
||||
func resolveSaveFileResult(ctx context.Context, val any) (string, error) {
|
||||
isPath, path, fileBytes, ext := resolveFileContent(val)
|
||||
@@ -513,6 +592,10 @@ func resolveFileContent(val any) (isPath bool, path string, fileBytes []byte, ex
|
||||
if isFileURL(s) {
|
||||
return true, s, nil, ""
|
||||
}
|
||||
// MinIO 对象裸路径(无 http 前缀,模型网关转存 OSS 后返回)
|
||||
if utils.IsOSSPath(s) {
|
||||
return true, s, nil, ""
|
||||
}
|
||||
// data URI:data:<mime>;base64,<payload>
|
||||
if b, mime, ok := parseDataURI(s); ok {
|
||||
return false, "", b, extOfMime(mime)
|
||||
|
||||
Reference in New Issue
Block a user