跳至内容

M02 LLM 全平台接入

在 Agent 系统里,LLM 调用层是后续所有能力的基础。工具调用、记忆系统、RAG、多智能体协作表面上各不相同,但最终都会回到同一个问题:如何稳定、可控地调用模型。

M01 已经准备好了 HTTP 客户端、context 生命周期控制和最小 Provider 接口。本章将在 M01 的基础上,搭出一层可扩展、可复用的模型接入抽象:对外提供统一接口,内部自行兼容各厂商的协议差异。

本章主线是模型调用层,不讨论提示词怎么写。消息组织、提示词模板、结构化输出策略和 Prompt Caching 会在 M03 展开;工具调用协议会在 M06 展开。

学习目标

学完本章,你应该能够:

  1. 定义统一的 Provider 接口与请求、响应结构;
  2. 实现一个基于 M01 transport 的 OpenAI 风格客户端;
  3. 用兼容层复用一套实现接入 DeepSeek、豆包、Kimi、Qwen、GLM 等平台;
  4. 用适配器接入 Claude 这类非 OpenAI 兼容平台;
  5. 处理流式输出,自动生成 JSON Schema,并计量 token 与成本;
  6. 用 Provider 工厂和路由网关把多个模型组织成可降级的系统能力。

前置要求是完成 M01,并准备至少一个可用的 OpenAI 兼容 API Key。配套练习是 llmrouter:一个命令行工具,可以配置多个 Provider,自动故障转移,并在回答后打印“由谁回答、消耗 token、估算成本”。

本章代码建立在 M01 的 transport 之上。为聚焦设计,部分重复的请求和解析样板会用注释省略;完整可运行版本需要在配套练习中补齐。

一、LLM API 协议家族

在开始编写具体代码之前,先了解一下目前市面上主流厂商的 LLM API。OpenAI、Anthropic、Gemini 三家协议在端点结构、消息格式、流式事件和工具调用上都有根本差异。理解了这些协议层的差异,后面对接 OpenAI 兼容平台、Claude、Ollama 时,才知道哪些地方应该复用,哪些地方必须适配。

为什么需要统一接口

很多项目第一次接 LLM 时,会直接写一段 HTTP 请求,拿到 JSON 后解析。只有一个 Provider 时,这样做没有问题;一旦接入第二家、第三家,代码很快会失控:

  • OpenAI、豆包、Qwen 的路径与模型名各不相同;
  • Claude 的请求结构与 OpenAI 风格不同;
  • Gemini 的 contentspartssystemInstruction 又是另一套表达;
  • 有的场景要同步返回,有的要流式输出;
  • 某家限流或超时时,需要自动切到别家。

没有统一抽象,业务层会塞满 if provider == ... 的分支。LLM 接入层要先把差异收敛到 Provider 内部,让上层只依赖“能不能 Chat、能不能流式、支持哪些能力”。

主流协议对比

截至 2026-06-02,LLM API 主要可以分成三类协议家族:OpenAI 兼容协议、Anthropic Messages 协议、Google Gemini 协议。下面按端点、消息、流式和工具四个维度看差异。

端点结构

维度OpenAI 兼容AnthropicGoogle Gemini
Base URLhttps://api.openai.com/v1https://api.anthropic.comhttps://generativelanguage.googleapis.com/v1beta
聊天端点/chat/completions/v1/messages/models/{model}:generateContent
流式端点同上,设置 stream: true同上,设置 stream: true/models/{model}:streamGenerateContent
鉴权Authorization: Bearer {key}x-api-key: {key} + anthropic-versionx-goog-api-key 或 OAuth2
Embedding/embeddings无独立 embedding 端点/models/{model}:embedContent

仅鉴权方式就有三种写法。OpenAI 兼容协议使用 Bearer token,Anthropic 使用自定义 header 和 API 版本号,Gemini REST 示例常用 x-goog-api-key

消息格式

请求体里消息历史的结构差异最大。

// OpenAI:messages 数组,system 是一种 role
{
  "model": "gpt-5",
  "messages": [
    {"role": "system", "content": "你是助手"},
    {"role": "user", "content": "你好"}
  ]
}

// Anthropic:system 是顶层字段,messages 只有 user/assistant
{
  "model": "claude-sonnet-4-20250514",
  "system": "你是助手",
  "messages": [
    {"role": "user", "content": "你好"}
  ],
  "max_tokens": 1024
}

// Gemini:contents 数组,role 使用 "model",systemInstruction 单独配置
{
  "contents": [
    {"role": "user", "parts": [{"text": "你好"}]}
  ],
  "systemInstruction": {
    "parts": [{"text": "你是助手"}]
  }
}

几个必须关注的差异点:

  1. system 的位置不同。OpenAI 把 system 作为 messages 中的一项;Anthropic 使用顶层 system 字段;Gemini 使用 systemInstruction
  2. role 命名不同。OpenAI 和 Anthropic 使用 assistant;Gemini 使用 model 表示模型回复。
  3. content 形态不同。OpenAI 常见是 string,也支持多模态数组;Anthropic 使用 string 或 content blocks;Gemini 使用 parts 数组。
  4. max_tokens 要求不同。Anthropic Messages API 要求请求中带 max_tokens;OpenAI 兼容协议通常可选。

流式约定

三家都可以走 SSE,但事件结构不同。

OpenAI:
data: {"choices":[{"delta":{"content":"H"}}]}
data: {"choices":[{"delta":{"content":"i"}}]}
data: [DONE]

Anthropic:
event: message_start
data: {"type":"message_start", ...}
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"H"}}
event: message_stop
data: {"type":"message_stop"}

Gemini:
data: {"candidates":[{"content":{"parts":[{"text":"H"}]}}]}
data: {"candidates":[{"content":{"parts":[{"text":"i"}]}}]}

每家的事件名、字段名和结束标记都不同。M01 讲的是如何用 channel 表达流;本章会补真实 SSE 字节流的解析。

工具调用

工具调用在 M06 会完整展开。这里先记住结论:

  • OpenAI 使用 tools[].functiontool_calls[]
  • Anthropic 使用 content block 中的 tool_use / tool_result
  • Gemini 使用 function_declarationsfunctionCall

因此工具调用协议也不能直接暴露给业务层。

综合对比

维度OpenAIAnthropicGemini
设计风格平铺、history-drivencontent blocks 统一抽象Proto 风格、强类型
system 处理role=system顶层 system 字段systemInstruction
role 命名user / assistantuser / assistantuser / model
contentstring 或多模态数组string 或 content blocksparts 数组
流式结束data: [DONE]message_stop 事件通常随 HTTP 流结束
工具协议tools[].functiontools[].input_schemafunction_declarations
多模态承载content 中的 image_urlcontent block 中的 imageparts 中的 inline_data
鉴权Bearer tokenx-api-key + versionAPI key header 或 OAuth
Embedding/embeddings不提供embedContent

OpenAI 兼容协议

国内外很多模型服务都提供 OpenAI 兼容接口。原因很直接:围绕 OpenAI SDK 和 Chat Completions 协议已经形成了大量工具、框架和集成方式。一个平台只要兼容 /chat/completions 的核心请求和响应结构,用户通常只需要改 base_url、API Key 和模型名就能接入。

这带来很大的工程红利:同一协议族的平台,可以复用一套基础实现,只把 baseURLapiKey 和默认模型名作为配置项。

下面是常见 OpenAI 兼容平台的地址示例。厂商 API 地址会变化,实际项目以官方文档为准。

厂商OpenAI 兼容 baseURL
OpenAIhttps://api.openai.com/v1
DeepSeekhttps://api.deepseek.com
豆包(火山方舟)https://ark.cn-beijing.volces.com/api/v3
Qwen(DashScope 兼容模式)https://dashscope.aliyuncs.com/compatible-mode/v1
Kimi(月之暗面)https://api.moonshot.cn/v1
GLM(智谱)https://open.bigmodel.cn/api/paas/v4
OpenAI 兼容通常指核心 Chat Completions 协议兼容,不代表扩展能力完全一致。工具调用细节、流式特殊字段、reasoning 内容、多模态格式、错误体和 usage 字段都可能有差异。

Provider 接口

三家协议差异这么大,业务代码不能直接绑定某家 JSON 结构。M01 已经起了一个最小 Provider 接口,本章将继续把它完善:上层只关心模型调用能力,具体协议由各 Provider 实现。

package llm

// Provider 是所有大模型供应商的统一抽象。上层只关心能否调用和能否流式,
// 不关心某家把 system 放哪、请求头怎么拼。
type Provider interface {
	Name() string
	Capabilities() Capability // 声明支持流式、思考、工具,路由时使用
	Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
	ChatStream(ctx context.Context, req ChatRequest) (<-chan StreamChunk, error)
}

// Capability 描述一个 Provider 的能力。
type Capability struct {
	Streaming bool
	Thinking  bool
	Tools     bool
}

Provider 接入架构

这里有两个取舍。

第一,ChatChatStream 一开始都放进接口。终端工具、网页聊天、SSE 转发都需要流式,流式不是可有可无的附加能力。

第二,接口统一的是能力,不是协议细节。工具调用、多模态等能力等真正用到的模块再扩展,不在 M02 一次性做大接口。

为什么不直接用 OpenAI 官方 Go SDK

如果大多数平台都 OpenAI 兼容,为什么不直接使用 OpenAI SDK?

第一,Claude、Gemini、Ollama 等平台并不都能完全用 OpenAI SDK 覆盖。业务层仍然会出现分支。

第二,SDK 调用方式会把具体协议和 SDK 类型暴露给业务代码。以后换 SDK、换协议或接入异构平台时,影响范围会扩大。

第三,项目需要自己的横切能力:M01 的生产级 HTTP 客户端、统一重试和限流、多 Provider 路由、成本统计、观测埋点。这些能力更适合收敛在自己的 Provider 层。

因此,本课程自己写一层薄抽象。Provider 内部可以复用 SDK 或手写 HTTP,但业务层只依赖 llm.Provider

二、统一请求与响应结构

为了让多个 Provider 复用同一套上层逻辑,需要一套项目内部统一的请求和响应结构。它不必和某家协议完全一致,只要能表达上层真正需要的字段即可。

package llm

type Role string

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

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

type ChatRequest struct {
	Model       string    `json:"model"`
	Messages    []Message `json:"messages"`
	Temperature float64   `json:"temperature,omitempty"`
	MaxTokens   int       `json:"max_tokens,omitempty"`
	Stream      bool      `json:"stream,omitempty"`
}

type ChatResponse struct {
	Content      string
	InputTokens  int
	OutputTokens int
}

接入不同平台时,再把这套结构复用或转换成各家真正的请求体。OpenAI 兼容协议和它基本同形,可以直接序列化;Anthropic 和 Gemini 则需要适配器。

请求构造使用函数式选项,避免字段一多就难用。

func NewChatRequest(model string, messages []Message, opts ...Option) ChatRequest {
	req := ChatRequest{Model: model, Messages: messages}
	for _, opt := range opts {
		opt(&req)
	}
	return req
}

type Option func(*ChatRequest)

func WithTemperature(t float64) Option {
	return func(r *ChatRequest) { r.Temperature = t }
}

func WithMaxTokens(n int) Option {
	return func(r *ChatRequest) { r.MaxTokens = n }
}

这部分只负责“消息发出去、结果收回来”。如何把消息写好,如何组织 few-shot、如何约束结构化输出,是 M03 的内容。

三、OpenAI 风格客户端

OpenAI 风格接口是事实标准,适合作为第一版统一实现。客户端本身保持很薄,连接池、超时、重试和退避由 M01 的 transport.Client 负责。

package openai

type Provider struct {
	name    string
	baseURL string
	apiKey  string
	client  *transport.Client // M01 的生产级客户端
}

// Config 收拢 Provider 构造参数。
// 三个字段都是 string,如果用位置参数容易把 baseURL 和 apiKey 写反。
type Config struct {
	Name    string // Provider 标识,如 "deepseek"
	BaseURL string // 形如 https://api.deepseek.com
	APIKey  string
}

// New 创建一个 OpenAI 兼容 Provider。
func New(cfg Config) *Provider {
	return &Provider{
		name:    cfg.Name,
		baseURL: cfg.BaseURL,
		apiKey:  cfg.APIKey,
		client:  transport.NewClient(),
	}
}

func (p *Provider) Name() string { return p.name }

func (p *Provider) Capabilities() llm.Capability {
	return llm.Capability{Streaming: true, Tools: true}
}

func (p *Provider) Chat(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
	body, _ := json.Marshal(req) // M06 接工具时会进一步细化请求体
	httpReq, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		p.baseURL+"/chat/completions",
		bytes.NewReader(body),
	)
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)

	resp, err := p.client.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("%s: %s", p.name, parseAPIError(resp))
	}

	var out struct {
		Choices []struct {
			Message struct {
				Content string `json:"content"`
			} `json:"message"`
		} `json:"choices"`
		Usage struct {
			PromptTokens     int `json:"prompt_tokens"`
			CompletionTokens int `json:"completion_tokens"`
		} `json:"usage"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	if len(out.Choices) == 0 {
		return nil, fmt.Errorf("%s: 空响应", p.name)
	}
	return &llm.ChatResponse{
		Content:      out.Choices[0].Message.Content,
		InputTokens:  out.Usage.PromptTokens,
		OutputTokens: out.Usage.CompletionTokens,
	}, nil
}

这段代码有三个值得提一下的好习惯。

第一,baseURL 不写死。接入兼容平台时只换配置,不需要复制客户端。

第二,错误处理要尽量完整。网络错误、Key 配错、上下文过长、参数非法、429 限流、5xx 上游异常都需要在错误信息中体现。示例里的 parseAPIError 可以按各家错误体进一步实现。

第三,用 httptest 测客户端。M01 已经演示过“先 429 再成功”的测试方式,本章可以继续扩展成功响应、非法 JSON、空 choices、401/400 错误体等用例。

流式 ChatStream 放到第七节统一处理。

四、兼容层复用

接入第二家厂商时,不应该复制一份新客户端。国内主流模型服务大多提供 OpenAI 兼容接口,接入核心不是重写请求逻辑,而是复用上面的 openai.Provider,只预置不同的 baseURLapiKey 和名称。

package openai

// 复用第三节的 Provider,只是预置各家的 baseURL。

func NewOpenAI(apiKey string) llm.Provider {
	return New(Config{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: apiKey})
}

// NewDeepSeek DeepSeek Provider
func NewDeepSeek(apiKey string) llm.Provider {
	return New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", APIKey: apiKey})
}

// NewDoubao 豆包 Provider
func NewDoubao(apiKey string) llm.Provider {
	return New(Config{Name: "doubao", BaseURL: "https://ark.cn-beijing.volces.com/api/v3", APIKey: apiKey})
}

// NewQwen 千问 Provider
func NewQwen(apiKey string) llm.Provider {
	return New(Config{Name: "qwen", BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", APIKey: apiKey})
}

// Kimi、GLM 同理:换 baseURL 即可。具体模型名按次通过 ChatRequest.Model 传入。

如果某个平台不返回 usage、错误体格式不同、流式字段有扩展,可以在小补丁里吸收。主路径仍然复用 OpenAI 兼容实现,不要每接一家复制一套客户端。

五、Claude 适配器

不是所有平台都适合复用 OpenAI 兼容层。Claude 使用 Anthropic Messages API,和 OpenAI 风格有几处硬差异:

  • system 是顶层字段,不放进 messages
  • max_tokens 必填;
  • 鉴权使用 x-api-keyanthropic-version
  • 响应 content 是内容块数组,可能包含 text 块、thinking 块或工具相关块。

更好的做法是保留统一 ChatRequest,在 Claude Provider 内部完成协议转换。

package claude

import "fmt" // 教学骨架 Chat 占位用,真实实现还会引入 context、encoding/json 等

func New(apiKey string) *Provider {
	/* baseURL=https://api.anthropic.com/v1,复用 transport */
}

func (p *Provider) Name() string { return "claude" }

func (p *Provider) Capabilities() llm.Capability {
	return llm.Capability{Streaming: true, Thinking: true, Tools: true}
}

// adaptRequest 把统一的 ChatRequest 翻译成 Anthropic 请求体。
func (p *Provider) adaptRequest(req llm.ChatRequest) map[string]any {
	var system string
	msgs := make([]map[string]string, 0, len(req.Messages))
	for _, m := range req.Messages {
		if m.Role == llm.RoleSystem {
			system += m.Content
			continue
		}
		msgs = append(msgs, map[string]string{
			"role":    string(m.Role),
			"content": m.Content,
		})
	}
	maxTokens := req.MaxTokens
	if maxTokens == 0 {
		maxTokens = 1024 // Anthropic 要求 max_tokens 必填
	}
	body := map[string]any{
		"model":      req.Model,
		"messages":   msgs,
		"max_tokens": maxTokens,
	}
	if system != "" {
		body["system"] = system
	}
	return body
}

func (p *Provider) Chat(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
	// 教学骨架,完整实现见配套练习。
	// 真实写法:
	//   1) body := adaptRequest(req)
	//   2) 带 x-api-key / anthropic-version 头通过 p.client.Do 发请求
	//   3) 解析 content 块数组,拼接所有 type=="text" 的块
	//   4) 用 usage.input_tokens / output_tokens 填 ChatResponse
	return nil, fmt.Errorf("anthropic.Provider.Chat: 教学骨架,完整实现见配套练习")
}

这段代码的重点是 adaptRequest。上层业务不知道 Claude 把 system 放在哪里,也不知道响应是 content blocks,只需要调用统一的 Chat

用一句话来区分:兼容层主要用来复用实现,适配器主要用来转换协议。

Claude 还有两个特性值得提前知道:

  • Prompt Caching 可以让稳定前缀命中缓存,M03 会讲如何组织提示词顺序;
  • Extended Thinking 会让模型先产出 thinking block,再给答案,适合困难任务但更慢更贵。

六、Ollama 本地模型

Ollama 可以在本机跑开源模型,适合离线开发、隐私场景和压测省钱。它提供 OpenAI 兼容端点,因此可以直接复用 openai.Provider

local := openai.New(openai.Config{
	Name:    "ollama",
	BaseURL: "http://localhost:11434/v1",
	APIKey:  "ollama", // 本地不校验 key,随便填
})

// 先执行:ollama pull qwen3
// ChatRequest.Model 填 "qwen3" 即可。

Ollama 的价值不在于替代云端大模型,而在于给开发和兜底提供一个成本可控的环境。真实产品仍要根据任务复杂度、响应质量、延迟和合规要求选择模型。

七、流式输出

聊天窗口、终端工具和实时响应都需要流式。OpenAI 兼容接口在 stream=true 时返回 SSE,一行行 data: {...},以空行分隔事件,OpenAI 风格通常用 data: [DONE] 收尾。

解析流式响应的流程要分成两层:

  • 第一层只负责读 SSE 线协议,按条拿到 data
  • 第二层由各 Provider 提供事件解析器,OpenAI 风格平台共用一套,Claude 用自己的解析器。

SSE 两层解析

先看典型的 SSE 数据。

data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

第一层是读取数据,放在 internal/transport

package transport

import (
	"bufio"
	"bytes"
	"io"
)

// ParseSSE 逐条读取 SSE 的 data 行,对每条非 [DONE] 数据回调 onData。
// onData 返回非 nil error 时提前终止;正常读到 [DONE] 或 EOF 即结束。
func ParseSSE(r io.Reader, onData func(data []byte) error) error {
	scanner := bufio.NewScanner(r)
	// 单条 SSE 数据可能很长,调大缓冲区上限。
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

	for scanner.Scan() {
		line := scanner.Bytes()
		if len(bytes.TrimSpace(line)) == 0 {
			continue // 事件分隔空行
		}
		if !bytes.HasPrefix(line, []byte("data:")) {
			continue // 忽略 event:/id:/注释行
		}
		data := bytes.TrimSpace(line[len("data:"):])
		if string(data) == "[DONE]" {
			return nil
		}
		buf := make([]byte, len(data)) // scanner 复用底层数组,跨回调留存必须 copy
		copy(buf, data)
		if err := onData(buf); err != nil {
			return err
		}
	}
	return scanner.Err()
}

这里有两个常见的坑。

  • 第一,bufio.Scanner 默认 token 上限较小,SSE 中的 JSON 数据可能更长,需要调大缓冲区;
  • 第二,scanner.Bytes() 返回的切片会被下一次扫描覆盖,跨回调留存时必须 copy。

第二层是 OpenAI 事件解析器。它只声明关心的字段,其余字段忽略。

func parseOpenAIDelta(data []byte) string {
	var chunk struct {
		Choices []struct {
			Delta struct {
				Content string `json:"content"`
			} `json:"delta"`
		} `json:"choices"`
	}
	if err := json.Unmarshal(data, &chunk); err != nil || len(chunk.Choices) == 0 {
		return ""
	}
	return chunk.Choices[0].Delta.Content
}

把两层结合起来,OpenAI 的 ChatStream 就把增量塞进 channel。这里不要忘了 M01 强调过的铁律:生产者执行 defer close(),并且发送时要监听 ctx.Done()

func (p *Provider) ChatStream(ctx context.Context, req llm.ChatRequest) (<-chan llm.StreamChunk, error) {
	req.Stream = true
	resp, err := p.doRequest(ctx, req) // 同 Chat 的发请求样板,略
	if err != nil {
		return nil, err
	}
	out := make(chan llm.StreamChunk)
	go func() {
		defer close(out)
		defer resp.Body.Close()
		_ = transport.ParseSSE(resp.Body, func(data []byte) error {
			delta := parseOpenAIDelta(data)
			if delta == "" {
				return nil
			}
			select {
			case <-ctx.Done():
				return ctx.Err()
			case out <- llm.StreamChunk{Content: delta}:
				return nil
			}
		})
	}()
	return out, nil
}

Claude 的 SSE 事件结构不同,文本增量藏在 content_block_delta 事件里。第一层 ParseSSE 可以复用,只需要换第二层解析器。

// parseClaudeDelta 只从文本增量块里抽出 delta.text;
// message_start / content_block_stop / message_stop 等事件返回空串。
func parseClaudeDelta(data []byte) string {
	var ev struct {
		Type  string `json:"type"`
		Delta struct {
			Type string `json:"type"`
			Text string `json:"text"`
		} `json:"delta"`
	}
	if err := json.Unmarshal(data, &ev); err != nil {
		return ""
	}
	if ev.Type == "content_block_delta" && ev.Delta.Type == "text_delta" {
		return ev.Delta.Text
	}
	return ""
}

这样,Claude 的 ChatStream 骨架和 OpenAI 版本基本一致:请求体换成 adaptRequest,解析函数换成 parseClaudeDelta。读流层共享,Provider 只负责解析自己的事件结构。

八、JSON Schema 自动生成

在真实业务中的很多场景下(意图识别、任务规划、路由决策、工具调用参数),我们并不希望模型返回自然语言结果,而是希望拿到更稳定的结构化结果,通常是约定一个 JSON 格式的返回结构。手写 JSON Schema 冗长,也容易和 Go 结构体不同步。更好的做法是用反射从结构体自动生成。

package schema

type Schema struct {
	Type        string             `json:"type,omitempty"`
	Description string             `json:"description,omitempty"`
	Properties  map[string]*Schema `json:"properties,omitempty"`
	Items       *Schema            `json:"items,omitempty"`
	Required    []string           `json:"required,omitempty"`
}

// Generate 为任意值的类型生成 JSON Schema。
// 实现是一段 reflect 递归:遍历字段、按 kind 映射、读取 json/desc 标签。
func Generate(v any) *Schema {
	/* reflect 递归:string→"string",struct→"object" ... */
	return nil
}

用法是先定义参数结构体,再生成 Schema。

type GetWeatherArgs struct {
	City string `json:"city" desc:"城市名"`
	Days int    `json:"days,omitempty" desc:"预报天数,默认 1"`
}

s := schema.Generate(GetWeatherArgs{}) // 交给模型的工具或结构化输出定义

拿到 Schema 后有两种方式:

  • 平台支持原生结构化输出时,优先用原生约束;
  • 平台不支持时,用提示词约束加解析重试兜底。

M06 的工具系统会把 Generate 接进工具注册流程。

九、Token 与成本计量

模型调用直接关系成本、延迟和上下文占用。Token 计量应该放在接入层统一处理,而不是让业务层各自估算。

package cost

type Pricing struct {
	InputPer1M  float64 // 每百万输入 token 单价
	OutputPer1M float64 // 每百万输出 token 单价
}

type Usage struct {
	InputTokens  int
	OutputTokens int
}

func (u Usage) Cost(p Pricing) float64 {
	return float64(u.InputTokens)/1e6*p.InputPer1M +
		float64(u.OutputTokens)/1e6*p.OutputPer1M
}

// Accumulator 并发安全地累计一轮会话的用量与成本。
type Accumulator struct {
	mu    sync.Mutex
	Usage Usage
	Cost  float64
}

func (a *Accumulator) Add(u Usage, p Pricing) {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.Usage.InputTokens += u.InputTokens
	a.Usage.OutputTokens += u.OutputTokens
	a.Cost += u.Cost(p)
}

真实计费应以 Provider 返回的 usage 为准。本地 token 估算只能作为提前预算和提示词裁剪的参考,因为不同模型的 tokenizer 并不完全一致。

上下文历史过长如何裁剪和压缩,会在 M03 和 M09 继续介绍。

十、多模型路由网关

前面介绍的各种能力最终会落到一个网关上:对上层只暴露一个 Chat,网关负责 Provider 组装、故障转移、按需分流和用量统计。

先用工厂函数按配置组装所有可用 Provider。Key 没配的 Provider 不启用,本地 Ollama 可以作为兜底。

func BuildAll(cfg Config) map[string]llm.Provider {
	ps := map[string]llm.Provider{}
	if cfg.DeepSeekKey != "" {
		ps["deepseek"] = openai.NewDeepSeek(cfg.DeepSeekKey)
	}
	if cfg.ArkKey != "" {
		ps["doubao"] = openai.NewDoubao(cfg.ArkKey)
	}
	if cfg.ClaudeKey != "" {
		ps["claude"] = claude.New(cfg.ClaudeKey)
	}
	ps["ollama"] = openai.New(openai.Config{
		Name:    "ollama",
		BaseURL: cfg.OllamaURL,
		APIKey:  "ollama",
	})
	return ps
}

再用一个轻量网关把它们串起来。Strategy 决定尝试顺序,Chat 按顺序尝试,失败后降级,并把“谁回答的”返回给上层。

package router

type Strategy interface {
	Order(providers []llm.Provider) []llm.Provider // 决定尝试顺序
}

type Priority struct{} // 最简策略:按注册顺序,主用第一个,挂了降级

func (Priority) Order(ps []llm.Provider) []llm.Provider { return ps }

type Router struct {
	providers []llm.Provider
	strategy  Strategy
}

func New(strategy Strategy, providers ...llm.Provider) *Router {
	return &Router{providers: providers, strategy: strategy}
}

func (r *Router) Chat(ctx context.Context, req llm.ChatRequest) (*llm.ChatResponse, string, error) {
	var lastErr error
	for _, p := range r.strategy.Order(r.providers) {
		resp, err := p.Chat(ctx, req)
		if err == nil {
			return resp, p.Name(), nil // 第二个返回值告诉上层“谁回答的”
		}
		lastErr = err
		if ctx.Err() != nil {
			return nil, "", ctx.Err() // 用户取消,不再尝试
		}
	}
	return nil, "", fmt.Errorf("所有 Provider 均失败: %w", lastErr)
}

多模型路由网关

Priority 是最简单的故障转移策略。按成本优先、按延迟优先、按能力标签选择,都可以通过替换 Strategy 实现。路由网关的价值不是把多个模型摆在一起,而是把它们组织成可治理、可扩展、可降级的系统能力。

配套练习:llmrouter

按下面顺序完成:

  1. 实现最小可用的 openai.Provider,支持 ChatChatStream
  2. 实现 claude.Provider,在内部完成协议适配,满足同一 Provider 接口;
  3. 用兼容层接入豆包或 DeepSeek,只换 baseURL、key、model;
  4. 实现 BuildAll 工厂和环境变量配置;
  5. 完成支持故障转移的 Router,回答后打印“由谁回答、token、估算成本”。

选做:

  • 实现 CheapestFirstLowestLatency 策略;
  • 统计 P50、P95 延迟;
  • parseOpenAIDelta 写表驱动测试。

本章小结

主题工程含义
Provider 接口上层只依赖能力,不碰协议细节
OpenAI 兼容层一套实现复用多个兼容平台
Claude 适配器把异构协议差异收在 Provider 内部
SSE 两层解析通用读流层复用,事件解析器按 Provider 替换
JSON Schema用结构体生成工具和结构化输出定义
Token 与成本在接入层统一统计用量与估算成本
路由网关把多个模型组织成可降级的系统能力

思考题

  1. 流式请求失败时,当前 Router 还没有实现 ChatStream 的重试和降级。如果模型吐到一半断流,你会整段重来,还是从断点续?这两种方案分别会带来什么问题?
  2. Prompt Caching 是前缀匹配。如果每次请求都在 system 最前面插一个变化的时间戳,缓存命中率会怎样?这对提示词顺序有什么启示?
  3. “路由 + 模型名”是耦合的:降级到 Claude 时需要使用 Claude 的模型名。你会如何设计,让上层只表达“给我一个便宜的中文模型”,而不关心具体供应商?

下一步

M03 会进入 Prompt 与上下文工程基础。M02 已经解决“如何把消息稳定发给模型并拿回结果”,下一章开始讨论“消息本身应该如何组织”,包括角色、提示词模板、上下文窗口、token 预算和 Prompt Caching。

参考资料

本章代码为教学片段,部分请求和解析样板按源讲义保留为“略”。落到项目代码后,请补齐完整实现,并运行 go build ./...go test ./...
最后更新于 • Q1mi