AI Agent 进阶:框架重构与 RAG 优化

🎯 从"能用"到"好用"——引入 LangChain 生态,提升检索质量

前情回顾:上篇文章的不足

在上一篇文章《RAG 检索增强生成》中,我们成功实现了:

  • ✅ 向量数据库存储和检索
  • ✅ Markdown 文档智能切分
  • ✅ RAG 集成到 Agent 对话流程

但是,这个实现存在几个问题:

问题 1:代码耦合严重

所有逻辑都堆在一个文件里,LLM 调用、记忆管理、消息处理混在一起:

// 旧代码:一个庞大的 ChatAgent
type ChatAgent struct {
    APIKey            string
    APIURL            string
    Model             string
    FullHistory       []Message       // 记忆管理
    OptimizedMessages []Message       // 记忆优化
    HTTPClient        *http.Client    // HTTP 调用
    MaxTokens         int             // Token 控制
    // ... 更多字段
}

// 一个方法做太多事情
func (ca *ChatAgent) SendMessage(userMessage string) (string, error) {
    // RAG 检索
    // 消息构建
    // Token 优化
    // 摘要生成
    // HTTP 请求
    // 响应解析
    // ... 300 行代码
}

这种设计难以维护、测试和扩展。

问题 2:缺乏标准化

我们自己实现了所有东西,但:

  • 消息格式不统一
  • 无法复用社区组件
  • 每次换 LLM 都要改代码

问题 3:检索质量不够好

向量相似度检索存在局限:

  • 语义相似 ≠ 问题相关
  • Top-K 结果可能包含噪音
  • 缺乏精排机制

本次更新:两大改进

本次更新解决了上述问题:

  1. 引入 LangChain 生态:使用 langchaingo 框架重构
  2. 添加 Rerank 重排:提升检索准确性

什么是 LangChain?

LangChain 是构建 LLM 应用的标准框架,提供:

  • 📦 统一抽象:LLM、Memory、Tools 的标准接口
  • 🔗 链式组合:将多个组件串联成工作流
  • 🧩 丰富生态:大量现成的组件和集成

LangChain 在各语言的实现

语言 项目 Stars 说明
Python langchain 100k+ 官方主实现
JavaScript langchainjs 15k+ 官方 JS 版
Go langchaingo 5k+ 社区实现,我们用这个
Java langchain4j 6k+ Java 社区实现

为什么选择 langchaingo?

// 使用 langchaingo 的好处
import "github.com/tmc/langchaingo/llms"

// 1. 统一的消息格式
message := llms.MessageContent{
    Role: llms.ChatMessageTypeHuman,
    Parts: []llms.ContentPart{
        llms.TextPart("你好"),
    },
}

// 2. 标准的 LLM 接口
type Model interface {
    GenerateContent(ctx context.Context, messages []MessageContent, ...) (*ContentResponse, error)
    Call(ctx context.Context, prompt string, ...) (string, error)
}

// 3. 开箱即用的组件
// - Memory(记忆管理)
// - Chains(链式调用)
// - Agents(代理)
// - Tools(工具)

重构后的架构

新的项目结构

agent/
├── agent.go         # ChatAgent 核心(精简后)
├── llm.go           # 🆕 LLM 提供者封装
├── llm_wrapper.go   # 🆕 自定义 LLM 包装器
├── memory.go        # 🆕 记忆管理器
├── cli.go           # 命令行交互
└── types.go         # 类型定义

架构图

┌─────────────────────────────────────────────────────────────┐
│                     ChatAgent (精简后)                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────┐    ┌─────────────────┐                │
│  │   LLMProvider   │    │  MemoryManager  │                │
│  │   LLM 提供者     │    │    记忆管理器    │                │
│  └────────┬────────┘    └────────┬────────┘                │
│           │                      │                          │
│           ▼                      ▼                          │
│  ┌─────────────────┐    ┌─────────────────┐                │
│  │ CustomLLMWrapper│    │ langchaingo     │                │
│  │  自定义 LLM 包装 │    │ MessageContent  │                │
│  └─────────────────┘    └─────────────────┘                │
│                                                             │
│  ┌─────────────────────────────────────────┐               │
│  │              RAG Retriever               │               │
│  │      (向量检索 + Rerank 重排)             │               │
│  └─────────────────────────────────────────┘               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

职责分离

组件 职责 代码行数
ChatAgent 协调各组件,处理对话流程 ~150 行
LLMProvider 封装 LLM 调用 ~40 行
CustomLLMWrapper 处理 API 请求/响应 ~160 行
MemoryManager 管理对话历史和优化 ~140 行

总代码量:从原来的 500+ 行单文件,拆分为 4 个模块,每个不超过 160 行。


核心组件详解

组件 1:LLMProvider(LLM 提供者)

// agent/llm.go

// LLMProvider 封装 langchaingo LLM 接口
type LLMProvider struct {
    llm llms.Model
}

// NewLLMProvider 创建一个新的 LLM 提供者
func NewLLMProvider(apiKey, apiURL, model string) (*LLMProvider, error) {
    // 使用自定义包装器,绕过 langchaingo 的角色检查
    llm := NewCustomLLMWrapper(apiKey, apiURL, model)
    return &LLMProvider{llm: llm}, nil
}

// Call 调用 LLM 生成响应
func (p *LLMProvider) Call(ctx context.Context, messages []llms.MessageContent) (string, error) {
    response, err := p.llm.GenerateContent(ctx, messages)
    if err != nil {
        return "", fmt.Errorf("failed to generate content: %w", err)
    }

    if len(response.Choices) == 0 {
        return "", fmt.Errorf("no choices in response")
    }

    return response.Choices[0].Content, nil
}

设计要点:

  • 遵循 langchaingo 的 llms.Model 接口
  • 隐藏底层实现细节
  • 统一的调用方式

组件 2:CustomLLMWrapper(自定义包装器)

为什么需要自定义包装器?

问题:langchaingo 对某些 API(如豆包)的角色名称检查过于严格

// langchaingo 期望的角色
llms.ChatMessageTypeHuman    // "human"
llms.ChatMessageTypeAI       // "ai"
llms.ChatMessageTypeSystem   // "system"

// 但有些 API 期望
"user"       // 而不是 "human"
"assistant"  // 而不是 "ai"

解决方案:自定义包装器,在发送前转换角色名称

// agent/llm_wrapper.go

// CustomLLMWrapper 包装 langchaingo 的 LLM,绕过角色检查
type CustomLLMWrapper struct {
    apiKey string
    apiURL string
    model  string
    client *http.Client
}

// GenerateContent 实现 llms.Model 接口
func (w *CustomLLMWrapper) GenerateContent(ctx context.Context, messages []llms.MessageContent, options ...llms.CallOption) (*llms.ContentResponse, error) {
    // 将 langchaingo 的消息格式转换为 API 期望的格式
    apiMessages := make([]map[string]string, 0, len(messages))
    
    for _, msg := range messages {
        // 提取文本内容
        var content string
        for _, part := range msg.Parts {
            if textContent, ok := part.(llms.TextContent); ok {
                content += textContent.Text
            }
        }

        // 🔑 关键:角色名称转换
        role := string(msg.Role)
        switch role {
        case "human":
            role = "user"        // human → user
        case "ai":
            role = "assistant"   // ai → assistant
        case "system":
            role = "system"      // 保持不变
        }

        apiMessages = append(apiMessages, map[string]string{
            "role":    role,
            "content": content,
        })
    }

    // 构建请求并发送...
    // 返回 langchaingo 格式的响应
    return &llms.ContentResponse{
        Choices: []*llms.ContentChoice{
            {Content: response.Choices[0].Message.Content},
        },
    }, nil
}

组件 3:MemoryManager(记忆管理器)

将记忆管理独立出来,职责单一:

// agent/memory.go

// MemoryManager 管理对话历史和记忆
type MemoryManager struct {
    fullHistory       []llms.MessageContent  // 完整历史
    optimizedMessages []llms.MessageContent  // 优化后的消息
    maxTokens         int
    keepRecentRounds  int
    summary           string                 // 摘要
}

// AddMessage 添加消息到历史
func (m *MemoryManager) AddMessage(role string, content string) {
    // 角色转换(兼容多种输入格式)
    var chatRole llms.ChatMessageType
    switch role {
    case "system":
        chatRole = llms.ChatMessageTypeSystem
    case "user", "human":
        chatRole = llms.ChatMessageTypeHuman
    case "assistant", "ai":
        chatRole = llms.ChatMessageTypeAI
    default:
        chatRole = llms.ChatMessageTypeHuman
    }

    msg := llms.MessageContent{
        Role: chatRole,
        Parts: []llms.ContentPart{
            llms.TextPart(content),
        },
    }
    m.fullHistory = append(m.fullHistory, msg)
}

// Optimize 优化消息列表,控制 token 数量
func (m *MemoryManager) Optimize(estimateTokens func(string) int) {
    // ... 与之前的逻辑相同
    // 但现在使用 langchaingo 的消息格式
}

组件 4:精简后的 ChatAgent

// agent/agent.go

// ChatAgent handles the chat functionality using langchaingo
type ChatAgent struct {
    llmProvider    *LLMProvider     // LLM 调用
    memoryManager  *MemoryManager   // 记忆管理
    estimateTokens func(string) int // Token 估算
    ragRetriever   *rag.Retriever   // RAG 检索
    ragEnabled     bool
}

// SendMessage 发送消息(精简后)
func (ca *ChatAgent) SendMessage(userMessage string) (string, error) {
    ctx := context.Background()

    // 1. RAG 检索
    ragContext := ""
    if ca.ragEnabled && ca.ragRetriever != nil {
        context, _ := ca.ragRetriever.GetRelevantContext(userMessage, 5)
        ragContext = context
    }

    // 2. 构建消息
    finalMessage := userMessage
    if ragContext != "" {
        finalMessage = ragContext + "\n\nUser question: " + userMessage
    }
    ca.memoryManager.AddMessage("user", finalMessage)

    // 3. 优化记忆
    ca.memoryManager.Optimize(ca.estimateTokens)

    // 4. 调用 LLM
    response, err := ca.llmProvider.Call(ctx, ca.memoryManager.GetOptimizedMessages())
    if err != nil {
        return "", err
    }

    // 5. 保存回复
    ca.memoryManager.AddMessage("assistant", response)

    return response, nil
}

对比:重构前 vs 重构后

指标 重构前 重构后
单文件行数 500+ 行 最大 160 行
文件数量 1 个 4 个
职责 混合 单一
可测试性
可扩展性

Rerank:提升检索质量

什么是 Rerank?

向量检索的局限性:

查询: "如何解决登录失败问题"

向量检索结果(按相似度):
1. "登录失败的常见原因"     相似度: 0.89  ← 不是解决方案
2. "用户登录流程说明"       相似度: 0.85  ← 不相关
3. "登录失败解决方案汇总"   相似度: 0.82  ← 这才是用户想要的!

问题:语义相似 ≠ 问题最相关

解决方案:Rerank(重排)—— 用更精准的模型对初筛结果进行二次排序

向量检索(粗筛)──▶ Top 20 结果 ──▶ Rerank(精排)──▶ Top 5 结果
    快速              召回多              准确

Rerank 的工作流程

┌─────────────────────────────────────────────────────────────┐
│                     Rerank 工作流程                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  用户查询: "如何解决登录失败"                                 │
│       │                                                     │
│       ▼                                                     │
│  ┌─────────────────────────────┐                           │
│  │   1. 向量检索(粗筛)         │                           │
│  │   返回 Top 20 相似文档        │                           │
│  └──────────────┬──────────────┘                           │
│                 │                                           │
│                 ▼                                           │
│  ┌─────────────────────────────┐                           │
│  │   2. Rerank 模型(精排)      │                           │
│  │   对每个文档计算相关性分数     │                           │
│  │   Query + Doc → 0.0~1.0     │                           │
│  └──────────────┬──────────────┘                           │
│                 │                                           │
│                 ▼                                           │
│  ┌─────────────────────────────┐                           │
│  │   3. 按新分数重新排序         │                           │
│  │   返回 Top 5 最相关文档       │                           │
│  └─────────────────────────────┘                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

实现:两种 Reranker

我们实现了两种 Reranker:

1. LLM-based Reranker(基于 LLM)

利用 LLM 的理解能力进行重排:

// rag/rerank.go

// LLMReranker 使用 LLM 进行重排
type LLMReranker struct {
    apiURL string
    apiKey string
    model  string
    client *http.Client
}

// Rerank 使用 LLM 对检索结果进行重排
func (r *LLMReranker) Rerank(query string, documents []QueryResult) ([]QueryResult, error) {
    // 构建重排提示
    prompt := buildRerankPrompt(query, documents)

    // 调用 LLM 获取分数
    scores, err := r.callLLM(prompt)
    if err != nil {
        return documents, err
    }

    // 更新分数并重新排序
    for i := range documents {
        documents[i].Score = scores[i]
    }

    sort.Slice(documents, func(i, j int) bool {
        return documents[i].Score > documents[j].Score
    })

    return documents, nil
}

// buildRerankPrompt 构建重排提示
func buildRerankPrompt(query string, documents []QueryResult) string {
    var prompt strings.Builder
    prompt.WriteString("你是一个文档检索重排系统。请根据用户查询,对以下文档进行相关性评分(0-1之间的小数)。\n\n")
    prompt.WriteString(fmt.Sprintf("用户查询:%s\n\n", query))
    prompt.WriteString("文档列表:\n")

    for i, doc := range documents {
        title := doc.Document.Metadata["title"].(string)
        content := doc.Document.Content
        if len(content) > 500 {
            content = content[:500] + "..."
        }
        prompt.WriteString(fmt.Sprintf("[%d] 标题: %s\n内容: %s\n\n", i+1, title, content))
    }

    prompt.WriteString("请返回一个 JSON 数组,包含每个文档的相关性分数。\n")
    prompt.WriteString("只返回 JSON 数组,例如:[0.95, 0.82, 0.65, 0.43, 0.21]")

    return prompt.String()
}
2. Cohere Reranker(专业重排模型)

使用 Cohere 提供的专业重排 API:

// CohereReranker 使用 Cohere Rerank API 进行重排
type CohereReranker struct {
    apiKey string
    model  string
    client *http.Client
}

// Rerank 使用 Cohere API 对检索结果进行重排
func (r *CohereReranker) Rerank(query string, documents []QueryResult) ([]QueryResult, error) {
    // 准备文档文本
    documentsText := make([]string, len(documents))
    for i, doc := range documents {
        documentsText[i] = doc.Document.Content
    }

    // 调用 Cohere Rerank API
    requestBody := map[string]interface{}{
        "model":     r.model,  // "rerank-english-v3.0"
        "query":     query,
        "documents": documentsText,
        "top_n":     len(documents),
    }

    // 发送请求到 https://api.cohere.ai/v1/rerank
    // 返回带有 relevance_score 的结果
}

Reranker 对比

特性 LLM Reranker Cohere Reranker
成本 按 token 计费 专门定价,较便宜
速度 较慢(生成式) 较快(专用模型)
质量 取决于 LLM 专门优化,质量高
部署 复用现有 LLM 需要 Cohere API
适用 小规模/测试 生产环境

集成到 Retriever

// rag/retriever.go

type Retriever struct {
    collectionManager *CollectionManager
    collectionName    string
    defaultTopK       int
    reranker          Reranker  // 🆕 重排器
}

// Query 在集合中搜索相似文档
func (r *Retriever) Query(queryText string, topK int) ([]QueryResult, error) {
    // 1. 向量检索(粗筛)
    results, err := collection.Query(r.ctx, queryText, topK, nil, nil)
    
    // 2. 如果启用了重排,进行精排
    if r.reranker != nil {
        reranked, err := r.reranker.Rerank(queryText, queryResults)
        if err != nil {
            // 重排失败不影响主流程
            fmt.Printf("⚠️ 警告: 重排失败,使用原始结果: %v\n", err)
            return queryResults, nil
        }
        return reranked, nil
    }

    return queryResults, nil
}

扩展知识:RAG 进阶技术

1. HyDE(Hypothetical Document Embeddings)

问题:用户查询通常很短,而文档通常很长,两者的向量空间不匹配。

查询向量: "登录失败怎么办"  → [0.1, 0.2, ...]  (短文本)
文档向量: "当用户登录失败时,通常有以下几种原因..." → [0.3, 0.4, ...] (长文本)

两者在向量空间中距离较远!

HyDE 的思路:先让 LLM 生成一个"假设的答案文档",再用这个假设文档去检索

┌─────────────────────────────────────────────────────────────┐
│                      HyDE 工作流程                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  用户查询: "登录失败怎么办"                                   │
│       │                                                     │
│       ▼                                                     │
│  ┌─────────────────────────────┐                           │
│  │   1. LLM 生成假设文档         │                           │
│  │   "登录失败通常是由于密码     │                           │
│  │    错误、账号被锁定或网络     │                           │
│  │    问题导致。解决方案包括..." │                           │
│  └──────────────┬──────────────┘                           │
│                 │                                           │
│                 ▼                                           │
│  ┌─────────────────────────────┐                           │
│  │   2. 用假设文档进行向量检索   │                           │
│  │   假设文档向量 与 真实文档    │                           │
│  │   向量更相似!               │                           │
│  └──────────────┬──────────────┘                           │
│                 │                                           │
│                 ▼                                           │
│  ┌─────────────────────────────┐                           │
│  │   3. 返回真正相关的文档       │                           │
│  └─────────────────────────────┘                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

HyDE 的实现思路(伪代码):

func HyDERetrieve(query string) ([]Document, error) {
    // 1. 生成假设文档
    prompt := fmt.Sprintf("请写一段文字来回答这个问题:%s", query)
    hypotheticalDoc, _ := llm.Generate(prompt)
    
    // 2. 用假设文档进行检索
    results, _ := vectorStore.Query(hypotheticalDoc, topK)
    
    return results, nil
}

2. Query Expansion(查询扩展)

问题:用户查询可能措辞不当,遗漏关键词

解决方案:用 LLM 扩展查询

原始查询: "登录不了"

扩展后:
- "登录失败"
- "无法登录"
- "登录问题"
- "登录错误"
- "账号登录异常"

用多个扩展查询进行检索,合并结果

3. Multi-Query Retrieval(多查询检索)

从不同角度生成多个查询,获取更全面的结果:

func MultiQueryRetrieve(originalQuery string) ([]Document, error) {
    // 1. 生成多角度查询
    prompt := fmt.Sprintf(`
        基于这个问题,从不同角度生成3个相关的搜索查询:
        问题:%s
        
        返回JSON数组格式
    `, originalQuery)
    
    queries := llm.Generate(prompt)  // ["查询1", "查询2", "查询3"]
    
    // 2. 对每个查询进行检索
    var allResults []Document
    for _, q := range queries {
        results, _ := vectorStore.Query(q, topK)
        allResults = append(allResults, results...)
    }
    
    // 3. 去重和排序
    return deduplicate(allResults), nil
}

4. RAG Fusion

结合多种检索方法的结果,用 Reciprocal Rank Fusion (RRF) 算法合并排名:

┌─────────────────────────────────────────────────────────────┐
│                     RAG Fusion                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  查询 ──┬──▶ 向量检索 ──▶ [Doc1, Doc3, Doc5]                │
│         │                                                   │
│         ├──▶ 关键词检索 ──▶ [Doc2, Doc1, Doc4]              │
│         │                                                   │
│         └──▶ 语义检索 ──▶ [Doc3, Doc2, Doc6]                │
│                                                             │
│                         │                                   │
│                         ▼                                   │
│              ┌─────────────────────┐                       │
│              │   RRF 融合排序       │                       │
│              │   score = Σ 1/(k+rank)                      │
│              └──────────┬──────────┘                       │
│                         │                                   │
│                         ▼                                   │
│               [Doc1, Doc3, Doc2, Doc5, ...]                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

RAG 技术对比

技术 解决问题 复杂度 效果提升
基础 RAG 知识扩展 基准
Rerank 排序准确性 ⭐⭐ +20%
HyDE 查询-文档匹配 ⭐⭐ +15%
Query Expansion 查询覆盖度 ⭐⭐ +10%
Multi-Query 多角度召回 ⭐⭐⭐ +15%
RAG Fusion 综合优化 ⭐⭐⭐⭐ +25%

配置示例

Rerank 配置

// config/config.go

type ChromaDBConfig struct {
    StoragePath string
    Collection  string
    RAGEnabled  bool
    RAGTopK     int
    Rerank      RerankConfig  // 🆕 重排配置
}

type RerankConfig struct {
    Enabled bool
    Type    string  // "llm" 或 "cohere"
    Model   string
    APIURL  string
    APIKey  string
}

// 使用 LLM 重排的配置
config := &Config{
    ChromaDB: ChromaDBConfig{
        RAGEnabled: true,
        RAGTopK:    20,  // 粗筛取 20 个
        Rerank: RerankConfig{
            Enabled: true,
            Type:    "llm",
            Model:   "gpt-3.5-turbo",
            APIURL:  "https://api.openai.com/v1/chat/completions",
        },
    },
}

总结

本次更新解决了什么?

问题 解决方案
代码耦合 引入 langchaingo,模块化拆分
缺乏标准 使用 langchaingo 的标准接口
检索不准 添加 Rerank 重排机制

关键收获

  1. 框架的价值:langchaingo 提供统一抽象,简化开发
  2. 职责分离:LLM、Memory、Retriever 各司其职
  3. Rerank 重要:向量检索只是第一步,重排提升精度

Agent 进化路线

Level 1: 基础对话 ✅
    ↓
Level 2: 智能记忆 ✅
    ↓
Level 3: RAG 知识库 ✅
    ↓
Level 4: 框架重构 + Rerank ✅ ← 当前
    ↓
Level 5: 工具调用(Function Calling)
    ↓
Level 6: 多 Agent 协作

作者注:从"能用"到"好用"的关键在于两点:框架化精细化。langchaingo 让代码更规范,Rerank 让检索更精准。这两步是 AI Agent 工程化的必经之路。

Happy Coding! 🚀

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐