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.go 是 mockgen 自动生成的:

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.go 的 TestChatModelAgentRun 是典型的表驱动:

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-614 的 fakeToolForTest:

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
}

关键设计:

  • 最小实现 :只实现 Info 和 InvokableRun,不引入额外依赖
  • 可观测 :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-69 的 mockRunnerAgent 是测试 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 有序执行。

相关推荐
互联网叫兽25 分钟前
RAG 知识库-基于元数据的细粒度权限控制
ai·llm·rag
挖掘狂人2 小时前
从 "调模型" 到 "搭系统":Harness Engineering 凭什么成为 2026 年 AI 圈最热的新词
人工智能·agent·ai编程
智码看视界2 小时前
第7篇:GPT 系列大模型架构演进与提示工程入门
gpt·自然语言处理·大模型·llm·rlhf·提示工程·思维链
VIP_CQCRE2 小时前
用 Ace Data Cloud 快速接入 Flux Videos API:从文生视频到图生视频
aigc·api·flux·ai视频·acedatacloud
Flynt2 小时前
这个日增 1400 星的 E2E 框架说"第二次跑不调模型",我改了按钮文案试了试
agent·ai编程·测试
库拉大叔2 小时前
知漫剧、豆包、可灵,角色一致性谁更强
人工智能·aigc
镜舟科技3 小时前
从 StarRocks 到 MIP:数据平台的下一步是“理解”
starrocks·sql·ai·prompt·agent·context·mip
XLYcmy3 小时前
PDF 论文处理器 — 改进方向与建议文档
python·pdf·llm·agent·dify·rag·harness
小范的技术工坊3 小时前
Agent VS Workflow?
大模型·agent
Martina_03214 小时前
AI生成3D场景导入 Unity/Unreal 后,NPC 总串层?用6步检查导航高度与跨层连接
人工智能·游戏·数学建模·3d·unity·自然语言处理·aigc