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 策略扩展成“多组件可替换”的结构,并补上相应测试用例。

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

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