Files
ai-agent/workflow/service/flow/graph_build.go
T
19904408334 d699f7ce14 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 与数据库密码配置
2026-09-03 13:22:22 +08:00

254 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package flow
import (
"ai-agent/gateway"
"ai-agent/workflow/consts/node"
flowDto "ai-agent/workflow/model/dto/flow"
"ai-agent/workflow/model/entity"
"context"
"fmt"
"time"
"github.com/cloudwego/eino/compose"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// init 注册自定义合并函数:处理 *flowDto.FlowExecutionInput 类型合并。
// 合并函数全局唯一且与图内容无关,放包级 init 注册一次(避免每次 BuildGraph 重注册全局状态)。
func init() {
compose.RegisterValuesMergeFunc(func(values []*flowDto.FlowExecutionInput) (*flowDto.FlowExecutionInput, error) {
if len(values) == 0 {
return nil, nil
}
// 首次运行所有并行分支共享同一个 ConfigMap 指针,直接返回 values[0] 即可。
// 但续跑(ReExecute)时各分支从 checkpoint 反序列化出独立的 ConfigMap 副本,
// 只返回 values[0] 会丢失其他分支写入的 OutputResult(用户实测:node-8 成功的结果
// 在汇合节点 node-7 变 null)。以第一个为基底,把其余分支中缺失的节点输出合并进来。
base := values[0]
for _, v := range values[1:] {
if v == nil {
continue
}
for nodeId, cfg := range v.ConfigMap {
if cfg == nil {
continue
}
baseCfg, ok := base.ConfigMap[nodeId]
if !ok || baseCfg == nil {
base.ConfigMap[nodeId] = cfg
continue
}
if len(baseCfg.OutputResult) == 0 && len(cfg.OutputResult) > 0 {
baseCfg.OutputResult = cfg.OutputResult
}
}
// 合并已执行节点列表(按 NodeId 去重),续跑时被恢复分支的进度不丢失
for _, en := range v.ExecutedNodes {
dup := false
for _, b := range base.ExecutedNodes {
if b.NodeId == en.NodeId {
dup = true
break
}
}
if !dup {
base.ExecutedNodes = append(base.ExecutedNodes, en)
}
}
}
return base, nil
})
}
// BuildGraph 根据 FlowInfo 构建完整的 Eino Graph 拓扑
func BuildGraph(ctx context.Context, flowContent *entity.FlowInfo) ([]entity.FlowNode, *compose.Graph[any, any]) {
graph := compose.NewGraph[any, any](
// 本地状态初始化
compose.WithGenLocalState(func(ctx context.Context) *flowDto.NodeExecutionState {
return &flowDto.NodeExecutionState{}
}),
)
// 注册所有节点
for _, item := range flowContent.Nodes {
registerNodeToGraph(graph, item)
}
// 注册开始节点
if flowContent.StartNodeId != "" {
_ = graph.AddEdge(compose.START, flowContent.StartNodeId)
}
var nodeList []entity.FlowNode
originalEndNodes := FindEndNodes(flowContent.StartNodeId, flowContent.Edges)
for _, endID := range originalEndNodes {
// 保存结果节点 ID 必须稳定:ReExecute 续跑重建图时复用同一 ID,
// 否则 checkpoint 里 ConfigMap 存的是上次的旧 ID,续跑时新图按新 ID 查不到配置,
// 报"节点信息为空"。一个末端节点对应一个保存结果节点,用 endID 派生即唯一且稳定。
summaryNodeId := fmt.Sprintf("%s_%s", node.NodeTypeSystemSum, endID)
summaryNode := entity.FlowNode{
Id: summaryNodeId,
NodeCode: node.NodeTypeSystemSum,
Name: node.GetNodeTypeName(node.NodeTypeSystemSum),
}
nodeList = append(nodeList, summaryNode)
flowContent.Nodes = append(flowContent.Nodes, summaryNode)
registerNodeToGraph(graph, summaryNode)
_ = graph.AddEdge(endID, summaryNodeId)
_ = graph.AddEdge(summaryNodeId, compose.END)
}
// 构建边关系
edgeMap := make(map[string][]entity.FlowEdge)
for _, edge := range flowContent.Edges {
edgeMap[edge.From] = append(edgeMap[edge.From], edge)
}
// 处理连线 & 分支
for _, edges := range edgeMap {
// 普通节点连线
for _, e := range edges {
_ = graph.AddEdge(e.From, e.To)
}
}
return nodeList, graph
}
// BuildGraphFromFlowContent 根据前端保存的工作流JSON,自动构建执行图并编译
func BuildGraphFromFlowContent(ctx context.Context, flowContent *entity.FlowInfo) ([]entity.FlowNode, compose.Runnable[any, any], error) {
nodeList, graph := BuildGraph(ctx, flowContent)
// BuildGraph 已把 summary(保存结果)节点追加进 flowContent.Nodes,此时是全部已注册节点的完整集合。
// 方案: 每个业务节点正常完成后自动暂停并落 checkpoint(编译期 WithInterruptAfterNodes),
// 崩溃恢复(BuildExecution(false)续跑)即跳过已完成的同步节点, 不再重跑/重复计费。
// 详见根目录《工作流节点断点续跑技术设计.md》。Start 型空载节点不为它落 cp; 只接 END 的节点
// Eino 不落暂停(无下游续跑点), 列了也无副作用。
interruptAfter := make([]string, 0, len(flowContent.Nodes))
for _, n := range flowContent.Nodes {
if n.NodeCode == node.NodeTypeStart {
continue
}
interruptAfter = append(interruptAfter, n.Id)
}
compile, err := graph.Compile(ctx,
compose.WithGraphName("auto_build_workflow"),
compose.WithCheckPointStore(NewDbCheckPointStore()),
compose.WithNodeTriggerMode(compose.AllPredecessor),
compose.WithInterruptAfterNodes(interruptAfter),
)
return nodeList, compile, err
}
// buildConfigMap 由 FlowInfo + 图节点列表构建 ConfigMap:先放流程配置节点,再放图中补充节点
// (保存结果节点等),供节点执行时按 nodeId 查配置。nodeList 取自 BuildGraph 返回值。
func buildConfigMap(flowContent *entity.FlowInfo, nodeList []entity.FlowNode) map[string]*entity.FlowNode {
configMap := make(map[string]*entity.FlowNode)
for _, cfg := range ExtractFlowNodeFrom(flowContent) {
configMap[cfg.Id] = cfg
}
for i := range nodeList {
configMap[nodeList[i].Id] = &nodeList[i]
}
return configMap
}
// registerNodeToGraph 将单个节点注册到图中(包含通用包装逻辑)
func registerNodeToGraph(graph *compose.Graph[any, any], flowNode entity.FlowNode) {
// 通用包装:全程入参都是 *FlowExecutionInput
wrapLambda := func(lambda func(ctx context.Context, input any) (any, error)) func(ctx context.Context, input any) (any, error) {
return func(ctx context.Context, input any) (any, error) {
startTime := time.Now()
// 构建节点执行入参(含中断恢复)
execInput, realInput, err := BuildNodeExecutionInput(ctx, input, flowNode)
if err != nil {
return nil, err
}
flowNodeDesc := flowNode.Desc
if g.IsEmpty(flowNodeDesc) {
flowNodeDesc = flowNode.Name
}
// 上报节点执行进度(WebSocket场景下推送进度给前端)
if reporter := GetProgressReporter(ctx); reporter != nil {
reporter.ReportStart(flowNode.Id, flowNodeDesc, nodeReportIndex(execInput, flowNode.Id, 1), len(execInput.ConfigMap))
}
// 上传入参到OSS
ossResult, err := gateway.Upload(ctx, fmt.Sprintf("nodeInput:%v.txt", time.Now().UnixMilli()), gconv.Bytes(gconv.String(realInput)))
if err != nil {
return nil, HandleFailedNodeExecution(ctx, execInput, 0, flowNode, err, 0)
}
// 创建节点执行记录
nodeExecutionId, err := CreateNodeExecutionRecord(ctx, execInput, flowNode, ossResult)
if err != nil {
return nil, HandleFailedNodeExecution(ctx, execInput, 0, flowNode, err, 0)
}
realInput.NodeExecutionId = nodeExecutionId
// 执行节点
_, err = lambda(ctx, realInput)
durationMs := time.Since(startTime).Milliseconds()
if err != nil {
// 执行失败处理
return nil, HandleFailedNodeExecution(ctx, execInput, nodeExecutionId, flowNode, err, durationMs)
}
// 执行成功处理
if err = HandleSuccessfulNodeExecution(ctx, execInput, realInput, nodeExecutionId, flowNode, durationMs); err != nil {
return nil, HandleFailedNodeExecution(ctx, execInput, nodeExecutionId, flowNode, err, durationMs)
}
// 上报节点执行进度(WebSocket场景下推送进度给前端)
if reporter := GetProgressReporter(ctx); reporter != nil {
reporter.ReportComplete(flowNode.Id, flowNodeDesc, nodeReportIndex(execInput, flowNode.Id, 0), len(execInput.ConfigMap))
}
// 返回整个 execInput,让下一个节点继续用
return execInput, nil
}
}
switch flowNode.NodeCode {
case node.NodeTypeStart:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(StartLambda)))
case node.NodeTypeSystemSum:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(SummaryLambda)))
case node.NodeTypeModel:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(ModelLambda)))
case node.NodeTypeForm:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(FormLambda)))
case node.NodeTypeDataMerge:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(DataMergeLambda)))
case node.NodeTypeSubFlow:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(SubFlowLambda)))
case node.NodeTypeHttp:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(HttpLambda)))
case node.NodeTypeScriptTranscribe:
_ = graph.AddLambdaNode(flowNode.Id, compose.InvokableLambda(wrapLambda(ScriptTranscribeLambda)))
}
}
// nodeReportIndex 计算节点在进度上报中的序号:节点已在已执行列表则取其位置,
// 否则按当前已执行数 + offset(start 上报时节点尚未入列 offset=1complete 后已入列命中 IndexOf
func nodeReportIndex(execInput *flowDto.FlowExecutionInput, nodeId string, offset int) int {
if idx := IndexOf(execInput.ExecutedNodes, nodeId); idx != -1 {
return idx
}
return len(execInput.ExecutedNodes) + offset
}
// IndexOf 返回元素第一次出现的下标,不存在返回 -1
func IndexOf(slice []flowDto.ExecutedNode, target string) int {
for i, v := range slice {
if v.NodeId == target {
return i + 1
}
}
return -1
}