8/19/2025

LLM 应用的单元测试要点

 

LLM 应用的单元测试要点

在 LLM 应用里,“对话变动大、输出不稳定、依赖外部服务”让单元测试比传统业务代码更棘手。核心是把“模型不确定性”与“业务逻辑确定性”解耦:对外用真实模型做集成/评测,对内用可控的 mock 做纯单元测试。


难点与解决思路

  • 非确定性输出: 相同输入得到不同文本,难以断言结果。
    解决:抽象 LLM 接口并使用 mock,对业务只断言“是否调用正确、是否走对分支、结构是否满足”,不要对真实语言内容做严格匹配。

  • 提示词/链路易碎: Prompt 修改会击穿大量断言。
    解决:断言“包含关系/结构特征”而非完整字符串;对关键 prompt 片段做“黄金样本”回归测试,允许非关键部分漂移。

  • 上下文与状态: 记忆、工具调用、RAG 依赖外部 IO。
    解决:边界外置(向量检索、工具、存储均以接口注入),在单元测试中替换为纯内存和 mock。

  • 流式与并发: Token 级别回调、取消、超时。
    解决:为流式接口引入 回调/通道,在测试里用 可控的 fake 逐步推送 token,断言顺序、取消与资源回收。

  • 评测与单测边界: 质量评测更像端到端测试或离线评估,而不是单元测试。
    解决:把“是否答对/是否优于基线”放到 集成/评测,把“代码在给定条件下的行为”放到 单元


单元测试的实践策略

  • 用接口隔离 LLM: 依赖 llms.Model(或等价)而非具体实现;在测试里注入 mock。
  • 只测可控行为: 分支选择、参数传递、prompt 关键片段、错误传播与重试逻辑。
  • 黄金样本回归: 对关键输出做少量 golden files(或字符串快照),配合 Review 审核变更。
  • 流式测试: fake 模型分片发送 token,测试消费端时序、背压与取消。
  • 并发与超时:context.WithTimeout,断言超时路径与资源释放。
  • RAG/工具 mock: 检索与函数调用以接口注入,测试中返回固定结果,覆盖“无结果/多结果/冲突结果”。

用 langchain-go 构建一个极简聊天机器人

下面示例基于 langchain-go(github.com/tmc/langchaingo),构建一个无记忆的“简洁助理”聊天机器人。实际接入 OpenAI/本地模型时只需替换注入的 llms.Model 实现。

// 文件: chatbot/bot.go
package chatbot

import (
    "context"
    "errors"

    "github.com/tmc/langchaingo/chain"
    "github.com/tmc/langchaingo/llms"
    "github.com/tmc/langchaingo/prompts"
)

type Bot struct {
    llm   llms.Model
    chain *chain.LLMChain
}

// NewBot 使用注入的 llms.Model 构建一个简单的聊天链。
// 模板:System 设定角色,人类消息带入变量 question。
func NewBot(model llms.Model) (*Bot, error) {
    if model == nil {
        return nil, errors.New("nil llm model")
    }

    tmpl := prompts.NewChatPromptTemplate(
        prompts.WithMessages(
            prompts.NewSystemMessagePromptTemplate("You are a concise assistant. Answer briefly and precisely.", nil),
            prompts.NewHumanMessagePromptTemplate("User question: {{.question}}", nil),
        ),
    )

    ch, err := chain.NewLLMChain(model, tmpl)
    if err != nil {
        return nil, err
    }

    return &Bot{
        llm:   model,
        chain: ch,
    }, nil
}

// Reply 生成单轮回复。业务只依赖链的输出,不绑定具体供应商。
func (b *Bot) Reply(ctx context.Context, question string) (string, error) {
    out, err := chain.Call(ctx, b.chain, map[string]any{
        "question": question,
    })
    if err != nil {
        return "", err
    }
    // langchain-go 的 LLMChain 默认在输出 map 中放 "text"
    text, _ := out["text"].(string)
    return text, nil
}

可选:接入真实模型(集成/手动测试时使用),单元测试不要直连外部服务。

// 文件: cmd/main.go (可选)
package main

import (
    "context"
    "fmt"
    "os"

    "github.com/tmc/langchaingo/llms/openai"

    "yourmodule/chatbot"
)

func main() {
    // 需要设置 OPENAI_API_KEY 环境变量
    llm, _ := openai.New()
    bot, _ := chatbot.NewBot(llm)

    reply, _ := bot.Reply(context.Background(), "用一句话解释 QUIC 和 TCP 的主要差异?")
    fmt.Println(reply)

    _ = os.Setenv("DUMMY", "") // 占位,避免未使用导入
}

单元测试代码(mock LLM + 断言)

我们实现一个可观测的 MockLLM:可注入固定回复、捕获收到的 prompt,并模拟错误。

// 文件: chatbot/bot_test.go
package chatbot

import (
    "context"
    "errors"
    "strings"
    "testing"
    "time"

    "github.com/tmc/langchaingo/llms"
)

// MockLLM 实现 llms.Model,用于可控输出与观测输入。
type MockLLM struct {
    // 如果设置,将在调用时返回该错误(模拟上游失败)
    Err error
    // 固定返回的文本(对所有 prompt)
    FixedText string
    // 捕获收到的所有 prompts,便于断言模板展开是否包含关键片段
    Captured []string
}

func (m *MockLLM) Generate(ctx context.Context, prompts []string, _ ...llms.CallOption) ([][]*llms.Generation, error) {
    if m.Err != nil {
        return nil, m.Err
    }
    m.Captured = append(m.Captured, prompts...)
    out := make([][]*llms.Generation, len(prompts))
    for i := range prompts {
        out[i] = []*llms.Generation{
            {Text: m.FixedText},
        }
    }
    return out, nil
}

// Test: 正常路径,返回固定文本
func TestBotReply_ReturnsText(t *testing.T) {
    mock := &MockLLM{FixedText: "hello, world"}
    bot, err := NewBot(mock)
    if err != nil {
        t.Fatalf("NewBot error: %v", err)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    got, err := bot.Reply(ctx, "ping")
    if err != nil {
        t.Fatalf("Reply error: %v", err)
    }
    if got != "hello, world" {
        t.Fatalf("unexpected reply: %q", got)
    }

    // 断言 prompt 中包含关键变量(不依赖完整格式,避免易碎)
    if len(mock.Captured) == 0 || !strings.Contains(mock.Captured[0], "ping") {
        t.Fatalf("prompt does not contain user question: %v", mock.Captured)
    }
    // 断言包含 System 指令的关键信息
    if !strings.Contains(mock.Captured[0], "concise assistant") {
        t.Fatalf("prompt missing system instruction: %v", mock.Captured[0])
    }
}

// Test: 上游错误应向外传播
func TestBotReply_PropagatesError(t *testing.T) {
    mock := &MockLLM{Err: errors.New("upstream failed")}
    bot, err := NewBot(mock)
    if err != nil {
        t.Fatalf("NewBot error: %v", err)
    }
    _, err = bot.Reply(context.Background(), "anything")
    if err == nil || !strings.Contains(err.Error(), "upstream failed") {
        t.Fatalf("expected upstream error, got: %v", err)
    }
}

// Test: 超时应生效(演示超时路径; 这里 mock 不阻塞,通常对真实或可阻塞 fake 测一下)
func TestBotReply_TimeoutContext(t *testing.T) {
    mock := &MockLLM{FixedText: "ok"}
    bot, _ := NewBot(mock)

    // 用已取消的 context 模拟超时/取消
    ctx, cancel := context.WithCancel(context.Background())
    cancel()
    _, err := bot.Reply(ctx, "q")
    if err == nil {
        t.Fatalf("expected context error, got nil")
    }
}

可扩展测试建议(按需加到你的项目中):

  • 流式回调测试:给 MockLLM 增加对 llms.WithStreamingFunc 的检测,逐 token 推送,断言消费顺序与取消。
  • 黄金样本测试:对关键 prompt 模板 tmpl.Format 的结果做 snapshot(允许非关键片段忽略)。
  • RAG 接口测试:为检索器/工具调用定义接口与 fake,实现无 IO 的分支覆盖。
  • 重试/退避:把重试策略放在业务层,注入“失败 N 次后成功”的 fake,断言次数与延迟策略。

进一步的工程化建议

  • 配置分层: 把模型、温度、最大 token 等做成可注入配置,测试里用零温度/禁采样或 mock。
  • 度量与日志: 为每轮调用打点(时延、token、错误),在测试里断言打点被调用,保证可观测性。
  • 边界清晰: 把“评测正确性”(如答案是否匹配参考)放到集成/离线评测;单元测试只证明代码在受控条件下行为正确。
  • 最小知识面测试: 除非必要,不在单测中引入真实 LLM 或外部网络;那是另一级测试的职责。

如果你想把对话历史、RAG、工具调用也纳入示例,我可以基于相同的 mock 策略扩展成“多组件可替换”的结构,并补上相应测试用例。

8/18/2025

大模型agent记忆方案有哪几种

 大模型 Agent 的记忆(Memory)方案,按存储方式、时间跨度、结构化程度可以分成几类,通常会结合使用。大体可以分成以下几种主流思路:


1. 短期记忆(Short-term / Context Memory)

  • 特点:直接放在模型上下文(prompt)里,随每次请求发送。

  • 优点:读取快、无需额外存储。

  • 缺点:受限于模型上下文长度,成本随 token 增加。

  • 常用方式

    • 最近 N 条对话直接拼接到 Prompt

    • 重要信息提炼成总结(Summary)再附加

    • Sliding Window(滑动窗口)策略


2. 长期记忆(Long-term Memory)

  • 特点:将过往信息存到外部存储,按需检索。

  • 常见实现

    1. 向量数据库(Vector DB)

      • 将历史对话/文档向量化(Embedding),用相似度搜索(如 cosine similarity)取回相关内容。

      • 代表工具:Milvus、Weaviate、Pinecone、FAISS。

      • 优点:语义检索,不依赖关键词;可扩展到百万级数据。

    2. 关键词索引(Keyword Index)

      • 存储结构化信息,按关键词或标签匹配。

      • 适合对高精确度、结构化查询有要求的场景。

    3. 混合索引(Hybrid Search)

      • 语义检索 + BM25 等关键词检索混合,提高召回率。


3. 工作记忆(Working Memory)

  • 特点:专门存储当前任务的中间结果、计划、变量等,不与长期对话混合。

  • 实现方式

    • JSON 状态对象(State Store)

    • 临时数据库或内存缓存(Redis)

    • 文件缓存(JSON/YAML)


4. 结构化知识库(Structured Knowledge Base)

  • 特点:将 Agent 学到的事实或规则转化为结构化数据存储,方便精确调用。

  • 实现

    • Graph Database(Neo4j、ArangoDB)存实体与关系

    • SQL 数据库存事实表

    • 用 Schema 存储技能(Skill)、工具(Tool)、知识点


5. 外部记忆插件(External Memory Plugins)

  • 特点:通过 API 调用外部服务读写记忆,减少本地负担。

  • 典型方案

    • LangChain Memory 模块(如 ConversationBufferMemoryVectorStoreRetrieverMemory

    • LlamaIndex(原 GPT Index)持久化记忆

    • OpenAI Assistant API 的 “persistent thread”


6. 混合记忆架构(Hybrid Memory Architecture)

  • 思路

    • 短期记忆:窗口内上下文

    • 长期记忆:向量库+摘要

    • 知识库:结构化事实

    • 工作记忆:临时状态

  • 优势:既保留对话连贯性,又能高效调用历史知识,不爆 token。


💡 常见组合示例

  • LangChain:短期(Buffer)+ 长期(VectorStore)+ 工作状态(State)

  • OpenAI Assistant API:对话线程(短期+摘要)+ 外部数据库(长期)

  • AutoGPT:文件存储(长期)+ 短期 Prompt + 临时计划(JSON)


Go: slice作为函数参数的解包

  在 Go 语言里, ... 跟在 slice 后面 ,是 “解包(unpack)”操作 ,它的作用是 把一个切片的元素逐个展开,作为可变参数传入函数 ,而不是把整个切片当作一个单独的参数。 📌 典型场景: append 追加切片 s1 := []int{1, 2,...