Files
smartmate/backend/services/llm/runtime_service.go
Losita 61db646805 Version: 0.9.80.dev.260506
后端:
1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。
2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。
3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。

前端:
4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。
5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。

仓库:
6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
2026-05-06 20:16:53 +08:00

316 lines
10 KiB
Go
Raw Permalink 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 llm
import (
"context"
"strings"
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/cloudwego/eino/schema"
)
// RuntimeService 是独立 LLM 进程对外暴露的业务门面。
//
// 职责边界:
// 1. 负责模型别名选择、BillingContext 注入、准入守卫与 outbox 写入;
// 2. 不负责 prompt 编排,调用方仍然直接传入 messages
// 3. 不负责价格换算细则,本轮先把 usage 事件稳定写入 outbox价格字段留给后续主代理接线。
type RuntimeService struct {
legacy *Service
textClients map[string]*Client
textModelNames map[string]string
responsesClient *ArkResponsesClient
responsesModel string
balanceGuard *CreditBalanceGuard
chargeRecorder *ChargeRecorder
defaultProvider string
}
type RuntimeServiceOptions struct {
LegacyService *Service
CacheDAO *llmdao.CacheDAO
PriceRuleDAO *llmdao.PriceRuleDAO
SnapshotProvider CreditBalanceSnapshotProvider
OutboxRepo *outboxinfra.Repository
OutboxMaxRetry int
ProviderName string
LiteModelName string
ProModelName string
MaxModelName string
CourseVisionModel string
}
func NewRuntimeService(opts RuntimeServiceOptions) (*RuntimeService, error) {
if opts.LegacyService == nil {
return nil, ErrRuntimeServiceNotReady
}
chargeRecorder, err := NewChargeRecorder(ChargeRecorderOptions{
Repo: opts.OutboxRepo,
MaxRetry: opts.OutboxMaxRetry,
ProviderName: opts.ProviderName,
Pricing: NewCreditPriceResolver(CreditPriceResolverOptions{DAO: opts.PriceRuleDAO}),
})
if err != nil {
return nil, err
}
return &RuntimeService{
legacy: opts.LegacyService,
textClients: map[string]*Client{
llmcontracts.ModelAliasLite: opts.LegacyService.LiteClient(),
llmcontracts.ModelAliasPro: opts.LegacyService.ProClient(),
llmcontracts.ModelAliasMax: opts.LegacyService.MaxClient(),
},
textModelNames: map[string]string{
llmcontracts.ModelAliasLite: strings.TrimSpace(opts.LiteModelName),
llmcontracts.ModelAliasPro: strings.TrimSpace(opts.ProModelName),
llmcontracts.ModelAliasMax: strings.TrimSpace(opts.MaxModelName),
},
responsesClient: opts.LegacyService.CourseImageResponsesClient(),
responsesModel: strings.TrimSpace(opts.CourseVisionModel),
balanceGuard: NewCreditBalanceGuard(CreditBalanceGuardOptions{
CacheDAO: opts.CacheDAO,
SnapshotProvider: opts.SnapshotProvider,
}),
chargeRecorder: chargeRecorder,
defaultProvider: firstNonEmptyString(strings.TrimSpace(opts.ProviderName), llmcontracts.ProviderNameArk),
}, nil
}
func (s *RuntimeService) LegacyService() *Service {
if s == nil {
return nil
}
return s.legacy
}
// GenerateText 负责处理一次非流式文本调用。
func (s *RuntimeService) GenerateText(ctx context.Context, req llmcontracts.TextRequest) (*TextResult, error) {
client, alias, modelName, err := s.resolveTextClient(req.ModelAlias)
if err != nil {
return nil, err
}
// 1. 先把跨进程 billing 副本还原回 ctx保持业务侧调用面不改签名。
// 2. 再做一次 Redis 快照级准入守卫;守卫失败直接短路,不继续发起模型调用。
// 3. 模型成功后同步写 LLM outbox写失败只打日志避免因为记账侧抖动反向打挂主链路。
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
billing = EnsureTextBillingIdentity(billing, req.Options, req.Messages)
if !billing.IsZero() {
ctx = WithBillingContext(ctx, billing)
}
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
return nil, err
}
result, err := client.GenerateText(ctx, req.Messages, toServiceGenerateOptions(req.Options))
if err != nil {
return nil, err
}
logChargeRecordError("llm.text.generate", s.chargeRecorder.RecordTextUsage(ctx, billing, alias, modelName, "llm.text.generate", result.Usage))
return result, nil
}
// StreamText 负责处理一次流式文本调用。
func (s *RuntimeService) StreamText(ctx context.Context, req llmcontracts.StreamTextRequest) (StreamReader, error) {
client, alias, modelName, err := s.resolveTextClient(req.ModelAlias)
if err != nil {
return nil, err
}
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
billing = EnsureTextBillingIdentity(billing, req.Options, req.Messages)
if !billing.IsZero() {
ctx = WithBillingContext(ctx, billing)
}
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
return nil, err
}
reader, err := client.Stream(ctx, req.Messages, toServiceGenerateOptions(req.Options))
if err != nil {
return nil, err
}
return NewUsageAccountingStreamReader(reader, func(usage *schema.TokenUsage) {
logChargeRecordError("llm.text.stream", s.chargeRecorder.RecordTextUsage(ctx, billing, alias, modelName, "llm.text.stream", usage))
}), nil
}
// GenerateResponsesText 负责处理课程图片解析使用的 Responses 文本调用。
func (s *RuntimeService) GenerateResponsesText(ctx context.Context, req llmcontracts.ResponsesRequest) (*ArkResponsesResult, error) {
client, alias, modelName, err := s.resolveResponsesClient(req.ModelAlias)
if err != nil {
return nil, err
}
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
billing = EnsureResponsesBillingIdentity(billing, req.Messages)
if !billing.IsZero() {
ctx = WithBillingContext(ctx, billing)
}
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
return nil, err
}
result, err := client.GenerateText(ctx, toServiceResponsesMessages(req.Messages), toServiceResponsesOptions(req.Options))
if err != nil {
return nil, err
}
logChargeRecordError("llm.responses.generate", s.chargeRecorder.RecordResponsesUsage(ctx, billing, alias, modelName, "llm.responses.generate", result.Usage))
return result, nil
}
func (s *RuntimeService) resolveTextClient(modelAlias string) (*Client, string, string, error) {
if s == nil {
return nil, "", "", ErrRuntimeServiceNotReady
}
alias := llmcontracts.NormalizeModelAlias(modelAlias)
client, ok := s.textClients[alias]
if !ok {
return nil, alias, "", ErrUnsupportedModelAlias
}
if client == nil {
return nil, alias, "", ErrRuntimeServiceNotReady
}
return client, alias, firstNonEmptyString(s.textModelNames[alias], alias), nil
}
func (s *RuntimeService) resolveResponsesClient(modelAlias string) (*ArkResponsesClient, string, string, error) {
if s == nil || s.responsesClient == nil {
return nil, "", "", ErrRuntimeServiceNotReady
}
alias := strings.TrimSpace(modelAlias)
if alias == "" {
alias = llmcontracts.ModelAliasCourseImageResponses
}
if alias != llmcontracts.ModelAliasCourseImageResponses {
return nil, alias, "", ErrUnsupportedModelAlias
}
return s.responsesClient, alias, firstNonEmptyString(s.responsesModel, alias), nil
}
func applyRequestBillingContext(ctx context.Context, input *llmcontracts.BillingContext, modelAlias string) (context.Context, BillingContext) {
billing := BillingContext{}
if input != nil {
billing = BillingContext{
UserID: input.UserID,
EventID: input.EventID,
Scene: input.Scene,
RequestID: input.RequestID,
ConversationID: input.ConversationID,
ModelAlias: input.ModelAlias,
SkipCharge: input.SkipCharge,
}
}
if strings.TrimSpace(billing.ModelAlias) == "" {
billing.ModelAlias = strings.TrimSpace(modelAlias)
}
if billing.IsZero() {
return ctx, billing
}
return WithBillingContext(ctx, billing), billing
}
func toServiceGenerateOptions(input llmcontracts.GenerateOptions) GenerateOptions {
return GenerateOptions{
Temperature: input.Temperature,
MaxTokens: input.MaxTokens,
Thinking: ThinkingMode(strings.TrimSpace(input.Thinking)),
Metadata: input.Metadata,
}
}
func toServiceResponsesMessages(input []llmcontracts.ResponsesMessage) []ArkResponsesMessage {
if len(input) == 0 {
return nil
}
output := make([]ArkResponsesMessage, 0, len(input))
for _, item := range input {
output = append(output, ArkResponsesMessage{
Role: item.Role,
Text: item.Text,
ImageURL: item.ImageURL,
ImageDetail: item.ImageDetail,
})
}
return output
}
func toServiceResponsesOptions(input llmcontracts.ResponsesOptions) ArkResponsesOptions {
return ArkResponsesOptions{
Model: input.Model,
Temperature: input.Temperature,
MaxOutputTokens: input.MaxOutputTokens,
Thinking: ThinkingMode(strings.TrimSpace(input.Thinking)),
TextFormat: input.TextFormat,
}
}
func toContractTextResult(result *TextResult) *llmcontracts.TextResult {
if result == nil {
return nil
}
return &llmcontracts.TextResult{
Text: result.Text,
Usage: CloneUsage(result.Usage),
FinishReason: result.FinishReason,
}
}
func toContractResponsesResult(result *ArkResponsesResult) *llmcontracts.ResponsesResult {
if result == nil {
return nil
}
output := &llmcontracts.ResponsesResult{
Text: result.Text,
Status: result.Status,
IncompleteReason: result.IncompleteReason,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
if result.Usage != nil {
output.Usage = &llmcontracts.ResponsesUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
return output
}
func toServiceTextResult(result *llmcontracts.TextResult) *TextResult {
if result == nil {
return nil
}
return &TextResult{
Text: result.Text,
Usage: CloneUsage(result.Usage),
FinishReason: result.FinishReason,
}
}
func toServiceResponsesResult(result *llmcontracts.ResponsesResult) *ArkResponsesResult {
if result == nil {
return nil
}
output := &ArkResponsesResult{
Text: result.Text,
Status: result.Status,
IncompleteReason: result.IncompleteReason,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
if result.Usage != nil {
output.Usage = &ArkResponsesUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
return output
}