feat: 支持工作流断点续跑并拆分错误信息存储

- 新增同会话+同工作流最近执行失败且参数一致时断点续跑逻辑
- exec_workflow/exec_chat 新增 error 字段存储原始错误,error_message 仅存友好提示
- 新增 UpdateExecChatReq 与 exec_chat_dao Update 方法
- 新增 GetLatestBySessionAndFlow 查询最近执行记录
- 修正 ListDates 分组与排序 SQL 表达式
- 新增 pipeline 配置结构,删除旧设计文档
This commit is contained in:
2026-08-21 09:54:06 +08:00
parent a939c45508
commit cef837a35c
34 changed files with 1519 additions and 515 deletions
+110 -27
View File
@@ -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 才入库;原始响应体 keyrespBody恒入库
// 第二层: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 URIdata:<mime>;base64,<payload>
if b, mime, ok := parseDataURI(s); ok {
return false, "", b, extOfMime(mime)