Files
model-gateway/service/model_call_service.go
T
19904408334 76c55fbb73 feat: 新增业务字段路径读写工具
新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
2026-08-18 10:11:09 +08:00

425 lines
15 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 service
import (
"context"
"errors"
"fmt"
"model-gateway/consts/model"
"model-gateway/dao"
"model-gateway/model/dto"
"model-gateway/model/entity"
modelUtils "model-gateway/service/utils"
"net/http"
"time"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
)
var ModelCall = &modelCallService{}
type modelCallService struct{}
// minTenantSurplusForUse 调用模型前租户余额最低门槛(元),余额须大于该值才允许调用
const minTenantSurplusForUse = 200.0
// CheckTenantBalance 调用模型前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用。
// 返回当前余额;余额不足或获取失败返回错误。
func CheckTenantBalance(ctx context.Context, tenantId uint64) (surplus float64, err error) {
surplus, err = GetTenantSurplus(ctx, tenantId)
if err != nil {
return 0, fmt.Errorf("获取租户余额失败: %w", err)
}
if surplus <= minTenantSurplusForUse {
return surplus, fmt.Errorf("租户余额不足,无法调用模型(需余额大于%.0f元,当前余额%.2f元)", minTenantSurplusForUse, surplus)
}
return surplus, nil
}
func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) {
// 1) 检查模型配置
var modelInfo *entity.ModelManage
modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{
Id: req.ModelId,
})
if err != nil {
return nil, fmt.Errorf("获取模型配置失败: %v", err)
}
if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) {
return nil, fmt.Errorf("模型不存在或未启用")
}
now := time.Now()
userInfo, err := utils.GetUserInfo(ctx)
if err != nil {
return
}
// 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用
if _, err = CheckTenantBalance(ctx, userInfo.TenantId); err != nil {
return nil, err
}
err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) {
if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() || *modelInfo.ResponseType == *model.ResponseTypeStream.Code() {
var newRequestParams map[string]any
var id int64
id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req)
if err != nil {
return err
}
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
return fmt.Errorf("保存模型请求参数失败")
}
if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() {
res, err = ModelSession.CreateSession(ctx, &dto.CallModelSessionReq{
Id: id,
ModelInfo: modelInfo,
RequestParams: newRequestParams,
})
} else {
res, err = ModelSession.CreateSessionStreamOnce(ctx, &dto.CallModelSessionReq{
Id: id,
ModelInfo: modelInfo,
RequestParams: newRequestParams,
})
}
}
if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() {
if g.IsEmpty(req.MsgTopic) {
return fmt.Errorf("请指定消息主题")
}
var newRequestParams map[string]any
var id int64
id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req)
if err != nil {
return err
}
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
return fmt.Errorf("保存模型请求参数失败")
}
res, err = ModelTaskStart.CreateTask(ctx, &dto.CallModelTaskStartReq{
Id: id,
ModelInfo: modelInfo,
RequestParams: newRequestParams,
})
}
// 扣减本次调用费用:同步/一次性流式返回实际费用;异步提交费用为 0 跳过,由任务完成时(handleSingleTask)扣减
if err == nil && res != nil && res.Cost > 0 {
if dedErr := DeductBalance(ctx, userInfo.TenantId, res.Cost); dedErr != nil {
g.Log().Errorf(ctx, "[扣减余额] 模型调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, res.Cost, dedErr)
}
}
return
})
return
}
func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseWriter, req *dto.ModelCallStreamReq) (err error) {
// 1) 检查模型配置
var modelInfo *entity.ModelManage
modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{
Id: req.ModelId,
})
if err != nil {
return fmt.Errorf("获取模型配置失败: %v", err)
}
if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) {
return fmt.Errorf("模型不存在或未启用")
}
if *modelInfo.ResponseType == *model.ResponseTypeStream.Code() {
now := time.Now()
userInfo, err := utils.GetUserInfo(ctx)
if err != nil {
return err
}
// 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用
if _, err = CheckTenantBalance(ctx, userInfo.TenantId); err != nil {
return err
}
err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) {
var newRequestParams map[string]any
var id int64
id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, &dto.ModelCallReq{
ModelId: req.ModelId,
RequestParams: req.RequestParams,
BusinessParams: req.BusinessParams,
SessionId: req.SessionId,
BizName: req.BizName,
})
if err != nil {
return err
}
if g.IsEmpty(id) || g.IsEmpty(newRequestParams) {
return fmt.Errorf("保存模型请求参数失败")
}
var streamRes *dto.ModelCallRes
streamRes, err = ModelSession.CreateSessionStream(ctx, w, &dto.CallModelSessionReq{
Id: id,
ModelInfo: modelInfo,
RequestParams: newRequestParams,
})
// 扣减本次流式调用费用(流结束返回实际费用)
if err == nil && streamRes != nil && streamRes.Cost > 0 {
if dedErr := DeductBalance(ctx, userInfo.TenantId, streamRes.Cost); dedErr != nil {
g.Log().Errorf(ctx, "[扣减余额] 流式调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, streamRes.Cost, dedErr)
}
}
return
})
} else {
return fmt.Errorf("模型响应类型错误")
}
return err
}
// saveModelRequestParams 保存模型请求参数
func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time.Time, modelInfo *entity.ModelManage, req *dto.ModelCallReq) (id int64, newRequestParams map[string]any, err error) {
// 统一走模板校验+构建:requestParams 只装模板字段,businessParams 只装业务字段
out, err := buildChatRequestParams(modelInfo, req.RequestParams, req.BusinessParams)
if err != nil {
return 0, nil, err
}
// 1) 上传模型原始请求参数文件(requestParams + businessParams 合并,保证审计完整)
originalParams := make(map[string]any, len(req.RequestParams)+len(req.BusinessParams))
for k, v := range req.RequestParams {
originalParams[k] = v
}
for k, v := range req.BusinessParams {
originalParams[k] = v
}
uploadOriginalReq, err := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: gconv.Bytes(gconv.String(originalParams)),
FileName: fmt.Sprintf("modelRequestParams:%v.json", now.UnixMilli()),
})
if err != nil {
return 0, nil, fmt.Errorf("上传模型原始请求参数文件失败: %v", err)
}
// 2) 上传模型解析成功的请求参数文件
uploadNewReq, err := Upload(ctx, &dto.UploadFileBytesReq{
FileBytes: gconv.Bytes(gconv.String(out)),
FileName: fmt.Sprintf("modelNewRequestParams:%v.json", now.UnixMilli()),
})
if err != nil {
return 0, nil, fmt.Errorf("上传模型解析请求参数文件失败:%v", err)
}
// 3) 保存模型请求信息(快照媒体类型供任务完成时换算费用;模型计费配置任务完成时按 modelId 现查)
if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() {
id, err = dao.ModelTaskStart.Insert(ctx, &dto.CreateModelTaskStartReq{
ModelId: req.ModelId,
BizName: req.BizName,
MsgTopic: req.MsgTopic,
RequestPath: uploadNewReq.FileURL,
OriginalRequestPath: uploadOriginalReq.FileURL,
MediaType: DetectMediaType(modelInfo.RequestBusinessFieldMapping, out),
})
if err != nil {
return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err)
}
} else {
id, err = dao.ModelSession.Insert(ctx, &dto.CreateModelSessionReq{
ModelId: req.ModelId,
BizName: req.BizName,
SessionId: req.SessionId,
RequestPath: uploadNewReq.FileURL,
OriginalRequestPath: uploadOriginalReq.FileURL,
})
if err != nil {
return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err)
}
}
return id, out, nil
}
func queue(ctx context.Context, modelName string, tenantId uint64, maxCon int64, f func(ctx context.Context) (err error)) (err error) {
lockKey := fmt.Sprintf("lock:tenantId-%s:model-%s", gconv.String(tenantId), modelName)
success, e := lock(ctx, lockKey, -1, int64(time.Minute.Seconds()*5), func(ctx context.Context) error {
const (
keyExpireSec = 600 // 计数Key兜底过期时间 10min
waitInterval = 10 * time.Second // 轮询等待间隔
)
// Redis 操作统一使用独立上下文,避免外部 ctx canceled
redisCtx := context.WithoutCancel(ctx)
// 模型并发计数Key
concurrencyKey := fmt.Sprintf("model:concurrency:%d:%s", tenantId, modelName)
// 循环尝试获取并发名额,超限则等待重试
var held bool // 标记当前是否持有未释放的计数
for {
// 检测全局上下文取消
if ctx.Err() != nil {
if held {
g.Redis().Decr(redisCtx, concurrencyKey)
}
return ctx.Err()
}
// 计数自增
currentCon, e := g.Redis().Incr(redisCtx, concurrencyKey)
if e != nil {
if held {
g.Redis().Decr(redisCtx, concurrencyKey)
}
glog.Errorf(ctx, "redis incr concurrency key err: %v", e)
return e
}
held = true
// 首次创建Key时设置过期时间(避免重复执行EXPIRE)
exists, errr := g.Redis().Exists(redisCtx, concurrencyKey)
if errr == nil && exists == 1 {
g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec)
}
// 未超限:跳出循环,执行业务
if currentCon <= maxCon {
glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, currentCon, maxCon)
break
}
// 超限立刻回减,撤销本次计数
g.Redis().Decr(redisCtx, concurrencyKey)
held = false
glog.Infof(ctx, "并发超限等待: %s %d/%d", concurrencyKey, currentCon, maxCon)
time.Sleep(waitInterval)
}
err = f(ctx)
if _, decrErr := g.Redis().Decr(redisCtx, concurrencyKey); decrErr != nil {
glog.Errorf(ctx, "redis decr concurrency key err: %v", decrErr)
}
if err != nil {
return err
}
return nil
})
if e != nil {
return e
}
if !success {
return gerror.New("任务排队已满,请稍后再试")
}
return
}
// lock 分布式锁 纯原生命令、无Lua、隔离上下文防 context canceled
func lock(ctx context.Context, key string, limit, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) {
if limit <= 0 {
limit = -1
}
// 过期时间合法校验(单位:秒)
const maxExpireSec = 86400 * 7
if expireSeconds < 1 || expireSeconds > maxExpireSec {
glog.Warningf(ctx, "锁过期时间非法,原值:%d,兜底为60秒", expireSeconds)
expireSeconds = 60
}
lockVal := "1"
LOOP:
// 检测父级上下文取消,防止无限重试阻塞 goroutine
if ctx.Err() != nil {
return false, ctx.Err()
}
if limit != -1 {
if limit < 0 {
return false, errors.New("锁重试次数耗尽,获取锁失败")
}
limit--
}
// 核心:创建独立上下文,不受外部 ctx 取消影响
redisCtx := context.WithoutCancel(ctx)
// 加锁
val, err := g.Redis().Set(redisCtx, key, lockVal, gredis.SetOption{
TTLOption: gredis.TTLOption{
EX: &expireSeconds,
},
NX: true,
})
if err != nil {
glog.Errorf(ctx, "redis set lock failed: %v", err)
time.Sleep(time.Second)
goto LOOP
}
if val.Bool() {
// 执行业务逻辑(使用原上下文)
runErr := fn(ctx)
// 释放锁:同样使用独立上下文 + 先GET再DEL防误删
getRes, err := g.Redis().Get(redisCtx, key)
if err != nil {
glog.Errorf(ctx, "redis get lock value failed: %v", err)
} else if getRes.String() == lockVal {
_, delErr := g.Redis().Del(redisCtx, key)
if delErr != nil {
glog.Errorf(ctx, "redis del lock failed: %v", delErr)
}
}
return true, runErr
}
// 抢锁失败,休眠重试
time.Sleep(time.Second)
goto LOOP
}
// buildChatRequestParams 按模型配置的请求模板 + 业务字段映射构建请求体(ModelCall 请求路径共用):
// 1. requestParams 只装模板字段,按 requestBodyMapping 模板校验(CheckParams+ 构建(ParseConfigTemplate);
// 未配置映射的字段(如未配置映射的 messages/tools)会被模板拒绝,明确报错
// 2. requestParams 为空时按配置模板构建请求结构(模板 defaultValue 生效),
// 避免"结构由模板声明、值全走业务字段"的场景因请求体为空而构建失败
// 3. businessParams 只装业务字段,按业务字段名(RequestBusinessFieldMapping 的 key)传值,
// TakeBusinessFields 解析为写入路径,构建完成后由 WriteBusinessFields 按路径写入最终请求体
func buildChatRequestParams(modelInfo *entity.ModelManage, requestParams, businessParams map[string]any) (map[string]any, error) {
rest := make(map[string]any, len(requestParams))
for k, v := range requestParams {
rest[k] = v
}
// 请求结构源:模板字段兜底(模板 value/defaultValue 生效)+ requestParams 覆盖同名;
// 保证模板声明的结构字段(如 stream_options 对象)即使 requestParams 未传也进请求体
src := rest
for k, v := range modelInfo.RequestBodyMapping {
mapV := gconv.Map(v)
if mapV["type"] == "array" {
continue
}
if _, has := src[k]; !has {
src[k] = v
}
}
if len(requestParams) > 0 {
// requestParams 非空才按模板严格校验模板字段(空值回填 default);
// 为空时跳过,避免业务字段未写入就误报必填缺失
if err := modelUtils.CheckParams(src, modelInfo.RequestBodyMapping); err != nil {
return nil, err
}
}
out := modelUtils.ParseConfigTemplate(src)
if !g.IsEmpty(businessParams) {
// 业务字段:businessParams 按业务字段名传值,解析为映射路径后写入
bizValues, err := modelUtils.TakeBusinessFields(businessParams, modelInfo.RequestBusinessFieldMapping)
if err != nil {
return nil, err
}
// 业务字段按映射路径写入最终请求体(写 out 而非 rest:rest 只是模板字段容器,out 才是下发模型的请求体)
if err = modelUtils.WriteBusinessFields(out, bizValues); err != nil {
return nil, err
}
// 合并后按模板约束整体校验:必填/长度/范围
if err = modelUtils.CheckBody(out, modelInfo.RequestBodyMapping); err != nil {
return nil, err
}
}
// 按模板声明的 type 归一字段值类型(模板字段 value / 业务字段写入值都可能与声明类型不符)
out = modelUtils.CoerceBodyTypes(out, modelInfo.RequestBodyMapping)
return out, nil
}