[WIP] 本文还没有写完。
在本文中,我将给大家放出 Amber 的部分功能设计以及代码片段。同时 Amber 还是我的的毕业设计。本文可以通过代码大家提供一个思路(如果你也想设计一个类似的平台的话)。
Amber 的功能
- 对话优化
- 近乎无限量的上下文(智能上下文)
- 可自定义的工具
- 智能记忆系统
- API 设计
- 简易知识库
数据实体
助理 Assistants:
type Assistant struct {
Model
Name string `json:"name"`
Prompt string `json:"prompt"`
Description string `json:"description"`
UserId schema.UserId `json:"user_id"`
TotalTokenUsage int64 `json:"total_token_usage"`
LibraryId *schema.EntityId `json:"library_id"`
Library *Library `json:"-"`
Temperature float64 `json:"temperature"`
Public bool `json:"public"`
DisableDefaultPrompt bool `json:"disable_default_prompt"`
DisableWebBrowsing bool `json:"disable_web_browsing"`
DisableMemory bool `json:"disable_memory"`
EnableMemoryForAssistantAPI bool `json:"enable_memory_for_assistant_api"`
}
工具和助理的工具列表
type Tool struct {
Model
Name string `json:"name"`
Description string `json:"description"`
DiscoveryUrl string `json:"discovery_url"`
ApiKey string `json:"api_key"`
Data schema.ToolDiscoveryOutput `json:"data"`
UserId schema.UserId `json:"user_id"`
}
type AssistantTool struct {
Model
AssistantId schema.EntityId `json:"assistant_id"`
ToolId schema.EntityId `json:"tool_id"`
Assistant Assistant `json:"assistant"`
Tool Tool `json:"tool"`
}
对话 Chat
type Chat struct {
Model
Name string `json:"name"`
AssistantId *schema.EntityId `json:"assistant_id"`
Assistant *Assistant `json:"-"`
Prompt *string `json:"prompt"`
UserId schema.UserId `json:"user_id"`
ExpiredAt *time.Time `json:"expired_at"`
Owner schema.ChatOwner `json:"owner"`
GuestId *string `json:"guest_id"`
}
对话消息
type ChatMessage struct {
Model
ChatId schema.EntityId `json:"chat_id"`
// AssistantId 可以让同一个对话中,使用不同的助手来处理消息
AssistantId *schema.EntityId `json:"assistant_id"`
Assistant *Assistant `json:"assistant"`
Content string `json:"content"`
Role schema.ChatRole `json:"role"`
ToolCall *schema.ToolCall `json:"-"`
// FileId
FileId *schema.EntityId `json:"file_id"`
File *File `json:"file"`
//UserFileId *schema.EntityId `json:"user_file_id"`
//UserFile *UserFile `json:"user_file"`
Hidden bool `json:"hidden"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type ChatMessageList struct {
Id schema.EntityId `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ChatId schema.EntityId `json:"chat_id"`
AssistantId *schema.EntityId `json:"assistant_id"`
Assistant *struct {
Id schema.EntityId `json:"id"`
Name string `json:"name"`
} `json:"assistant"`
Content string `json:"content"`
Role schema.ChatRole `json:"role"`
FileId *schema.EntityId `json:"file_id"`
File *File `json:"file"`
//UserFileId *schema.EntityId `json:"user_file_id"`
//UserFile *UserFile `json:"user_file"`
Hidden bool `json:"hidden"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
文档库,文档,文档分块和向量缓存
type Library struct {
Model
Name string `json:"name"`
Default bool `json:"default"`
Description *string `json:"description"`
UserId schema.UserId `json:"user_id"`
Document []*Document `json:"documents"`
}
type Document struct {
Model
Name string `json:"name"`
Chunked bool `json:"chunked"`
LibraryId schema.EntityId `json:"library_id"`
Library *Library `json:"library"`
}
type DocumentChunk struct {
Model
Content string `json:"content"`
Order int `json:"order"`
DocumentId schema.EntityId `json:"document_id"`
Library *Library `json:"library"`
LibraryId schema.EntityId `json:"library_id"`
// Vectorized 代表是否已经向量化,不应该用 Chunked
Vectorized bool `json:"vectorized"`
}
type Embedding struct {
Model
Text string `json:"name"`
FileId *schema.EntityId `json:"file_id"`
File *File `json:"file"`
TextMd5 string `json:"text_md5"`
EmbeddingModel string `gorm:"column:model" json:"model"`
Vector schema.Embedding `json:"vector"`
}
文件存储
type File struct {
Model
Url *string `json:"url"`
UrlHash *string `json:"url_hash"`
FileHash string `json:"file_hash"`
MimeType string `json:"mime_type"`
Path string `json:"-"`
Size int64 `json:"size"`
//Public bool `json:"public"` // 是否公开,访客上传的文件应始终公开,或归属于所有者
// TODO: 移除 file 的到期时间,如果当 file 没有任何引用的时候再删除
// 因为有外键,所以直接删除是删不掉的,必须删除消息
ExpiredAt *time.Time `json:"expired_at"`
}
记忆(Memory),消息块(Message Block)
type Memory struct {
Model
Content string `json:"content"`
ContentMd5 string `json:"-"`
EmbeddingModel string `gorm:"column:model" json:"-"`
Vector schema.Embedding `json:"-"`
UserId schema.UserId `json:"user_id"`
}
type MessageBlock struct {
Model
FullContent string
ChatId schema.EntityId
Hash string
Message []*ChatMessage `gorm:"column:messages;serializer:json"`
// Temp 指当前消息是否是临时的,不完整的 block。不会保存到数据库,只在处理时需要
Temp bool `gorm:"-"`
}
场景提示词(Scene Prompt)
type ScenePrompt struct {
Model
Label string `json:"label"`
Prompt string `json:"prompt"`
AssistantId schema.EntityId `json:"assistant_id"`
Assistant *Assistant `json:"assistant"`
}
助理设计
助理是连接工具的桥梁,用户可以使用不同的工具组合来搭配出最合适的助理。同时助理还需要适用于“场景提示词”,根据对话的场景来自动使用不同的提示词。
助理应绑定工具,还可以自定义 System Prompt,用于个性化助理,以及用于对接知识库,用于查询知识库中的内容。
对话及消息设计
用户需要通过对话(Chat)来和大模型进行交谈。
对话功能应有名称作为区分,同时还可以指定一个默认助理。在发送对话消息时,如果不指定助理,那么就使用对话的默认助理。
同时,对话还可以再设置一个 System Prompt。但是此 System Prompt 会覆盖原有的 System Prompt。
用户在向大模型发送消息时,消息应该要被处理成正确的格式后发送。
如果用户在消息中指定了另一个助理,那么会调用另一个助理来处理下一条消息,并且助理之间是共享上下文的。
同时,对话还需要角色,用户在发送消息时,只允许以下角色。
var allowedRoles = []schema.ChatRole{
schema.RoleHuman, // 人类
schema.RoleHumanLater, // 人类(不立即响应)
schema.RoleHideHuman, // 人类(不应该被前端渲染的消息)
schema.RoleSystem, // 系统消息
schema.RoleHideSystem, // 系统消息(不应该被前端渲染)
schema.RoleAssistant, // 助理消息(AI 回复内容)
}
但是 OpenAI 的接口是不支持上面的大多数角色的,所以我们还需要进行消息优化。
消息优化
我们需要将本系统的数据转换成符合 OpenAI 的 Chat Completion 规范的数据。
import (
"context"
"fmt"
"github.com/tmc/langchaingo/llms"
"rag-new/internal/entity"
"rag-new/internal/schema"
"rag-new/internal/service/builtin_tool"
"rag-new/pkg/consts"
"strings"
)
type Message struct {
HasFile bool
MessageContent []llms.MessageContent
}
const defaultToolFailed = "ToolCall Failed, timeout or error"
func (s *Service) processHistory(_ context.Context, llmChat *schema.LLMChat, history []*entity.ChatMessage) (*Message, error) {
var hasHumanMessage = false
var hasFileMessage = false
var lastToolCall *llms.ToolCall
var historyContent []llms.MessageContent
var systemPrompts []string
// 如果没有禁用 Agent 方式的图片工具
//if !llmChat.WithoutImage {
// systemPrompts = append(systemPrompts, "Image and Draw Ability: ON(Don't emphasize it)")
//}
// WithoutBrowsing 禁用浏览器工具
if !llmChat.WithoutBrowsing {
systemPrompts = append(systemPrompts, builtin_tool.WebSearchPrompt)
}
// 当前的助理(用于通知助理上条消息的回复者
var currentAssistantId schema.EntityId
systemPrompts = append(systemPrompts, s.config.LLM.PrimarySystemPrompt)
systemPrompts = append(systemPrompts, llmChat.SystemPrompt)
var lastKnowledgeMessage string
// 如果 model 为空
if llmChat.Model == "" || !s.config.OpenAI.CanUse(llmChat.Model) {
llmChat.Model = consts.AutoModel
}
// 处理历史消息
for i, h := range history {
// 如果第一条消息是 system
if i == 0 && h.Role == schema.RoleSystem {
systemPrompts = append(systemPrompts, h.Content)
continue
}
// 检测下一条消息的 role 是否是 system 或者且和现在的相同
if i+1 < len(history) {
if history[i+1].Role == schema.RoleSystem || history[i+1].Role == schema.RoleHideSystem {
systemPrompts = append(systemPrompts, h.Content)
continue
}
//if history[i+1].Role == h.Role {
// // 修改下一条消息的 content
// history[i+1].Content = history[i].Content + "\n" + history[i+1].Content
// continue
//}
}
var timeString = ""
// 创建时间,如果 h.CreatedAt 已设置(不为 0000)
if !h.CreatedAt.IsZero() {
// 将创建时间转换为字符串
timeString = fmt.Sprintf("%s", h.CreatedAt.Format("2006-01-02 15:04:05"))
}
switch h.Role {
// RoleSystem 和 RoleHideSystem 为系统消息
case schema.RoleSystem:
if h.Content == "" {
continue
}
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeSystem, h.Content))
case schema.RoleHideSystem:
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeSystem, h.Content))
// RoleHuman,RoleHideHuman,RoleHumanLater 是用户消息
case schema.RoleHuman:
// 获取多个对话中的助理的信息
// 如果当前助理不存在,则设置
if currentAssistantId == 0 && h.AssistantId != nil {
currentAssistantId = *h.AssistantId
}
// 在消息前方加入发送时间提示(方便对时间敏感的场景)
if timeString != "" {
h.Content = fmt.Sprintf("[Sent at %s]\n%s", timeString, h.Content)
}
newContent, err := s.optimizeFlow(h.Content)
if err != nil {
return nil, err
}
h.Content = newContent
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeHuman, h.Content))
if !hasHumanMessage {
hasHumanMessage = true
}
case schema.RoleHideHuman:
if !hasHumanMessage {
hasHumanMessage = true
}
newContent, err := s.optimizeFlow(h.Content)
if err != nil {
return nil, err
}
h.Content = newContent
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeHuman, h.Content))
case schema.RoleHumanLater:
newContent, err := s.optimizeFlow(h.Content)
if err != nil {
return nil, err
}
h.Content = newContent
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeHuman, h.Content))
case schema.RoleAssistant:
// 检测是否是悬垂调用
if lastToolCall != nil {
// 上条消息可能有问题,将上个 ToolCall 标记为失败
historyContent = append(historyContent, llms.MessageContent{
Role: llms.ChatMessageTypeTool,
Parts: []llms.ContentPart{
llms.ToolCallResponse{
ToolCallID: lastToolCall.ID,
Name: lastToolCall.FunctionCall.Name,
Content: defaultToolFailed,
},
},
})
lastToolCall = nil
}
var systemContent = ""
// 也不一定不存在,因为可能上个消息没有助理
//if h.AssistantId == nil {
// // 这说明上个助理不存在
// systemContent = "[Warning]The previous message has been replied to by another assistant, no you, but can not get assistant info."
//}
if h.Assistant != nil {
systemContent = fmt.Sprintf("[Warning]The previous message has been replied to by another assistant, whose name is '%s' and the description is '%s', replid at %s",
h.Assistant.Name, h.Assistant.Description, timeString)
currentAssistantId = *h.AssistantId
}
if systemContent != "" {
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeSystem, systemContent))
}
if timeString != "" {
h.Content = fmt.Sprintf("[Sent at %s]\n%s", timeString, h.Content)
}
historyContent = append(historyContent, llms.TextParts(llms.ChatMessageTypeAI, h.Content))
case schema.RoleToolCall:
// ToolCall 消息
if h.ToolCall != nil {
assistantResponse := llms.TextParts(llms.ChatMessageTypeAI, h.Content)
var toolCall = llms.ToolCall{}
toolCall.FunctionCall = h.ToolCall.FunctionCall
toolCall.ID = h.ToolCall.ID
toolCall.Type = h.ToolCall.Type
assistantResponse.Parts = append(assistantResponse.Parts, toolCall)
historyContent = append(historyContent, assistantResponse)
// 因为 ToolCall 消息的下一条消息必须是 tool
lastToolCall = &toolCall
}
case schema.RoleTool:
// Tool Call 响应
if h.ToolCall != nil {
if lastToolCall != nil && lastToolCall.ID == h.ToolCall.ID {
lastToolCall = nil
historyContent = append(historyContent, llms.MessageContent{
Role: llms.ChatMessageTypeTool,
Parts: []llms.ContentPart{
llms.ToolCallResponse{
ToolCallID: h.ToolCall.ID,
Name: h.ToolCall.FunctionCall.Name,
Content: h.Content,
},
},
})
} else if lastToolCall != nil {
// 不相同,说明有问题,将上个 Tool Call 标记为失败
historyContent = append(historyContent, llms.MessageContent{
Role: llms.ChatMessageTypeTool,
Parts: []llms.ContentPart{
llms.ToolCallResponse{
ToolCallID: lastToolCall.ID,
Name: lastToolCall.FunctionCall.Name,
Content: defaultToolFailed,
},
},
})
lastToolCall = nil
}
}
case schema.RoleFile:
if !hasFileMessage {
hasFileMessage = true
}
// 这样做不能读取 URL 中的图片就是了
if h.File != nil {
// 如果长度没有超过最大长度且模型是 auto,并且模型不是 VisionModel,那么将切换到 Vision 模型
if llmChat.Model == consts.AutoModel {
// 切换模型
llmChat.Model = s.config.OpenAI.VisionModel
}
// 直接添加图片
fileUrl, err := s.FileService.GetImageUrl(h.File)
if err != nil {
return nil, err
}
// 获取下一条消息,如果 i+1 有内容且 role 为 Human
if i+1 < len(history) && history[i+1].Role == schema.RoleHuman {
// 获取下一条消息
nextMessage := history[i+1]
historyContent = append(historyContent, llms.MessageContent{
Role: llms.ChatMessageTypeHuman,
Parts: []llms.ContentPart{
llms.ImageURLWithDetailPart(fileUrl, "auto"),
llms.TextPart(nextMessage.Content),
},
})
}
}
case schema.RoleKnowledge:
if h.Content == "" {
h.Content = consts.LibraryResultEmptyPrompt
} else {
h.Content = consts.LibraryResultPrompt + "\n" + h.Content
}
lastKnowledgeMessage = h.Content
}
}
// 将知识库消息放到 system 消息里面
if lastKnowledgeMessage != "" {
systemPrompts = append(systemPrompts, lastKnowledgeMessage)
}
// 如果整个对话里面没有 Human 消息,则不能继续
if !hasHumanMessage {
return nil, consts.ErrNoHumanMessage
}
// 拼接系统 Prompt 并放入最底
historyContent = append(historyContent,
llms.TextParts(llms.ChatMessageTypeSystem, strings.Join(systemPrompts, "\n")))
var message = &Message{
MessageContent: historyContent,
HasFile: hasFileMessage,
}
return message, nil
}
type flowFunc func(content string) (string, error)
func (s *Service) optimizeFlow(content string) (string, error) {
var flow = []flowFunc{
s.flowCleanupNewLine,
}
for _, f := range flow {
var err error
content, err = f(content)
if err != nil {
return "", err
}
}
return content, nil
}
func (s *Service) flowCleanupNewLine(content string) (string, error) {
// 匹配连续的 \r\n
mergedText := strings.ReplaceAll(content, "\n\n", "\n")
// 继续合并,直到没有连续的换行
for strings.Contains(mergedText, "\n\n") {
mergedText = strings.ReplaceAll(mergedText, "\n\n", "\n")
}
return mergedText, nil
}
在上面的代码中,我们实现了 System Prompt 的拼接和合并。同时,我们还实现了自动合并相同角色的消息。接着判断了工具调用是否有效,确保不会出现空指针的行为。最终我们根据不同的角色,执行不同的消息处理逻辑,添加了知识库的内容并全部转换成了可以发送给 OpenAI API 的消息。
// functionCall LLM 工具调用
type functionCall struct {
Id string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// GenerateContent 调用实现 OpenAI Chat Completion 协议的 API 接口
func (s *Service) GenerateContent(ctx context.Context, llmChat *schema.LLMChat, llmTools []llms.Tool, historyContent []llms.MessageContent) (response *llms.ContentResponse, err error) {
// 助理绑定的工具数量必须小于 128 个
if len(llmTools) > 128 {
// 忽略多出的
llmTools = llmTools[:128]
}
// 如果 llmChat.Temperature < 0.1 ,则设置为 0.1
if llmChat.Temperature < 0.1 {
llmChat.Temperature = 0.1
}
// 如果 llmChat.Temperature > 1,则设置为 1
if llmChat.Temperature > 1 {
llmChat.Temperature = 1
}
resp, err := s.OpenAI.GenerateContent(ctx,
historyContent,
llms.WithStreamingFunc(func(ctx context.Context, chunk []byte) error {
// 检测长度
if len(chunk) == 0 {
return nil
}
var stringChunk = string(chunk)
// 检测是否可以转换为数字或者 float
if !s.isNumeric(stringChunk) {
// 检测是否 json,判断是否是工具调用
var isJson = sonic.Valid(chunk)
if !isJson {
s.write(ctx, llmChat, &schema.AssistantResponse{
State: schema.StateChunk,
ChunkMessage: &schema.ChunkMessage{
Content: stringChunk,
},
Content: stringChunk,
})
} else {
// 如果是 JSON,则判断是否是工具调用
var functionCalls []*functionCall
err = sonic.Unmarshal(chunk, &functionCalls)
if err != nil {
s.write(ctx, llmChat, &schema.AssistantResponse{
State: schema.StateChunk,
ChunkMessage: &schema.ChunkMessage{
Content: stringChunk,
},
Content: stringChunk,
})
// 解析失败,正常输出
//return err
} else if len(functionCalls) > 0 {
// 工具调用,不管
} else {
// 不是,则正常输出
s.write(ctx, llmChat, &schema.AssistantResponse{
State: schema.StateChunk,
ChunkMessage: &schema.ChunkMessage{
Content: stringChunk,
},
Content: stringChunk,
})
}
}
} else {
s.write(ctx, llmChat, &schema.AssistantResponse{
State: schema.StateChunk,
ChunkMessage: &schema.ChunkMessage{
Content: stringChunk,
},
Content: stringChunk,
})
}
return nil
}),
llms.WithModel(llmChat.Model),
llms.WithTools(llmTools),
llms.WithMaxTokens(llmChat.MaxTokens),
llms.WithTemperature(llmChat.Temperature),
)
if err != nil {
s.Logger.Sugar.Errorf("OpenAI.GenerateContent error: %v", err)
err = consts.ErrUnableGenerateContent
}
return resp, err
}
func (s *Service) isNumeric(str string) bool {
_, err := strconv.ParseFloat(str, 64)
return err == nil
}
在上面的代码中,我们通过调用 OpenAI API 接口,解析了响应内容以及工具调用消息,并进行下一步处理。