给你的 new-api 开启 任意模型 联网 功能

搞完后你就可以在任意地方使用联网功能了,不会受到任何客户端的限制。

大概效果:
chatbox:

后台:

直接开干。

首先,我们要明确我们的需求:

我们的搜索需要可插拔,如同一个功能可以赋能到任意模型上,其最终效果是这样的:我们可以添加任意多个的搜索引擎,可以为某些模型开启搜索功能,可以配置使用什么模型进行是否搜索的判断和提取搜索关键词等。



流程:一串消息传过来,我们从中抽取最后 N 条信息,交由分析模型进行分析,得到【是否需要联网搜索、搜索关键词、做出判断原因、置信度】,根据我们设定的置信度阈值判断是否要进行搜索,如果要进行搜索,那么就调用我们为这个模型配置的搜索引擎,进行搜索,将搜索到的结果以特定的形式拼接回用户上下文,并添加额外的prompt提示模型进行根据这些搜索结果进行回复。

我们调用小模型是不额外收费的,毕竟我觉得大家都做中转了,这点小成本应该是随便都能cover的。

实现:(只给出关键代码)

先搞几个model

// SearchEngine 搜索引擎配置
type SearchEngine struct {
	Id          int    `json:"id"`
	Name        string `json:"name"`          // 搜索引擎名称
	Type        string `json:"type"`          // 搜索引擎类型,如 jina
	BaseURL     string `json:"base_url"`      // 搜索引擎基础URL
	ApiKey      string `json:"api_key"`       // API密钥
	TokenPerUse int    `json:"token_per_use"` // 每次使用消耗的token数量
	Status      int    `json:"status"`        // 状态 1-启用 2-禁用
	CreatedAt   int64  `json:"created_at"`
	UpdatedAt   int64  `json:"updated_at"`
}

// ModelSearchConfig 模型搜索配置
type ModelSearchConfig struct {
	Id            int    `json:"id"`
	ModelName     string `json:"model_name"`     // 原始模型名称
	SearchEnabled bool   `json:"search_enabled"` // 是否启用搜索
	SearchEngine  string `json:"search_engine"`  // 使用的搜索引擎
	CreatedAt     int64  `json:"created_at"`
	UpdatedAt     int64  `json:"updated_at"`
}

// SearchAnalysisConfig 搜索分析配置
type SearchAnalysisConfig struct {
	Id                 int     `json:"id"`
	Name               string  `json:"name"`                 // 配置名称
	Model              string  `json:"model"`                // 使用的分析模型
	BaseURL            string  `json:"base_url"`             // 基础URL,为空时使用默认
	ApiKey             string  `json:"api_key"`              // API密钥,为空时使用默认
	MaxContextMessages int     `json:"max_context_messages"` // 最大上下文消息数
	MinConfidence      float64 `json:"min_confidence"`       // 最小置信度
	SystemPrompt       string  `json:"system_prompt"`        // 系统提示词
	Status             int     `json:"status"`               // 状态 1-启用 2-禁用
	CreatedAt          int64   `json:"created_at"`
	UpdatedAt          int64   `json:"updated_at"`
}

再搞一个默认的分析提示词。

// GetDefaultSearchAnalysisConfig 获取默认的搜索分析配置
func GetDefaultSearchAnalysisConfig() (*SearchAnalysisConfig, error) {
	var config SearchAnalysisConfig
	err := DB.Where("status = ?", 1).First(&config).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			// 返回默认配置
			return &SearchAnalysisConfig{
				Name:               "默认配置",
				Model:              "gpt-4o-mini",
				BaseURL:            "", // 为空时使用系统默认
				ApiKey:             "", // 为空时使用系统默认
				MaxContextMessages: 5,
				MinConfidence:      0.6,
				SystemPrompt: `你是一个专业的搜索分析助手。你需要分析用户的问题及其上下文,判断是否需要进行知识库搜索,并提取最合适的搜索关键词。

你的主要职责是:
1. 分析用户问题的意图和上下文
2. 解析问题中的代词指代
3. 判断是否需要搜索知识库
4. 提取最相关的搜索关键词(1-3个)

判断是否需要搜索的标准:
1. 需要搜索的情况:
   - 询问具体的业务逻辑、系统功能、配置说明等专业问题
   - 需要特定文档或参考资料支持的问题
   - 涉及系统特定功能或模块的问题
   - 需要技术细节或实现方式的问题
   - 上下文相关的追问,且需要新的信息支持

2. 不需要搜索的情况:
   - 简单的问候或闲聊
   - 纯粹的数学计算或逻辑推理
   - 常识性问题
   - 对已有搜索结果的简单确认
   - 主观意见或建议

请以JSON格式返回,包含以下字段:
{
  "need_search": true/false,      // 是否需要搜索
  "keywords": [                   // 搜索关键词数组,最多3个
    "关键词1",
    "关键词2",
    "关键词3"
  ],
  "reason": "判断理由",          // 简要说明判断理由
  "confidence": 0.0-1.0,          // 判断的置信度
  "context_analysis": {           // 上下文分析结果
    "has_context": true/false,    // 是否依赖上下文
    "reference_entities": [       // 上下文中提到的关键实体
      "实体1",
      "实体2"
    ]
  }
}

注意:
1. 关键词应该是问题中最具体和最相关的术语
2. 避免使用过于宽泛或通用的关键词
3. 关键词应该按重要性排序
4. 如果问题中包含代词(如"它"、"这个"等),请分析上下文找出指代对象
5. 如果不确定是否需要搜索,使用较低的置信度
6. 对于上下文相关的问题,应该结合之前的对话内容提取关键词`,
				Status: 1,
			}, nil
		}
		return nil, err
	}
	return &config, nil
}

再在relay-text中搞个消息预处理。

func preprocessMessages(messages []dto.Message, relayInfo *relaycommon.RelayInfo, c *gin.Context) ([]dto.Message, error) {
	// 检查是否启用了搜索功能
	common.LogInfo(c, fmt.Sprintf("modelName: %s", relayInfo.UpstreamModelName))
	modelConfig, err := model.GetModelSearchConfig(relayInfo.UpstreamModelName)
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, err
	}

	// 只在启用了搜索功能时进行搜索
	if modelConfig != nil && modelConfig.SearchEnabled {
		// 获取搜索引擎配置
		engines, err := model.GetEnabledSearchEngines()
		if err != nil {
			return nil, err
		}
		if len(engines) == 0 {
			return nil, fmt.Errorf("no enabled search engine found")
		}

		// 使用第一个启用的搜索引擎
		searchEngine := engines[0]
		searchService := service.NewSearchService(searchEngine)

		// 获取最后一条用户消息
		var lastUserMsg dto.Message
		for i := len(messages) - 1; i >= 0; i-- {
			if messages[i].Role == "user" {
				lastUserMsg = messages[i]
				break
			}
		}

		// 如果找到了用户消息,分析是否需要搜索
		if lastUserMsg.Role == "user" {
			// 获取搜索分析配置
			analysisConfig, err := model.GetDefaultSearchAnalysisConfig()
			if err != nil {
				common.LogError(c, fmt.Sprintf("get search analysis config failed: %v", err))
				return messages, nil
			}

			// 构建分析提示,加入上下文
			var contextMessages []dto.Message
			startIdx := len(messages) - analysisConfig.MaxContextMessages
			if startIdx < 0 {
				startIdx = 0
			}
			contextMessages = messages[startIdx:]

			// 替换系统提示词中的变量
			systemPrompt := replacePromptVariables(analysisConfig.SystemPrompt)

			// 构建分析消息,正确处理多行文本
			contextText := formatMessagesForAnalysis(contextMessages)
			userContent := fmt.Sprintf("以下是对话上下文,请分析最后一个问题:\n\n%s", contextText)

			analyzeMessages := []dto.Message{
				{
					Role:    "system",
					Content: json.RawMessage(fmt.Sprintf("%q", systemPrompt)),
				},
				{
					Role:    "user",
					Content: json.RawMessage(fmt.Sprintf("%q", userContent)),
				},
			}

			// 创建分析请求
			analyzeRequest := &dto.GeneralOpenAIRequest{
				Model:    analysisConfig.Model,
				Messages: analyzeMessages,
			}

			// 调用 LLM 进行分析
			analyzeRelayInfo := *relayInfo
			analyzeRelayInfo.IsStream = false
			// 使用配置中的 BaseURL 和 ApiKey(如果有设置)
			if analysisConfig.BaseURL != "" {
				analyzeRelayInfo.BaseUrl = analysisConfig.BaseURL
			}
			if analysisConfig.ApiKey != "" {
				analyzeRelayInfo.ApiKey = analysisConfig.ApiKey
			}

			// 创建分析上下文
			analyzeCtx := &gin.Context{
				Request: c.Request,
				Writer:  c.Writer,
			}
			for k, v := range c.Keys {
				analyzeCtx.Set(k, v)
			}

			// 执行分析
			resp, err := service.ChatCompletionHelper(analyzeCtx, &analyzeRelayInfo, analyzeRequest)
			if err != nil {
				common.LogError(c, fmt.Sprintf("analyze search failed: %v", err))
				return messages, nil
			}

			// 添加原始响应内容的日志
			common.LogInfo(c, fmt.Sprintf("raw analysis response: %s", resp.Choices[0].Message.Content))

			// 解析分析结果
			var analysis struct {
				NeedSearch      bool     `json:"need_search"`
				Keywords        []string `json:"keywords"`
				Reason          string   `json:"reason"`
				Confidence      float64  `json:"confidence"`
				ContextAnalysis struct {
					HasContext        bool     `json:"has_context"`
					ReferenceEntities []string `json:"reference_entities"`
				} `json:"context_analysis"`
			}

			// 先将字符串内容解码
			var jsonStr string
			if err := json.Unmarshal([]byte(resp.Choices[0].Message.Content), &jsonStr); err != nil {
				// 如果解码字符串失败,说明可能直接是 JSON 对象
				if err := json.Unmarshal([]byte(resp.Choices[0].Message.Content), &analysis); err != nil {
					common.LogError(c, fmt.Sprintf("failed to parse analysis result: %v, raw content: %s", err, resp.Choices[0].Message.Content))
					return messages, nil
				}
			} else {
				// 解码内部的 JSON 对象
				if err := json.Unmarshal([]byte(jsonStr), &analysis); err != nil {
					common.LogError(c, fmt.Sprintf("failed to parse inner analysis result: %v, raw content: %s", err, jsonStr))
					return messages, nil
				}
			}

			// 记录分析结果
			common.LogInfo(c, fmt.Sprintf("search analysis: need_search=%v, keywords=%v, reason=%s, confidence=%.2f, has_context=%v, reference_entities=%v",
				analysis.NeedSearch, analysis.Keywords, analysis.Reason, analysis.Confidence,
				analysis.ContextAnalysis.HasContext, analysis.ContextAnalysis.ReferenceEntities))

			// 如果需要搜索,且置信度足够高,使用分析出的关键词进行搜索
			if analysis.NeedSearch && analysis.Confidence >= analysisConfig.MinConfidence && len(analysis.Keywords) > 0 {
				searchQuery := strings.Join(analysis.Keywords, " ")
				results, err := searchService.Search(searchQuery)
				if err != nil {
					return nil, err
				}

				// 扣除搜索引擎使用配额
				err = model.PreConsumeTokenQuota(relayInfo, searchEngine.TokenPerUse)
				if err != nil {
					return nil, err
				}

				// 将搜索结果添加到对话中
				searchContext := service.FormatSearchResultsAsContext(results)
				searchContextSimple := service.FormatSearchResultsForContext(results)
				// 保存搜索结果到上下文中,供后续使用
				c.Set("search_context", searchContextSimple)

				// 构建搜索说明
				var searchExplanation string
				if analysis.ContextAnalysis.HasContext {
					searchExplanation = fmt.Sprintf("我理解您在问关于「%s」的信息,",
						strings.Join(analysis.ContextAnalysis.ReferenceEntities, "、"))
				}
				searchExplanation += fmt.Sprintf("我已经根据分析提取的关键词「%s」为您搜索到以下相关信息(置信度:%.0f%%):\n\n%s\n\n让我根据这些信息来回答您的问题。",
					searchQuery, analysis.Confidence*100, searchContext)

				// 将助手消息内容转换为 json.RawMessage
				assistantContentJson, err := json.Marshal(searchExplanation)
				if err != nil {
					return nil, fmt.Errorf("failed to marshal assistant content: %v", err)
				}

				assistantMsg := dto.Message{
					Role:    "assistant",
					Content: assistantContentJson,
				}

				// 在用户消息之后插入搜索结果
				newMessages := make([]dto.Message, 0, len(messages)+1)
				for _, msg := range messages {
					newMessages = append(newMessages, msg)
					if msg.Role == "user" && bytes.Equal(msg.Content, lastUserMsg.Content) {
						newMessages = append(newMessages, assistantMsg)
					}
				}
				messages = newMessages
			}
		}
	}

	return messages, nil
}

把预处理加到原来的 TextHelper 里。

textRequest, err := getAndValidateTextRequest(c, relayInfo)
	if err != nil {
		common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
		return service.OpenAIErrorWrapperLocal(err, "invalid_text_request", http.StatusBadRequest)
	}

// 预处理消息,添加搜索结果
textRequest.Messages, err = preprocessMessages(textRequest.Messages, relayInfo, c)
if err != nil {
	common.LogError(c, fmt.Sprintf("preprocessMessages failed: %s", err.Error()))
	return service.OpenAIErrorWrapper(err, "preprocess_messages_failed", http.StatusInternalServerError)
}

最后去每个channel中将每次的搜索结果返回

这里以 relay/channel/openai/relay-openai 为例。

// 在 [DONE] 前发送最后一个响应,并添加搜索结果
if lastStreamData != "" {
	if searchCtx := c.GetString("search_context"); searchCtx != "" {
		var streamResponse dto.ChatCompletionsStreamResponse
		err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse)
		if err == nil {
			// 初始化 Choices 切片
			if len(streamResponse.Choices) == 0 {
				streamResponse.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 1)
				streamResponse.Choices[0].Delta = dto.ChatCompletionsStreamResponseChoiceDelta{}
			}

			searchBlock := fmt.Sprintf("\n\n<search>\n\n%s\n\n</search>", searchCtx)
			content := ""
			if streamResponse.Choices[0].Delta.Content != nil {
				content = *streamResponse.Choices[0].Delta.Content
			}
			content += searchBlock
			streamResponse.Choices[0].Delta.SetContentString(content)
			modifiedData, _ := json.Marshal(streamResponse)
			lastStreamData = string(modifiedData)
		}
	}

	err := sendStreamData(c, lastStreamData, forceFormat)
	if err != nil {
		common.LogError(c, "streaming error: "+err.Error())
	}
}

前端代码就没啥好说的了,让claude随便糊一下就好了。

29 个赞

感谢分享

1 个赞

太强了,大佬

1 个赞

这代码看得我头疼,又开始一学习就想睡觉了 :grimacing:

1 个赞

太强辣大佬

1 个赞

佬太强了

1 个赞

学习一下

1 个赞

太强了佬

1 个赞

大佬厉害啊

1 个赞

很好,但是不会用

1 个赞

马克住 学习一下

1 个赞

要学的太多了

1 个赞

点开:这题我会
看完:我不会

2 个赞

佬 太强了。

问题来了,小白咋用 :joy: 服务器docker搭建的

1 个赞

膜拜大神!

1 个赞

看上去不错,得仔细看看

1 个赞

得写点代码的

现在麻烦的就是小白不会,而且他是直接docker搭建

太强了,大佬

太强了,赞