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