Files
ai-agent/gateway/model_stream.go
T

158 lines
5.1 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 gateway
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"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(文本增量转发),
// 流结束返回 ModelCallResContent 为累加全量文本({"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", requestHeaders(ctx), &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()
}