上一篇 讲了 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 的测试策略核心就三步:
- mock LLM :用
fakeChatModel(手写)或MockToolCallingChatModel(gomock 生成)替换真模型,控制返回值 - fakeTool:最小化工具实现,返回确定性结果
- 表驱动 + 状态机 :用
t.Run组织场景,用状态机编排多步流程,用事件断言验证结果
这套策略不依赖网络、不花钱、秒级跑完,而且结果确定------今天绿,明天也绿。
下一篇(E97)讲数据库迁移不翻车------golang-migrate 实战,34 个 DDL 有序执行。