- 新增计费模块:执行开始建单、终态结算/取消/失败处理,支持按条/按秒/按token计费 - 新增执行生命周期跟踪:优雅关停时取消运行中执行并等待落库 - 新增异步任务等待/通知机制(Wait/Notify) - 重构执行记录落库与进度上报,统一失败分类与重试语义 - 重命名文件:async_task.go→async.go、flow_checkpoint_store.go→exec_checkpoint.go、flow_graph_util.go→exec_record.go - 更新 .gitignore 与数据库密码配置
159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
package gateway
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"strings"
|
||
|
||
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
|
||
"gitea.redpowerfuture.com/red-future/common/utils"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/util/gconv"
|
||
)
|
||
|
||
// ModelCallStreamReq 调用模型网关流式(client 侧请求体)
|
||
type ModelCallStreamReq struct {
|
||
ModelId int64 `json:"modelId" dc:"模型ID"`
|
||
BizName string `json:"bizName" dc:"业务名称"`
|
||
SessionId string `json:"sessionId" dc:"会话ID"`
|
||
RequestParams map[string]any `json:"requestParams" dc:"请求参数(模板字段)"`
|
||
BusinessParams map[string]any `json:"businessParams" dc:"业务参数"`
|
||
}
|
||
|
||
// ModelCallStream 流式调用模型网关:POST /modelCallStream,逐 chunk 调 onChunk(文本增量转发),
|
||
// 流结束返回 ModelCallRes:Content 为累加全量文本({"respBody": ...}),Tools 为流末工具列表。
|
||
func ModelCallStream(ctx context.Context, modelId int64, sessionId string, requestParams, businessParams map[string]any, onChunk func(chunk map[string]any) error) (*ModelCallRes, error) {
|
||
req := ModelCallStreamReq{
|
||
ModelId: modelId,
|
||
BizName: g.Cfg().MustGet(ctx, "server.name").String(),
|
||
SessionId: sessionId,
|
||
RequestParams: requestParams,
|
||
BusinessParams: businessParams,
|
||
}
|
||
body, err := commonHttp.PostStream(ctx, "model-gateway/model/call/modelCallStream", utils.HeadersFromCtx(ctx, utils.HeadersOptions{TokenFromQuery: true}), &req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer body.Close()
|
||
|
||
res := new(ModelCallRes)
|
||
var contentBuf strings.Builder
|
||
scanErr := parseSSEStream(ctx, body, func(chunk map[string]any) error {
|
||
tools, done := processStreamChunk(chunk)
|
||
if done {
|
||
res.Tools = tools
|
||
// 流末 done 事件携带该次调用累计 token 与费用:纯工具调用步骤无文本 chunk,靠它取数(>0 才覆盖,兼容旧网关无 token/cost 的 done 事件)
|
||
if total := gconv.Int64(chunk["totalTokens"]); total > 0 {
|
||
res.TotalTokens = total
|
||
res.PromptTokens = gconv.Int64(chunk["promptTokens"])
|
||
res.CompletionTokens = gconv.Int64(chunk["completionTokens"])
|
||
}
|
||
if cost := gconv.Float64(chunk["cost"]); cost > 0 {
|
||
res.Cost = cost
|
||
}
|
||
return nil
|
||
}
|
||
if onChunk != nil {
|
||
if err := onChunk(chunk); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
contentBuf.WriteString(streamTextDelta(chunk))
|
||
// 网关端每个 chunk 携带累计 token 值,取最后一个文本 chunk 即为该步总消耗
|
||
res.TotalTokens = gconv.Int64(chunk["totalTokens"])
|
||
res.PromptTokens = gconv.Int64(chunk["promptTokens"])
|
||
res.CompletionTokens = gconv.Int64(chunk["completionTokens"])
|
||
res.Cost = gconv.Float64(chunk["cost"])
|
||
return nil
|
||
})
|
||
if scanErr != nil {
|
||
// 流被中断(如 ctx 取消)时仍返回已累计的 res,调用方可读取已产生的 token 正常落库
|
||
return res, scanErr
|
||
}
|
||
if contentBuf.Len() > 0 {
|
||
res.Content = map[string]any{"respBody": contentBuf.String()}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// processStreamChunk 处理单个流式 chunk。done 事件({"type":"done","tools":[...]})返回解析出的
|
||
// tools(可为空)且 done=true;其余为文本增量 chunk,返回 done=false。
|
||
func processStreamChunk(chunk map[string]any) (tools []ModelTool, done bool) {
|
||
if typ, _ := chunk["type"].(string); typ == "done" {
|
||
raw, _ := chunk["tools"].([]any)
|
||
for _, t := range raw {
|
||
m := gconv.Map(t)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
var tool ModelTool
|
||
tool.Id = gconv.String(m["id"])
|
||
tool.Type = gconv.String(m["type"])
|
||
if fn := gconv.Map(m["function"]); fn != nil {
|
||
tool.Function.Name = gconv.String(fn["name"])
|
||
tool.Function.Arguments = gconv.String(fn["arguments"])
|
||
}
|
||
tools = append(tools, tool)
|
||
}
|
||
return tools, true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// streamTextDelta 提取文本增量 chunk 的文本(读 content 子对象,字段名由网关端结构体统一管理)
|
||
func streamTextDelta(chunk map[string]any) string {
|
||
var sb strings.Builder
|
||
if content, ok := chunk["content"].(map[string]any); ok {
|
||
for _, v := range content {
|
||
if s := gconv.String(v); s != "" {
|
||
sb.WriteString(s)
|
||
}
|
||
}
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// parseSSEStream 标准 SSE 解析:逐 data 行 JSON 回调,支持 [DONE] 与上下文取消。
|
||
// 对齐 model-gateway ParseSSEStream 语义:跳过空行、[DONE]、非 data 行。
|
||
func parseSSEStream(ctx context.Context, reader io.Reader, onChunk func(chunk map[string]any) error) error {
|
||
scanner := bufio.NewScanner(reader)
|
||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||
var sb strings.Builder
|
||
for scanner.Scan() {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
line := scanner.Text()
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
if sb.Len() == 0 {
|
||
continue
|
||
}
|
||
data := sb.String()
|
||
sb.Reset()
|
||
if data == "[DONE]" {
|
||
continue
|
||
}
|
||
var chunk map[string]any
|
||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||
continue
|
||
}
|
||
if onChunk != nil {
|
||
if err := onChunk(chunk); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, "data:") {
|
||
sb.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||
}
|
||
}
|
||
return scanner.Err()
|
||
}
|