本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段二 Go 调用大模型 · 第 10 课(17)
上一篇:重试、指数退避、限流与成本控制
经过前九课,Go 客户端已经具备请求、解析、密钥管理、错误处理和可靠性控制。但如果业务代码到处出现 DeepSeek URL、请求结构体和供应商错误,换模型就会变成全项目改造。
这一课引入模型适配层:业务层依赖统一接口,供应商细节留在适配器中。它也为后续使用 Eino 的模型抽象打下基础。
一、本课学习目标
学完本课,你应该能够:
- 解释业务层为什么不应直接绑定供应商。
- 使用 Go
interface定义模型调用契约。 - 实现 DeepSeek 适配器。
- 在程序组装层注入具体实现。
- 使用模拟模型测试业务逻辑。
- 判断什么时候需要抽象,什么时候保持简单。
二、直接调用的耦合问题
假设业务函数内部直接构造 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 调用大模型的核心工程链路:
- 理解 HTTP 请求和响应。
- 用
net/http完成真实调用。 - 安全管理 API Key。
- 设计 JSON 边界与业务校验。
- 分类错误并记录脱敏日志。
- 读取 SSE 流式输出。
- 管理多轮对话历史。
- 传播 Context 超时与取消。
- 实现有限重试、退避和成本控制。
- 用接口隔离业务逻辑与模型供应商。
下一阶段进入 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:从大模型到工程落地