Go AI Agent 实战 17|模型适配层设计:让模型可替换

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段二 Go 调用大模型 · 第 10 课(17)

上一篇:重试、指数退避、限流与成本控制

经过前九课,Go 客户端已经具备请求、解析、密钥管理、错误处理和可靠性控制。但如果业务代码到处出现 DeepSeek URL、请求结构体和供应商错误,换模型就会变成全项目改造。

这一课引入模型适配层:业务层依赖统一接口,供应商细节留在适配器中。它也为后续使用 Eino 的模型抽象打下基础。

一、本课学习目标

学完本课,你应该能够:

  1. 解释业务层为什么不应直接绑定供应商。
  2. 使用 Go interface 定义模型调用契约。
  3. 实现 DeepSeek 适配器。
  4. 在程序组装层注入具体实现。
  5. 使用模拟模型测试业务逻辑。
  6. 判断什么时候需要抽象,什么时候保持简单。

二、直接调用的耦合问题

假设业务函数内部直接构造 DeepSeek 请求:

go 复制代码
func GenerateArticleSummary(content string) (string, error) {
	// DeepSeek API 地址
	// DeepSeek 请求 JSON
	// DeepSeek 错误格式
	// DeepSeek 响应解析
	return "", nil
}

功能少时这样写很快。随着需求增长,业务层会混入:

  • 供应商 URL 和模型标识。
  • API Key 与认证头。
  • 供应商专用请求、响应和错误结构。
  • 流式协议与重试实现。

更换模型、增加本地模型或写单元测试时,业务代码会被迫一起修改。

更合理的依赖方向是:

text 复制代码
业务层 AIService
        ↓ 只依赖
ChatModel 接口
        ↑ 实现
DeepSeekAdapter / OtherAdapter / FakeModel

程序启动时决定注入哪个实现,业务层只负责编排任务。

三、定义统一接口

本课先聚焦普通文本生成:

go 复制代码
type ChatModel interface {
	Generate(
		ctx context.Context,
		messages []ChatMessage,
	) (string, error)
}

它规定了三项契约:

  • ctx:调用方可以取消和设置截止时间。
  • messages:输入是多轮消息。
  • (string, error):成功返回文本,失败返回可分类错误。

Go 使用隐式接口实现。类型不需要写 implements,只要方法集合匹配,就自动实现接口。

四、项目结构

text 复制代码
model-adapter/
├── go.mod
├── main.go
├── model.go
├── deepseek.go
└── model_test.go

初始化:

powershell 复制代码
mkdir model-adapter
Set-Location model-adapter
go mod init model-adapter

本课只使用标准库。

五、model.go:定义业务契约

go 复制代码
package main

import "context"

type ChatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatModel interface {
	Generate(
		ctx context.Context,
		messages []ChatMessage,
	) (string, error)
}

这个文件不包含 API URL、密钥或供应商响应格式。它描述的是"业务需要什么能力",不是"某家服务如何实现"。

六、deepseek.go:封装供应商细节

go 复制代码
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"
)

type DeepSeekConfig struct {
	APIKey  string
	Model   string
	BaseURL string
	Client  *http.Client
}

type DeepSeekAdapter struct {
	apiKey  string
	model   string
	baseURL string
	client  *http.Client
}

type deepSeekRequest struct {
	Model    string        `json:"model"`
	Messages []ChatMessage `json:"messages"`
}

type deepSeekResponse struct {
	Choices []struct {
		Message ChatMessage `json:"message"`
	} `json:"choices"`
}

func NewDeepSeekAdapter(cfg DeepSeekConfig) (*DeepSeekAdapter, error) {
	if strings.TrimSpace(cfg.APIKey) == "" {
		return nil, errors.New("DeepSeek API Key 未设置")
	}

	if cfg.Model == "" {
		cfg.Model = "deepseek-flash"
	}
	if cfg.BaseURL == "" {
		cfg.BaseURL = "https://api.deepseek.com/chat/completions"
	}
	if cfg.Client == nil {
		cfg.Client = &http.Client{Timeout: 45 * time.Second}
	}

	return &DeepSeekAdapter{
		apiKey:  cfg.APIKey,
		model:   cfg.Model,
		baseURL: cfg.BaseURL,
		client:  cfg.Client,
	}, nil
}

func (d *DeepSeekAdapter) Generate(
	ctx context.Context,
	messages []ChatMessage,
) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}

	if len(messages) == 0 {
		return "", errors.New("messages 不能为空")
	}

	body, err := json.Marshal(deepSeekRequest{
		Model:    d.model,
		Messages: messages,
	})
	if err != nil {
		return "", fmt.Errorf("序列化请求失败: %w", err)
	}

	req, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		d.baseURL,
		bytes.NewReader(body),
	)
	if err != nil {
		return "", fmt.Errorf("创建 HTTP 请求失败: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+d.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := d.client.Do(req)
	if err != nil {
		return "", fmt.Errorf("调用 DeepSeek 失败: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		errorBody, readErr := io.ReadAll(
			io.LimitReader(resp.Body, 1<<20),
		)
		if readErr != nil {
			return "", fmt.Errorf(
				"DeepSeek 返回 HTTP %d,读取错误响应失败: %w",
				resp.StatusCode,
				readErr,
			)
		}

		return "", fmt.Errorf(
			"DeepSeek 返回 HTTP %d: %s",
			resp.StatusCode,
			strings.TrimSpace(string(errorBody)),
		)
	}

	var result deepSeekResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("解析 DeepSeek 响应失败: %w", err)
	}

	if len(result.Choices) == 0 {
		return "", errors.New("DeepSeek 响应中 choices 为空")
	}

	answer := strings.TrimSpace(result.Choices[0].Message.Content)
	if answer == "" {
		return "", errors.New("DeepSeek 返回了空回答")
	}

	return answer, nil
}

// 编译期确认适配器满足统一接口。
var _ ChatModel = (*DeepSeekAdapter)(nil)

这个适配器集中封装了:

  • API URL。
  • 模型标识。
  • Bearer Token。
  • 供应商请求和响应结构。
  • HTTP 状态码与响应校验。

后续增加结构化日志、重试或供应商错误分类时,也优先在这一层或包装层实现。

七、main.go:业务层只依赖接口

go 复制代码
package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"strings"
	"time"
)

func AskAI(
	ctx context.Context,
	model ChatModel,
	question string,
) (string, error) {
	question = strings.TrimSpace(question)
	if question == "" {
		return "", errors.New("问题不能为空")
	}

	messages := []ChatMessage{
		{
			Role:    "system",
			Content: "你是一名 Go 后端技术助手,请用中文回答。",
		},
		{
			Role:    "user",
			Content: question,
		},
	}

	return model.Generate(ctx, messages)
}

func run() error {
	apiKey := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
	if apiKey == "" {
		return errors.New("DEEPSEEK_API_KEY 未设置")
	}

	modelName := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))

	adapter, err := NewDeepSeekAdapter(DeepSeekConfig{
		APIKey: apiKey,
		Model:  modelName,
	})
	if err != nil {
		return err
	}

	// 组装层决定注入哪个 ChatModel 实现。
	var model ChatModel = adapter

	ctx, cancel := context.WithTimeout(
		context.Background(),
		40*time.Second,
	)
	defer cancel()

	answer, err := AskAI(
		ctx,
		model,
		"请解释 Go interface 的隐式实现,并举一个例子。",
	)
	if err != nil {
		return err
	}

	fmt.Println("模型回答:")
	fmt.Println(answer)
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "运行失败:", err)
		os.Exit(1)
	}
}

运行:

powershell 复制代码
$env:DEEPSEEK_API_KEY = "你的真实API_KEY"
go run .

AskAI 的参数类型是 ChatModel。它不知道底层是 DeepSeek、其他云模型还是本地模型,只负责组织消息并调用统一契约。

八、用模拟模型测试业务逻辑

创建 model_test.go:

go 复制代码
package main

import (
	"context"
	"errors"
	"testing"
)

type FakeChatModel struct {
	Answer string
	Err    error
}

func (f *FakeChatModel) Generate(
	ctx context.Context,
	messages []ChatMessage,
) (string, error) {
	if err := ctx.Err(); err != nil {
		return "", err
	}
	if f.Err != nil {
		return "", f.Err
	}
	return f.Answer, nil
}

func TestAskAI(t *testing.T) {
	fake := &FakeChatModel{
		Answer: "Go interface 使用隐式实现。",
	}

	answer, err := AskAI(
		context.Background(),
		fake,
		"什么是 interface?",
	)
	if err != nil {
		t.Fatalf("AskAI 返回错误: %v", err)
	}

	if answer != fake.Answer {
		t.Fatalf("回答不匹配,得到 %q", answer)
	}
}

func TestAskAIWithModelError(t *testing.T) {
	fake := &FakeChatModel{
		Err: errors.New("模拟模型调用失败"),
	}

	_, err := AskAI(
		context.Background(),
		fake,
		"什么是 interface?",
	)
	if err == nil {
		t.Fatal("预期返回错误,但实际没有错误")
	}
}

运行:

powershell 复制代码
go test ./...

这些测试不需要网络、API Key 或真实费用,能够稳定验证业务层:

  • 正常回答能否返回。
  • 模型错误能否向上传递。
  • 输入校验是否符合预期。

但模拟测试不能证明真实供应商的 HTTP、认证和响应解析一定正确,仍需要少量集成测试验证适配器。

九、适配层在项目中的位置

一个示意目录如下:

text 复制代码
internal/
└── mods/
    └── ai/
        ├── api/
        │   └── handler.go
        ├── biz/
        │   └── service.go
        ├── model/
        │   └── contract.go
        └── adapter/
            └── deepseek.go
层次 职责
Handler 接收 HTTP 请求、校验外部参数、返回响应
Service 业务编排,调用 ChatModel
Contract 定义消息、接口和业务所需类型
Adapter 实现供应商请求、解析和错误映射

关键不是目录名,而是依赖方向:业务依赖接口,适配器实现接口,组装层选择具体实现。

十、什么时候不要抽象

适配层也有成本:

  • 多一层类型和调用链。
  • 不同供应商能力差异可能泄漏到接口设计。
  • 过早抽象容易把当前需求想得过于通用。

如果项目只有一个模型、一个调用点,简单函数已经足够。当出现替换模型、多供应商、独立测试或统一可靠性策略等真实需求时,再逐步抽取接口更稳妥。

本课定义的 ChatModel 是本系列的教学接口,不是 Eino 官方接口。后续会以 Eino 当前版本的官方 API 为准,把这里的思路映射到框架组件。

十一、第二阶段总结

第二阶段完成了 Go 调用大模型的核心工程链路:

  1. 理解 HTTP 请求和响应。
  2. 用 net/http 完成真实调用。
  3. 安全管理 API Key。
  4. 设计 JSON 边界与业务校验。
  5. 分类错误并记录脱敏日志。
  6. 读取 SSE 流式输出。
  7. 管理多轮对话历史。
  8. 传播 Context 超时与取消。
  9. 实现有限重试、退避和成本控制。
  10. 用接口隔离业务逻辑与模型供应商。

下一阶段进入 Tool Calling。模型将不只是生成文本,而是提出"调用某个工具"的请求;Go 后端负责验证参数、执行工具,并把结果交回模型继续处理。这是从普通大模型应用走向 Agent 的关键一步。

十二、课后练习

练习 1:为什么依赖接口

AskAI 为什么接收 ChatModel,而不是 *DeepSeekAdapter?

参考答案:接收接口可以让业务层不绑定具体供应商,支持替换模型、包装重试策略并注入模拟实现测试。接收具体类型会把业务代码与 DeepSeek 实现直接耦合。

练习 2:是否要写 implements

LocalModelAdapter 的方法签名与 Generate 一致,还需要显式声明实现 ChatModel 吗?

参考答案 :不需要。Go 使用隐式接口实现,只要方法集合满足接口即可。可以用 var _ ChatModel = (*LocalModelAdapter)(nil) 做编译期检查。

练习 3:为什么模拟测试很重要

为什么业务层测试通常先使用 FakeChatModel,而不是每次都调用真实 API?

参考思路:模拟实现不依赖网络、密钥和费用,执行稳定且便于构造成功、超时和错误场景。它能验证业务编排,但不能替代少量真实集成测试。


下一篇:Go AI Agent 实战 18|Tool Calling 的基本原理与完整执行流程

专栏:Go 开发者学 AI Agent:从大模型到工程落地

相关推荐
lucas_AI1 小时前
别再迷信 OCR 专用小模型:8 个 7B 以下 VLM 实测,抽取 JSON 结构还得靠通用选手
人工智能
神一样的老师1 小时前
#移远产品体验官 Quectel Pi M2 实战:完全离线的中文语音助手
人工智能
EnCi Zheng1 小时前
AI Agent工具误调用怎么解决-从参数校验到高危二次确认的四层工程防线
大数据·数据库·人工智能
一级新生1 小时前
多智能体协作:常用协作模式详解
人工智能·状态模式·agent·多智能体·智能体
小灰灰搞电子1 小时前
Qt 中使用 QSettings 存储结构体数据详解
开发语言·qt
Bruce_Liuxiaowei1 小时前
AI 红队实践:用 garak 扫描本地 qwen3:8b 模型
人工智能·大模型安全
szxinmai主板定制专家1 小时前
基于 AM62x + Artix-7 的低功耗 ARM+FPGA 平台在轨道交通车门控制中的应用
arm开发·人工智能·fpga开发·zynq·am62x
愤怒的代码1 小时前
KV Cache 压缩技术:把大模型的“记忆”塞进一个小盒子
人工智能·agent