Agent 怎么测试:mock LLM + 状态机表驱动,不调真模型的测试策略(第96篇-E82)

上一篇 讲了 Callbacks 回调机制,可以在 Agent 执行的每个环节注入监控。但有一个更根本的问题:怎么测试 Agent?

调真模型太贵、太慢,而且结果不确定。Eino 的测试策略是:mock LLM + 状态机表驱动 + 事件断言。这篇拆解三种测试模式。

为什么不能调真模型

问题 影响
成本 每次 go test 跑 100 个 case 调 API,月度账单爆炸
速度 真模型 500ms--2s/次,单元测试跑几分钟
不确定 同样输入不同输出,今天绿明天红
网络 测试依赖外部 API 可用性,CI 环境可能没网

答案:把 LLM 替换成 mock,把工具替换成 fake,用状态机表驱动编排多步流程。

(一)mock ChatModel:控制 LLM 行为

手写 fakeChatModel

failover_chatmodel_test.go:33-49

go 复制代码
type fakeChatModel struct {
    generate         func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error)
    stream           func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error)
    callbacksEnabled bool
}

func (m *fakeChatModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
    return m.generate(ctx, input, opts...)
}

用法:注入函数字段,完全控制返回值。

go 复制代码
cm := &fakeChatModel{
    generate: func(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
        return schema.AssistantMessage("Hello, I am an AI assistant.", nil), nil
    },
}

agent, _ := NewChatModelAgent(ctx, &ChatModelAgentConfig{
    Name: "TestAgent", Model: cm,
})

优点 :零依赖,灵活,适合简单场景。缺点 :没有 EXPECT 断言能力,需要手动验证调用次数。

gomock 生成

internal/mock/components/model/ChatModel_mock.gomockgen 自动生成的:

go 复制代码
//go:generate mockgen -destination ../../internal/mock/components/model/ChatModel_mock.go \
//  --package model github.com/cloudwego/eino/components/model BaseChatModel,ChatModel,ToolCallingChatModel

用法:

go 复制代码
ctrl := gomock.NewController(t)
cm := mockModel.NewMockToolCallingChatModel(ctrl)

// 设定期望:Generate 被调用 1 次,返回指定消息
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
    Return(schema.AssistantMessage("Hello", nil), nil).
    Times(1)

// 设定期望:WithTools 可以被调用任意次
cm.EXPECT().WithTools(gomock.Any()).Return(cm, nil).AnyTimes()

优点 :自动验证调用次数、参数匹配、调用顺序。缺点:需要 gomock 依赖,生成文件不能手动编辑。

表驱动:经典 Go 测试模式

chatmodel_test.goTestChatModelAgentRun 是典型的表驱动:

go 复制代码
func TestChatModelAgentRun(t *testing.T) {
    t.Run("BasicFunctionality", func(t *testing.T) { /* ... */ })
    t.Run("StreamOutput", func(t *testing.T) { /* ... */ })
    t.Run("ErrorHandling", func(t *testing.T) { /* ... */ })
    t.Run("WithTools", func(t *testing.T) { /* ... */ })
}

每个 t.Run 是一个独立的测试场景,互不干扰。

(二)fakeTool:模拟工具行为

react_test.go:584-614fakeToolForTest

go 复制代码
type fakeToolForTest struct {
    tarCount int  // 目标调用次数
    curCount int  // 当前调用次数
}

func (t *fakeToolForTest) Info(_ context.Context) (*schema.ToolInfo, error) {
    return &schema.ToolInfo{
        Name: "test_tool",
        Desc: "test tool for unit testing",
        ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
            "name": {Desc: "user name", Required: true, Type: schema.String},
        }),
    }, nil
}

func (t *fakeToolForTest) InvokableRun(_ context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
    t.curCount++
    return "success", nil
}

关键设计:

  • 最小实现 :只实现 InfoInvokableRun,不引入额外依赖
  • 可观测curCount 记录调用次数,测试可以断言
  • 确定性:总是返回固定值,不依赖外部状态

测试工具调用链路:

go 复制代码
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
    Return(schema.AssistantMessage("Using tool", []schema.ToolCall{
        {ID: "tool-call-1", Function: schema.FunctionCall{
            Name: info.Name, Arguments: `{"name": "test user"}`,
        }},
    }), nil).Times(1)

cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
    Return(schema.AssistantMessage("Task completed", nil), nil).Times(1)

预期事件序列:Assistant(带 tool_call)→ Tool(工具结果)→ Assistant(最终回复)。

(三)状态机表驱动:多步流程测试

Agent 的执行不是单次 LLM 调用,而是一个状态机:用户消息 → LLM 回复 → 可能调用工具 → LLM 再回复 → ... 直到完成。

测试这种多步流程,可以用状态机表驱动:

go 复制代码
steps := []stateStep{
    {
        desc:       "Step 1: LLM returns tool call",
        mockOutput: &Message{Role: RoleAssistant, ToolCalls: []ToolCall{
            {ID: "tc1", ToolName: "search", Args: "weather"},
        }},
    },
    {
        desc: "Step 2: LLM summarizes tool result",
        mockOutput: &Message{Role: RoleAssistant, Content: "天气是晴天,25°C"},
    },
}

每个 step 定义:mock LLM 返回什么 → 工具怎么执行 → 断言什么。

这比单独测每个子组件更接近真实场景------它验证的是整个 ReAct 循环的正确性。

(四)Runner 测试:mockRunnerAgent

runner_test.go:29-69mockRunnerAgent 是测试 Runner 的模式:

go 复制代码
type mockRunnerAgent struct {
    name        string
    description string
    responses   []*AgentEvent  // 预定义响应序列
    callCount   int
    lastInput   *AgentInput
}

func (a *mockRunnerAgent) Run(_ context.Context, input *AgentInput, _ ...AgentRunOption) *AsyncIterator[*AgentEvent] {
    a.callCount++
    a.lastInput = input
    iterator, generator := NewAsyncIteratorPair[*AgentEvent]()
    go func() {
        defer generator.Close()
        for _, event := range a.responses {
            generator.Send(event)
        }
    }()
    return iterator
}

用法:预定义 Agent 的响应序列,验证 Runner 的行为(如 CheckPoint 保存、SSE 合并、多轮会话)。

(五)AgentEvent 断言

测试 Agent 不只是验证"返回了什么",还要验证事件的类型和顺序

go 复制代码
// 验证事件1:Assistant 消息
event1, ok := iterator.Next()
assert.True(t, ok)
assert.Equal(t, schema.Assistant, event1.Output.MessageOutput.Role)

// 验证事件2:Tool 消息
event2, ok := iterator.Next()
assert.Equal(t, schema.Tool, event2.Output.MessageOutput.Role)

// 验证事件3:最终回复
event3, ok := iterator.Next()
assert.Equal(t, "Task completed", event3.Output.MessageOutput.Message.Content)

// 没有更多事件
_, ok = iterator.Next()
assert.False(t, ok)

常见断言维度:

  • event.Output.MessageOutput.Role:事件角色(Assistant/Tool)
  • event.Output.MessageOutput.Message.Content:消息内容
  • event.Action:特殊动作(Exit、Transfer)
  • event.Err:错误
  • event.Output.MessageOutput.IsStreaming:是否流式

(六)错误路径测试

不要只测正常路径。错误路径更重要:

go 复制代码
// LLM 返回错误
cm.EXPECT().Generate(gomock.Any(), gomock.Any(), gomock.Any()).
    Return(nil, errors.New("model error")).Times(1)

event, ok := iterator.Next()
assert.True(t, ok)
assert.NotNil(t, event.Err)
assert.Contains(t, event.Err.Error(), "model error")

chatmodel_test.go 覆盖了这些错误路径:

  • LLM 返回 error
  • 工具 Info 返回 error(errorTool
  • preprocessComposeCheckpoint 迁移错误
  • ModelFailoverConfig 配置校验错误

(七)测试策略总结

三层测试金字塔

层级 测试什么 工具
单元测试 单个组件(ChatModel、Tool) mock + fake
集成测试 Agent 内部流程(ReAct 循环) mock + 状态机表驱动
E2E 测试 完整用户交互 真模型(仅关键路径)

什么时候用真模型

  • 发版前的冒烟测试
  • 新模型接入验证
  • Prompt 效果评估
  • 工具调用格式兼容性验证

日常开发中,mock 覆盖 95% 的场景。

什么时候不需要 mock

  • 纯函数测试(字符串处理、JSON 解析)
  • 工具内部逻辑测试(不依赖 LLM 的纯计算)
  • 配置校验测试

小结

Eino 的测试策略核心就三步:

  1. mock LLM :用 fakeChatModel(手写)或 MockToolCallingChatModel(gomock 生成)替换真模型,控制返回值
  2. fakeTool:最小化工具实现,返回确定性结果
  3. 表驱动 + 状态机 :用 t.Run 组织场景,用状态机编排多步流程,用事件断言验证结果

这套策略不依赖网络、不花钱、秒级跑完,而且结果确定------今天绿,明天也绿。

下一篇(E97)讲数据库迁移不翻车------golang-migrate 实战,34 个 DDL 有序执行。

相关推荐
prog_61031 小时前
【笔记】用cursor手搓cursor(九)
人工智能·笔记·大语言模型·agent
程序猿DD2 小时前
豆包工作送 30 天会员!我把每天要盯的事情都交给了它,结果...
agent
殷紫川2 小时前
长程任务 Agent 为什么总半途而废?PLAN-AND-ACT 用"先规划后动手"给出 SOTA 答案
llm·agent·ai编程
SelectDB2 小时前
Apache Doris 在内容 AI 生产链路中的实践:从内容打标到可追溯数据链路
大数据·agent·图片资源
宋哥转AI3 小时前
深入理解 AI Agent · 多 Agent 编排 #01:多 Agent 编排的四种核心模式
人工智能·agent·ai编程
今日无bug4 小时前
从零实现 Mini-Cursor 编程助手:Agent 开发实战指南
agent·cursor
jump_jump4 小时前
我让本地 Qwen3.8 27B 搭了一个内网穿透服务
人工智能·llm·ai编程
苏灿烤鱼4 小时前
一个 1.7 万 Star 的开源项目,教我们怎么判断 AI 工具值不值得用
html·github·agent
武子康4 小时前
Claude 已经给文本加水印了吗?真正缺的不是声明,而是逐模型状态
人工智能·llm·agent