搞完后你就可以在任意地方使用联网功能了,不会受到任何客户端的限制。
大概效果:
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随便糊一下就好了。