Week 1 Day 2:OpenAI SDK封装 - 苏格拉底教学

💡 一句话核心:不要直接把OpenAI SDK散落在handler里——用interface把"LLM调用"这件事抽象成你自己的领域概念,让生产代码既可替换、可测试,又能做好错误分类。

学习目标

  • 理解为什么要在第三方SDK之上再包一层
  • 设计生产级的 LLMClient interface
  • 实现 OpenAIClient / MockClient / CachingClient 三种形态
  • 学会对LLM调用做错误分类(rate limit / timeout / invalid / server)

第一部分:问题驱动

🤔 问题1:为什么要封装OpenAI SDK?直接用它不香吗?

引导问题:

  1. 明年你的老板说:"我们不用OpenAI了,换成Anthropic Claude",你要改多少行代码?
  2. 你要给 /chat 写单元测试——真的每次都发请求到OpenAI吗?要花多少钱?网络不通怎么办?
  3. OpenAI偶尔返回429(限流)你怎么处理?每个调用点都写一次重试?
  4. 你的上下游系统需要知道"是模型挂了"还是"你的请求格式错了",SDK的error能告诉你吗?

答案揭示:

  • 可替换性(Replaceability):封装后,切换底层provider只改一个实现
  • 可测性(Testability):给interface注入mock,handler测试无需联网
  • 错误分类(Error Classification):原始error是一坨字符串,上层要能精准判断重试策略
  • 依赖反转(Dependency Inversion):高层模块(Agent)依赖抽象(LLMClient),不依赖具体SDK

你应该理解:

  • 这不是过度设计,是生产系统的标配
  • 面试时被问到"你怎么保证系统可测",这就是标准答案

🤔 问题2:interface应该长什么样?

引导问题:

  1. 一次LLM调用需要传什么?(消息历史、温度、tools、max_tokens...)
  2. 一次LLM调用返回什么?(文本、tool_calls、token用量、finish reason...)
  3. 如果你只暴露 Chat(messages []Message) string,后面想加tool calling怎么办?
  4. 如果想支持stream输出呢?

答案揭示:

  • 结构体封装请求/响应(GenerateRequest/GenerateResponse),而不是一堆参数
  • 留足扩展字段:Tools、Temperature、MaxTokens、Stop...
  • 返回值必须包含元信息:Usage(计费)、FinishReason(诊断)

🤔 问题3:OpenAI错误有几种?你真的分得清吗?

引导问题:

  1. 429 Rate Limit:立即重试还是退避?
  2. 408/context deadline exceeded:重试可能成功还是浪费钱?
  3. 400 Invalid Request:重试有意义吗?
  4. 500/503 Server Error:该让用户看到还是自己吞掉重试?

答案揭示:

错误类型 典型原因 可重试? 策略
RateLimit 429 指数退避
Timeout ctx cancel / 网络 限次重试
InvalidRequest 400 / schema错 立即失败 + 报警
ServerError 5xx 少量重试
  • 重试逻辑要看错误类型,不能无脑重试
  • 错误类型 = 上层做决策的信号

第二部分:动手实现

✅ 版本1:interface和数据结构

// internal/llm/types.go
package llm

import "context"

// Role:对话角色
type Role string

const (
	RoleSystem    Role = "system"
	RoleUser      Role = "user"
	RoleAssistant Role = "assistant"
	RoleTool      Role = "tool"
)

// Message:一条对话消息
type Message struct {
	Role       Role       `json:"role"`
	Content    string     `json:"content"`
	ToolCalls  []ToolCall `json:"tool_calls,omitempty"`
	ToolCallID string     `json:"tool_call_id,omitempty"` // tool消息必填
	Name       string     `json:"name,omitempty"`
}

// ToolCall:模型发起的工具调用
type ToolCall struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	Arguments string `json:"arguments"` // JSON string
}

// ToolDefinition:给模型看的工具描述(简化版,Day 3会细化)
type ToolDefinition struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Parameters  map[string]any `json:"parameters"` // JSON Schema
}

// GenerateRequest:一次生成请求
type GenerateRequest struct {
	Model       string           `json:"model"`
	Messages    []Message        `json:"messages"`
	Tools       []ToolDefinition `json:"tools,omitempty"`
	Temperature float32          `json:"temperature"`
	MaxTokens   int              `json:"max_tokens,omitempty"`
	Stop        []string         `json:"stop,omitempty"`
}

// Usage:token消耗统计
type Usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	TotalTokens      int `json:"total_tokens"`
}

// FinishReason:为什么停止生成
type FinishReason string

const (
	FinishStop      FinishReason = "stop"
	FinishLength    FinishReason = "length"
	FinishToolCalls FinishReason = "tool_calls"
	FinishError     FinishReason = "error"
)

// GenerateResponse:一次生成结果
type GenerateResponse struct {
	Content      string       `json:"content"`
	ToolCalls    []ToolCall   `json:"tool_calls,omitempty"`
	Usage        Usage        `json:"usage"`
	FinishReason FinishReason `json:"finish_reason"`
	Model        string       `json:"model"`
}

// LLMClient:核心抽象
type LLMClient interface {
	Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error)
}

反思题:

  • 为什么 Generate 的第一个参数一定要是 context.Context?(超时、取消、trace_id)
  • 为什么用指针 *GenerateRequest 而不是值?(避免大结构体拷贝 + 允许未来扩展中间件修改)

✅ 版本2:错误类型

// internal/llm/errors.go
package llm

import (
	"errors"
	"fmt"
)

// ErrorType:LLM错误分类
type ErrorType string

const (
	ErrorTypeRateLimit      ErrorType = "rate_limit"
	ErrorTypeTimeout        ErrorType = "timeout"
	ErrorTypeInvalidRequest ErrorType = "invalid_request"
	ErrorTypeServerError    ErrorType = "server_error"
	ErrorTypeUnknown        ErrorType = "unknown"
)

// LLMError:带分类的错误
type LLMError struct {
	Type       ErrorType
	Message    string
	StatusCode int
	Retryable  bool
	Err        error // 原始error
}

func (e *LLMError) Error() string {
	return fmt.Sprintf("llm error [%s]: %s (status=%d)", e.Type, e.Message, e.StatusCode)
}

func (e *LLMError) Unwrap() error { return e.Err }

// 判断是否可重试
func IsRetryable(err error) bool {
	var le *LLMError
	if errors.As(err, &le) {
		return le.Retryable
	}
	return false
}

// 构造函数(在OpenAIClient内部用)
func newRateLimitError(err error) *LLMError {
	return &LLMError{Type: ErrorTypeRateLimit, Message: err.Error(), StatusCode: 429, Retryable: true, Err: err}
}

func newTimeoutError(err error) *LLMError {
	return &LLMError{Type: ErrorTypeTimeout, Message: err.Error(), Retryable: true, Err: err}
}

func newInvalidRequestError(err error) *LLMError {
	return &LLMError{Type: ErrorTypeInvalidRequest, Message: err.Error(), StatusCode: 400, Retryable: false, Err: err}
}

func newServerError(err error, status int) *LLMError {
	return &LLMError{Type: ErrorTypeServerError, Message: err.Error(), StatusCode: status, Retryable: true, Err: err}
}

关键点:

  • Retryable 字段让上层无脑判断要不要重试
  • 实现了 Unwrap(),可以用标准库的 errors.Is/As

✅ 版本3:OpenAIClient真实实现

// internal/llm/openai.go
package llm

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"time"

	openai "github.com/sashabaranov/go-openai"
)

type OpenAIClient struct {
	client       *openai.Client
	defaultModel string
	timeout      time.Duration
}

type OpenAIConfig struct {
	APIKey       string
	BaseURL      string // 支持OpenAI兼容的endpoint
	DefaultModel string
	Timeout      time.Duration
}

func NewOpenAIClient(cfg OpenAIConfig) *OpenAIClient {
	c := openai.DefaultConfig(cfg.APIKey)
	if cfg.BaseURL != "" {
		c.BaseURL = cfg.BaseURL
	}
	if cfg.Timeout == 0 {
		cfg.Timeout = 30 * time.Second
	}
	if cfg.DefaultModel == "" {
		cfg.DefaultModel = openai.GPT4oMini
	}
	return &OpenAIClient{
		client:       openai.NewClientWithConfig(c),
		defaultModel: cfg.DefaultModel,
		timeout:      cfg.Timeout,
	}
}

func (o *OpenAIClient) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {
	// 1. 给ctx加超时(如果外层没设置)
	ctx, cancel := context.WithTimeout(ctx, o.timeout)
	defer cancel()

	// 2. 转换成OpenAI SDK的请求格式
	model := req.Model
	if model == "" {
		model = o.defaultModel
	}

	oaiMessages := make([]openai.ChatCompletionMessage, 0, len(req.Messages))
	for _, m := range req.Messages {
		msg := openai.ChatCompletionMessage{
			Role:       string(m.Role),
			Content:    m.Content,
			Name:       m.Name,
			ToolCallID: m.ToolCallID,
		}
		for _, tc := range m.ToolCalls {
			msg.ToolCalls = append(msg.ToolCalls, openai.ToolCall{
				ID:       tc.ID,
				Type:     openai.ToolTypeFunction,
				Function: openai.FunctionCall{Name: tc.Name, Arguments: tc.Arguments},
			})
		}
		oaiMessages = append(oaiMessages, msg)
	}

	var oaiTools []openai.Tool
	for _, t := range req.Tools {
		oaiTools = append(oaiTools, openai.Tool{
			Type: openai.ToolTypeFunction,
			Function: &openai.FunctionDefinition{
				Name:        t.Name,
				Description: t.Description,
				Parameters:  t.Parameters,
			},
		})
	}

	chatReq := openai.ChatCompletionRequest{
		Model:       model,
		Messages:    oaiMessages,
		Tools:       oaiTools,
		Temperature: req.Temperature,
		MaxTokens:   req.MaxTokens,
		Stop:        req.Stop,
	}

	// 3. 调用并做错误分类
	resp, err := o.client.CreateChatCompletion(ctx, chatReq)
	if err != nil {
		return nil, classifyOpenAIError(ctx, err)
	}

	if len(resp.Choices) == 0 {
		return nil, &LLMError{Type: ErrorTypeServerError, Message: "empty choices", Retryable: true}
	}

	choice := resp.Choices[0]
	out := &GenerateResponse{
		Content:      choice.Message.Content,
		FinishReason: mapFinishReason(string(choice.FinishReason)),
		Model:        resp.Model,
		Usage: Usage{
			PromptTokens:     resp.Usage.PromptTokens,
			CompletionTokens: resp.Usage.CompletionTokens,
			TotalTokens:      resp.Usage.TotalTokens,
		},
	}
	for _, tc := range choice.Message.ToolCalls {
		out.ToolCalls = append(out.ToolCalls, ToolCall{
			ID:        tc.ID,
			Name:      tc.Function.Name,
			Arguments: tc.Function.Arguments,
		})
	}
	return out, nil
}

// 错误分类:把SDK error映射到我们的ErrorType
func classifyOpenAIError(ctx context.Context, err error) error {
	// 优先判断context错误
	if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
		return newTimeoutError(err)
	}

	var apiErr *openai.APIError
	if errors.As(err, &apiErr) {
		switch apiErr.HTTPStatusCode {
		case 429:
			return newRateLimitError(err)
		case 400, 401, 403, 404:
			return newInvalidRequestError(err)
		case 408:
			return newTimeoutError(err)
		case 500, 502, 503, 504:
			return newServerError(err, apiErr.HTTPStatusCode)
		}
	}

	// 未知,保守起见认为可重试
	return &LLMError{Type: ErrorTypeUnknown, Message: err.Error(), Retryable: true, Err: err}
}

func mapFinishReason(r string) FinishReason {
	switch r {
	case "stop":
		return FinishStop
	case "length":
		return FinishLength
	case "tool_calls", "function_call":
		return FinishToolCalls
	default:
		return FinishError
	}
}

// DebugJSON:调试辅助
func (o *OpenAIClient) debugJSON(v any) string {
	b, _ := json.Marshal(v)
	return string(b)
}

var _ = fmt.Sprintf // prevent unused import

问题: 为什么要把 classifyOpenAIError 独立成一个函数? (答:纯函数易测;未来可以对它做单元测试,覆盖所有错误分支)


✅ 版本4:MockClient(测试用)

// internal/llm/mock.go
package llm

import (
	"context"
	"fmt"
	"sync"
)

// MockClient:可脚本化的假client
type MockClient struct {
	mu        sync.Mutex
	responses []MockResponse
	idx       int
	Calls     []*GenerateRequest // 记录所有调用,便于断言
}

type MockResponse struct {
	Resp *GenerateResponse
	Err  error
}

func NewMockClient(responses ...MockResponse) *MockClient {
	return &MockClient{responses: responses}
}

func (m *MockClient) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {
	m.mu.Lock()
	defer m.mu.Unlock()

	m.Calls = append(m.Calls, req)

	if m.idx >= len(m.responses) {
		return nil, fmt.Errorf("mock: no more responses (call #%d)", m.idx+1)
	}
	r := m.responses[m.idx]
	m.idx++
	return r.Resp, r.Err
}

// 便捷方法:让mock返回文本回复
func RespondText(s string) MockResponse {
	return MockResponse{Resp: &GenerateResponse{
		Content:      s,
		FinishReason: FinishStop,
		Usage:        Usage{TotalTokens: 10},
	}}
}

// 便捷方法:让mock返回一个tool_call
func RespondToolCall(name, args string) MockResponse {
	return MockResponse{Resp: &GenerateResponse{
		ToolCalls: []ToolCall{
			{ID: "call_mock", Name: name, Arguments: args},
		},
		FinishReason: FinishToolCalls,
	}}
}

使用示例(测试代码里):

client := llm.NewMockClient(
    llm.RespondToolCall("search_kb", `{"query":"sso reset"}`),
    llm.RespondText("根据KB,你需要访问SSO门户..."),
)
// 把client注入Agent,跑测试,断言 client.Calls 的内容

✅ 版本5:CachingClient(decorator模式)

// internal/llm/cache.go
package llm

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"sync"
	"time"
)

// CachingClient:包一层,命中相同请求直接返回
type CachingClient struct {
	inner LLMClient
	ttl   time.Duration
	mu    sync.RWMutex
	store map[string]cacheEntry
}

type cacheEntry struct {
	resp      *GenerateResponse
	expiresAt time.Time
}

func NewCachingClient(inner LLMClient, ttl time.Duration) *CachingClient {
	return &CachingClient{
		inner: inner,
		ttl:   ttl,
		store: make(map[string]cacheEntry),
	}
}

func (c *CachingClient) Generate(ctx context.Context, req *GenerateRequest) (*GenerateResponse, error) {
	// 有tool或temp>0时不缓存(非幂等)
	if len(req.Tools) > 0 || req.Temperature > 0 {
		return c.inner.Generate(ctx, req)
	}

	key := hashRequest(req)

	c.mu.RLock()
	if entry, ok := c.store[key]; ok && time.Now().Before(entry.expiresAt) {
		c.mu.RUnlock()
		return entry.resp, nil
	}
	c.mu.RUnlock()

	resp, err := c.inner.Generate(ctx, req)
	if err != nil {
		return nil, err
	}

	c.mu.Lock()
	c.store[key] = cacheEntry{resp: resp, expiresAt: time.Now().Add(c.ttl)}
	c.mu.Unlock()

	return resp, nil
}

func hashRequest(req *GenerateRequest) string {
	b, _ := json.Marshal(req)
	h := sha256.Sum256(b)
	return hex.EncodeToString(h[:])
}

反思:

  • 为什么有tools/temperature时不缓存?(非确定性,缓存会骗人)
  • 这个实现是装饰器(Decorator)模式:inner也是LLMClient,所以能无限嵌套
  • 生产上要用Redis而不是sync.Map

第三部分:关键概念

1. 依赖反转(DIP)

// 坏:Agent直接依赖SDK
type Agent struct {
    openai *openai.Client  // ❌
}

// 好:Agent依赖抽象
type Agent struct {
    llm llm.LLMClient  // ✅
}

测试时传MockClient,生产传OpenAIClient,换厂商时传ClaudeClient。

2. 装饰器链

UserHandler ↓ MetricsClient (统计QPS、latency) ↓ RetryClient (基于ErrorType重试) ↓ CachingClient (相同请求走缓存) ↓ OpenAIClient (真正发请求)

每层职责单一,可独立测试、独立替换。

3. Context传递

  • context.Context 是Go里传递超时/取消/trace的唯一正道
  • 永远是第一个参数
  • 不要存 ctx 到struct里(反模式)

4. 幂等性(Idempotency)

  • temperature=0 + 固定seed + 无tool → 幂等,可缓存
  • 否则不可缓存(除非你接受返回过时结果)

第四部分:自测清单

  • 我能说清楚封装SDK的4个理由(可替换/可测/错误分类/依赖反转)
  • 我能画出 GenerateRequestGenerateResponse 的所有字段
  • 我知道OpenAI的429/400/500分别对应哪个 ErrorType,哪个 Retryable=true
  • 我能说清 MockClientCachingClient 分别解决什么问题
  • 我知道为什么tool_calls和temperature>0时不能缓存
  • 我能写一个单元测试,用MockClient验证Agent行为

第五部分:作业

任务1:完成三份实现

  • internal/llm/types.go:interface + 所有数据结构
  • internal/llm/errors.go:LLMError + IsRetryable
  • internal/llm/openai.go:真实OpenAI调用 + 错误分类
  • internal/llm/mock.go:MockClient + 便捷constructor
  • internal/llm/cache.go:CachingClient(内存版)

任务2:写单元测试

// internal/llm/openai_test.go
func TestClassifyOpenAIError_RateLimit(t *testing.T) {
    err := &openai.APIError{HTTPStatusCode: 429, Message: "rate limit"}
    classified := classifyOpenAIError(context.Background(), err)
    var le *LLMError
    require.ErrorAs(t, classified, &le)
    require.Equal(t, ErrorTypeRateLimit, le.Type)
    require.True(t, le.Retryable)
}

至少覆盖:RateLimit / Timeout / InvalidRequest / ServerError / Unknown 五种分支。

任务3:集成到Day1的 /chat

  • 把mock回答改成真实OpenAI调用
  • 用环境变量 OPENAI_API_KEY 读取key
  • 本地跑 curl -X POST localhost:8080/chat -d '{"message":"你好"}' 能收到真实回复

任务4:思考题

  • 如果要支持Claude,要新增哪些代码?改动哪些代码?(提示:理想情况下只新增一个 ClaudeClient
  • CachingClient用内存存,Pod重启就没了——生产怎么办?

第六部分:常见问题解答

Q1:为什么不直接用 openai.Client 本身作为interface? A:它的方法太多、参数耦合OpenAI特定概念(如 tool_choice: "auto")。自己定义interface让你有领域语言,而不是"OpenAI方言"。

Q2:错误分类除了重试,还有什么用? A:

  • 报警分级:InvalidRequest通常是代码bug,应该立刻报警;RateLimit是容量问题,自动缓解即可
  • 计费归因:超时不收费,非法请求不收费
  • 上游返回给用户的错误码:400 vs 503

Q3:CachingClient会不会有并发问题? A:用 sync.RWMutex 保护map即可。但要注意 cache stampede——同一个key短时间内多个请求同时miss,都打到上游。解决方案:singleflight(golang.org/x/sync/singleflight)。

Q4:为什么 GenerateRequest 不直接包含 SystemPrompt 字段? A:SystemPrompt本质是 Messages[0] with role=system。多加字段会让API语义重复。保持 Messages是唯一的真相来源

Q5:生产上timeout应该多长? A:依赖调用场景:

  • 交互式chat(同步):15-30s
  • 后台批处理:60-120s
  • 流式输出:每个chunk 5s,整体可以更长

超时必须从外部注入(通过 context.WithTimeout),不要写死在client里。


配套算法题

1. Longest Substring Without Repeating Characters(LeetCode 3)

题目: 给一个字符串,返回最长无重复字符子串的长度。

思路: 滑动窗口 + hashmap 记录字符最近出现位置。

// algo/longest_substring.go
package algo

func lengthOfLongestSubstring(s string) int {
	last := make(map[byte]int) // 字符 → 最近一次下标
	best, left := 0, 0
	for right := 0; right < len(s); right++ {
		c := s[right]
		if idx, ok := last[c]; ok && idx >= left {
			left = idx + 1
		}
		last[c] = right
		if right-left+1 > best {
			best = right - left + 1
		}
	}
	return best
}

复杂度: O(n) 时间,O(min(n, Σ)) 空间。

Agent上下文联想: 滑动窗口在chunk去重、token窗口裁剪里很常见。


2. Valid Parentheses(LeetCode 20)

题目: 判断 ()[]{} 括号字符串是否合法。

思路: 栈。

// algo/valid_parentheses.go
package algo

func isValid(s string) bool {
	pair := map[byte]byte{')': '(', ']': '[', '}': '{'}
	stack := make([]byte, 0, len(s))
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch c {
		case '(', '[', '{':
			stack = append(stack, c)
		case ')', ']', '}':
			if len(stack) == 0 || stack[len(stack)-1] != pair[c] {
				return false
			}
			stack = stack[:len(stack)-1]
		}
	}
	return len(stack) == 0
}

复杂度: O(n) / O(n)。

Agent上下文联想: 解析LLM输出的JSON arguments时,配套理解token/bracket平衡能帮你做fail-fast检测。


3. Binary Search(LeetCode 704)

题目: 有序数组中查找target,返回下标。

思路: 标准二分,注意边界。

// algo/binary_search.go
package algo

func search(nums []int, target int) int {
	lo, hi := 0, len(nums)-1
	for lo <= hi {
		mid := lo + (hi-lo)/2 // 防溢出
		switch {
		case nums[mid] == target:
			return mid
		case nums[mid] < target:
			lo = mid + 1
		default:
			hi = mid - 1
		}
	}
	return -1
}

复杂度: O(log n) / O(1)。

Agent上下文联想: Embedding检索后的top-k排序、时间戳日志定位、cost预算二分逼近都会用到。


下一步:Day 3 预告

明天我们会:

  1. 设计 ToolDefinition 完整结构(Name/Description/InputSchema/RiskLevel/Timeout/Execute)
  2. 实现 Tool Registry(Register/Get/Execute/List)
  3. 把registry里的tool 自动转换成OpenAI function calling schema
  4. 写3个mock tool:search_kb / create_ticket / get_user_info
  5. 处理工具执行的 timeout 和 cancellation

准备问题:

  • 如果LLM幻觉调用了一个不存在的tool名,怎么办?
  • 一个tool执行慢/卡死,怎么保证不拖垮整个agent?
  • 危险操作(如删除数据)该直接执行还是要人工审批?