练习 4:provider 抽象——同一份代码接两种协议
前三章你一直在说一种方言:OpenAI 协议。它是这个行业的通用语,但不是唯一的话—— Anthropic(Claude 背后的公司)有自己的一套,而且分歧不是字段改个名那么浅。
这一章你把练习 3 的 REPL 拆成两半:循环归循环,协议归协议。
然后接入第二种协议,REPL 一个字不改。你不需要 Anthropic 的 key——
你一直在用的两家都会说它的话:DeepSeek 有 Anthropic 兼容端点(同一个 key),
本机 Ollama 也听得懂 /v1/messages。
敲进去
新建目录 ex04,go 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/completions | POST …/v1/messages |
| 认证 | Authorization: Bearer sk-… | x-api-key: sk-… |
| 版本头 | 无 | anthropic-version: 2023-06-01,官方必带 |
| system | messages 数组第 0 条 | 顶层字段,不进 messages |
| max_tokens | 可省略 | 必填(Ollama 会直接拒绝) |
| 回复 | choices[0].message.content,一个字符串 | content,一个列表,每项自带类型 |
| 说完了没 | finish_reason: "stop" | stop_reason: "end_turn" |
| 账单 | usage.prompt_tokens / completion_tokens | usage.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/400:
x-api-key和anthropic-version两个头都要在。 兼容端点大多不查版本头,官方查。 - 走 Anthropic 协议时输出 token 更高:思考(
thinking项)也是输出, 公开且计费——同一个模型同一道题,账单差在"思考写不写进回复"。
加分练习
-
把 Anthropic 协议的原始响应完整打印出来,找到装着思考过程的那一项 (
"type": "thinking")——再对比练习 3 加分练习 1 里 OpenAI 协议的reasoning_content字段。同一件事,一边写进了协议规范,一边是规范外的私货。 -
用 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_start、event: content_block_delta……每个事件带类型。 对比练习 2 的裸data:行——现在你亲眼确认了"流式没有跨协议标准"。 -
数一数
anthropicProvider连类型带方法一共多少行。接入一整个新协议生态, 就这么多——下次有人跟你报"支持多模型"的工作量,你心里有数。 -
给
provider接口加第三个实现:一个fakeProvider,不发网络请求, 固定返回"收到"。用它跑 REPL——你刚刚写出了第一个测试替身, 后面练习的测试全靠这个思路。