chatmodel_component_mocks 模块技术深度解析
1. 问题空间:为什么需要这个模块
在构建依赖 LLM(大语言模型)的应用系统时,测试往往是最棘手的环节之一。想象一下:你正在开发一个智能代理系统,它需要调用聊天模型来生成响应、执行工具调用。如果每次运行测试都要真正调用外部 API,会带来三个致命问题:
- 速度慢:每次测试都要网络往返,CI/CD 流水线可能从几分钟变成几小时
- 成本高:真实 API 调用会产生费用,测试阶段的累积成本不可忽视
- 不可控:网络波动、API 限流、模型输出的不确定性都会导致测试 flaky(时而通过时而失败)
更重要的是,我们需要验证系统在各种边界条件下的行为:当模型返回错误时会怎样?当工具调用参数格式不对时如何处理?当流式输出中断时系统能否正确恢复?真实 API 很难可靠地复现这些场景。
这就是 chatmodel_component_mocks 模块存在的意义——它为聊天模型接口提供了完整的模拟实现,让你可以在测试中完全控制模型的行为,快速、可靠、低成本地验证系统逻辑。
2. 心智模型:把 mock 看作"可编程的替身演员"
理解这个模块的最佳方式是把 mock 对象想象成电影片场的替身演员。真实的聊天模型是"主角",但在彩排(测试)时,我们不需要主角亲自上场——替身(mock)可以:
- 按剧本执行:你提前告诉它"当调用 Generate 时,返回这个特定的消息"
- 记录动作:记住自己被调用了多少次、每次传入了什么参数
- 验证交互:确认导演(测试代码)确实让它做了预期的事情
在 Go 语言的 gomock 框架下,这个模式具体表现为三个核心角色:
- Mock 对象(如
MockToolCallingChatModel):实现了目标接口的"替身",所有方法调用都会转发给控制器 - Recorder(如
MockToolCallingChatModelMockRecorder):负责"记录剧本"——设置期望的调用和返回值 - Controller(
gomock.Controller):"场记",协调 mock 对象和 recorder,验证所有期望是否满足
3. 架构与组件关系
这个模块的结构非常清晰,完全对应 components/model 包中定义的接口层次。让我们看一下组件关系:
组件关系示意图:
接口层:
BaseChatModel (接口) <-- ChatModel (接口, 继承)
BaseChatModel (接口) <-- ToolCallingChatModel (接口, 继承)
Mock 层:
BaseChatModel --> MockBaseChatModel (生成mock)
ChatModel --> MockChatModel (生成mock)
ToolCallingChatModel --> MockToolCallingChatModel (生成mock)
Recorder 关系:
MockBaseChatModel --> MockBaseChatModelMockRecorder (拥有)
MockChatModel --> MockChatModelMockRecorder (拥有)
MockToolCallingChatModel --> MockToolCallingChatModelMockRecorder (拥有)
接口层次与对应 mock
这个模块完美镜像了真实接口的继承关系:
- BaseChatModel → MockBaseChatModel:最基础的聊天模型,支持
Generate和Stream - ChatModel → MockChatModel:继承自 BaseChatModel,增加了
BindTools方法(注意:这个接口已被标记为 deprecated) - ToolCallingChatModel → MockToolCallingChatModel:继承自 BaseChatModel,增加了
WithTools方法(推荐使用的不可变模式)
4. 核心组件深度解析
让我们深入每个 mock 类的实现细节,理解它们是如何工作的。
MockBaseChatModel:基础聊天模型模拟
这是最基础的 mock 实现,对应 BaseChatModel 接口。
type MockBaseChatModel struct {
ctrl *gomock.Controller
recorder *MockBaseChatModelMockRecorder
isgomock struct{} // 标记这是一个 gomock 生成的类型
}
核心机制:
- 所有方法调用都会通过
ctrl.Call()转发给控制器 - 控制器会检查是否有匹配的期望设置
- 如果有,返回预设的值;如果没有,测试失败
关键方法:
EXPECT():返回 recorder,用于设置期望Generate():模拟生成完整响应Stream():模拟流式响应
MockChatModel:带工具绑定的聊天模型模拟(已废弃)
这个 mock 对应 ChatModel 接口,增加了 BindTools 方法:
func (m *MockChatModel) BindTools(tools []*schema.ToolInfo) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BindTools", tools)
ret0, _ := ret[0].(error)
return ret0
}
设计注意事项:
- 这个接口存在的问题是
BindTools会修改内部状态,可能导致并发安全问题 - 它被保留是为了向后兼容,但新代码应该使用
ToolCallingChatModel
MockToolCallingChatModel:推荐的工具调用模型模拟
这是目前推荐使用的 mock,对应 ToolCallingChatModel 接口:
func (m *MockToolCallingChatModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "WithTools", tools)
ret0, _ := ret[0].(model.ToolCallingChatModel)
ret1, _ := ret[1].(error)
return ret0, ret1
}
设计优势:
WithTools返回一个新的实例,而不是修改当前实例- 这种不可变模式天然支持并发安全
- 更符合函数式设计理念
5. 数据流向:一个典型的测试场景
让我们通过一个完整的测试用例,看看数据是如何流动的:
数据流向(测试场景):
1. 设置阶段
测试代码 -> gomock.Controller: NewController(t)
测试代码 -> MockToolCallingChatModel: NewMockToolCallingChatModel(ctrl)
2. 期望录制
测试代码 -> MockToolCallingChatModelMockRecorder: Mock.EXPECT().Generate(...)
MockToolCallingChatModelMockRecorder -> gomock.Controller: 记录期望调用
3. 执行阶段
测试代码 -> 被测试系统: 将 mock 注入系统
被测试系统 -> MockToolCallingChatModel: Generate(ctx, messages)
MockToolCallingChatModel -> gomock.Controller: 转发调用
gomock.Controller -> gomock.Controller: 检查期望匹配
gomock.Controller --> MockToolCallingChatModel: 返回预设值
MockToolCallingChatModel --> 被测试系统: 返回消息
被测试系统 --> 测试代码: 返回结果
4. 验证阶段
测试代码 -> gomock.Controller: ctrl.Finish()
gomock.Controller -> 测试代码: 验证所有期望满足
关键步骤解析:
- 设置阶段:测试代码创建 Controller 和 mock 对象
- 期望录制:通过
EXPECT()方法告诉 mock 预期会收到什么调用,应该返回什么 - 注入与执行:将 mock 注入被测试系统,触发业务逻辑
- 调用验证:mock 收到调用后,Controller 验证是否符合期望
- 收尾检查:调用
ctrl.Finish()确保所有期望的调用都发生了
6. 设计权衡与决策
这个模块的设计体现了几个重要的权衡:
代码生成 vs 手动实现
选择:使用 mockgen 自动生成代码
原因:
- 接口一旦定义,mock 实现就是机械性的工作
- 手动维护 mock 容易出错,特别是当接口变更时
mockgen可以保证 mock 与接口始终保持一致
权衡:
- ✅ 优点:减少重复劳动,保证一致性
- ❌ 缺点:生成的代码看起来复杂,不直观
记录-重放模式 vs 直接 stubbing
选择:采用 gomock 的记录-重放模式
原因:
- 不仅可以 stub 返回值,还可以验证交互("这个方法确实被调用了,而且用的是这些参数")
- 对于状态变更的操作(如
BindTools),验证调用比只检查返回值更重要 - 支持灵活的参数匹配(
gomock.Any(),gomock.Eq()等)
权衡:
- ✅ 优点:强大的交互验证能力
- ❌ 缺点:测试代码稍微冗长一些
三个独立 mock vs 组合模式
选择:为每个接口生成独立的 mock 类
原因:
- 接口本身就是分离的,mock 也应该保持对应
- 有些测试只需要
BaseChatModel,不需要工具调用功能 - 遵循接口隔离原则
权衡:
- ✅ 优点:模块化好,按需使用
- ❌ 缺点:代码量稍多(但通过生成解决了)
7. 实际使用指南与常见模式
让我们看一些实际的使用示例。
基本用法:模拟 Generate 方法
func TestAgent_Generate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish() // 确保所有期望都被满足
// 创建 mock
mockModel := model.NewMockBaseChatModel(ctrl)
// 设置期望:当调用 Generate 时,返回特定消息
expectedMsg := &schema.Message{
Role: schema.RoleAssistant,
Content: "Hello, world!",
}
mockModel.EXPECT().
Generate(gomock.Any(), gomock.Any()).
Return(expectedMsg, nil)
// 使用 mock 进行测试
agent := NewAgent(mockModel)
result, err := agent.Run(context.Background(), "Hi")
// 断言结果
assert.NoError(t, err)
assert.Equal(t, "Hello, world!", result.Content)
}
验证参数:确保调用使用了正确的输入
func TestAgent_UsesCorrectMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockModel := model.NewMockBaseChatModel(ctrl)
// 验证传入的 messages 参数
mockModel.EXPECT().
Generate(
gomock.Any(),
gomock.Cond(func(x any) bool {
msgs := x.([]*schema.Message)
return len(msgs) == 2 &&
msgs[0].Role == schema.RoleSystem &&
msgs[1].Content == "Hello"
}),
).
Return(&schema.Message{Role: schema.RoleAssistant, Content: "Hi"}, nil)
agent := NewAgent(mockModel)
agent.Run(context.Background(), "Hello")
}
模拟工具调用:测试 WithTools
func TestAgent_BindsTools(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockModel := model.NewMockToolCallingChatModel(ctrl)
mockModelWithTools := model.NewMockToolCallingChatModel(ctrl)
// 设置期望:WithTools 被调用,返回另一个 mock
mockModel.EXPECT().
WithTools(gomock.Any()).
Return(mockModelWithTools, nil)
// 设置期望:新的 mock 会被调用 Generate
mockModelWithTools.EXPECT().
Generate(gomock.Any(), gomock.Any()).
Return(&schema.Message{Role: schema.RoleAssistant, Content: "Done"}, nil)
agent := NewAgent(mockModel)
agent.SetTools([]*schema.ToolInfo{...})
agent.Run(context.Background(), "Use the tool")
}
模拟错误:测试异常处理
func TestAgent_HandlesModelError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockModel := model.NewMockBaseChatModel(ctrl)
// 模拟模型返回错误
mockModel.EXPECT().
Generate(gomock.Any(), gomock.Any()).
Return(nil, errors.New("model unavailable"))
agent := NewAgent(mockModel)
result, err := agent.Run(context.Background(), "Hi")
// 验证 agent 正确处理了错误
assert.Error(t, err)
assert.True(t, strings.Contains(err.Error(), "model unavailable"))
}
模拟流式响应
func TestAgent_Stream(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockModel := model.NewMockBaseChatModel(ctrl)
// 创建一个模拟的 StreamReader
streamReader := schema.NewStreamReader(func() (*schema.Message, error) {
// 模拟流式输出...
})
mockModel.EXPECT().
Stream(gomock.Any(), gomock.Any()).
Return(streamReader, nil)
// 测试流式处理逻辑
}
8. 边缘情况与陷阱
虽然这个模块使用起来相对简单,但有几个常见的陷阱需要注意:
1. 忘记调用 ctrl.Finish()
问题:如果你设置了期望但没有调用 ctrl.Finish(),未满足的期望不会导致测试失败。
解决:始终使用 defer ctrl.Finish()
// ✅ 正确
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// ❌ 错误 - 可能漏掉未满足的期望
ctrl := gomock.NewController(t)
2. 期望顺序问题
问题:默认情况下,gomock 不关心调用的顺序。如果你需要验证顺序,需要使用 InOrder。
解决:
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mock := model.NewMockBaseChatModel(ctrl)
gomock.InOrder(
mock.EXPECT().Generate(...).Return(...),
mock.EXPECT().Generate(...).Return(...),
)
3. 可变参数匹配
问题:对于 opts ...model.Option 这样的可变参数,匹配可能不直观。
解决:使用 gomock.Any() 或 gomock.Len(0)
// 匹配任何选项
mock.EXPECT().Generate(ctx, msgs, gomock.Any()).Return(...)
// 匹配没有选项的情况
mock.EXPECT().Generate(ctx, msgs, gomock.Len(0)).Return(...)
4. 过度指定期望
问题:如果你的期望过于具体,测试会变得脆弱——实现细节的微小变化就会导致测试失败。
解决:只匹配你真正关心的内容
// ❌ 过于具体
mock.EXPECT().
Generate(
gomock.Eq(specificCtx),
gomock.Eq(exactMessages),
gomock.Eq(option1),
gomock.Eq(option2),
)
// ✅ 恰到好处
mock.EXPECT().
Generate(
gomock.Any(), // context 通常不关心具体值
gomock.Cond(func(x any) bool {
msgs := x.([]*schema.Message)
return len(msgs) > 0 && msgs[len(msgs)-1].Content == "Hello"
}),
gomock.Any(), // 选项通常不关心
)
5. WithTools 的返回值处理
问题:测试使用 ToolCallingChatModel 的代码时,很容易忘记设置 WithTools 的返回值期望。
解决:记住 WithTools 返回一个新实例,你需要设置这个新实例的期望
// ✅ 正确
originalMock := model.NewMockToolCallingChatModel(ctrl)
newMock := model.NewMockToolCallingChatModel(ctrl)
originalMock.EXPECT().WithTools(...).Return(newMock, nil)
newMock.EXPECT().Generate(...).Return(...) // 设置新实例的期望
// ❌ 错误 - 会失败,因为实际调用的是 newMock,不是 originalMock
originalMock.EXPECT().WithTools(...).Return(newMock, nil)
originalMock.EXPECT().Generate(...).Return(...) // 设置了错误实例的期望
9. 与其他模块的关系
这个模块在整个系统中的位置非常明确:
被谁依赖:
- 测试代码:任何需要测试聊天模型交互的组件
adk/chatmodel_react_and_retry_runtime中的测试adk/agent_contracts_and_context中的测试
依赖谁:
- components/model:定义了被模拟的接口
- schema:提供消息和工具的类型定义
go.uber.org/mock/gomock:提供 mock 框架
10. 总结
chatmodel_component_mocks 模块是一个简洁但强大的工具,它解决了测试 LLM 驱动系统时的核心痛点:
- 通过自动生成的 mock 类,让你可以完全控制聊天模型的行为
- 支持完整的接口层次,从基础的
BaseChatModel到工具调用的ToolCallingChatModel - 提供丰富的验证能力,不仅可以 stub 返回值,还可以验证交互
- 设计上遵循接口隔离原则,每个 mock 专注于一个接口
使用这个模块的关键是理解"记录-重放"模式:先告诉 mock 你期望什么,然后运行代码,最后验证期望是否满足。记住常见的陷阱——特别是 WithTools 返回值的处理和不要过度指定期望——你的测试会变得既可靠又易于维护。
这个模块虽然是生成的代码,看起来复杂,但核心思想很简单:把不可控的外部依赖变成可编程的测试资产。掌握了这一点,你就能轻松测试任何依赖聊天模型的代码了。