练习 4:provider 抽象——同一份代码接两种协议

前三章你一直在说一种方言:OpenAI 协议。它是这个行业的通用语,但不是唯一的话—— Anthropic(Claude 背后的公司)有自己的一套,而且分歧不是字段改个名那么浅。

这一章你把练习 3 的 REPL 拆成两半:循环归循环,协议归协议。 然后接入第二种协议,REPL 一个字不改。你不需要 Anthropic 的 key—— 你一直在用的两家都会说它的话:DeepSeek 有 Anthropic 兼容端点(同一个 key), 本机 Ollama 也听得懂 /v1/messages

敲进去

新建目录 ex04go mod init ex04,新建 main.go。 这一章退回非流式——不是退步,是让两种协议的差别裸露出来, 不被流式解析的代码淹没(流式的归宿见加分练习 2):

// Learn Agent the Hard Way — 练习 4:provider 抽象
//
// 同一份 REPL,接两种协议。差别全部关进两个适配器里,
// 循环本身一个字不改——这就是抽象层的全部价值。
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

// message 是我们自己的中立形状——不属于任何一家协议。
type message struct {
	Role    string
	Content string
}

// provider 是本书第一个接口:给我系统提示词和历史,还我一句回复。
// 两种协议的全部差异,都消失在这个签名后面。
type provider interface {
	send(system string, history []message) (reply string, err error)
}

// ---- OpenAI 协议适配器(练习 1 的代码装进盒子)----

type openaiProvider struct {
	base, key, model string
}

func (p openaiProvider) send(system string, history []message) (string, error) {
	// OpenAI 协议里 system 是 messages 数组的第 0 条——塞回去。
	type apiMsg struct {
		Role    string `json:"role"`
		Content string `json:"content"`
	}
	msgs := []apiMsg{{Role: "system", Content: system}}
	for _, m := range history {
		msgs = append(msgs, apiMsg{Role: m.Role, Content: m.Content})
	}
	body, _ := json.Marshal(map[string]any{
		"model":      p.model,
		"max_tokens": 1024, // 可以不传,各家有默认值
		"messages":   msgs,
	})

	req, err := http.NewRequest("POST", p.base+"/chat/completions", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+p.key) // 认证:标准 Bearer

	raw, err := do(req)
	if err != nil {
		return "", err
	}

	var r struct {
		Choices []struct {
			Message      struct{ Content string }
			FinishReason string `json:"finish_reason"`
		}
		Usage struct {
			PromptTokens     int `json:"prompt_tokens"`
			CompletionTokens int `json:"completion_tokens"`
		}
	}
	if err := json.Unmarshal(raw, &r); err != nil {
		return "", fmt.Errorf("解析失败: %w", err)
	}
	if len(r.Choices) == 0 {
		return "", fmt.Errorf("空响应: %s", raw)
	}
	fmt.Fprintf(os.Stderr, "[输入 %d tokens · 输出 %d tokens · finish_reason=%s]\n",
		r.Usage.PromptTokens, r.Usage.CompletionTokens, r.Choices[0].FinishReason)
	return r.Choices[0].Message.Content, nil
}

// ---- Anthropic 协议适配器(对照组)----

type anthropicProvider struct {
	base, key, model string
}

func (p anthropicProvider) send(system string, history []message) (string, error) {
	type apiMsg struct {
		Role    string `json:"role"`
		Content string `json:"content"`
	}
	msgs := make([]apiMsg, 0, len(history))
	for _, m := range history {
		msgs = append(msgs, apiMsg{Role: m.Role, Content: m.Content})
	}
	body, _ := json.Marshal(map[string]any{
		"model":      p.model,
		"max_tokens": 1024,   // 这家必填(Ollama 不传直接拒绝)
		"system":     system, // system 不进 messages,是顶层字段
		"messages":   msgs,
	})

	req, err := http.NewRequest("POST", p.base+"/v1/messages", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("x-api-key", p.key)                // 认证:自家头,没有 Bearer
	req.Header.Set("anthropic-version", "2023-06-01") // 版本头,官方必带

	raw, err := do(req)
	if err != nil {
		return "", err
	}

	var r struct {
		Content []struct {
			Type string `json:"type"` // "text" | "thinking" | "tool_use"…
			Text string `json:"text"`
		}
		StopReason string `json:"stop_reason"`
		Usage      struct {
			InputTokens  int `json:"input_tokens"`
			OutputTokens int `json:"output_tokens"`
		}
	}
	if err := json.Unmarshal(raw, &r); err != nil {
		return "", fmt.Errorf("解析失败: %w", err)
	}
	// 回复不是一个字符串,是一个列表——每一项自带类型标签,
	// 正文只是其中一种(还有思考、工具调用……)。我们只挑正文。
	var reply strings.Builder
	for _, b := range r.Content {
		if b.Type == "text" {
			reply.WriteString(b.Text)
		}
	}
	fmt.Fprintf(os.Stderr, "[输入 %d tokens · 输出 %d tokens · stop_reason=%s]\n",
		r.Usage.InputTokens, r.Usage.OutputTokens, r.StopReason)
	return reply.String(), nil
}

// do 发出请求,非 200 时把响应体当错误返回。两个适配器共用。
func do(req *http.Request) ([]byte, error) {
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
	}
	return raw, nil
}

// ---- 同一个 REPL,练习 3 原样搬来(只是退回非流式)----

func main() {
	var p provider
	switch proto := os.Getenv("PROTOCOL"); proto {
	case "", "openai":
		base := os.Getenv("OPENAI_BASE_URL")
		if base == "" {
			base = "https://api.openai.com/v1"
		}
		p = openaiProvider{base: base, key: os.Getenv("OPENAI_API_KEY"), model: os.Getenv("MODEL")}
	case "anthropic":
		base := os.Getenv("ANTHROPIC_BASE_URL")
		if base == "" {
			base = "https://api.anthropic.com"
		}
		p = anthropicProvider{base: base, key: os.Getenv("ANTHROPIC_API_KEY"), model: os.Getenv("MODEL")}
	default:
		fmt.Fprintf(os.Stderr, "未知 PROTOCOL %q(要 openai 或 anthropic)\n", proto)
		os.Exit(1)
	}

	const system = "你是一个说话简洁的助手,回答不超过三句话。"
	var history []message

	fmt.Println(`输入你的话,回车发送;输入 exit 退出。`)
	stdin := bufio.NewScanner(os.Stdin)
	fmt.Print("> ")
	for stdin.Scan() {
		input := strings.TrimSpace(stdin.Text())
		if input == "" {
			fmt.Print("> ")
			continue
		}
		if input == "exit" {
			break
		}

		history = append(history, message{Role: "user", Content: input})
		reply, err := p.send(system, history)
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			history = history[:len(history)-1] // 失败弹回,练习 3 的纪律
			fmt.Print("> ")
			continue
		}
		fmt.Println(reply)
		history = append(history, message{Role: "assistant", Content: reply})
		fmt.Print("> ")
	}
}

先别问为什么。敲完,跑起来,我们再回头讲。

跑起来

先用老环境变量跑一遍主线,确认行为和练习 3 一致(PROTOCOL 不设就是 openai):

go build -o ex04 . && ./ex04

然后切到对照协议。DeepSeek 或本机 Ollama 任选,都不需要新 key

# DeepSeek 的 Anthropic 兼容端点——key 就是你手里那个
export PROTOCOL=anthropic
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
export ANTHROPIC_API_KEY=sk-你的DeepSeek-key
export MODEL=deepseek-v4-flash

# 或者本机 Ollama——它也会说这门话
export PROTOCOL=anthropic
export ANTHROPIC_BASE_URL=http://localhost:11434
export ANTHROPIC_API_KEY=ollama
export MODEL=qwen3:4b-instruct

./ex04

你应该看到什么

用 DeepSeek 走 Anthropic 协议,还是那个实验:

输入你的话,回车发送;输入 exit 退出。
> 我叫小明
你好,小明。很高兴认识你。请问有什么需要帮忙的吗?
[输入 97 tokens · 输出 118 tokens · stop_reason=end_turn]
> 我叫什么?
你叫小明。
[输入 118 tokens · 输出 87 tokens · stop_reason=end_turn]
> exit

REPL 的行为和练习 3 分毫不差:它记得,输入 token 照样逐轮上涨。 变的只有那行账单——finish_reason=stop 变成了 stop_reason=end_turn。 同一个模型、同一段对话、同一个循环,换了一门方言。

发生了什么

把两个适配器并排放,差异一共八行:

OpenAI 协议Anthropic 协议
路径POST …/chat/completionsPOST …/v1/messages
认证Authorization: Bearer sk-…x-api-key: sk-…
版本头anthropic-version: 2023-06-01,官方必带
systemmessages 数组第 0 条顶层字段,不进 messages
max_tokens可省略必填(Ollama 会直接拒绝)
回复choices[0].message.content,一个字符串content,一个列表,每项自带类型
说完了没finish_reason: "stop"stop_reason: "end_turn"
账单usage.prompt_tokens / completion_tokensusage.input_tokens / output_tokens

把表读一遍,你会发现没有一行是本质差异——全是同一件事的两种拼法。 模型读历史、说话、报账,中间那个东西一模一样。 所以 60 行就能写完一个适配器,所以你的 REPL 一个字不用改。

只有一行值得多看一眼:回复的形状。OpenAI 协议里,回复就是一个字符串。 Anthropic 协议里,回复是一个列表,每一项贴着类型标签、各装各的东西: text 项装正文,thinking 项装思考过程,将来还有 tool_use 项装工具调用 (协议里管这样的一项叫 content block,内容块——原始 JSON 里你会看到这个说法)。 用 DeepSeek 的 Anthropic 端点问"我叫什么",原始响应长这样(节选):

"content": [
  { "type": "thinking", "thinking": "我们需要理解用户的问题。用户先说\"我叫小明\"……" },
  { "type": "text", "text": "你叫小明。" }
],
"stop_reason": "end_turn",
"usage": { "input_tokens": 108, "output_tokens": 103,
           "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0 }

练习 3 里藏在 usage 犄角旮旯里的思考,在这个协议里是一等公民—— 直接躺在回复里给你看。这不是谁抄谁的问题,是两种设计哲学: OpenAI 协议把回复当字符串,扩展靠各家往 delta 里缝私有字段(练习 2 你见过 思考字段的两个名字);Anthropic 协议把回复当成带类型的列表,加新能力就是加一种新的列表项。 顺带注意 usage 里那两个 cache_* 字段——协议层面自带缓存账目,这是后话。

也解释一下这章为什么退回非流式:流式没有跨协议标准。 你在练习 2 啃下的 data: 行是 OpenAI 的流式方言;Anthropic 的流式 是另一套带类型的事件模型(加分练习 2 去看一眼)。所以生产 harness 的惯例是: 接口只强制要求"能发能收"(我们的 send),流式是每个适配器的可选技能—— 会的自己加,不会的退回缓冲。

最后收线。协议会换名字、会打补丁、会退场,你从练习 1 攒到现在的东西—— 维护历史的纪律、盯 token 的习惯、马上要写的工具循环——一样都不用动, 因为它们都活在 provider 接口的上面。适配器是 harness 的边境海关: 方言的混乱关在国境线上,境内只说一种话。 从练习 5 起我们回到主线 OpenAI 协议,Anthropic 只在需要对照时再出场。

常见问题

  • 404:Anthropic 协议的路径是代码拼的 base + "/v1/messages"—— base 别自带 /v1。DeepSeek 的兼容端点是 …/anthropic,Ollama 就是裸端口。
  • max_tokens is required and must be positive:Anthropic 协议它必填。 Ollama 严格执行;个别兼容端点宽容不报——别学它们,官方端点是要报错的。
  • 官方 Anthropic 端点 401/400x-api-keyanthropic-version 两个头都要在。 兼容端点大多不查版本头,官方查。
  • 走 Anthropic 协议时输出 token 更高:思考(thinking 项)也是输出, 公开且计费——同一个模型同一道题,账单差在"思考写不写进回复"。

加分练习

  1. 把 Anthropic 协议的原始响应完整打印出来,找到装着思考过程的那一项 ("type": "thinking")——再对比练习 3 加分练习 1 里 OpenAI 协议的 reasoning_content 字段。同一件事,一边写进了协议规范,一边是规范外的私货。

  2. 用 curl 给 Anthropic 端点发 "stream": true,看它的流长什么样:

    curl -N http://localhost:11434/v1/messages \
      -H "Content-Type: application/json" -H "x-api-key: ollama" \
      -H "anthropic-version: 2023-06-01" \
      -d '{"model":"qwen3:4b-instruct","max_tokens":50,"stream":true,"messages":[{"role":"user","content":"数到3"}]}'
    

    event: message_startevent: content_block_delta……每个事件带类型。 对比练习 2 的裸 data: 行——现在你亲眼确认了"流式没有跨协议标准"。

  3. 数一数 anthropicProvider 连类型带方法一共多少行。接入一整个新协议生态, 就这么多——下次有人跟你报"支持多模型"的工作量,你心里有数。

  4. provider 接口加第三个实现:一个 fakeProvider,不发网络请求, 固定返回"收到"。用它跑 REPL——你刚刚写出了第一个测试替身, 后面练习的测试全靠这个思路。